-
Notifications
You must be signed in to change notification settings - Fork 0
H1-H3: add deterministic issue prioritization backend #39
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
7 commits
Select commit
Hold shift + click to select a range
6031a50
docs: decide issue ranking policy
mchwang 09d132c
Add deterministic issue prioritization backend
mchwang 508d8a8
Preserve cancellation and bound issue payloads
mchwang 54e6105
Guard issue refresh publication by generation
mchwang 5f9d83f
Document external payload budget invariant
mchwang e6244a6
Cover escaped issue page payloads
mchwang 5f73c0b
Accept canonical GitHub repository casing
mchwang File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<string>(); | ||
| 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<IssuePriorityState, { state: 'fresh' }> | 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<IssuePriorityState> { | ||
| 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<IssuePriorityState, { state: 'fresh' }> = { | ||
| 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) }; | ||
|
mchwang marked this conversation as resolved.
|
||
| 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), | ||
| }; | ||
| } | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<IssueSnapshot>; | ||
| } | ||
|
|
||
| type RunGh = (args: readonly string[], options?: { signal?: AbortSignal }) => Promise<string>; | ||
|
|
||
| const associations = new Set<IssueAuthorAssociation>([ | ||
| 'OWNER', 'MEMBER', 'COLLABORATOR', 'CONTRIBUTOR', 'FIRST_TIMER', | ||
| 'FIRST_TIME_CONTRIBUTOR', 'MANNEQUIN', 'NONE', | ||
| ]); | ||
|
|
||
| function object(value: unknown, message: string): Record<string, unknown> { | ||
| if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error(message); | ||
| return value as Record<string, unknown>; | ||
| } | ||
|
|
||
| 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<RepositoryIssue[]> { | ||
| const issues: RepositoryIssue[] = []; | ||
| const numbers = new Set<number>(); | ||
| 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<IssueSnapshot> { | ||
| 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); | ||
|
mchwang marked this conversation as resolved.
|
||
| 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); | ||
| } | ||
| } | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.