Skip to content

Commit 8453867

Browse files
authored
feat(issue): carry upstream comments into the mirror on issue pull (#50)
* feat(issue): carry upstream comments into the mirror on issue pull `venfork issue pull` only copied the upstream issue body, dropping the comment thread the team needs for triage. Fetch comments via `gh issue view --json ...,comments` and snapshot them into the mirror issue body under an "Upstream comments" section. Comments flow upstream->mirror only; `issue stage` still promotes the body alone (redacted), so internal comments are never pushed upstream. * refactor(issue): mark comment body optional, cover render path in pull test Address self-review: IssueComment.body is optional to match the renderer's defensive `c.body?.trim()`, and the issue-pull unit test mock now includes a comment so the in-command render path is exercised.
1 parent 06c9ce8 commit 8453867

4 files changed

Lines changed: 75 additions & 5 deletions

File tree

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -385,7 +385,7 @@ Move *issue* context between the private mirror and upstream — the same shape
385385

386386
**Sub-commands:**
387387
- `stage <internal-#>` — read an internal triage issue from the mirror, redact `<!-- venfork:internal -->...<!-- /venfork:internal -->` blocks (same convention as `stage --pr`), and open the upstream counterpart via `gh issue create`.
388-
- `pull <upstream-#>` — read an upstream issue, create a parallel internal issue on the mirror titled `[upstream #N] <original title>` so the team can triage it without leaving the private space.
388+
- `pull <upstream-#>` — read an upstream issue **and its comments**, create a parallel internal issue on the mirror titled `[upstream #N] <original title>` so the team can triage it without leaving the private space. The upstream comment thread is snapshotted into the mirror issue body under an "Upstream comments" section.
389389

390390
**Flags:**
391391
- `--title <text>` - Override the destination issue's title.
@@ -405,7 +405,7 @@ Both sub-commands write a linkage to `venfork-config`:
405405
- `shippedIssues[<internal-#>]` for `stage`
406406
- `pulledIssues[<internal-#>]` for `pull`
407407

408-
This is **only the linkage** — comments and state changes do *not* sync. If the upstream issue is closed, the internal one stays open until you close it manually (and vice versa). Treat the records as a "where did this go?" audit log rather than a live mirror.
408+
This is **only the linkage**there is no *live* sync. `pull` snapshots the upstream body and comments into the mirror issue at pull time, but later comments and state changes do not propagate. If the upstream issue is closed, the internal one stays open until you close it manually (and vice versa). Treat the records as a "where did this go?" audit log rather than a live mirror.
409409

410410
### `venfork schedule <status|set <cron>|disable>`
411411

src/commands.ts

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2572,6 +2572,26 @@ function translateInternalBody(body: string): string {
25722572
return stripInternalBlocks(body).trim();
25732573
}
25742574

2575+
/**
2576+
* Renders upstream issue comments into a Markdown section for the mirror copy
2577+
* created by `venfork issue pull`. Returns an empty string when there are no
2578+
* comments so the body stays clean.
2579+
*
2580+
* @internal Exported for unit testing.
2581+
*/
2582+
export function renderPulledComments(
2583+
comments: IssueComment[] | undefined
2584+
): string {
2585+
if (!comments || comments.length === 0) return '';
2586+
const blocks = comments.map((c) => {
2587+
const who = c.author?.login ? `@${c.author.login}` : '(unknown)';
2588+
const when = c.createdAt ? ` — ${c.createdAt}` : '';
2589+
return `**${who}**${when}:\n\n${c.body?.trim() || '(empty)'}`;
2590+
});
2591+
const label = comments.length === 1 ? 'comment' : 'comments';
2592+
return `\n\n---\n\n### Upstream ${label} (${comments.length})\n\n${blocks.join('\n\n---\n\n')}`;
2593+
}
2594+
25752595
/**
25762596
* Generates a synthetic upstream PR body from the branch's commit log when no
25772597
* internal review PR was found. Lists the non-merge commits in
@@ -3156,13 +3176,22 @@ export async function pullRequestCommand(
31563176
}
31573177
}
31583178

3179+
interface IssueComment {
3180+
author?: { login: string };
3181+
/** Optional: gh may omit a body for reaction-only or deleted comments. */
3182+
body?: string;
3183+
/** ISO timestamp from gh. */
3184+
createdAt?: string;
3185+
}
3186+
31593187
interface IssueMeta {
31603188
number: number;
31613189
url: string;
31623190
title: string;
31633191
body: string;
31643192
state: string;
31653193
author?: { login: string };
3194+
comments?: IssueComment[];
31663195
}
31673196

31683197
async function readIssue(
@@ -3173,7 +3202,7 @@ async function readIssue(
31733202
const result = await $({
31743203
cwd,
31753204
reject: false,
3176-
})`gh issue view ${number} --repo ${repoPath} --json number,url,title,body,state,author`;
3205+
})`gh issue view ${number} --repo ${repoPath} --json number,url,title,body,state,author,comments`;
31773206
if (result.exitCode !== 0) {
31783207
throw new Error(
31793208
`Failed to read issue #${number} from ${repoPath}: ${result.stderr.trim() || `exit ${result.exitCode}`}`
@@ -3361,11 +3390,14 @@ export async function issueCommand(
33613390

33623391
s.start(`Reading upstream issue #${upstreamNumber}`);
33633392
const upstream = await readIssue(upstreamRepoPath, upstreamNumber, repoDir);
3364-
s.stop(`Read: ${upstream.title} (${upstream.state})`);
3393+
const commentCount = upstream.comments?.length ?? 0;
3394+
s.stop(
3395+
`Read: ${upstream.title} (${upstream.state}, ${commentCount} comment${commentCount === 1 ? '' : 's'})`
3396+
);
33653397

33663398
const internalTitle =
33673399
options.title ?? `[upstream #${upstream.number}] ${upstream.title}`;
3368-
const internalBody = `${upstream.body || '(no body provided)'}\n\n> Pulled from upstream issue: ${upstream.url}\n> Author: ${upstream.author?.login ?? '(unknown)'}\n> State: ${upstream.state}`;
3400+
const internalBody = `${upstream.body || '(no body provided)'}${renderPulledComments(upstream.comments)}\n\n> Pulled from upstream issue: ${upstream.url}\n> Author: ${upstream.author?.login ?? '(unknown)'}\n> State: ${upstream.state}`;
33693401

33703402
p.note(
33713403
[

tests/commands.test.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,7 @@ import {
225225
cloneCommand,
226226
issueCommand,
227227
pullRequestCommand,
228+
renderPulledComments,
228229
scheduleCommand,
229230
setupCommand,
230231
showHelp,
@@ -3430,6 +3431,13 @@ describe('issueCommand', () => {
34303431
body: 'Body text.',
34313432
state: 'OPEN',
34323433
author: { login: 'reporter' },
3434+
comments: [
3435+
{
3436+
author: { login: 'commenter' },
3437+
body: 'Me too',
3438+
createdAt: '2026-01-02',
3439+
},
3440+
],
34333441
}),
34343442
stderr: '',
34353443
});
@@ -3458,6 +3466,29 @@ describe('issueCommand', () => {
34583466
).toBe(true);
34593467
});
34603468

3469+
test('renderPulledComments: empty/undefined yields no section', () => {
3470+
expect(renderPulledComments(undefined)).toBe('');
3471+
expect(renderPulledComments([])).toBe('');
3472+
});
3473+
3474+
test('renderPulledComments: renders author, timestamp, and body', () => {
3475+
const out = renderPulledComments([
3476+
{ author: { login: 'alice' }, body: 'first', createdAt: '2026-01-02' },
3477+
{ author: { login: 'bob' }, body: 'second' },
3478+
]);
3479+
expect(out).toContain('### Upstream comments (2)');
3480+
expect(out).toContain('**@alice** — 2026-01-02:');
3481+
expect(out).toContain('first');
3482+
expect(out).toContain('**@bob**:');
3483+
expect(out).toContain('second');
3484+
});
3485+
3486+
test('renderPulledComments: singular label and missing author fallback', () => {
3487+
const out = renderPulledComments([{ body: 'orphan' }]);
3488+
expect(out).toContain('### Upstream comment (1)');
3489+
expect(out).toContain('**(unknown)**:');
3490+
});
3491+
34613492
test('rejects unknown action', async () => {
34623493
setupCommonRemotes();
34633494
await expect(

tests/e2e/sync-flow.test.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -488,6 +488,10 @@ e2eDescribe('venfork e2e — scheduled sync flow', () => {
488488
body: 'Reported by an external user.',
489489
});
490490

491+
// Add a comment upstream so the pull has comments to carry over.
492+
const upstreamComment = `Follow-up detail ${RUN_ID}`;
493+
await $`gh issue comment ${upstreamReport.number} --repo ${UPSTREAM_OWNER}/${names.upstream} --body ${upstreamComment}`;
494+
491495
await runVenfork(['issue', 'pull', String(upstreamReport.number)], {
492496
cwd: localMirrorPath,
493497
env: { VENFORK_NONINTERACTIVE: '1' },
@@ -511,6 +515,9 @@ e2eDescribe('venfork e2e — scheduled sync flow', () => {
511515
}
512516
expect(mirrorIssue).toBeDefined();
513517
expect(mirrorIssue?.body).toContain(upstreamReport.url);
518+
// The upstream comment was carried into the mirror copy.
519+
expect(mirrorIssue?.body).toContain('Upstream comment');
520+
expect(mirrorIssue?.body).toContain(upstreamComment);
514521

515522
// Suppress unused-var warnings for helpers we leave in place for
516523
// future debugging hooks.

0 commit comments

Comments
 (0)