import githubRequest from 'kody:@kentcdodds/github/request' import { makeMentionSafe } from './mention-safe.ts' import createFrictionIssue, { type CreateFrictionIssueInput, } from './create.ts' import { qualifyTarget, parseGithubRepo, type FrictionTarget, } from './qualify.ts' import { buildFrictionBody, normalizeFrictionTitle, reuseCommentSkipReason, type ReuseCommentSkipped, } from './shared.ts' export type { FrictionHost, FrictionTarget } from './qualify.ts' export type FileFrictionPapercut = { 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 } export type FileFrictionInput = { /** Required: applied to every item in the batch. */ target: FrictionTarget /** One or more papercuts (preferred for the end-of-ship pass). */ items?: Array /** * When reusing an open same-title GitHub issue, post this papercut's body * as a comment (default true). Ignored for host kody. */ commentOnReuse?: boolean dryRun?: boolean } & Partial export type FileFrictionFiled = { action: 'filed' number: number title: string htmlUrl: string target: FrictionTarget } export type FileFrictionReused = { action: 'reused' number: number title: string htmlUrl: string commented: boolean /** Why no "Seen again" comment was posted, when one was not. */ commentSkipped?: ReuseCommentSkipped labeled: boolean target: FrictionTarget } export type FileFrictionWoke = { action: 'woke' bot: 'patch' woke: boolean title: string target: FrictionTarget dryRun?: boolean timedOut?: boolean } export type FileFrictionSkipped = { action: 'skipped' title: string reason: string target?: FrictionTarget } export type FileFrictionResult = { ok: true dryRun: boolean filed: Array reused: Array woke: Array skipped: Array } type GithubRequestResult = { ok?: boolean status?: number data?: unknown text?: 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 collectItems(input: FileFrictionInput): Array { if (Array.isArray(input?.items) && input.items.length > 0) { return input.items.map((item) => ({ title: String(item?.title ?? ''), body: item?.body, whatHappened: item?.whatHappened, whatYouWanted: item?.whatYouWanted, howToReproduce: item?.howToReproduce, cost: item?.cost, preliminaryInvestigation: item?.preliminaryInvestigation, relevantFiles: item?.relevantFiles, })) } if (String(input?.title ?? '').trim()) { return [ { title: String(input.title), body: input.body, whatHappened: input.whatHappened, whatYouWanted: input.whatYouWanted, howToReproduce: input.howToReproduce, cost: input.cost, preliminaryInvestigation: input.preliminaryInvestigation, relevantFiles: input.relevantFiles, }, ] } return [] } async function commentOnIssue( owner: string, repo: string, number: number, body: string, ) { requireOk( await githubRequest({ account: 'bot', method: 'POST', path: `/repos/${owner}/${repo}/issues/${number}/comments`, body: { body: makeMentionSafe(`### Seen again\n\n${body}`) }, }), `POST /repos/${owner}/${repo}/issues/${number}/comments`, ) } /** * File one or more friction papercuts against a single required `target` * (end-of-ship / session pass). * * - github: dedupes via `./create`, `friction` label, optional reuse comment * - kody: wakes Patch per item; results land in `woke` (not GitHub issues) * * Soft-skips via qualify (session-bound / missing gap / missing * preliminaryInvestigation on structured filings) go in `skipped`. * * @param input.target - Required; applied to every item * @param input.items - Papercuts; each should include preliminaryInvestigation * @param input.dryRun - Preview without posting */ export default async function fileFriction( input: FileFrictionInput, ): Promise { const dryRun = input?.dryRun === true const commentOnReuse = input?.commentOnReuse !== false const filed: Array = [] const reused: Array = [] const woke: Array = [] const skipped: Array = [] const targetOk = qualifyTarget(input?.target) if (!targetOk.ok) { skipped.push({ action: 'skipped', title: '', reason: targetOk.reason }) return { ok: true, dryRun, filed, reused, woke, skipped } } const target: FrictionTarget = { host: input.target.host, repo: String(input.target.repo).trim(), } const items = collectItems(input) if (items.length === 0) { skipped.push({ action: 'skipped', title: '', reason: 'items is required (or pass a single title + body fields)', target, }) return { ok: true, dryRun, filed, reused, woke, skipped } } for (const item of items) { const displayTitle = normalizeFrictionTitle(String(item.title ?? '')) || String(item.title ?? '') const createInput: CreateFrictionIssueInput = { title: item.title, body: item.body, whatHappened: item.whatHappened, whatYouWanted: item.whatYouWanted, howToReproduce: item.howToReproduce, cost: item.cost, preliminaryInvestigation: item.preliminaryInvestigation, relevantFiles: item.relevantFiles, target, dryRun, } const result = await createFrictionIssue(createInput) if ('skipped' in result && result.skipped === true) { skipped.push({ action: 'skipped', title: String(result.title || displayTitle), reason: result.reason, target, }) continue } if ('routed' in result && result.routed === 'patch-wake') { woke.push({ action: 'woke', bot: 'patch', woke: result.woke === true, title: String(result.title || displayTitle), target, ...(result.dryRun ? { dryRun: true } : {}), ...(result.timedOut ? { timedOut: true } : {}), }) continue } const title = String(result.title || displayTitle) const number = Number(result.number || 0) const htmlUrl = String(result.htmlUrl || '') const body = buildFrictionBody(item) const commentSkipped = reuseCommentSkipReason({ commentOnReuse, body, sameBodyAsExisting: result.sameBodyAsExisting === true, }) if (dryRun) { if (result.wouldReuse === true) { reused.push({ action: 'reused', number, title, htmlUrl, commented: false, ...(commentSkipped ? { commentSkipped } : {}), labeled: false, target, }) } else { filed.push({ action: 'filed', number: 0, title, htmlUrl: '', target, }) } continue } if (result.created === true) { filed.push({ action: 'filed', number, title, htmlUrl, target }) continue } let commented = false const parsed = parseGithubRepo(target.repo) if (!commentSkipped && number && parsed) { await commentOnIssue(parsed.owner, parsed.name, number, body) commented = true } reused.push({ action: 'reused', number, title, htmlUrl, commented, ...(commentSkipped ? { commentSkipped } : {}), labeled: result.labeled === true, target, }) } return { ok: true, dryRun, filed, reused, woke, skipped } }