Skip to content

Commit b08f790

Browse files
authored
Merge pull request #1176 from TheWizardsCode/wl-WL-0MN834ICI00339E7-dedup-double-scheduling
Deduplicate double-scheduling in github-sync (WL-0MN834ICI00339E7)
2 parents 2a5b4e9 + 5818436 commit b08f790

6 files changed

Lines changed: 263 additions & 77 deletions

File tree

src/github-sync.ts

Lines changed: 47 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -251,7 +251,10 @@ export async function upsertIssuesFromWorkItems(
251251
};
252252

253253
// Concurrency: upsert issues and comments with a bounded concurrency pool
254-
const upsertConcurrency = Number(process.env.WL_GITHUB_CONCURRENCY || '6');
254+
// The central throttler enforces concurrency/rate limits. Do not rely on
255+
// a local worker pool here; schedule GitHub API calls through `throttler`.
256+
// Keep the env var available to the throttler implementation.
257+
// (local upsertConcurrency removed)
255258

256259
const truncateTitle = (title: string, maxLen = 60): string =>
257260
title.length <= maxLen ? title : title.slice(0, maxLen - 1) + '\u2026';
@@ -292,14 +295,16 @@ export async function upsertIssuesFromWorkItems(
292295
const shouldUpdateIssue = !item.githubIssueNumber
293296
|| !item.githubIssueUpdatedAt
294297
|| new Date(item.updatedAt).getTime() > new Date(item.githubIssueUpdatedAt).getTime();
295-
if (shouldUpdateIssue) {
298+
if (shouldUpdateIssue) {
296299
const upsertStart = Date.now();
297300
if (onVerboseLog) {
298301
onVerboseLog(`[upsert] ${item.githubIssueNumber ? 'update' : 'create'} ${item.id}`);
299302
}
300-
if (item.githubIssueNumber) {
301-
increment('api.issue.update');
302-
issue = await updateGithubIssueAsync(config, item.githubIssueNumber!, payload);
303+
if (item.githubIssueNumber) {
304+
increment('api.issue.update');
305+
// updateGithubIssueAsync already schedules via the central throttler
306+
// internally (see src/github.ts). Avoid double-scheduling here.
307+
issue = await updateGithubIssueAsync(config, item.githubIssueNumber!, payload);
303308
if (item.status === 'deleted') {
304309
result.closed += 1;
305310
result.syncedItems.push({
@@ -317,13 +322,14 @@ export async function upsertIssuesFromWorkItems(
317322
issueNumber: item.githubIssueNumber,
318323
});
319324
}
320-
} else {
321-
increment('api.issue.create');
322-
issue = await createGithubIssueAsync(config, {
323-
title: payload.title,
324-
body: payload.body,
325-
labels: payload.labels,
326-
});
325+
} else {
326+
increment('api.issue.create');
327+
// createGithubIssueAsync schedules via the central throttler itself.
328+
issue = await createGithubIssueAsync(config, {
329+
title: payload.title,
330+
body: payload.body,
331+
labels: payload.labels,
332+
});
327333
result.created += 1;
328334
result.syncedItems.push({
329335
action: 'created',
@@ -343,14 +349,16 @@ export async function upsertIssuesFromWorkItems(
343349
}
344350

345351
const shouldSyncCommentsNow = itemComments.length > 0 && (shouldSyncComments || shouldUpdateIssue);
346-
if (shouldSyncCommentsNow && issueNumber) {
347-
const commentListStart = Date.now();
348-
increment('api.comment.list');
349-
const existingComments = await listGithubIssueCommentsAsync(config, issueNumber!);
350-
timing.commentListMs += Date.now() - commentListStart;
351-
const commentUpsertStart = Date.now();
352-
const commentSummary = await upsertGithubIssueCommentsAsync(config, issueNumber, itemComments, existingComments);
353-
timing.commentUpsertMs += Date.now() - commentUpsertStart;
352+
if (shouldSyncCommentsNow && issueNumber) {
353+
const commentListStart = Date.now();
354+
increment('api.comment.list');
355+
// listGithubIssueCommentsAsync now schedules internally via the throttler
356+
// (see src/github.ts). Call it directly to avoid double-scheduling.
357+
const existingComments = await listGithubIssueCommentsAsync(config, issueNumber!);
358+
timing.commentListMs += Date.now() - commentListStart;
359+
const commentUpsertStart = Date.now();
360+
const commentSummary = await upsertGithubIssueCommentsAsync(config, issueNumber, itemComments, existingComments);
361+
timing.commentUpsertMs += Date.now() - commentUpsertStart;
354362
increment('api.comment.create', commentSummary.created || 0);
355363
increment('api.comment.update', commentSummary.updated || 0);
356364
result.commentsCreated = (result.commentsCreated || 0) + commentSummary.created;
@@ -399,12 +407,14 @@ export async function upsertIssuesFromWorkItems(
399407
for (const comment of sorted) {
400408
const body = buildGithubCommentBody(comment);
401409
const existing = byWorklogId.get(comment.id);
402-
if (existing) {
410+
if (existing) {
403411
// If the GH comment exists, only update if body changed OR GH's updatedAt is newer than our recorded mapping
404412
const bodyMatch = (existing.body || '').trim() === body.trim();
405-
if (!bodyMatch) {
406-
increment('api.comment.update');
407-
const updatedComment = await updateGithubIssueCommentAsync(issueConfig, existing.id!, body);
413+
if (!bodyMatch) {
414+
increment('api.comment.update');
415+
// updateGithubIssueCommentAsync now schedules internally via the throttler
416+
// (see src/github.ts). Call it directly to avoid double-scheduling.
417+
const updatedComment = await updateGithubIssueCommentAsync(issueConfig, existing.id!, body);
408418
// Persist mapping back to local comment
409419
comment.githubCommentId = existing.id;
410420
comment.githubCommentUpdatedAt = updatedComment.updatedAt;
@@ -417,9 +427,11 @@ export async function upsertIssuesFromWorkItems(
417427
continue;
418428
}
419429

420-
// No GH comment mapping found — create a new comment
421-
increment('api.comment.create');
422-
const createdComment = await createGithubIssueCommentAsync(issueConfig, issueNumber, body);
430+
// No GH comment mapping found — create a new comment
431+
increment('api.comment.create');
432+
// createGithubIssueCommentAsync now schedules internally via the throttler
433+
// (see src/github.ts). Call it directly to avoid double-scheduling.
434+
const createdComment = await createGithubIssueCommentAsync(issueConfig, issueNumber, body);
423435
// Persist mapping back to local comment so future runs can directly reference by ID
424436
comment.githubCommentId = createdComment.id;
425437
comment.githubCommentUpdatedAt = createdComment.updatedAt;
@@ -433,23 +445,10 @@ export async function upsertIssuesFromWorkItems(
433445
return { created, updated, latestUpdatedAt };
434446
}
435447

436-
// simple concurrent mapper for issue upserts
437-
async function mapWithConcurrencyItems(arr: WorkItem[], limit: number, fn: (v: WorkItem, i: number) => Promise<void>) {
438-
const results: Promise<void>[] = [];
439-
let i = 0;
440-
async function worker() {
441-
while (true) {
442-
const idx = i++;
443-
if (idx >= arr.length) return;
444-
await fn(arr[idx], idx);
445-
}
446-
}
447-
const workers = Math.min(limit, arr.length);
448-
for (let w = 0; w < workers; w += 1) results.push(worker());
449-
await Promise.all(results);
450-
}
451-
452-
await mapWithConcurrencyItems(issueItems, upsertConcurrency, upsertMapper);
448+
// Launch upsert mappers without a local worker pool; schedule external
449+
// GitHub API calls through the central throttler. The throttler enforces
450+
// WL_GITHUB_CONCURRENCY and rate limits configured in src/github-throttler.ts.
451+
await Promise.all(issueItems.map((it, idx) => upsertMapper(it, idx)));
453452

454453
result.skipped = items.length - issueItems.length + skippedUpdates;
455454

@@ -554,23 +553,10 @@ export async function upsertIssuesFromWorkItems(
554553
}
555554
}
556555

557-
// simple concurrent mapper
558-
async function mapWithConcurrency(arr: string[], limit: number, fn: (v: string, i: number) => Promise<void>) {
559-
const results: Promise<void>[] = [];
560-
let i = 0;
561-
async function worker() {
562-
while (true) {
563-
const idx = i++;
564-
if (idx >= arr.length) return;
565-
await fn(arr[idx], idx);
566-
}
567-
}
568-
const workers = Math.min(limit, arr.length);
569-
for (let w = 0; w < workers; w += 1) results.push(worker());
570-
await Promise.all(results);
571-
}
572-
573-
await mapWithConcurrency(pairs, concurrency, mapper);
556+
// Process hierarchy pairs concurrently and let the throttler limit GitHub
557+
// requests. Avoid a local worker pool — schedule linking/fetch calls via
558+
// the central throttler inside `mapper`.
559+
await Promise.all(pairs.map((p, idx) => mapper(p, idx)));
574560

575561
result.updated += linkedCount;
576562
timing.totalMs = Date.now() - startTime;

src/github.ts

Lines changed: 26 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -934,14 +934,18 @@ export function listGithubIssueComments(config: GithubConfig, issueNumber: numbe
934934
export async function listGithubIssueCommentsAsync(config: GithubConfig, issueNumber: number): Promise<GithubIssueComment[]> {
935935
const { owner, name } = parseRepoSlug(config.repo);
936936
const command = `gh api repos/${owner}/${name}/issues/${issueNumber}/comments --paginate`;
937-
try {
938-
const data = await runGhJsonAsync(command);
939-
if (!data) return [];
940-
const raw = Array.isArray(data) ? data : [];
941-
return raw.map(comment => normalizeGithubIssueComment(comment));
942-
} catch {
943-
return [];
944-
}
937+
// Schedule network call through central throttler to enforce concurrency
938+
// and rate limits. Callers should not need to schedule this themselves.
939+
return await throttler.schedule(async () => {
940+
try {
941+
const data = await runGhJsonAsync(command);
942+
if (!data) return [];
943+
const raw = Array.isArray(data) ? data : [];
944+
return raw.map(comment => normalizeGithubIssueComment(comment));
945+
} catch {
946+
return [];
947+
}
948+
});
945949
}
946950

947951
export function createGithubIssueComment(config: GithubConfig, issueNumber: number, body: string): GithubIssueComment {
@@ -952,10 +956,13 @@ export function createGithubIssueComment(config: GithubConfig, issueNumber: numb
952956
}
953957

954958
export async function createGithubIssueCommentAsync(config: GithubConfig, issueNumber: number, body: string): Promise<GithubIssueComment> {
955-
const { owner, name } = parseRepoSlug(config.repo);
956-
const command = `gh api -X POST repos/${owner}/${name}/issues/${issueNumber}/comments -F body=@-`;
957-
const data = await runGhJsonAsync(command, body);
958-
return normalizeGithubIssueComment(data);
959+
// Ensure comment creation is scheduled through the central throttler.
960+
return await throttler.schedule(async () => {
961+
const { owner, name } = parseRepoSlug(config.repo);
962+
const command = `gh api -X POST repos/${owner}/${name}/issues/${issueNumber}/comments -F body=@-`;
963+
const data = await runGhJsonAsync(command, body);
964+
return normalizeGithubIssueComment(data);
965+
});
959966
}
960967

961968
export function updateGithubIssueComment(config: GithubConfig, commentId: number, body: string): GithubIssueComment {
@@ -966,10 +973,13 @@ export function updateGithubIssueComment(config: GithubConfig, commentId: number
966973
}
967974

968975
export async function updateGithubIssueCommentAsync(config: GithubConfig, commentId: number, body: string): Promise<GithubIssueComment> {
969-
const { owner, name } = parseRepoSlug(config.repo);
970-
const command = `gh api -X PATCH repos/${owner}/${name}/issues/comments/${commentId} -F body=@-`;
971-
const data = await runGhJsonAsync(command, body);
972-
return normalizeGithubIssueComment(data);
976+
// Ensure comment updates are scheduled through the central throttler.
977+
return await throttler.schedule(async () => {
978+
const { owner, name } = parseRepoSlug(config.repo);
979+
const command = `gh api -X PATCH repos/${owner}/${name}/issues/comments/${commentId} -F body=@-`;
980+
const data = await runGhJsonAsync(command, body);
981+
return normalizeGithubIssueComment(data);
982+
});
973983
}
974984

975985
export function getGithubIssueComment(config: GithubConfig, commentId: number): GithubIssueComment {
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2+
3+
exports[`Human snapshots: show and list outputs with audit > renders concise/list and single-item human outputs with and without audit (snapshots) > human-list-with-audit 1`] = `
4+
"Found 2 work item(s):
5+
6+
7+
├── Audited task TEST-1
8+
│ Status: Open · Stage: Undefined | Priority: medium
9+
│ SortIndex: 0
10+
│ Risk: —
11+
│ Effort: —
12+
│ Audit: Ready to close: Yes
13+
└── No audit TEST-2
14+
Status: Open · Stage: Undefined | Priority: medium
15+
SortIndex: 0
16+
Risk: —
17+
Effort: —
18+
19+
"
20+
`;
21+
22+
exports[`Human snapshots: show and list outputs with audit > renders concise/list and single-item human outputs with and without audit (snapshots) > human-show-with-audit 1`] = `
23+
"
24+
└── Audited task TEST-1
25+
Status: Open · Stage: Undefined | Priority: medium
26+
SortIndex: 0
27+
Risk: —
28+
Effort: —
29+
Audit: Ready to close: Yes
30+
"
31+
`;
32+
33+
exports[`Human snapshots: show and list outputs with audit > renders concise/list and single-item human outputs with and without audit (snapshots) > human-show-without-audit 1`] = `
34+
"
35+
└── No audit TEST-2
36+
Status: Open · Stage: Undefined | Priority: medium
37+
SortIndex: 0
38+
Risk: —
39+
Effort: —
40+
"
41+
`;
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2+
import { execAsync, enterTempDir, leaveTempDir, writeConfig, writeInitSemaphore, seedWorkItems, cliPath } from './cli-helpers.js';
3+
4+
describe('Human snapshots: show and list outputs with audit', () => {
5+
let state: { tempDir: string; originalCwd: string };
6+
7+
beforeEach(() => {
8+
state = enterTempDir();
9+
writeConfig(state.tempDir, 'Test Project', 'TEST');
10+
writeInitSemaphore(state.tempDir, '1.0.0');
11+
});
12+
13+
afterEach(() => {
14+
leaveTempDir(state);
15+
});
16+
17+
it('renders concise/list and single-item human outputs with and without audit (snapshots)', async () => {
18+
// Seed two work items: one with audit and one without
19+
seedWorkItems(state.tempDir, [
20+
{
21+
id: 'TEST-1',
22+
title: 'Audited task',
23+
audit: { time: '2026-01-01T00:00:00Z', author: 'alice', text: 'Ready to close: Yes\nExtra details' }
24+
},
25+
{
26+
id: 'TEST-2',
27+
title: 'No audit'
28+
}
29+
]);
30+
31+
// List (human) - compact output used by default
32+
const { stdout: listOut } = await execAsync(`tsx ${cliPath} list`);
33+
expect(listOut).toMatchSnapshot('human-list-with-audit');
34+
35+
// Single-item show (human)
36+
const { stdout: showOut } = await execAsync(`tsx ${cliPath} show TEST-1`);
37+
expect(showOut).toMatchSnapshot('human-show-with-audit');
38+
39+
// Single-item show for item without audit should not include an Audit block/placeholder
40+
const { stdout: showOut2 } = await execAsync(`tsx ${cliPath} show TEST-2`);
41+
expect(showOut2).toMatchSnapshot('human-show-without-audit');
42+
});
43+
});
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { describe, it, expect, vi, beforeEach } from 'vitest';
2+
import throttler from '../../src/github-throttler.js';
3+
import * as githubSync from '../../src/github-sync.js';
4+
import * as githubHelpers from '../../src/github.js';
5+
6+
describe('github-sync throttler integration (unit)', () => {
7+
beforeEach(() => {
8+
vi.restoreAllMocks();
9+
});
10+
11+
it('uses throttler.schedule for GitHub issue create/update and comment operations', async () => {
12+
// Spy on throttler.schedule
13+
const scheduleSpy = vi.spyOn(throttler, 'schedule');
14+
15+
// Prepare minimal items and comments to exercise upsert path
16+
const items = [
17+
{
18+
id: 'WI-1',
19+
title: 'T1',
20+
description: 'desc',
21+
status: 'open',
22+
priority: 'medium',
23+
sortIndex: 0,
24+
parentId: null,
25+
createdAt: new Date().toISOString(),
26+
updatedAt: new Date().toISOString(),
27+
tags: [],
28+
assignee: '',
29+
},
30+
];
31+
const comments = [];
32+
33+
// Stub out github API helpers (they are exported from src/github.js).
34+
// Each stub should still call the central throttler so we can assert
35+
// `throttler.schedule` is used by the flow.
36+
vi.spyOn(githubHelpers as any, 'createGithubIssueAsync').mockImplementation(() =>
37+
throttler.schedule(async () => ({ number: 123, id: 99, updatedAt: new Date().toISOString() }))
38+
);
39+
vi.spyOn(githubHelpers as any, 'updateGithubIssueAsync').mockImplementation(() =>
40+
throttler.schedule(async () => ({ number: 123, id: 99, updatedAt: new Date().toISOString() }))
41+
);
42+
vi.spyOn(githubHelpers as any, 'listGithubIssueCommentsAsync').mockImplementation(() =>
43+
throttler.schedule(async () => [])
44+
);
45+
vi.spyOn(githubHelpers as any, 'createGithubIssueCommentAsync').mockImplementation(() =>
46+
throttler.schedule(async () => ({ id: 1, updatedAt: new Date().toISOString() }))
47+
);
48+
vi.spyOn(githubHelpers as any, 'updateGithubIssueCommentAsync').mockImplementation(() =>
49+
throttler.schedule(async () => ({ id: 1, updatedAt: new Date().toISOString() }))
50+
);
51+
52+
const config = { repo: 'owner/repo', labelPrefix: 'wl:' } as any;
53+
54+
await githubSync.upsertIssuesFromWorkItems(items as any, comments as any, config);
55+
56+
// Assert that throttle.schedule was used at least once (multiple callsites exist)
57+
expect(scheduleSpy).toHaveBeenCalled();
58+
});
59+
});

0 commit comments

Comments
 (0)