Skip to content

Built for people who want to own their automations. Join the waitlist for an invite.

Package listing

@kody/skills

src/on-repo-pushed.ts

154 lines · 3.8 KB · TypeScript
import { isDryRun } from './dry-run.ts'
import migrateToRepo from './migrate-to-repo.ts'
import { SKILLS_REPO_NAME } from './repo.ts'

/**
 * Cloudflare Artifacts → Kody `repo.pushed` payload (metadata-first).
 * @see https://github.com/kentcdodds/kody/blob/main/docs/guides/package-subscriptions.md
 */
export type RepoPushedEvent = {
	event: 'repo.pushed'
	repo: {
		source_id: string
		repo_id: string
		entity_kind: 'repo' | 'package' | 'job'
		entity_id: string
		name: string | null
		kody_id: string | null
	}
	push: {
		ref: string
		before: string
		after: string
		total_commits_count: number
		commits_truncated: boolean
		commits: Array<{
			id: string
			message: string
			message_truncated: boolean
			timestamp: string
			author: { name: string; email: string }
			committer: { name: string; email: string }
			parents: Array<string>
		}>
	}
	artifacts: {
		namespace: string
		event_timestamp: string
		event_subscription_id: string
	}
}

export type OnRepoPushedResult =
	| {
			skipped: true
			reason:
				| 'wrong_event'
				| 'not_plain_repo'
				| 'different_repo'
				| 'session_branch'
				| 'not_default_branch'
			repo_name?: string | null
			entity_kind?: string | null
			ref?: string
	  }
	| {
			skipped: false
			synced: true
			ref: string
			after: string
			skills_indexed: number
			skill_ids: string[]
			backend_after: string | null
			dryRun?: true
	  }

function asRepoPushedEvent(
	params: Record<string, unknown> | undefined,
): RepoPushedEvent | null {
	if (!params || params.event !== 'repo.pushed') return null
	const repo = params.repo
	const push = params.push
	const artifacts = params.artifacts
	if (!repo || typeof repo !== 'object' || !push || typeof push !== 'object') {
		return null
	}
	if (!artifacts || typeof artifacts !== 'object') return null
	return params as unknown as RepoPushedEvent
}

/**
 * Subscription handler for `repo.pushed`.
 * When the plain `skills` repo receives commits on the default branch
 * (git lane or session publish), resync the skills_index projection used by
 * skill_list / skill-search. Session-branch pushes are ignored: opening a
 * session creates those refs, and reacting to them re-opens another session.
 */
export default async function onRepoPushed(
	params: Record<string, unknown> = {},
): Promise<OnRepoPushedResult> {
	const event = asRepoPushedEvent(params)
	if (!event) {
		return { skipped: true, reason: 'wrong_event' }
	}

	const entityKind = event.repo?.entity_kind
	const repoName = event.repo?.name

	if (entityKind !== 'repo') {
		return {
			skipped: true,
			reason: 'not_plain_repo',
			entity_kind: entityKind ?? null,
			repo_name: repoName ?? null,
		}
	}

	if (repoName !== SKILLS_REPO_NAME) {
		return {
			skipped: true,
			reason: 'different_repo',
			entity_kind: entityKind,
			repo_name: repoName ?? null,
		}
	}

	const ref = event.push?.ref ?? ''
	const branch = ref.startsWith('refs/heads/')
		? ref.slice('refs/heads/'.length)
		: ref
	// Opening a repo session git-pushes `sessions/<id>`. That push used to
	// re-enter this handler, which opened another session — a tight loop that
	// exhausted the repo_sessions entitlement.
	if (branch.startsWith('sessions/')) {
		return {
			skipped: true,
			reason: 'session_branch',
			entity_kind: entityKind,
			repo_name: repoName,
			ref,
		}
	}
	if (branch !== 'main' && branch !== 'master') {
		return {
			skipped: true,
			reason: 'not_default_branch',
			entity_kind: entityKind,
			repo_name: repoName,
			ref,
		}
	}

	const dryRun = isDryRun(params.dryRun)
	const result = await migrateToRepo({ switch_reads: true, dryRun })
	return {
		skipped: false,
		synced: true,
		ref: event.push?.ref ?? '',
		after: event.push?.after ?? '',
		skills_indexed: result.skills_indexed,
		skill_ids: result.skill_ids,
		backend_after: result.backend_after,
		...(dryRun ? { dryRun: true as const } : {}),
	}
}