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/file.ts

303 lines · 7.5 KB · TypeScript
Raw
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<string>
}

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<FileFrictionPapercut>
	/**
	 * 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<FileFrictionPapercut>

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<FileFrictionFiled>
	reused: Array<FileFrictionReused>
	woke: Array<FileFrictionWoke>
	skipped: Array<FileFrictionSkipped>
}

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<FileFrictionPapercut> {
	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<FileFrictionResult> {
	const dryRun = input?.dryRun === true
	const commentOnReuse = input?.commentOnReuse !== false
	const filed: Array<FileFrictionFiled> = []
	const reused: Array<FileFrictionReused> = []
	const woke: Array<FileFrictionWoke> = []
	const skipped: Array<FileFrictionSkipped> = []

	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 }
}