diff --git a/README.md b/README.md index ef99a1a..91cbea1 100644 --- a/README.md +++ b/README.md @@ -385,7 +385,7 @@ Move *issue* context between the private mirror and upstream — the same shape **Sub-commands:** - `stage ` — read an internal triage issue from the mirror, redact `...` blocks (same convention as `stage --pr`), and open the upstream counterpart via `gh issue create`. -- `pull ` — read an upstream issue, create a parallel internal issue on the mirror titled `[upstream #N] ` so the team can triage it without leaving the private space. +- `pull ` — read an upstream issue **and its comments**, create a parallel internal issue on the mirror titled `[upstream #N] ` 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. **Flags:** - `--title ` - Override the destination issue's title. @@ -405,7 +405,7 @@ Both sub-commands write a linkage to `venfork-config`: - `shippedIssues[]` for `stage` - `pulledIssues[]` for `pull` -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. +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. ### `venfork schedule |disable>` diff --git a/src/commands.ts b/src/commands.ts index 70b0d2f..d998bdb 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -2572,6 +2572,26 @@ function translateInternalBody(body: string): string { return stripInternalBlocks(body).trim(); } +/** + * Renders upstream issue comments into a Markdown section for the mirror copy + * created by `venfork issue pull`. Returns an empty string when there are no + * comments so the body stays clean. + * + * @internal Exported for unit testing. + */ +export function renderPulledComments( + comments: IssueComment[] | undefined +): string { + if (!comments || comments.length === 0) return ''; + const blocks = comments.map((c) => { + const who = c.author?.login ? `@${c.author.login}` : '(unknown)'; + const when = c.createdAt ? ` — ${c.createdAt}` : ''; + return `**${who}**${when}:\n\n${c.body?.trim() || '(empty)'}`; + }); + const label = comments.length === 1 ? 'comment' : 'comments'; + return `\n\n---\n\n### Upstream ${label} (${comments.length})\n\n${blocks.join('\n\n---\n\n')}`; +} + /** * Generates a synthetic upstream PR body from the branch's commit log when no * internal review PR was found. Lists the non-merge commits in @@ -3156,6 +3176,14 @@ export async function pullRequestCommand( } } +interface IssueComment { + author?: { login: string }; + /** Optional: gh may omit a body for reaction-only or deleted comments. */ + body?: string; + /** ISO timestamp from gh. */ + createdAt?: string; +} + interface IssueMeta { number: number; url: string; @@ -3163,6 +3191,7 @@ interface IssueMeta { body: string; state: string; author?: { login: string }; + comments?: IssueComment[]; } async function readIssue( @@ -3173,7 +3202,7 @@ async function readIssue( const result = await $({ cwd, reject: false, - })`gh issue view ${number} --repo ${repoPath} --json number,url,title,body,state,author`; + })`gh issue view ${number} --repo ${repoPath} --json number,url,title,body,state,author,comments`; if (result.exitCode !== 0) { throw new Error( `Failed to read issue #${number} from ${repoPath}: ${result.stderr.trim() || `exit ${result.exitCode}`}` @@ -3361,11 +3390,14 @@ export async function issueCommand( s.start(`Reading upstream issue #${upstreamNumber}`); const upstream = await readIssue(upstreamRepoPath, upstreamNumber, repoDir); - s.stop(`Read: ${upstream.title} (${upstream.state})`); + const commentCount = upstream.comments?.length ?? 0; + s.stop( + `Read: ${upstream.title} (${upstream.state}, ${commentCount} comment${commentCount === 1 ? '' : 's'})` + ); const internalTitle = options.title ?? `[upstream #${upstream.number}] ${upstream.title}`; - 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}`; + 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}`; p.note( [ diff --git a/tests/commands.test.ts b/tests/commands.test.ts index 7e66137..2756116 100644 --- a/tests/commands.test.ts +++ b/tests/commands.test.ts @@ -225,6 +225,7 @@ import { cloneCommand, issueCommand, pullRequestCommand, + renderPulledComments, scheduleCommand, setupCommand, showHelp, @@ -3430,6 +3431,13 @@ describe('issueCommand', () => { body: 'Body text.', state: 'OPEN', author: { login: 'reporter' }, + comments: [ + { + author: { login: 'commenter' }, + body: 'Me too', + createdAt: '2026-01-02', + }, + ], }), stderr: '', }); @@ -3458,6 +3466,29 @@ describe('issueCommand', () => { ).toBe(true); }); + test('renderPulledComments: empty/undefined yields no section', () => { + expect(renderPulledComments(undefined)).toBe(''); + expect(renderPulledComments([])).toBe(''); + }); + + test('renderPulledComments: renders author, timestamp, and body', () => { + const out = renderPulledComments([ + { author: { login: 'alice' }, body: 'first', createdAt: '2026-01-02' }, + { author: { login: 'bob' }, body: 'second' }, + ]); + expect(out).toContain('### Upstream comments (2)'); + expect(out).toContain('**@alice** — 2026-01-02:'); + expect(out).toContain('first'); + expect(out).toContain('**@bob**:'); + expect(out).toContain('second'); + }); + + test('renderPulledComments: singular label and missing author fallback', () => { + const out = renderPulledComments([{ body: 'orphan' }]); + expect(out).toContain('### Upstream comment (1)'); + expect(out).toContain('**(unknown)**:'); + }); + test('rejects unknown action', async () => { setupCommonRemotes(); await expect( diff --git a/tests/e2e/sync-flow.test.ts b/tests/e2e/sync-flow.test.ts index fe5bfa2..950847d 100644 --- a/tests/e2e/sync-flow.test.ts +++ b/tests/e2e/sync-flow.test.ts @@ -488,6 +488,10 @@ e2eDescribe('venfork e2e — scheduled sync flow', () => { body: 'Reported by an external user.', }); + // Add a comment upstream so the pull has comments to carry over. + const upstreamComment = `Follow-up detail ${RUN_ID}`; + await $`gh issue comment ${upstreamReport.number} --repo ${UPSTREAM_OWNER}/${names.upstream} --body ${upstreamComment}`; + await runVenfork(['issue', 'pull', String(upstreamReport.number)], { cwd: localMirrorPath, env: { VENFORK_NONINTERACTIVE: '1' }, @@ -511,6 +515,9 @@ e2eDescribe('venfork e2e — scheduled sync flow', () => { } expect(mirrorIssue).toBeDefined(); expect(mirrorIssue?.body).toContain(upstreamReport.url); + // The upstream comment was carried into the mirror copy. + expect(mirrorIssue?.body).toContain('Upstream comment'); + expect(mirrorIssue?.body).toContain(upstreamComment); // Suppress unused-var warnings for helpers we leave in place for // future debugging hooks.