GV

GeezerVison

The World Wide Web, restored to 800 pixels of glory.

Home|About|Gaming cabinet|Archives|How it works|Starter sites

On this site

  • Sign in
  • ActionsCI/CD and automation
  • CodespacesCloud development environments
  • All Features
  • Documentation
  • Training
  • Certifications
  • Build with Copilot
  • Customize Copilot
  • Integrations
  • Trending repos
  • GitHub Sponsors
  • Developer articles
  • Enterprises

This view

  • Original page
  • New address

GeezerVison is showing github.com — Fix repository-scoped issue trust (#42) · codeabovelab/codeboost@dbdc917

File tree

Expand file treeCollapse file tree
Open diff view settings
Filter options
  • docs/implementation
    • issue-prioritization.md
  • github
    • issues.ts
  • test
    • issue-ranking.test.ts
    • issues.test.ts
Expand file treeCollapse file tree
Open diff view settings
Collapse file

‎docs/implementation/issue-prioritization.md‎

Copy file name to clipboardExpand all lines: docs/implementation/issue-prioritization.md
+20-8Lines changed: 20 additions & 8 deletions
  • Display the source diff
  • Display the rich diff
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,12 @@
33
Baseline: `0ae71a503592de90926063fa563c1f7c715db22b` (`origin/main`,
44
2026-09-24).
55

6+
Follow-up #41 was reproduced from validated-head baseline
7+
`1e59b7dfda199c8156c09a9cb9a690e5b55f1b5e`: GitHub documents `MEMBER` as
8+
organization membership, while its repository-collaborator endpoint reports
9+
the identities with access to the repository. The trust policy below records
10+
the corrected repository-scoped boundary.
11+
612
## H1 decision: ranking policy
713

814
The first released policy is deterministic, explainable, and independent of an
@@ -26,9 +32,12 @@ Issues sort by descending score, then oldest creation time, then ascending issue
2632
number. Every contributing signal is emitted as a user-visible reason. An issue
2733
with no contributing signal says that it has no configured priority signals.
2834

29-
An issue is trusted by default only when GitHub reports its author association
30-
as `OWNER`, `MEMBER`, or `COLLABORATOR`. Other issues remain visible but require
31-
an explicit trust decision before queueing. Trust affects eligibility, never the
35+
An issue is trusted by default only when its author appears in GitHub's current,
36+
repository-scoped collaborator list. GitHub's `author_association` remains
37+
validated and visible metadata, but does not grant trust: in particular,
38+
`MEMBER` proves organization membership rather than access to this repository.
39+
Issues from deleted or non-collaborating authors remain visible but require an
40+
explicit trust decision before queueing. Trust affects eligibility, never the
3241
score, so an untrusted author cannot improve rank by embedding instructions in
3342
issue text.
3443

@@ -41,14 +50,17 @@ dedicated read-only gateway with these boundaries:
4150

4251
- Codeboost invokes `gh` with literal arguments; issue text is parsed only as
4352
data and is never interpolated into a shell command or prompt.
44-
- The gateway fetches open issues, excludes pull requests, follows bounded
45-
pagination, and validates every field used for normalization or ranking.
53+
- The gateway fetches open issues and the repository's current collaborators,
54+
excludes pull requests, follows bounded pagination for both collections, and
55+
validates every field used for normalization, trust, or ranking. If the
56+
configured GitHub identity cannot read collaborators, the refresh is
57+
unavailable rather than falling back to author-association metadata.
4658
- A complete successful snapshot includes repository identity and a retrieval
4759
timestamp. A subsequent retrieval failure returns an explicit stale snapshot
4860
only when a previously validated snapshot exists; otherwise it is unavailable.
49-
- Malformed fields, an exceeded issue/page limit, an unknown author association,
50-
or an incomplete response fail the entire refresh closed. Partial records are
51-
never ranked as if missing values were zero.
61+
- Malformed fields, an exceeded issue or collaborator limit, an unknown author
62+
association, or an incomplete response fail the entire refresh closed.
63+
Partial records are never ranked as if missing values were zero.
5264

5365
## Ownership and staged delivery
5466

Collapse file

‎github/issues.ts‎

Copy file name to clipboardExpand all lines: github/issues.ts
+51-2Lines changed: 51 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ const runFile = promisify(execFile);
55
const PAGE_SIZE = 100;
66
const MAX_PAGES = 10;
77
const MAX_ISSUES = PAGE_SIZE * MAX_PAGES;
8+
const MAX_COLLABORATORS = PAGE_SIZE * MAX_PAGES;
89
const MAX_BODY_LENGTH = 65_536;
910
// Covers one bounded 100-record page, including JSON-escaped bodies, labels and response overhead.
1011
export const ISSUE_PAGE_MAX_BYTES = 64 * 1024 * 1024;
@@ -24,6 +25,7 @@ export interface RepositoryIssue {
2425
readonly comments: number;
2526
readonly positiveReactions: number;
2627
readonly labels: readonly string[];
28+
readonly authorLogin: string | null;
2729
readonly authorAssociation: IssueAuthorAssociation;
2830
readonly trust: 'trusted' | 'requires-approval';
2931
}
@@ -62,6 +64,12 @@ function count(value: unknown, field: string): number {
6264
return value as number;
6365
}
6466

67+
function login(value: unknown, context: 'issue author' | 'collaborator'): string {
68+
if (typeof value !== 'string' || !value || value.length > 100 || /[\s\u0000-\u001f\u007f]/u.test(value))
69+
throw new Error(`GitHub returned an invalid ${context} login.`);
70+
return value;
71+
}
72+
6573
function repositoryName(value: string): boolean {
6674
if (value.length > 201 || !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(value)) return false;
6775
return value.split('/').every(part => part !== '.' && part !== '..' && part.length <= 100);
@@ -113,6 +121,10 @@ function normalizeIssue(repository: string, value: unknown): RepositoryIssue | n
113121
if (typeof authorAssociation !== 'string' || !associations.has(authorAssociation as IssueAuthorAssociation))
114122
throw new Error('GitHub returned an unknown issue author association.');
115123
const association = authorAssociation as IssueAuthorAssociation;
124+
if (!Object.hasOwn(issue, 'user')) throw new Error('GitHub omitted the issue author.');
125+
const authorLogin = issue.user === null
126+
? null
127+
: login(object(issue.user, 'GitHub returned an invalid issue author.').login, 'issue author');
116128
const title = boundedString(issue.title, 'title', 4096);
117129
if (!title.trim()) throw new Error('GitHub returned an empty issue title.');
118130
return {
@@ -126,8 +138,9 @@ function normalizeIssue(repository: string, value: unknown): RepositoryIssue | n
126138
comments: count(issue.comments, 'comment count'),
127139
positiveReactions,
128140
labels,
141+
authorLogin,
129142
authorAssociation: association,
130-
trust: ['OWNER', 'MEMBER', 'COLLABORATOR'].includes(association) ? 'trusted' : 'requires-approval',
143+
trust: 'requires-approval',
131144
};
132145
}
133146

@@ -147,7 +160,7 @@ export class GhIssueGateway implements IssueGateway {
147160
this.now = now;
148161
}
149162

150-
async #load(signal: AbortSignal): Promise<RepositoryIssue[]> {
163+
async #loadIssues(signal: AbortSignal): Promise<RepositoryIssue[]> {
151164
const issues: RepositoryIssue[] = [];
152165
const numbers = new Set<number>();
153166
for (let page = 1; page <= MAX_PAGES; page++) {
@@ -172,6 +185,42 @@ export class GhIssueGateway implements IssueGateway {
172185
throw new Error(`Issue retrieval exceeded the ${MAX_ISSUES}-record safety limit.`);
173186
}
174187

188+
async #loadCollaborators(signal: AbortSignal): Promise<Set<string>> {
189+
const collaborators = new Set<string>();
190+
for (let page = 1; page <= MAX_PAGES; page++) {
191+
const output = await this.run([
192+
'api', '--method', 'GET', '-H', 'Accept: application/vnd.github+json',
193+
`repos/${this.repository}/collaborators`, '-f', 'affiliation=all',
194+
'-f', `per_page=${PAGE_SIZE}`, '-f', `page=${page}`,
195+
], { signal });
196+
let decoded: unknown;
197+
try { decoded = JSON.parse(output); }
198+
catch { throw new Error('GitHub returned invalid collaborator JSON.'); }
199+
if (!Array.isArray(decoded)) throw new Error('GitHub returned an invalid collaborator page.');
200+
if (decoded.length > PAGE_SIZE) throw new Error('GitHub returned an oversized collaborator page.');
201+
for (const value of decoded) {
202+
const author = object(value, 'GitHub returned an invalid collaborator.');
203+
const key = login(author.login, 'collaborator').toLocaleLowerCase('en-US');
204+
if (collaborators.has(key)) throw new Error('GitHub returned a duplicate collaborator.');
205+
collaborators.add(key);
206+
}
207+
if (decoded.length < PAGE_SIZE) return collaborators;
208+
}
209+
throw new Error(`Collaborator retrieval exceeded the ${MAX_COLLABORATORS}-record safety limit.`);
210+
}
211+
212+
async #load(signal: AbortSignal): Promise<RepositoryIssue[]> {
213+
const issues = await this.#loadIssues(signal);
214+
signal.throwIfAborted();
215+
const collaborators = await this.#loadCollaborators(signal);
216+
return issues.map(issue => ({
217+
...issue,
218+
trust: issue.authorLogin !== null && collaborators.has(issue.authorLogin.toLocaleLowerCase('en-US'))
219+
? 'trusted'
220+
: 'requires-approval',
221+
}));
222+
}
223+
175224
async fetch(options: { signal?: AbortSignal; timeoutMs?: number } = {}): Promise<IssueSnapshot> {
176225
const timeoutMs = options.timeoutMs ?? 12_000;
177226
if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 30_000) throw new Error('Invalid issue retrieval timeout.');
Collapse file

‎test/issue-ranking.test.ts‎

Copy file name to clipboardExpand all lines: test/issue-ranking.test.ts
+1Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ const issue = (number: number, overrides: Partial<RepositoryIssue> = {}): Reposi
1313
comments: 0,
1414
positiveReactions: 0,
1515
labels: [],
16+
authorLogin: 'member',
1617
authorAssociation: 'MEMBER',
1718
trust: 'trusted',
1819
...overrides,
Collapse file

‎test/issues.test.ts‎

Copy file name to clipboardExpand all lines: test/issues.test.ts
+89-6Lines changed: 89 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,38 @@ const rawIssue = (overrides: Record<string, unknown> = {}) => ({
1010
created_at: '2026-01-01T00:00:00Z',
1111
updated_at: '2026-01-02T00:00:00Z',
1212
comments: 3,
13+
user: { login: 'member' },
1314
author_association: 'MEMBER',
1415
labels: [{ name: 'bug' }, { name: 'P1' }],
1516
reactions: { '+1': 2, heart: 1, hooray: 0, rocket: 1 },
1617
...overrides,
1718
});
1819

20+
const isCollaboratorRequest = (args: readonly string[]) =>
21+
args.some(argument => argument.toLocaleLowerCase('en-US').endsWith('/collaborators'));
22+
const responses = (issues: readonly unknown[], collaborators: readonly string[] = ['MEMBER']) =>
23+
async (args: readonly string[]) => JSON.stringify(isCollaboratorRequest(args)
24+
? collaborators.map(login => ({ login }))
25+
: issues);
26+
1927
describe('GitHub issue retrieval', () => {
28+
it.each([
29+
'OWNER',
30+
'MEMBER',
31+
'COLLABORATOR',
32+
'CONTRIBUTOR',
33+
'FIRST_TIMER',
34+
'FIRST_TIME_CONTRIBUTOR',
35+
'MANNEQUIN',
36+
'NONE',
37+
] as const)('classifies the intermediate %s association using current repository membership', async (authorAssociation) => {
38+
const issue = rawIssue({ author_association: authorAssociation });
39+
const trusted = await new GhIssueGateway('owner/repo', responses([issue])).fetch();
40+
const outside = await new GhIssueGateway('owner/repo', responses([issue], [])).fetch();
41+
expect(trusted.issues[0]).toMatchObject({ authorAssociation, authorLogin: 'member', trust: 'trusted' });
42+
expect(outside.issues[0]).toMatchObject({ authorAssociation, authorLogin: 'member', trust: 'requires-approval' });
43+
});
44+
2045
it.each(['./repo', '../repo', 'owner/..'])('rejects unsafe repository identity %s', repository => {
2146
expect(() => new GhIssueGateway(repository)).toThrow('GitHub repository');
2247
});
@@ -25,12 +50,15 @@ describe('GitHub issue retrieval', () => {
2550
const calls: readonly string[][] = [];
2651
const run = vi.fn(async (args: readonly string[]) => {
2752
(calls as string[][]).push([...args]);
28-
return JSON.stringify([rawIssue()]);
53+
return responses([rawIssue()])(args);
2954
});
3055
const snapshot = await new GhIssueGateway('owner/repo', run, () => new Date('2026-02-01T00:00:00Z')).fetch();
3156
expect(calls).toEqual([[
3257
'api', '--method', 'GET', '-H', 'Accept: application/vnd.github+json',
3358
'repos/owner/repo/issues', '-f', 'state=open', '-f', 'per_page=100', '-f', 'page=1',
59+
], [
60+
'api', '--method', 'GET', '-H', 'Accept: application/vnd.github+json',
61+
'repos/owner/repo/collaborators', '-f', 'affiliation=all', '-f', 'per_page=100', '-f', 'page=1',
3462
]]);
3563
expect(snapshot).toEqual({
3664
repository: 'owner/repo',
@@ -39,23 +67,23 @@ describe('GitHub issue retrieval', () => {
3967
repository: 'owner/repo', number: 7, title: 'Fix retries', body: 'Keep issue text as data.',
4068
url: 'https://github.com/owner/repo/issues/7', createdAt: '2026-01-01T00:00:00Z',
4169
updatedAt: '2026-01-02T00:00:00Z', comments: 3, positiveReactions: 4,
42-
labels: ['bug', 'P1'], authorAssociation: 'MEMBER', trust: 'trusted',
70+
labels: ['bug', 'P1'], authorLogin: 'member', authorAssociation: 'MEMBER', trust: 'trusted',
4371
}],
4472
});
4573
});
4674

4775
it('excludes pull requests and requires approval for outside authors', async () => {
48-
const run = vi.fn(async () => JSON.stringify([
76+
const run = vi.fn(responses([
4977
rawIssue({ pull_request: { url: 'https://api.github.com/repos/owner/repo/pulls/7' } }),
5078
rawIssue({ number: 8, html_url: 'https://github.com/owner/repo/issues/8', author_association: 'CONTRIBUTOR' }),
51-
]));
79+
], []));
5280
const snapshot = await new GhIssueGateway('owner/repo', run).fetch();
5381
expect(snapshot.issues).toHaveLength(1);
5482
expect(snapshot.issues[0]).toMatchObject({ number: 8, trust: 'requires-approval' });
5583
});
5684

5785
it('accepts canonical URL casing without relaxing repository identity or URL shape', async () => {
58-
const run = vi.fn(async () => JSON.stringify([
86+
const run = vi.fn(responses([
5987
rawIssue(),
6088
rawIssue({
6189
number: 8,
@@ -68,13 +96,36 @@ describe('GitHub issue retrieval', () => {
6896
});
6997

7098
it('accepts the maximum bounded issue body', async () => {
71-
const gateway = new GhIssueGateway('owner/repo', async () => JSON.stringify([
99+
const gateway = new GhIssueGateway('owner/repo', responses([
72100
rawIssue({ body: 'x'.repeat(65_536) }),
73101
]));
74102
const snapshot = await gateway.fetch();
75103
expect(snapshot.issues[0]?.body).toHaveLength(65_536);
76104
});
77105

106+
it('requires approval when GitHub explicitly reports a deleted author', async () => {
107+
const snapshot = await new GhIssueGateway('owner/repo', responses([
108+
rawIssue({ user: null, author_association: 'OWNER' }),
109+
])).fetch();
110+
expect(snapshot.issues[0]).toMatchObject({ authorLogin: null, trust: 'requires-approval' });
111+
});
112+
113+
it('fails closed on collaborator-access failure even when every author is deleted', async () => {
114+
const gateway = new GhIssueGateway('owner/repo', async args => {
115+
if (isCollaboratorRequest(args)) throw new Error('Collaborators unavailable.');
116+
return JSON.stringify([rawIssue({ user: null, author_association: 'OWNER' })]);
117+
});
118+
await expect(gateway.fetch()).rejects.toThrow('Collaborators unavailable.');
119+
});
120+
121+
it('fails closed on collaborator-access failure even when no issues are open', async () => {
122+
const gateway = new GhIssueGateway('owner/repo', async args => {
123+
if (isCollaboratorRequest(args)) throw new Error('Collaborators unavailable.');
124+
return '[]';
125+
});
126+
await expect(gateway.fetch()).rejects.toThrow('Collaborators unavailable.');
127+
});
128+
78129
it('budgets for a maximum page of JSON-escaped control-character bodies', () => {
79130
const body = '\0'.repeat(65_536);
80131
const page = Array.from({ length: 100 }, (_, index) => rawIssue({
@@ -92,6 +143,8 @@ describe('GitHub issue retrieval', () => {
92143
['state', { state: 'closed' }],
93144
['pull request marker', { pull_request: {} }],
94145
['author association', { author_association: 'UNKNOWN' }],
146+
['author object', { user: 42 }],
147+
['author login', { user: { login: '' } }],
95148
['title', { title: ' ' }],
96149
['body length', { body: 'x'.repeat(65_537) }],
97150
['comments', { comments: -1 }],
@@ -104,6 +157,36 @@ describe('GitHub issue retrieval', () => {
104157
await expect(gateway.fetch()).rejects.toThrow(/GitHub returned/);
105158
});
106159

160+
it('fails closed when collaborator pagination reaches its bounded limit', async () => {
161+
const gateway = new GhIssueGateway('owner/repo', async args => {
162+
if (!isCollaboratorRequest(args)) return JSON.stringify([rawIssue()]);
163+
const page = Number(args.at(-1)?.split('=')[1]);
164+
return JSON.stringify(Array.from({ length: 100 }, (_, index) => ({
165+
login: `member-${(page - 1) * 100 + index + 1}`,
166+
})));
167+
});
168+
await expect(gateway.fetch()).rejects.toThrow('Collaborator retrieval exceeded the 1000-record safety limit');
169+
});
170+
171+
it('distinguishes an omitted author from an explicitly deleted author', async () => {
172+
const issue: Record<string, unknown> = rawIssue();
173+
delete issue.user;
174+
await expect(new GhIssueGateway('owner/repo', responses([issue])).fetch())
175+
.rejects.toThrow('omitted the issue author');
176+
});
177+
178+
it.each([
179+
['page shape', {}],
180+
['record shape', [null]],
181+
['login', [{ login: '' }]],
182+
['duplicate', [{ login: 'member' }, { login: 'MEMBER' }]],
183+
])('fails closed on invalid collaborator %s', async (_label, collaboratorPage) => {
184+
const gateway = new GhIssueGateway('owner/repo', async args => JSON.stringify(
185+
isCollaboratorRequest(args) ? collaboratorPage : [rawIssue()],
186+
));
187+
await expect(gateway.fetch()).rejects.toThrow(/GitHub returned/);
188+
});
189+
107190
it('rejects duplicate records across pages', async () => {
108191
let page = 0;
109192
const gateway = new GhIssueGateway('owner/repo', async () => {

0 commit comments

Comments
0 (0)
You are visitor 01402520 — GeezerVison does not store the pages you look up.
Best viewed with Netscape Navigator 4.7 or Internet Explorer 6 at 800×600. No cookies required.