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

215 lines · 6.0 KB · TypeScript
Raw
import {
	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')
}