Skip to content
← Public packages

@kentcdodds/friction-log

Qualify and file friction papercuts to GitHub (any owner/repo) or wake Patch for Kody packages; daily sweep of kody-hq/kody friction issues.

FollowFork

src/create.ts

343 lines · 8.5 KB · TypeScript
Raw
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<string>
	/** 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<string> = [title],
): Promise<GithubIssue | null> {
	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<GithubIssue> }
	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<CreateFrictionGithubResult | CreateFrictionSkipped> {
	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<CreateFrictionKodyResult> {
	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<CreateFrictionIssueResult> {
	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)
}