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 } : {}),
}
}