← 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.
src/file.ts
303 lines · 7.5 KB · TypeScriptimport 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 }
}