Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
113 changes: 113 additions & 0 deletions core/issue-ranking.ts
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);
Comment thread
mchwang marked this conversation as resolved.
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) };
Comment thread
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),
};
}
}
}
63 changes: 63 additions & 0 deletions docs/implementation/issue-prioritization.md
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.
198 changes: 198 additions & 0 deletions github/issues.ts
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);
Comment thread
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);
}
}
}
Loading
Loading