← 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/create.ts
343 lines · 8.5 KB · TypeScriptimport 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)
}