import { kody } from 'kody:runtime'
import { createAgent } from 'kody:@kody/cursor/agents'
import githubRequest from 'kody:@kody/github/request'
import notify from 'kody:@kody/notify'
import { draftLoop } from './draft-loop.ts'
import { githubRefFromConfig } from './loop-helpers.ts'
import { readLoopConfig, writeLoopConfig } from './loop-storage.ts'
import type { LoopConfig, PlanetscaleAnomalySummary, PlanetscaleAuthInput } from './types.ts'
import { optionalBoolean, optionalString, requireRecord, requireString } from './types.ts'
export type RunLoopInput = PlanetscaleAuthInput & {
organization?: string
database?: string
branch?: string
anomalyId?: string
githubOwner?: string
githubRepo?: string
repositoryUrl?: string
cursorRef?: string
githubIntegrationName?: string
githubSecretName?: string
labels?: Array<string>
dryRun?: boolean
confirm?: boolean
}
function mergeTarget(stored: LoopConfig, input: RunLoopInput) {
const organization = requireString(input.organization ?? stored.organization, 'organization')
const database = requireString(input.database ?? stored.database, 'database')
const branch = optionalString(input.branch, 'branch') ?? stored.branch ?? 'main'
return {
organization,
database,
branch,
githubOwner: optionalString(input.githubOwner, 'githubOwner') ?? stored.githubOwner,
githubRepo: optionalString(input.githubRepo, 'githubRepo') ?? stored.githubRepo,
repositoryUrl: optionalString(input.repositoryUrl, 'repositoryUrl') ?? stored.repositoryUrl,
cursorRef: optionalString(input.cursorRef, 'cursorRef') ?? stored.cursorRef ?? 'main',
githubIntegrationName:
optionalString(input.githubIntegrationName, 'githubIntegrationName') ??
stored.githubIntegrationName,
githubSecretName: optionalString(input.githubSecretName, 'githubSecretName') ?? stored.githubSecretName,
labels: input.labels ?? stored.labels,
account: optionalString(input.account, 'account') ?? stored.account,
secretName: optionalString(input.secretName, 'secretName'),
}
}
function anomalyKey(anomaly: PlanetscaleAnomalySummary | null, title: string): string {
return anomaly?.id || `draft:${title}`
}
/**
* Preview or run the Insights → GitHub issue → Cursor implementer → human ping loop.
* Default is dry-run. Live writes require confirm: true. createAgent is never
* called unless confirm is set.
* @example
* import runLoop from 'kody:@kody/planetscale/run-loop'
* const preview = await runLoop({ dryRun: true })
*/
export async function runLoop(input: RunLoopInput = {}) {
const stored = await readLoopConfig()
const target = mergeTarget(stored, input)
const live = input.confirm === true && input.dryRun !== true
const github = githubRefFromConfig(target)
const draft = await draftLoop({
organization: target.organization,
database: target.database,
branch: target.branch,
anomalyId: optionalString(input.anomalyId, 'anomalyId'),
githubOwner: target.githubOwner,
githubRepo: target.githubRepo,
repositoryUrl: target.repositoryUrl,
labels: target.labels,
account: target.account,
secretName: target.secretName,
})
const source = draft.anomaly ? 'anomaly' : 'query'
const processedKey = anomalyKey(draft.anomaly, draft.title)
if (live && stored.lastProcessedAnomalyId === processedKey) {
return {
ok: true,
skipped: true,
source,
reason: 'This anomaly was already turned into an issue/PR. Wait for a new Insights signal.',
draft,
}
}
const wouldCall = {
githubIssue: github
? {
method: 'POST',
path: github.issuePath,
title: draft.title,
labels: target.labels ?? [],
}
: null,
cursorAgent: github
? {
name: draft.title.slice(0, 80),
repository: github.repositoryUrl,
ref: target.cursorRef,
autoCreatePR: true,
skipReviewerRequest: false,
}
: null,
email: { subject: draft.title, text: draft.humanPing },
notify: { subject: draft.title, text: draft.humanPing },
}
if (!live) {
return {
ok: true,
dryRun: true,
source,
draft,
wouldCall,
next:
github
? 'Re-run with confirm: true to open the GitHub issue, launch the Cursor implementer, and ping for human review.'
: 'Save githubOwner + githubRepo (or repositoryUrl) with configure-loop, then re-run with confirm: true.',
}
}
const created: Record<string, unknown> = {}
if (github) {
created.issue = await githubRequest({
method: 'POST',
path: github.issuePath,
body: {
title: draft.title,
body: draft.issueBody,
labels: target.labels ?? [],
},
integrationName: target.githubIntegrationName,
secretName: target.githubSecretName,
confirm: true,
})
created.agent = await createAgent({
name: draft.title.slice(0, 80),
prompt: {
text: [
draft.implementerPrompt,
'',
'After you open the PR, a reviewer should look at it. Human review is required before merge.',
'',
draft.reviewerPrompt,
].join('\n'),
},
repository: github.repositoryUrl,
ref: target.cursorRef,
autoCreatePR: true,
skipReviewerRequest: false,
})
}
created.email = await kody.email_send({
subject: draft.title,
text: draft.humanPing,
})
created.notify = await notify({
subject: draft.title,
text: draft.humanPing,
})
await writeLoopConfig({
...stored,
...target,
lastProcessedAnomalyId: processedKey,
})
return {
ok: true,
dryRun: false,
source,
draft,
created,
}
}
/**
* Preview or run the Insights → GitHub issue → Cursor implementer → human ping loop.
* @example
* import runLoop from 'kody:@kody/planetscale/run-loop'
* await runLoop({ dryRun: true })
*/
export default async function runLoopEntrypoint(
params: Partial<RunLoopInput> & Record<string, unknown> = {},
) {
const input = requireRecord(params, 'run-loop')
return runLoop({
organization: optionalString(input.organization, 'organization'),
database: optionalString(input.database, 'database'),
branch: optionalString(input.branch, 'branch'),
anomalyId: optionalString(input.anomalyId, 'anomalyId'),
githubOwner: optionalString(input.githubOwner, 'githubOwner'),
githubRepo: optionalString(input.githubRepo, 'githubRepo'),
repositoryUrl: optionalString(input.repositoryUrl, 'repositoryUrl'),
cursorRef: optionalString(input.cursorRef, 'cursorRef'),
githubIntegrationName: optionalString(input.githubIntegrationName, 'githubIntegrationName'),
githubSecretName: optionalString(input.githubSecretName, 'githubSecretName'),
account: optionalString(input.account, 'account'),
secretName: optionalString(input.secretName, 'secretName'),
dryRun: optionalBoolean(input.dryRun, 'dryRun'),
confirm: optionalBoolean(input.confirm, 'confirm'),
})
}