diff --git a/AGENTS.md b/AGENTS.md index df0b6fcb..2e568295 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -54,6 +54,7 @@ Every reproduced race requires a failing-before and passing-after regression. As ## Guarded external actions - A bounded safety scan must fail closed when its limit is exceeded. Never truncate evidence and report the result as clear. +- Align subprocess output limits with every payload the schema accepts, or tighten the upstream page and field bounds; valid bounded input must not fail only because the transport budget is smaller. - Exclude the subject of a duplicate or supersession check by stable identity only. A shared branch name or other mutable attribute does not prove two records are the same subject. - Preserve repository identity with pull request numbers in cross-reference scans. Never resolve or exclude a repository-qualified reference by number alone. - After the final asynchronous external validation, re-read the local generation immediately before an irreversible action. A generation check performed before that await is insufficient. diff --git a/core/issue-ranking.ts b/core/issue-ranking.ts new file mode 100644 index 00000000..320eadf9 --- /dev/null +++ b/core/issue-ranking.ts @@ -0,0 +1,113 @@ +import type { IssueGateway, IssueSnapshot, RepositoryIssue } from '../github/issues.ts'; + +export interface RankedIssue extends RepositoryIssue { + readonly score: number; + readonly reasons: readonly string[]; +} + +export type IssuePriorityState = + | { state: 'fresh'; repository: string; retrievedAt: string; issues: RankedIssue[] } + | { state: 'stale'; repository: string; retrievedAt: string; failedAt: string; error: string; issues: RankedIssue[] } + | { state: 'unavailable'; repository: string; attemptedAt: string; error: string; issues: [] }; + +const priorityScores = new Map([['p0', 100], ['p1', 75], ['p2', 50], ['p3', 25]]); + +function clock(value: Date): string { + if (!Number.isFinite(value.getTime())) throw new Error('Issue ranking clock is invalid.'); + return value.toISOString(); +} + +export function rankIssues(issues: readonly RepositoryIssue[], at: Date): RankedIssue[] { + const now = at.getTime(); + if (!Number.isFinite(now)) throw new Error('Issue ranking clock is invalid.'); + const seen = new Set(); + const ranked = issues.map(issue => { + const identity = `${issue.repository}#${issue.number}`; + if (seen.has(identity)) throw new Error('Cannot rank duplicate issues.'); + seen.add(identity); + const labels = new Set(issue.labels.map(label => label.toLocaleLowerCase('en-US'))); + let score = 0; + const reasons: string[] = []; + for (const label of ['p0', 'p1', 'p2', 'p3']) { + if (!labels.has(label)) continue; + const points = priorityScores.get(label)!; + score += points; + reasons.push(`${points} points: ${label.toUpperCase()} priority label`); + break; + } + if (labels.has('security')) { score += 40; reasons.push('40 points: security label'); } + if (labels.has('bug')) { score += 20; reasons.push('20 points: bug label'); } + const reactions = Math.min(issue.positiveReactions, 20); + if (reactions) { score += reactions; reasons.push(`${reactions} point${reactions === 1 ? '' : 's'}: ${issue.positiveReactions} positive reaction${issue.positiveReactions === 1 ? '' : 's'}${issue.positiveReactions > 20 ? ' (cap 20)' : ''}`); } + const comments = Math.min(issue.comments, 10); + if (comments) { score += comments; reasons.push(`${comments} point${comments === 1 ? '' : 's'}: ${issue.comments} comment${issue.comments === 1 ? '' : 's'}${issue.comments > 10 ? ' (cap 10)' : ''}`); } + const ageDays = Math.max(0, Math.floor((now - Date.parse(issue.createdAt)) / 86_400_000)); + const age = Math.min(12, Math.floor(ageDays / 30)); + if (age) { score += age; reasons.push(`${age} point${age === 1 ? '' : 's'}: ${ageDays} days old${ageDays >= 360 ? ' (cap 12)' : ''}`); } + if (!reasons.length) reasons.push('No configured priority signals.'); + return { ...issue, labels: [...issue.labels], score, reasons }; + }); + return ranked.sort((left, right) => right.score - left.score + || Date.parse(left.createdAt) - Date.parse(right.createdAt) + || left.number - right.number); +} + +function failure(error: unknown): string { + const message = error instanceof Error ? error.message : 'Issue retrieval failed.'; + return message.slice(0, 500) || 'Issue retrieval failed.'; +} + +function copyIssues(issues: readonly RankedIssue[]): RankedIssue[] { + return issues.map(issue => ({ ...issue, labels: [...issue.labels], reasons: [...issue.reasons] })); +} + +class SupersededIssueRefreshError extends Error { + constructor() { super('Issue refresh was superseded by a newer request.'); } +} + +export class IssuePrioritizer { + readonly gateway: IssueGateway; + readonly now: () => Date; + #last: Extract | null = null; + #generation = 0; + + constructor(gateway: IssueGateway, now: () => Date = () => new Date()) { + this.gateway = gateway; + this.now = now; + } + + async refresh(options: { signal?: AbortSignal; timeoutMs?: number } = {}): Promise { + options.signal?.throwIfAborted(); + const generation = ++this.#generation; + try { + const snapshot: IssueSnapshot = await this.gateway.fetch(options); + options.signal?.throwIfAborted(); + if (snapshot.repository !== this.gateway.repository) throw new Error('Issue snapshot repository mismatch.'); + if (snapshot.issues.some(issue => issue.repository !== snapshot.repository)) + throw new Error('Issue snapshot contains an issue from another repository.'); + const result: Extract = { + state: 'fresh', + repository: snapshot.repository, + retrievedAt: snapshot.retrievedAt, + issues: rankIssues(snapshot.issues, new Date(snapshot.retrievedAt)), + }; + if (generation !== this.#generation) throw new SupersededIssueRefreshError(); + this.#last = { ...result, issues: copyIssues(result.issues) }; + return result; + } catch (error) { + if (options.signal?.aborted) throw options.signal.reason; + if (error instanceof SupersededIssueRefreshError || generation !== this.#generation) + throw new SupersededIssueRefreshError(); + const attemptedAt = clock(this.now()); + if (!this.#last) return { state: 'unavailable', repository: this.gateway.repository, attemptedAt, error: failure(error), issues: [] }; + return { + state: 'stale', + repository: this.#last.repository, + retrievedAt: this.#last.retrievedAt, + failedAt: attemptedAt, + error: failure(error), + issues: copyIssues(this.#last.issues), + }; + } + } +} diff --git a/docs/implementation/issue-prioritization.md b/docs/implementation/issue-prioritization.md new file mode 100644 index 00000000..fa74aac7 --- /dev/null +++ b/docs/implementation/issue-prioritization.md @@ -0,0 +1,63 @@ +# Lane H: issue prioritization + +Baseline: `0ae71a503592de90926063fa563c1f7c715db22b` (`origin/main`, +2026-09-24). + +## H1 decision: ranking policy + +The first released policy is deterministic, explainable, and independent of an +AI model. It ranks open GitHub issues by the following additive score: + +| Signal | Score | +| --- | ---: | +| Highest priority label: `P0`, `P1`, `P2`, or `P3` | 100, 75, 50, or 25 | +| `security` label | +40 | +| `bug` label | +20 | +| Positive reactions (`+1`, `heart`, `hooray`, `rocket`) | +1 each, capped at 20 | +| Comments | +1 each, capped at 10 | +| Age | +1 per complete 30 days, capped at 12 | + +Labels are compared case-insensitively. When several priority labels are +present, only the highest priority contributes. Labels other than those listed +above do not affect the score. AI triage is excluded because the same issue set +must produce the same order without a provider call. + +Issues sort by descending score, then oldest creation time, then ascending issue +number. Every contributing signal is emitted as a user-visible reason. An issue +with no contributing signal says that it has no configured priority signals. + +An issue is trusted by default only when GitHub reports its author association +as `OWNER`, `MEMBER`, or `COLLABORATOR`. Other issues remain visible but require +an explicit trust decision before queueing. Trust affects eligibility, never the +score, so an untrusted author cannot improve rank by embedding instructions in +issue text. + +## Issue-access contract inspection + +The existing `github/merge.ts` gateway is scoped to one configured issue and +pull request. It reads an issue timeline only for duplicate-work detection and +does not expose an issue-list contract that H can reuse. H therefore adds a +dedicated read-only gateway with these boundaries: + +- Codeboost invokes `gh` with literal arguments; issue text is parsed only as + data and is never interpolated into a shell command or prompt. +- The gateway fetches open issues, excludes pull requests, follows bounded + pagination, and validates every field used for normalization or ranking. +- A complete successful snapshot includes repository identity and a retrieval + timestamp. A subsequent retrieval failure returns an explicit stale snapshot + only when a previously validated snapshot exists; otherwise it is unavailable. +- Malformed fields, an exceeded issue/page limit, an unknown author association, + or an incomplete response fail the entire refresh closed. Partial records are + never ranked as if missing values were zero. + +## Ownership and staged delivery + +H1-H3 own dedicated issue retrieval, normalization and ranking modules plus +their tests and this document. They do not edit the Store, runner, shared web +shell, package files or CI. H4 waits for G4 to release shared web files and will +record explicit trust decisions through the then-current storage owner. + +H1 is complete when this policy and access inspection are committed. H2/H3 are +complete when dedicated tests prove normalized retrieval, deterministic reasons, +stable tie-breaking, trust classification, bounded failure, and stale versus +unavailable states, and `npm run typecheck` passes. diff --git a/github/issues.ts b/github/issues.ts new file mode 100644 index 00000000..e77ca60c --- /dev/null +++ b/github/issues.ts @@ -0,0 +1,198 @@ +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; + +const runFile = promisify(execFile); +const PAGE_SIZE = 100; +const MAX_PAGES = 10; +const MAX_ISSUES = PAGE_SIZE * MAX_PAGES; +const MAX_BODY_LENGTH = 65_536; +// Covers one bounded 100-record page, including JSON-escaped bodies, labels and response overhead. +export const ISSUE_PAGE_MAX_BYTES = 64 * 1024 * 1024; + +export type IssueAuthorAssociation = + | 'OWNER' | 'MEMBER' | 'COLLABORATOR' | 'CONTRIBUTOR' + | 'FIRST_TIMER' | 'FIRST_TIME_CONTRIBUTOR' | 'MANNEQUIN' | 'NONE'; + +export interface RepositoryIssue { + readonly repository: string; + readonly number: number; + readonly title: string; + readonly body: string; + readonly url: string; + readonly createdAt: string; + readonly updatedAt: string; + readonly comments: number; + readonly positiveReactions: number; + readonly labels: readonly string[]; + readonly authorAssociation: IssueAuthorAssociation; + readonly trust: 'trusted' | 'requires-approval'; +} + +export interface IssueSnapshot { + readonly repository: string; + readonly retrievedAt: string; + readonly issues: readonly RepositoryIssue[]; +} + +export interface IssueGateway { + readonly repository: string; + fetch(options?: { signal?: AbortSignal; timeoutMs?: number }): Promise; +} + +type RunGh = (args: readonly string[], options?: { signal?: AbortSignal }) => Promise; + +const associations = new Set([ + 'OWNER', 'MEMBER', 'COLLABORATOR', 'CONTRIBUTOR', 'FIRST_TIMER', + 'FIRST_TIME_CONTRIBUTOR', 'MANNEQUIN', 'NONE', +]); + +function object(value: unknown, message: string): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error(message); + return value as Record; +} + +function boundedString(value: unknown, field: string, maximum: number, nullable = false): string { + if (nullable && value === null) return ''; + if (typeof value !== 'string' || value.length > maximum) throw new Error(`GitHub returned an invalid issue ${field}.`); + return value; +} + +function count(value: unknown, field: string): number { + if (!Number.isSafeInteger(value) || (value as number) < 0) throw new Error(`GitHub returned an invalid issue ${field}.`); + return value as number; +} + +function repositoryName(value: string): boolean { + if (value.length > 201 || !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(value)) return false; + return value.split('/').every(part => part !== '.' && part !== '..' && part.length <= 100); +} + +function sameGithubUrl(value: unknown, expected: string, field: string): string { + const url = boundedString(value, field, 2048); + if (url.toLowerCase() !== expected.toLowerCase()) throw new Error(`GitHub returned an invalid issue ${field}.`); + return url; +} + +function timestamp(value: unknown, field: string): string { + const text = boundedString(value, field, 64); + const parsed = Date.parse(text); + const canonical = text.includes('.') ? text : text.replace('Z', '.000Z'); + if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z$/.test(text) + || !Number.isFinite(parsed) || new Date(parsed).toISOString() !== canonical) + throw new Error(`GitHub returned an invalid issue ${field}.`); + return text; +} + +function normalizeIssue(repository: string, value: unknown): RepositoryIssue | null { + const issue = object(value, 'GitHub returned a malformed issue.'); + if (!Number.isSafeInteger(issue.number) || (issue.number as number) < 1) throw new Error('GitHub returned an invalid issue number.'); + const number = issue.number as number; + if (Object.hasOwn(issue, 'pull_request')) { + const marker = object(issue.pull_request, 'GitHub returned an invalid pull request marker.'); + sameGithubUrl(marker.url, `https://api.github.com/repos/${repository}/pulls/${number}`, 'pull request marker'); + return null; + } + if (issue.state !== 'open') throw new Error('GitHub returned a non-open issue.'); + const url = sameGithubUrl(issue.html_url, `https://github.com/${repository}/issues/${number}`, 'URL'); + const createdAt = timestamp(issue.created_at, 'creation time'); + const updatedAt = timestamp(issue.updated_at, 'update time'); + if (Date.parse(updatedAt) < Date.parse(createdAt)) throw new Error('GitHub returned an issue update before its creation.'); + if (!Array.isArray(issue.labels) || issue.labels.length > 100) throw new Error('GitHub returned invalid issue labels.'); + const labels = issue.labels.map(label => { + const name = boundedString(object(label, 'GitHub returned an invalid issue label.').name, 'label', 100); + if (!name.trim()) throw new Error('GitHub returned an empty issue label.'); + return name; + }); + if (new Set(labels.map(label => label.toLocaleLowerCase('en-US'))).size !== labels.length) + throw new Error('GitHub returned duplicate issue labels.'); + const reactionData = object(issue.reactions, 'GitHub returned invalid issue reactions.'); + const positiveReactions = ['+1', 'heart', 'hooray', 'rocket'] + .reduce((total, key) => total + count(reactionData[key], `${key} reactions`), 0); + if (!Number.isSafeInteger(positiveReactions)) throw new Error('GitHub returned an invalid positive reaction count.'); + const authorAssociation = issue.author_association; + if (typeof authorAssociation !== 'string' || !associations.has(authorAssociation as IssueAuthorAssociation)) + throw new Error('GitHub returned an unknown issue author association.'); + const association = authorAssociation as IssueAuthorAssociation; + const title = boundedString(issue.title, 'title', 4096); + if (!title.trim()) throw new Error('GitHub returned an empty issue title.'); + return { + repository, + number, + title, + body: boundedString(issue.body, 'body', MAX_BODY_LENGTH, true), + url, + createdAt, + updatedAt, + comments: count(issue.comments, 'comment count'), + positiveReactions, + labels, + authorAssociation: association, + trust: ['OWNER', 'MEMBER', 'COLLABORATOR'].includes(association) ? 'trusted' : 'requires-approval', + }; +} + +/** Read-only GitHub CLI adapter. Issue content is returned only as data. */ +export class GhIssueGateway implements IssueGateway { + readonly repository: string; + readonly run: RunGh; + readonly now: () => Date; + + constructor(repository: string, run?: RunGh, now: () => Date = () => new Date()) { + if (!repositoryName(repository)) throw new Error('A GitHub repository is required for issue retrieval.'); + this.repository = repository; + this.run = run ?? (async (args, options) => (await runFile('gh', [...args], { + maxBuffer: ISSUE_PAGE_MAX_BYTES, + signal: options?.signal, + })).stdout); + this.now = now; + } + + async #load(signal: AbortSignal): Promise { + const issues: RepositoryIssue[] = []; + const numbers = new Set(); + for (let page = 1; page <= MAX_PAGES; page++) { + const output = await this.run([ + 'api', '--method', 'GET', '-H', 'Accept: application/vnd.github+json', + `repos/${this.repository}/issues`, '-f', 'state=open', '-f', `per_page=${PAGE_SIZE}`, '-f', `page=${page}`, + ], { signal }); + let decoded: unknown; + try { decoded = JSON.parse(output); } + catch { throw new Error('GitHub returned invalid issue JSON.'); } + if (!Array.isArray(decoded)) throw new Error('GitHub returned an invalid issue page.'); + if (decoded.length > PAGE_SIZE) throw new Error('GitHub returned an oversized issue page.'); + for (const value of decoded) { + const issue = normalizeIssue(this.repository, value); + if (!issue) continue; + if (numbers.has(issue.number)) throw new Error('GitHub returned a duplicate issue.'); + numbers.add(issue.number); + issues.push(issue); + } + if (decoded.length < PAGE_SIZE) return issues; + } + throw new Error(`Issue retrieval exceeded the ${MAX_ISSUES}-record safety limit.`); + } + + async fetch(options: { signal?: AbortSignal; timeoutMs?: number } = {}): Promise { + const timeoutMs = options.timeoutMs ?? 12_000; + if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 30_000) throw new Error('Invalid issue retrieval timeout.'); + options.signal?.throwIfAborted(); + const controller = new AbortController(); + const relay = () => controller.abort(options.signal?.reason); + options.signal?.addEventListener('abort', relay, { once: true }); + const timer = setTimeout(() => controller.abort(new Error('Issue retrieval timed out.')), timeoutMs); + try { + const issues = await this.#load(controller.signal); + controller.signal.throwIfAborted(); + const retrievedAt = this.now(); + if (!Number.isFinite(retrievedAt.getTime())) throw new Error('Issue retrieval clock is invalid.'); + return { repository: this.repository, retrievedAt: retrievedAt.toISOString(), issues }; + } catch (error) { + if (options.signal?.aborted) throw options.signal.reason; + if (controller.signal.aborted) throw new Error('Issue retrieval timed out.'); + throw error; + } finally { + clearTimeout(timer); + options.signal?.removeEventListener('abort', relay); + } + } +} diff --git a/test/issue-ranking.test.ts b/test/issue-ranking.test.ts new file mode 100644 index 00000000..59a7708a --- /dev/null +++ b/test/issue-ranking.test.ts @@ -0,0 +1,210 @@ +import { describe, expect, it } from 'vitest'; +import { IssuePrioritizer, rankIssues } from '../core/issue-ranking.ts'; +import type { IssueGateway, RepositoryIssue } from '../github/issues.ts'; + +const issue = (number: number, overrides: Partial = {}): RepositoryIssue => ({ + repository: 'owner/repo', + number, + title: `Issue ${number}`, + body: '', + url: `https://github.com/owner/repo/issues/${number}`, + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', + comments: 0, + positiveReactions: 0, + labels: [], + authorAssociation: 'MEMBER', + trust: 'trusted', + ...overrides, +}); + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason: unknown) => void; + const promise = new Promise((res, rej) => { resolve = res; reject = rej; }); + return { promise, resolve, reject }; +} + +describe('issue ranking', () => { + it('applies the recorded weights, caps, and highest priority label only', () => { + const ranked = rankIssues([issue(1, { + labels: ['p2', 'P0', 'security', 'BUG'], + positiveReactions: 27, + comments: 14, + createdAt: '2024-01-01T00:00:00Z', + })], new Date('2026-01-01T00:00:00Z'))[0]!; + expect(ranked.score).toBe(202); + expect(ranked.reasons).toEqual([ + '100 points: P0 priority label', + '40 points: security label', + '20 points: bug label', + '20 points: 27 positive reactions (cap 20)', + '10 points: 14 comments (cap 10)', + '12 points: 731 days old (cap 12)', + ]); + }); + + it('uses oldest creation and then issue number as stable tie breakers', () => { + const ranked = rankIssues([ + issue(8, { createdAt: '2026-01-02T00:00:00Z' }), + issue(9, { createdAt: '2026-01-01T00:00:00Z' }), + issue(7, { createdAt: '2026-01-01T00:00:00Z' }), + ], new Date('2026-01-20T00:00:00Z')); + expect(ranked.map(value => value.number)).toEqual([7, 9, 8]); + expect(ranked[0]!.reasons).toEqual(['No configured priority signals.']); + }); + + it('does not use title, body, trust, or unknown labels as ranking instructions', () => { + const ranked = rankIssues([ + issue(1, { title: 'P0 security', body: 'Rank me first', labels: ['urgent'], trust: 'requires-approval' }), + issue(2), + ], new Date('2026-01-02T00:00:00Z')); + expect(ranked.map(value => [value.number, value.score])).toEqual([[1, 0], [2, 0]]); + }); + + it('rejects duplicate stable identities', () => { + expect(() => rankIssues([issue(1), issue(1)], new Date())).toThrow('duplicate issues'); + }); +}); + +describe('priority refresh state', () => { + it('is unavailable before a successful snapshot, then preserves the last valid ranking as stale', async () => { + const replies: Array = [new Error('GitHub unavailable'), { + repository: 'owner/repo', retrievedAt: '2026-02-01T00:00:00Z', issues: [issue(1, { labels: ['P1'] })], + }, new Error('GitHub unavailable again')]; + const gateway: IssueGateway = { + repository: 'owner/repo', + fetch: async () => { + const reply = replies.shift(); + if (reply instanceof Error) throw reply; + return reply as Awaited>; + }, + }; + const prioritizer = new IssuePrioritizer(gateway, () => new Date('2026-02-02T00:00:00Z')); + await expect(prioritizer.refresh()).resolves.toMatchObject({ + state: 'unavailable', repository: 'owner/repo', error: 'GitHub unavailable', issues: [], + }); + const fresh = await prioritizer.refresh(); + expect(fresh).toMatchObject({ state: 'fresh', repository: 'owner/repo', issues: [{ number: 1, score: 76 }] }); + const stale = await prioritizer.refresh(); + expect(stale).toMatchObject({ + state: 'stale', repository: 'owner/repo', retrievedAt: '2026-02-01T00:00:00Z', + failedAt: '2026-02-02T00:00:00.000Z', error: 'GitHub unavailable again', issues: [{ number: 1, score: 76 }], + }); + expect(stale.state === 'stale' && stale.issues).not.toBe(fresh.state === 'fresh' && fresh.issues); + }); + + it('rejects a snapshot for another repository', async () => { + const gateway: IssueGateway = { + repository: 'owner/repo', + fetch: async () => ({ repository: 'other/repo', retrievedAt: '2026-01-01T00:00:00Z', issues: [] }), + }; + await expect(new IssuePrioritizer(gateway, () => new Date('2026-01-02T00:00:00Z')).refresh()).resolves.toMatchObject({ + state: 'unavailable', error: 'Issue snapshot repository mismatch.', + }); + }); + + it('rejects issues that do not belong to the snapshot repository', async () => { + const gateway: IssueGateway = { + repository: 'owner/repo', + fetch: async () => ({ + repository: 'owner/repo', retrievedAt: '2026-01-01T00:00:00Z', + issues: [issue(1, { repository: 'other/repo' })], + }), + }; + await expect(new IssuePrioritizer(gateway).refresh()).resolves.toMatchObject({ + state: 'unavailable', error: 'Issue snapshot contains an issue from another repository.', + }); + }); + + it('does not expose the cached fresh ranking to caller mutation', async () => { + let succeeds = true; + const gateway: IssueGateway = { + repository: 'owner/repo', + fetch: async () => { + if (!succeeds) throw new Error('offline'); + return { repository: 'owner/repo', retrievedAt: '2026-02-01T00:00:00Z', issues: [issue(1, { labels: ['P1'] })] }; + }, + }; + const prioritizer = new IssuePrioritizer(gateway, () => new Date('2026-02-02T00:00:00Z')); + const fresh = await prioritizer.refresh(); + if (fresh.state !== 'fresh') throw new Error('Expected a fresh ranking.'); + Reflect.set(fresh.issues[0]!, 'score', 0); + succeeds = false; + await expect(prioritizer.refresh()).resolves.toMatchObject({ state: 'stale', issues: [{ score: 76 }] }); + }); + + it('preserves explicit caller cancellation instead of converting it to availability state', async () => { + const controller = new AbortController(); + const cancelled = new Error('Stopped by caller.'); + const gateway: IssueGateway = { + repository: 'owner/repo', + fetch: async ({ signal } = {}) => new Promise((_resolve, reject) => { + signal?.addEventListener('abort', () => reject(signal.reason), { once: true }); + }), + }; + const pending = new IssuePrioritizer(gateway).refresh({ signal: controller.signal }); + controller.abort(cancelled); + await expect(pending).rejects.toBe(cancelled); + }); + + it('rechecks cancellation after fetch settles and does not cache the cancelled snapshot', async () => { + const controller = new AbortController(); + const cancelled = new Error('Stopped while fetch settled.'); + let attempt = 0; + const gateway: IssueGateway = { + repository: 'owner/repo', + fetch: async () => { + attempt++; + if (attempt === 1) { + controller.abort(cancelled); + return { repository: 'owner/repo', retrievedAt: '2026-02-01T00:00:00Z', issues: [issue(1)] }; + } + throw new Error('offline'); + }, + }; + const prioritizer = new IssuePrioritizer(gateway, () => new Date('2026-02-02T00:00:00Z')); + await expect(prioritizer.refresh({ signal: controller.signal })).rejects.toBe(cancelled); + await expect(prioritizer.refresh()).resolves.toMatchObject({ state: 'unavailable', issues: [] }); + }); + + it('rejects a late successful refresh and preserves the newer snapshot', async () => { + const older = deferred>>(); + const newer = deferred>>(); + let attempt = 0; + const gateway: IssueGateway = { + repository: 'owner/repo', + fetch: async () => { + attempt++; + if (attempt === 1) return older.promise; + if (attempt === 2) return newer.promise; + throw new Error('offline'); + }, + }; + const prioritizer = new IssuePrioritizer(gateway, () => new Date('2026-02-03T00:00:00Z')); + const first = prioritizer.refresh(); + const second = prioritizer.refresh(); + newer.resolve({ repository: 'owner/repo', retrievedAt: '2026-02-02T00:00:00Z', issues: [issue(2, { labels: ['P0'] })] }); + await expect(second).resolves.toMatchObject({ state: 'fresh', issues: [{ number: 2, score: 101 }] }); + older.resolve({ repository: 'owner/repo', retrievedAt: '2026-02-01T00:00:00Z', issues: [issue(1, { labels: ['P3'] })] }); + await expect(first).rejects.toThrow('superseded'); + await expect(prioritizer.refresh()).resolves.toMatchObject({ state: 'stale', issues: [{ number: 2, score: 101 }] }); + }); + + it('rejects a late failed refresh instead of returning stale state over a newer result', async () => { + const older = deferred>>(); + const newer = deferred>>(); + let attempt = 0; + const gateway: IssueGateway = { + repository: 'owner/repo', + fetch: async () => (++attempt === 1 ? older.promise : newer.promise), + }; + const prioritizer = new IssuePrioritizer(gateway); + const first = prioritizer.refresh(); + const second = prioritizer.refresh(); + newer.resolve({ repository: 'owner/repo', retrievedAt: '2026-02-02T00:00:00Z', issues: [issue(2)] }); + await expect(second).resolves.toMatchObject({ state: 'fresh', issues: [{ number: 2 }] }); + older.reject(new Error('older request failed')); + await expect(first).rejects.toThrow('superseded'); + }); +}); diff --git a/test/issues.test.ts b/test/issues.test.ts new file mode 100644 index 00000000..0b3f67ba --- /dev/null +++ b/test/issues.test.ts @@ -0,0 +1,150 @@ +import { describe, expect, it, vi } from 'vitest'; +import { GhIssueGateway, ISSUE_PAGE_MAX_BYTES } from '../github/issues.ts'; + +const rawIssue = (overrides: Record = {}) => ({ + number: 7, + title: 'Fix retries', + body: 'Keep issue text as data.', + html_url: 'https://github.com/owner/repo/issues/7', + state: 'open', + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-02T00:00:00Z', + comments: 3, + author_association: 'MEMBER', + labels: [{ name: 'bug' }, { name: 'P1' }], + reactions: { '+1': 2, heart: 1, hooray: 0, rocket: 1 }, + ...overrides, +}); + +describe('GitHub issue retrieval', () => { + it.each(['./repo', '../repo', 'owner/..'])('rejects unsafe repository identity %s', repository => { + expect(() => new GhIssueGateway(repository)).toThrow('GitHub repository'); + }); + + it('uses literal read-only pagination arguments and normalizes trusted issues', async () => { + const calls: readonly string[][] = []; + const run = vi.fn(async (args: readonly string[]) => { + (calls as string[][]).push([...args]); + return JSON.stringify([rawIssue()]); + }); + const snapshot = await new GhIssueGateway('owner/repo', run, () => new Date('2026-02-01T00:00:00Z')).fetch(); + expect(calls).toEqual([[ + 'api', '--method', 'GET', '-H', 'Accept: application/vnd.github+json', + 'repos/owner/repo/issues', '-f', 'state=open', '-f', 'per_page=100', '-f', 'page=1', + ]]); + expect(snapshot).toEqual({ + repository: 'owner/repo', + retrievedAt: '2026-02-01T00:00:00.000Z', + issues: [{ + repository: 'owner/repo', number: 7, title: 'Fix retries', body: 'Keep issue text as data.', + url: 'https://github.com/owner/repo/issues/7', createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-02T00:00:00Z', comments: 3, positiveReactions: 4, + labels: ['bug', 'P1'], authorAssociation: 'MEMBER', trust: 'trusted', + }], + }); + }); + + it('excludes pull requests and requires approval for outside authors', async () => { + const run = vi.fn(async () => JSON.stringify([ + rawIssue({ pull_request: { url: 'https://api.github.com/repos/owner/repo/pulls/7' } }), + rawIssue({ number: 8, html_url: 'https://github.com/owner/repo/issues/8', author_association: 'CONTRIBUTOR' }), + ])); + const snapshot = await new GhIssueGateway('owner/repo', run).fetch(); + expect(snapshot.issues).toHaveLength(1); + expect(snapshot.issues[0]).toMatchObject({ number: 8, trust: 'requires-approval' }); + }); + + it('accepts canonical URL casing without relaxing repository identity or URL shape', async () => { + const run = vi.fn(async () => JSON.stringify([ + rawIssue(), + rawIssue({ + number: 8, + html_url: 'https://github.com/owner/repo/issues/8', + pull_request: { url: 'https://api.github.com/repos/owner/repo/pulls/8' }, + }), + ])); + const snapshot = await new GhIssueGateway('Owner/Repo', run).fetch(); + expect(snapshot).toMatchObject({ repository: 'Owner/Repo', issues: [{ repository: 'Owner/Repo', number: 7 }] }); + }); + + it('accepts the maximum bounded issue body', async () => { + const gateway = new GhIssueGateway('owner/repo', async () => JSON.stringify([ + rawIssue({ body: 'x'.repeat(65_536) }), + ])); + const snapshot = await gateway.fetch(); + expect(snapshot.issues[0]?.body).toHaveLength(65_536); + }); + + it('budgets for a maximum page of JSON-escaped control-character bodies', () => { + const body = '\0'.repeat(65_536); + const page = Array.from({ length: 100 }, (_, index) => rawIssue({ + number: index + 1, + html_url: `https://github.com/owner/repo/issues/${index + 1}`, + body, + })); + const serializedBytes = Buffer.byteLength(JSON.stringify(page)); + expect(serializedBytes).toBeGreaterThan(32 * 1024 * 1024); + expect(serializedBytes).toBeLessThan(ISSUE_PAGE_MAX_BYTES); + }); + + it.each([ + ['repository URL', { html_url: 'https://github.com/other/repo/issues/7' }], + ['state', { state: 'closed' }], + ['pull request marker', { pull_request: {} }], + ['author association', { author_association: 'UNKNOWN' }], + ['title', { title: ' ' }], + ['body length', { body: 'x'.repeat(65_537) }], + ['comments', { comments: -1 }], + ['reactions', { reactions: { '+1': 0, heart: 0, hooray: 0 } }], + ['labels', { labels: [{ name: 'bug' }, { name: 'BUG' }] }], + ['timestamps', { updated_at: '2025-01-01T00:00:00Z' }], + ['calendar timestamp', { created_at: '2026-02-31T00:00:00Z' }], + ])('fails the complete refresh on invalid %s', async (_label, overrides) => { + const gateway = new GhIssueGateway('owner/repo', async () => JSON.stringify([rawIssue(overrides)])); + await expect(gateway.fetch()).rejects.toThrow(/GitHub returned/); + }); + + it('rejects duplicate records across pages', async () => { + let page = 0; + const gateway = new GhIssueGateway('owner/repo', async () => { + page++; + return JSON.stringify(page === 1 + ? Array.from({ length: 100 }, (_, index) => rawIssue({ number: index + 1, html_url: `https://github.com/owner/repo/issues/${index + 1}` })) + : [rawIssue({ number: 1, html_url: 'https://github.com/owner/repo/issues/1' })]); + }); + await expect(gateway.fetch()).rejects.toThrow('duplicate issue'); + }); + + it('fails closed when the bounded page limit is exhausted', async () => { + const gateway = new GhIssueGateway('owner/repo', async args => { + const page = Number(args.at(-1)?.split('=')[1]); + return JSON.stringify(Array.from({ length: 100 }, (_, index) => { + const number = (page - 1) * 100 + index + 1; + return rawIssue({ number, html_url: `https://github.com/owner/repo/issues/${number}` }); + })); + }); + await expect(gateway.fetch()).rejects.toThrow('1000-record safety limit'); + }); + + it('preserves caller cancellation and classifies its own deadline', async () => { + const run = vi.fn((_args: readonly string[], options?: { signal?: AbortSignal }) => new Promise((_resolve, reject) => { + options?.signal?.addEventListener('abort', () => reject(options.signal?.reason), { once: true }); + })); + const controller = new AbortController(); + const cancelled = new Error('Stopped by caller.'); + const first = new GhIssueGateway('owner/repo', run).fetch({ signal: controller.signal }); + controller.abort(cancelled); + await expect(first).rejects.toBe(cancelled); + await expect(new GhIssueGateway('owner/repo', run).fetch({ timeoutMs: 1 })).rejects.toThrow('timed out'); + }); + + it('rechecks caller cancellation after a runner returns successfully', async () => { + const controller = new AbortController(); + const cancelled = new Error('Stopped while the response settled.'); + const gateway = new GhIssueGateway('owner/repo', async () => { + controller.abort(cancelled); + return JSON.stringify([rawIssue()]); + }); + await expect(gateway.fetch({ signal: controller.signal })).rejects.toBe(cancelled); + }); +});