Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -385,7 +385,7 @@ Move *issue* context between the private mirror and upstream — the same shape

**Sub-commands:**
- `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`.
- `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.
- `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.

**Flags:**
- `--title <text>` - Override the destination issue's title.
Expand All @@ -405,7 +405,7 @@ Both sub-commands write a linkage to `venfork-config`:
- `shippedIssues[<internal-#>]` for `stage`
- `pulledIssues[<internal-#>]` 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 <status|set <cron>|disable>`

Expand Down
38 changes: 35 additions & 3 deletions src/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -3156,13 +3176,22 @@ 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;
title: string;
body: string;
state: string;
author?: { login: string };
comments?: IssueComment[];
}

async function readIssue(
Expand All @@ -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}`}`
Expand Down Expand Up @@ -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(
[
Expand Down
31 changes: 31 additions & 0 deletions tests/commands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,7 @@ import {
cloneCommand,
issueCommand,
pullRequestCommand,
renderPulledComments,
scheduleCommand,
setupCommand,
showHelp,
Expand Down Expand Up @@ -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: '',
});
Expand Down Expand Up @@ -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(
Expand Down
7 changes: 7 additions & 0 deletions tests/e2e/sync-flow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand All @@ -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.
Expand Down
Loading