Skip to content

Commit 508d8a8

Browse files
committed
Preserve cancellation and bound issue payloads
1 parent 09d132c commit 508d8a8

4 files changed

Lines changed: 47 additions & 2 deletions

File tree

‎core/issue-ranking.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,8 +72,10 @@ export class IssuePrioritizer {
7272
}
7373

7474
async refresh(options: { signal?: AbortSignal; timeoutMs?: number } = {}): Promise<IssuePriorityState> {
75+
options.signal?.throwIfAborted();
7576
try {
7677
const snapshot: IssueSnapshot = await this.gateway.fetch(options);
78+
options.signal?.throwIfAborted();
7779
if (snapshot.repository !== this.gateway.repository) throw new Error('Issue snapshot repository mismatch.');
7880
if (snapshot.issues.some(issue => issue.repository !== snapshot.repository))
7981
throw new Error('Issue snapshot contains an issue from another repository.');

‎github/issues.ts‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@ 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_BODY_LENGTH = 65_536;
9+
// Covers one bounded 100-record page, including multi-byte bodies, labels and JSON overhead.
10+
const MAX_PAGE_BYTES = 32 * 1024 * 1024;
811

912
export type IssueAuthorAssociation =
1013
| 'OWNER' | 'MEMBER' | 'COLLABORATOR' | 'CONTRIBUTOR'
@@ -112,7 +115,7 @@ function normalizeIssue(repository: string, value: unknown): RepositoryIssue | n
112115
repository,
113116
number,
114117
title,
115-
body: boundedString(issue.body, 'body', 1_000_000, true),
118+
body: boundedString(issue.body, 'body', MAX_BODY_LENGTH, true),
116119
url,
117120
createdAt,
118121
updatedAt,
@@ -134,7 +137,7 @@ export class GhIssueGateway implements IssueGateway {
134137
if (!repositoryName(repository)) throw new Error('A GitHub repository is required for issue retrieval.');
135138
this.repository = repository;
136139
this.run = run ?? (async (args, options) => (await runFile('gh', [...args], {
137-
maxBuffer: 8 * 1024 * 1024,
140+
maxBuffer: MAX_PAGE_BYTES,
138141
signal: options?.signal,
139142
})).stdout);
140143
this.now = now;
@@ -175,6 +178,7 @@ export class GhIssueGateway implements IssueGateway {
175178
const timer = setTimeout(() => controller.abort(new Error('Issue retrieval timed out.')), timeoutMs);
176179
try {
177180
const issues = await this.#load(controller.signal);
181+
controller.signal.throwIfAborted();
178182
const retrievedAt = this.now();
179183
if (!Number.isFinite(retrievedAt.getTime())) throw new Error('Issue retrieval clock is invalid.');
180184
return { repository: this.repository, retrievedAt: retrievedAt.toISOString(), issues };

‎test/issue-ranking.test.ts‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,4 +140,24 @@ describe('priority refresh state', () => {
140140
controller.abort(cancelled);
141141
await expect(pending).rejects.toBe(cancelled);
142142
});
143+
144+
it('rechecks cancellation after fetch settles and does not cache the cancelled snapshot', async () => {
145+
const controller = new AbortController();
146+
const cancelled = new Error('Stopped while fetch settled.');
147+
let attempt = 0;
148+
const gateway: IssueGateway = {
149+
repository: 'owner/repo',
150+
fetch: async () => {
151+
attempt++;
152+
if (attempt === 1) {
153+
controller.abort(cancelled);
154+
return { repository: 'owner/repo', retrievedAt: '2026-02-01T00:00:00Z', issues: [issue(1)] };
155+
}
156+
throw new Error('offline');
157+
},
158+
};
159+
const prioritizer = new IssuePrioritizer(gateway, () => new Date('2026-02-02T00:00:00Z'));
160+
await expect(prioritizer.refresh({ signal: controller.signal })).rejects.toBe(cancelled);
161+
await expect(prioritizer.refresh()).resolves.toMatchObject({ state: 'unavailable', issues: [] });
162+
});
143163
});

‎test/issues.test.ts‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,12 +54,21 @@ describe('GitHub issue retrieval', () => {
5454
expect(snapshot.issues[0]).toMatchObject({ number: 8, trust: 'requires-approval' });
5555
});
5656

57+
it('accepts the maximum bounded issue body', async () => {
58+
const gateway = new GhIssueGateway('owner/repo', async () => JSON.stringify([
59+
rawIssue({ body: 'x'.repeat(65_536) }),
60+
]));
61+
const snapshot = await gateway.fetch();
62+
expect(snapshot.issues[0]?.body).toHaveLength(65_536);
63+
});
64+
5765
it.each([
5866
['repository URL', { html_url: 'https://github.com/other/repo/issues/7' }],
5967
['state', { state: 'closed' }],
6068
['pull request marker', { pull_request: {} }],
6169
['author association', { author_association: 'UNKNOWN' }],
6270
['title', { title: ' ' }],
71+
['body length', { body: 'x'.repeat(65_537) }],
6372
['comments', { comments: -1 }],
6473
['reactions', { reactions: { '+1': 0, heart: 0, hooray: 0 } }],
6574
['labels', { labels: [{ name: 'bug' }, { name: 'BUG' }] }],
@@ -103,4 +112,14 @@ describe('GitHub issue retrieval', () => {
103112
await expect(first).rejects.toBe(cancelled);
104113
await expect(new GhIssueGateway('owner/repo', run).fetch({ timeoutMs: 1 })).rejects.toThrow('timed out');
105114
});
115+
116+
it('rechecks caller cancellation after a runner returns successfully', async () => {
117+
const controller = new AbortController();
118+
const cancelled = new Error('Stopped while the response settled.');
119+
const gateway = new GhIssueGateway('owner/repo', async () => {
120+
controller.abort(cancelled);
121+
return JSON.stringify([rawIssue()]);
122+
});
123+
await expect(gateway.fetch({ signal: controller.signal })).rejects.toBe(cancelled);
124+
});
106125
});

0 commit comments

Comments
 (0)