← 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/qualify.ts
215 lines · 6.0 KB · TypeScriptimport {
normalizeFrictionTitle,
normalizeRelevantFiles,
} from './shared.ts'
export type FrictionHost = 'github' | 'kody'
export type FrictionTarget = {
host: FrictionHost
/** github: "owner/repo"; kody: "@owner/leaf" or "leaf" */
repo: string
}
export type QualifyFrictionInput = {
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>
target?: FrictionTarget | null
}
export type QualifyFrictionResult =
| { ok: true }
| { ok: false; reason: string }
const MIN_STRUCTURED_CHARS = 40
const SHORT_WHAT_HAPPENED = 40
/**
* Soft qualification bar for friction papercuts.
*
* Prefer durable, recurring, clear-owner, reproducible contract gaps.
* Soft-skip (reason string, never throw) when the report looks session-bound,
* one-off agent confusion, or missing a reproducible gap — not via a deny-list
* of titles.
*/
export function parseGithubRepo(
repo: string,
): { owner: string; name: string } | null {
const trimmed = String(repo ?? '').trim()
if (!trimmed || trimmed.includes('@')) return null
const parts = trimmed.split('/').filter(Boolean)
if (parts.length !== 2) return null
const [owner, name] = parts
if (!owner || !name) return null
return { owner, name }
}
export function isFrictionHost(value: unknown): value is FrictionHost {
return value === 'github' || value === 'kody'
}
export function qualifyTarget(
target: FrictionTarget | null | undefined,
): QualifyFrictionResult {
if (!target || typeof target !== 'object') {
return {
ok: false,
reason: 'target is required ({ host: "github"|"kody", repo })',
}
}
if (!isFrictionHost(target.host)) {
return { ok: false, reason: 'target.host must be "github" or "kody"' }
}
const repo = String(target.repo ?? '').trim()
if (!repo) {
return { ok: false, reason: 'target.repo is required' }
}
if (target.host === 'github' && !parseGithubRepo(repo)) {
return {
ok: false,
reason: 'target.repo must be "owner/repo" for host github',
}
}
return { ok: true }
}
function structuredText(input: QualifyFrictionInput) {
return {
body: String(input.body ?? '').trim(),
whatHappened: String(input.whatHappened ?? '').trim(),
whatYouWanted: String(input.whatYouWanted ?? '').trim(),
howToReproduce: String(input.howToReproduce ?? '').trim(),
cost: String(input.cost ?? '').trim(),
preliminaryInvestigation: String(
input.preliminaryInvestigation ?? '',
).trim(),
relevantFiles: normalizeRelevantFiles(input.relevantFiles),
}
}
/**
* Soft-skip when the papercut fails the durable-gap bar.
* Callers should return `{ ok: true, skipped: true, reason }` — do not throw.
*/
export function qualifyFriction(
input: QualifyFrictionInput = {},
): QualifyFrictionResult {
const targetCheck = qualifyTarget(input.target)
if (!targetCheck.ok) return targetCheck
const host = input.target!.host
const rawTitle = String(input.title ?? '').trim()
if (host === 'github') {
const title = normalizeFrictionTitle(rawTitle)
if (!title.replace(/^Friction:\s*/i, '').trim()) {
return { ok: false, reason: 'title is required' }
}
} else if (!rawTitle) {
return { ok: false, reason: 'title is required' }
}
const fields = structuredText(input)
const structuredLen =
fields.whatHappened.length +
fields.whatYouWanted.length +
fields.howToReproduce.length +
fields.cost.length
// Structured filings need a short preliminary investigation (files optional)
if (!fields.body && !fields.preliminaryInvestigation) {
return {
ok: false,
reason:
'preliminaryInvestigation is required: short notes on what was already looked at (relevantFiles optional; do not invent a root cause)',
}
}
// Missing reproducible gap: no howToReproduce, no body, structured too thin
if (
!fields.howToReproduce &&
!fields.body &&
structuredLen < MIN_STRUCTURED_CHARS
) {
return {
ok: false,
reason:
'missing reproducible gap: provide howToReproduce, body, or richer structured fields (durable contract/API/docs gap)',
}
}
// Transient / session-bound heuristic (not a title deny-list):
// missing howToReproduce + cost empty + whatHappened under ~40 chars
if (
!fields.howToReproduce &&
!fields.cost &&
fields.whatHappened.length > 0 &&
fields.whatHappened.length < SHORT_WHAT_HAPPENED &&
!fields.body
) {
return {
ok: false,
reason:
'looks transient/session-bound: add howToReproduce and cost, or describe a clear recurring contract gap',
}
}
return { ok: true }
}
export function buildPatchWakePrompt(input: {
title: string
target: FrictionTarget
body?: string
whatHappened?: string
whatYouWanted?: string
howToReproduce?: string
cost?: string
preliminaryInvestigation?: string
relevantFiles?: Array<string>
}) {
const fields = structuredText(input)
const lines = [
'Package friction routed to Patch — triage/fix the Kody package; escalate to Cole only if it\'s platform.',
'',
`Title: ${String(input.title ?? '').trim()}`,
`Package: ${String(input.target.repo ?? '').trim()}`,
`Host: kody`,
]
if (fields.body) {
lines.push('', '## Body', '', fields.body)
}
if (fields.whatHappened) {
lines.push('', '## What happened', '', fields.whatHappened)
}
if (fields.whatYouWanted) {
lines.push('', '## What you wanted', '', fields.whatYouWanted)
}
if (fields.howToReproduce) {
lines.push('', '## How to reproduce', '', fields.howToReproduce)
}
if (fields.cost) {
lines.push('', '## Cost', '', fields.cost)
}
if (fields.preliminaryInvestigation || fields.relevantFiles.length > 0) {
lines.push('', '## Preliminary investigation', '')
if (fields.preliminaryInvestigation) {
lines.push(fields.preliminaryInvestigation)
}
if (fields.relevantFiles.length > 0) {
if (fields.preliminaryInvestigation) lines.push('')
lines.push('Relevant files:')
for (const file of fields.relevantFiles) {
lines.push(`- \`${file}\``)
}
}
}
return lines.join('\n')
}