Skip to content
← Public packages

@kentcdodds/skills

Store, retrieve, edit, version, and revert reusable agent skill documents via skillList then skillGet.

src/on-repo-pushed.ts

152 lines · 3.7 KB · TypeScript
import migrateToRepo from './migrate-to-repo.ts'
import { SKILLS_REPO_NAME } from './repo.ts'

/**
 * Cloudflare Artifacts → Kody `repo.pushed` payload (metadata-first).
 * Field names match the platform wire format (snake_case) — intentional exception.
 * @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:
				| 'wrongEvent'
				| 'notPlainRepo'
				| 'differentRepo'
				| 'sessionBranch'
				| 'notDefaultBranch'
			repoName?: string | null
			entityKind?: string | null
			ref?: string
	  }
	| {
			skipped: false
			synced: true
			ref: string
			after: string
			skillsIndexed: number
			skillIds: string[]
			backendAfter: string | null
	  }

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
 * skillList / 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: 'wrongEvent' }
	}

	// Wire: entity_kind / name come from the platform payload.
	const entityKind = event.repo?.entity_kind
	const repoName = event.repo?.name

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

	if (repoName !== SKILLS_REPO_NAME) {
		return {
			skipped: true,
			reason: 'differentRepo',
			entityKind,
			repoName: 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: 'sessionBranch',
			entityKind,
			repoName,
			ref,
		}
	}
	if (branch !== 'main' && branch !== 'master') {
		return {
			skipped: true,
			reason: 'notDefaultBranch',
			entityKind,
			repoName,
			ref,
		}
	}

	const result = await migrateToRepo({ switchReads: true })
	return {
		skipped: false,
		synced: true,
		ref: event.push?.ref ?? '',
		after: event.push?.after ?? '',
		skillsIndexed: result.skillsIndexed,
		skillIds: result.skillIds,
		backendAfter: result.backendAfter,
	}
}