Skip to content

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

Package listing

@kody/planetscale

src/run-loop.ts

212 lines · 6.6 KB · TypeScript
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'),
	})
}