import githubRequest from 'kody:@kentcdodds/github/request' import { makeMentionSafe } from './mention-safe.ts' import wake from 'kody:@kentcdodds/grok-bot/wake' import { buildPatchWakePrompt, parseGithubRepo, qualifyFriction, type FrictionTarget, } from './qualify.ts' import { buildFrictionBody, frictionLabel, isSameFrictionBody, normalizeFrictionTitle, } from './shared.ts' export type { FrictionHost, FrictionTarget } from './qualify.ts' export type CreateFrictionIssueInput = { title: string body?: string whatHappened?: string whatYouWanted?: string howToReproduce?: string cost?: string /** Short notes on what was already looked at. Not a root cause. */ preliminaryInvestigation?: string /** Optional paths that look relevant; omit when none are known. */ relevantFiles?: Array /** Required: which repo and whether it lives on github or kody. */ target: FrictionTarget dryRun?: boolean } export type CreateFrictionSkipped = { ok: true skipped: true reason: string title: string target?: FrictionTarget } export type CreateFrictionGithubResult = { ok: true skipped?: false routed?: 'github' dryRun?: boolean title: string body?: string labels: string[] created: boolean wouldReuse?: boolean /** * Set when an open same-title issue exists: true when this papercut's body * matches that issue's body exactly (nothing new to add as a comment). */ sameBodyAsExisting?: boolean labeled: boolean number?: number htmlUrl?: string target: FrictionTarget } export type CreateFrictionKodyResult = { ok: true skipped?: false routed: 'patch-wake' woke: boolean title: string target: FrictionTarget dryRun?: boolean timedOut?: boolean } export type CreateFrictionIssueResult = | CreateFrictionSkipped | CreateFrictionGithubResult | CreateFrictionKodyResult type GithubRequestResult = { ok?: boolean status?: number data?: unknown text?: string } type GithubIssue = { number?: number title?: string | null html_url?: string | null body?: string | null pull_request?: unknown labels?: Array<{ name?: string } | string> } function requireOk(result: GithubRequestResult, path: string) { if (result?.ok === false || (result.status && result.status >= 400)) { throw new Error( `GitHub ${path} failed (${result.status}): ${String(result.text || '').slice(0, 200)}`, ) } return result.data } function issueLabels(issue: GithubIssue) { return (issue.labels || []).map((label) => typeof label === 'string' ? label : String(label?.name || ''), ) } function hasFrictionLabel(issue: GithubIssue) { return issueLabels(issue).includes(frictionLabel) } async function findOpenIssueByTitle( owner: string, repo: string, title: string, acceptTitles: Array = [title], ): Promise { const query = `repo:${owner}/${repo} is:issue is:open in:title ${JSON.stringify(title)}` const data = requireOk( await githubRequest({ account: 'bot', path: `/search/issues?q=${encodeURIComponent(query)}&per_page=10`, }), '/search/issues', ) as { items?: Array } const items = Array.isArray(data?.items) ? data.items : [] const exact = items.find( (issue) => !issue.pull_request && acceptTitles.includes(normalizeFrictionTitle(String(issue.title || ''))), ) return exact ?? null } async function ensureLabel( owner: string, repo: string, issue: GithubIssue, ) { const number = Number(issue.number || 0) if (!number) return issue if (hasFrictionLabel(issue)) return issue requireOk( await githubRequest({ account: 'bot', method: 'POST', path: `/repos/${owner}/${repo}/issues/${number}/labels`, body: { labels: [frictionLabel] }, }), `POST /repos/${owner}/${repo}/issues/${number}/labels`, ) return { ...issue, labels: [...(issue.labels || []), { name: frictionLabel }] } } async function createGithubFriction( input: CreateFrictionIssueInput, target: FrictionTarget, ): Promise { const parsed = parseGithubRepo(target.repo) if (!parsed) { return { ok: true, skipped: true, reason: 'target.repo must be "owner/repo" for host github', title: String(input.title ?? ''), target, } } const { owner, name: repo } = parsed // Public issue: package names go in inline code and credit mentions are // dropped before posting. Dedupe still matches titles filed before this pass. const rawTitle = normalizeFrictionTitle(input?.title) const title = makeMentionSafe(rawTitle) const rawBody = buildFrictionBody(input) const body = makeMentionSafe(rawBody) const sameBody = (existingBody: unknown) => isSameFrictionBody(body, existingBody) || isSameFrictionBody(rawBody, existingBody) const existing = await findOpenIssueByTitle(owner, repo, rawTitle, [rawTitle, title]) if (input?.dryRun === true) { if (existing) { return { ok: true, routed: 'github', dryRun: true, title, body, labels: [frictionLabel], created: false, wouldReuse: true, sameBodyAsExisting: sameBody(existing.body), labeled: false, number: Number(existing.number || 0), htmlUrl: String(existing.html_url || ''), target, } } return { ok: true, routed: 'github', dryRun: true, title, body, labels: [frictionLabel], created: false, wouldReuse: false, labeled: false, target, } } if (existing) { const alreadyLabeled = hasFrictionLabel(existing) const labeled = await ensureLabel(owner, repo, existing) return { ok: true, routed: 'github', created: false, sameBodyAsExisting: sameBody(existing.body), labeled: !alreadyLabeled, number: Number(labeled.number || 0), title, htmlUrl: String(labeled.html_url || ''), labels: [frictionLabel], target, } } const created = requireOk( await githubRequest({ account: 'bot', method: 'POST', path: `/repos/${owner}/${repo}/issues`, body: { title, body, labels: [frictionLabel], }, }), `POST /repos/${owner}/${repo}/issues`, ) as GithubIssue return { ok: true, routed: 'github', created: true, labeled: true, number: Number(created.number || 0), title: String(created.title || title), htmlUrl: String(created.html_url || ''), labels: [frictionLabel], target, } } async function wakePatchForPackage( input: CreateFrictionIssueInput, target: FrictionTarget, ): Promise { const title = String(input.title ?? '').trim() const prompt = buildPatchWakePrompt({ title, target, body: input.body, whatHappened: input.whatHappened, whatYouWanted: input.whatYouWanted, howToReproduce: input.howToReproduce, cost: input.cost, preliminaryInvestigation: input.preliminaryInvestigation, relevantFiles: input.relevantFiles, }) const dryRun = input?.dryRun === true const result = await wake({ bot: 'patch', prompt, dryRun, }) const woke = dryRun === true ? false : Boolean((result as { woke?: boolean })?.woke === true) return { ok: true, routed: 'patch-wake', woke, title, target, ...(dryRun ? { dryRun: true } : {}), ...((result as { timedOut?: boolean })?.timedOut ? { timedOut: true } : {}), } } /** * File friction against an explicit target. * * - `host: 'github'` → GitHub issue labeled `friction` on `target.repo` * (owner/repo). Dedupes open same-title issues. Pass * `repo: 'kody-hq/kody'` when the platform repo is the intended target. * - `host: 'kody'` → wake Patch via `@kentcdodds/grok-bot/wake` (no GitHub issue). * * Soft-skips (does not throw) when qualify fails — session-bound / missing gap * / missing preliminaryInvestigation on structured filings. * * @param input.title - Papercut title * @param input.target - Required `{ host, repo }` * @param input.preliminaryInvestigation - Short notes on what was already looked at (required unless `body`) * @param input.relevantFiles - Optional relevant paths; omit when none known * @param input.dryRun - Preview without posting */ export default async function createFrictionIssue( input: CreateFrictionIssueInput, ): Promise { const titleHint = String(input?.title ?? '').trim() const qualify = qualifyFriction(input) if (!qualify.ok) { return { ok: true, skipped: true, reason: qualify.reason, title: titleHint ? normalizeFrictionTitle(titleHint) : titleHint, ...(input?.target ? { target: input.target } : {}), } } const target: FrictionTarget = { host: input.target.host, repo: String(input.target.repo).trim(), } if (target.host === 'kody') { return wakePatchForPackage(input, target) } return createGithubFriction(input, target) }