Skip to content

SYMPHONY: Implement fork detection and integration in Symphony workflows#533

Open
chr1syy wants to merge 10 commits intoRunMaestro:mainfrom
chr1syy:fix-symphony-fork
Open

SYMPHONY: Implement fork detection and integration in Symphony workflows#533
chr1syy wants to merge 10 commits intoRunMaestro:mainfrom
chr1syy:fix-symphony-fork

Conversation

@chr1syy
Copy link
Contributor

@chr1syy chr1syy commented Mar 8, 2026

This pull request adds comprehensive test coverage for fork-related workflows in Symphony IPC handlers and services. The main focus is on ensuring correct handling and persistence of fork information when creating branches and draft pull requests, including error scenarios and cross-fork PRs. The changes introduce mocking for the fork setup utility and validate that fork metadata is correctly passed through the system and used in GitHub CLI commands.

Fork workflow test coverage:

  • Added mocking for the ensureForkSetup function from symphony-fork in both src/__tests__/main/ipc/handlers/symphony.test.ts and src/__tests__/main/services/symphony-runner.test.ts, enabling simulation of fork scenarios in tests. [1] [2]
  • Added tests to symphony.test.ts for branch creation and draft PR flows, verifying:
    • ensureForkSetup is called after branch creation and errors are handled if fork setup fails. [1] [2]
    • Fork info (isFork, forkSlug, upstreamSlug) is persisted in state/metadata and passed to gh pr create for cross-fork PRs, ensuring correct CLI arguments. [1] [2] [3] [4]
    • PR creation omits fork-specific arguments when metadata has no fork info.

Test dependency setup:

  • Updated test setup to import and mock ensureForkSetup in relevant files, and set default mock return values to simulate both fork and non-fork scenarios. [1] [2] [3]

These changes ensure that fork handling logic is robustly tested and that fork metadata is correctly integrated throughout Symphony's contribution and PR creation flows.

closes #531

Summary by CodeRabbit

  • New Features

    • Automatic fork detection and setup; cross-fork PR flows now support creating, discovering, targeting, and cleaning up PRs against upstream forks with persisted fork metadata.
    • Contribution state persists fork info (isFork, fork owner slug, upstream repo, upstream default branch).
  • Tests

    • Expanded test coverage for fork setup, remote handling, ordering/error paths, cleanup, and cross-fork PR behaviors across start/create/finalize/ready/comment/cancel flows.

chr1syy and others added 5 commits March 8, 2026 10:05
Add isFork, forkSlug, and upstreamSlug fields to ActiveContribution
interface. Create symphony-fork.ts utility that detects push access,
forks repos via gh CLI, and reconfigures git remotes. Includes
comprehensive test coverage for all paths.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
Wire ensureForkSetup into all three Symphony contribution flows:
- symphony:startContribution (new two-step flow)
- symphony:start (older inline flow)
- symphony:createDraftPR (deferred PR creation)

Update createDraftPR to support cross-fork PRs by adding --repo
and --head forkOwner:branch args when contributing via fork.
Persist fork metadata (isFork, forkSlug, upstreamSlug) in both
contribution state and metadata.json for later PR creation.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
Integrate ensureForkSetup into symphony-runner.ts so the standalone
contribution service handles forked repos correctly:

- startContribution: calls ensureForkSetup after clone/branch, returns
  isFork/forkSlug metadata, passes fork info to createDraftPR
- createDraftPR: accepts upstreamSlug/forkOwner, adds --repo and --head
  flags for cross-fork PR creation
- finalizeContribution: accepts upstreamSlug, adds --repo to gh pr
  ready/edit/view commands
- cancelContribution: accepts upstreamSlug, adds --repo to gh pr close

Co-Authored-By: Claude Opus 4.6 <[email protected]>
- Update markPRReady and postPRComment to accept optional upstreamSlug
  and pass --repo flag to gh CLI for cross-fork PR operations
- Thread fork metadata (isFork/forkSlug/upstreamSlug) through the
  symphony:complete handler to both functions
- Sync fork fields from metadata.json to ActiveContribution state in
  checkPRStatuses and syncContribution handlers
- Add structured logging at all fork decision points in both
  symphony.ts and symphony-runner.ts for workflow visibility

Co-Authored-By: Claude Opus 4.6 <[email protected]>
@coderabbitai
Copy link

coderabbitai bot commented Mar 8, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds a fork-setup utility and threads fork metadata (isFork, forkSlug, upstreamSlug, upstreamDefaultBranch) through IPC and runner flows; performs fork detection/setup during startContribution, pushes/targets forks when needed, and builds gh PR commands with appropriate --repo/--head handling for cross-fork PRs.

Changes

Cohort / File(s) Summary
Fork Utility
src/main/utils/symphony-fork.ts
New module exporting ensureForkSetup(repoPath, repoSlug): Promise<ForkSetupResult> and ForkSetupResult interface; performs auth/permission checks, idempotent fork creation, retrieves fork clone URL, and reconfigures git remotes (rename/set-url fallbacks); returns { isFork, forkSlug?, error?, upstreamDefaultBranch? }.
Type Definitions
src/shared/symphony-types.ts
Extended ActiveContribution to include optional isFork?: boolean, forkSlug?: string, and upstreamSlug?: string (and propagation of upstream default branch where applicable).
Runner Service
src/main/services/symphony-runner.ts
Invokes ensureForkSetup in startContribution; returns and persists isFork/forkSlug; updates signatures for createDraftPR, finalizeContribution, and cancelContribution to accept upstreamSlug/forkOwner; conditions gh CLI usage and cleanup based on fork context; adds error handling/cleanup on fork setup failure.
IPC Handlers
src/main/ipc/handlers/symphony.ts
Updated handler signatures (createDraftPR, markPRReady, postPRComment, discoverPRByBranch) to accept fork/upstream args; threads fork metadata into state/metadata; constructs gh PR/comment/ready args using --repo upstreamSlug and --head forkOwner:branch when appropriate; discovery supports headOwner.
Tests — Fork Utility
src/__tests__/main/utils/symphony-fork.test.ts
Comprehensive test coverage for ensureForkSetup: auth failures, ownership and push-permission checks, fork creation/idempotence, clone-URL retrieval, remote rename vs set-url fallbacks, existing-fork handling, and error propagation.
Tests — Runner & Handlers
src/__tests__/main/services/symphony-runner.test.ts, src/__tests__/main/ipc/handlers/symphony.test.ts
Added mocks for ensureForkSetup (default non-fork) and extensive fork-path tests verifying invocation ordering (checkout before fork setup), error propagation and cleanup on fork setup failure, persistence/propagation of isFork/forkSlug/upstreamSlug/upstreamDefaultBranch, and correct gh args (--repo, --head, --base) across start, draft PR creation, mark-ready, comment, finalize, and cancel flows.
Public API / Signatures
src/main/utils/symphony-fork.ts, src/main/services/..., src/main/ipc/handlers/...
Added ensureForkSetup export and updated multiple method signatures to accept/return fork-related parameters (upstreamSlug, forkOwner, isFork, forkSlug, upstreamDefaultBranch).

Sequence Diagram(s)

sequenceDiagram
    actor User
    participant Runner as Symphony Runner
    participant Fork as Fork Utility (ensureForkSetup)
    participant GH as GitHub CLI/API
    participant Git as Git CLI
    participant Handler as IPC Handler

    User->>Runner: startContribution(repoPath, repoSlug)
    Runner->>Fork: ensureForkSetup(repoPath, repoSlug)
    Fork->>GH: gh api user (auth)
    GH-->>Fork: user info
    Fork->>GH: gh api repos/{repoSlug} (permissions)
    GH-->>Fork: repo permissions
    alt No push access
        Fork->>GH: gh repo fork (create if needed)
        GH-->>Fork: fork created/existing
        Fork->>GH: gh api repos/{forkSlug} (clone URL)
        GH-->>Fork: forkCloneUrl
        Fork->>Git: git remote rename origin upstream (or set-url fallback)
        Git-->>Fork: remote configured
        Fork->>Git: git remote add origin {forkCloneUrl}
        Git-->>Fork: origin set
        Fork-->>Runner: {isFork: true, forkSlug, upstreamSlug, upstreamDefaultBranch}
    else Has push access
        Fork-->>Runner: {isFork: false}
    end
    alt Fork detected
        Runner->>Handler: createDraftPR(..., upstreamSlug, forkOwner)
        Handler->>GH: gh pr create --repo {upstreamSlug} --head {forkOwner}:{branch}
        GH-->>Handler: prUrl
        Handler-->>Runner: prUrl
    else No fork
        Runner->>Handler: createDraftPR(..., undefined, undefined)
        Handler->>GH: gh pr create --head {branch}
        GH-->>Handler: prUrl
        Handler-->>Runner: prUrl
    end
    Runner-->>User: contribution started (includes isFork/forkSlug/upstreamSlug)
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: implementing fork detection and integration throughout Symphony workflows, which aligns with the primary objective from issue #531.
Linked Issues check ✅ Passed The PR implements all core requirements from #531: fork detection via ensureForkSetup, remote reconfiguration, cross-fork PR creation with proper --head/--repo arguments, and fork metadata persistence throughout workflows.
Out of Scope Changes check ✅ Passed All changes are directly scoped to fork detection and integration in Symphony workflows as specified in #531; no unrelated modifications were introduced.
Docstring Coverage ✅ Passed Docstring coverage is 83.33% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@greptile-apps
Copy link

greptile-apps bot commented Mar 8, 2026

Greptile Summary

This PR introduces fork detection and end-to-end fork support for Symphony contribution workflows. A new ensureForkSetup utility checks whether the authenticated GitHub user has push access to the target repo and, when they don't, forks it and reconfigures the local origin/upstream remotes so that subsequent git push and gh pr create commands work correctly against the fork. Fork metadata (isFork, forkSlug, upstreamSlug) is propagated through ActiveContribution, the on-disk contribution metadata, and all downstream GitHub CLI calls. Comprehensive test coverage is added across the handler, runner, and utility layers.

Key changes:

  • New src/main/utils/symphony-fork.ts: ensureForkSetup function that checks ownership → push access → forks if needed → reconfigures remotes.
  • symphony.ts + symphony-runner.ts: Call ensureForkSetup after branch creation; pass fork metadata to createDraftPR and downstream gh operations.
  • symphony-types.ts: Added optional isFork, forkSlug, upstreamSlug fields to ActiveContribution.
  • Tests: Comprehensive test coverage across three test files with fork scenario mocking.

One issue identified:

  • In symphony-runner.ts's createDraftPR, the --repo flag is incorrectly gated on both upstreamSlug && forkOwner. The flag should be set whenever upstreamSlug is present, regardless of forkOwner availability. This pattern correctly appears in symphony.ts for reference.

Confidence Score: 3/5

  • PR has one logic issue in createDraftPR that could cause PRs to be created in the fork instead of upstream if forkOwner is unavailable while upstreamSlug is set.
  • The PR is mostly solid with comprehensive test coverage and well-structured fork integration. However, there is a real logic issue in symphony-runner.ts where the --repo flag is gated on both upstreamSlug && forkOwner, when it should depend only on upstreamSlug. This would silently cause cross-fork PRs to be created in the wrong repository if forkOwner is ever undefined (allowed by the ForkSetupResult type). Additionally, there is a minor test comment inaccuracy that should be fixed for maintainability. These issues should be resolved before merge.
  • src/main/services/symphony-runner.ts (line 126, --repo flag gating) requires fixing the logic issue before this is safe to merge.

Important Files Changed

Filename Overview
src/main/services/symphony-runner.ts Fork support integrated into startContribution/finalizeContribution/cancelContribution. One logic issue: createDraftPR gates both --repo and --head on upstreamSlug && forkOwner, but --repo should be set independently whenever upstreamSlug is present, otherwise the PR is created in the fork instead of upstream.
src/tests/main/services/symphony-runner.test.ts Good fork coverage added for contribution lifecycle; one positional mock comment is inaccurate (git rev-parse HEAD vs the actual --abbrev-ref HEAD call). Minor maintainability issue.
src/main/ipc/handlers/symphony.ts Fork detection integrated after branch creation; fork metadata correctly persisted in state and passed through to downstream operations. Implements the correct pattern for separating --repo and --head flag logic.
src/main/utils/symphony-fork.ts New utility implementing fork detection and remote reconfiguration. Implementation correctly returns forkSlug whenever isFork: true. Well-structured error handling and remote reconfiguration fallback logic.
src/shared/symphony-types.ts Added optional isFork, forkSlug, and upstreamSlug fields to ActiveContribution; well-typed and non-breaking addition.
src/tests/main/utils/symphony-fork.test.ts Comprehensive unit tests for ensureForkSetup covering auth failure, ownership, push access, remote reconfiguration, and fallback paths. Good test coverage across success and error scenarios.
src/tests/main/ipc/handlers/symphony.test.ts Fork-related handler tests added covering setup invocation, error propagation, state persistence, and cross-fork PR argument validation; coverage is thorough and mocking approach is consistent.

Last reviewed commit: 42c649b

Comment on lines +126 to +136
if (upstreamSlug && forkOwner) {
args.push('--repo', upstreamSlug);
// For cross-fork PRs, --head must specify the fork owner and branch
const branchResult = await execFileNoThrow(
'git',
['rev-parse', '--abbrev-ref', 'HEAD'],
localPath
);
const branchName = branchResult.stdout.trim();
args.push('--head', `${forkOwner}:${branchName}`);
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

--repo should be set independently of --head

Both the --repo and --head flags are currently gated on upstreamSlug && forkOwner. However, --repo should always be passed when upstreamSlug is set, regardless of whether forkOwner is available. If forkOwner is undefined while upstreamSlug is set (possible per the ForkSetupResult type), the --repo flag will be silently omitted and gh pr create will target the fork instead of the upstream repository.

This differs from the equivalent logic in src/main/ipc/handlers/symphony.ts, which correctly separates the two:

if (upstreamSlug) {
    prArgs.push('--repo', upstreamSlug);
}

Suggestion — split the guards so --repo is always set when upstreamSlug is provided:

Suggested change
if (upstreamSlug && forkOwner) {
args.push('--repo', upstreamSlug);
// For cross-fork PRs, --head must specify the fork owner and branch
const branchResult = await execFileNoThrow(
'git',
['rev-parse', '--abbrev-ref', 'HEAD'],
localPath
);
const branchName = branchResult.stdout.trim();
args.push('--head', `${forkOwner}:${branchName}`);
}
if (upstreamSlug) {
args.push('--repo', upstreamSlug);
}
if (upstreamSlug && forkOwner) {
// For cross-fork PRs, --head must specify the fork owner and branch
const branchResult = await execFileNoThrow(
'git',
['rev-parse', '--abbrev-ref', 'HEAD'],
localPath
);
const branchName = branchResult.stdout.trim();
args.push('--head', `${forkOwner}:${branchName}`);
}

.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 }) // config user.email
.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 }) // commit --allow-empty
.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 }) // push
.mockResolvedValueOnce({ stdout: 'symphony/test-branch', stderr: '', exitCode: 0 }) // git rev-parse HEAD
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mock comment inaccuracy

The comment says git rev-parse HEAD but the actual call is git rev-parse --abbrev-ref HEAD (which gets the branch name, not the commit SHA). Since the mocks are positional, an inaccurate comment makes it harder to maintain this test.

Suggested change
.mockResolvedValueOnce({ stdout: 'symphony/test-branch', stderr: '', exitCode: 0 }) // git rev-parse HEAD
.mockResolvedValueOnce({ stdout: 'symphony/test-branch', stderr: '', exitCode: 0 }) // git rev-parse --abbrev-ref HEAD

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (2)
src/main/ipc/handlers/symphony.ts (2)

2072-2077: Add validation when syncing fork metadata.

The sync copies metadata.forkSlug and metadata.upstreamSlug without validating they exist. If metadata has isFork: true but missing slugs (due to a corrupted or partially written file), the contribution will have incomplete fork state.

🛡️ Suggested defensive check
 // Sync fork info from metadata to state
-if (metadata.isFork && !contribution.isFork) {
+if (metadata.isFork && metadata.upstreamSlug && !contribution.isFork) {
     contribution.isFork = metadata.isFork;
     contribution.forkSlug = metadata.forkSlug;
     contribution.upstreamSlug = metadata.upstreamSlug;
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main/ipc/handlers/symphony.ts` around lines 2072 - 2077, The sync block
that copies fork metadata (checking metadata.isFork and writing to
contribution.isFork, contribution.forkSlug, contribution.upstreamSlug) must
validate that metadata.forkSlug and metadata.upstreamSlug are present and
non-empty before assigning; update the conditional in the sync logic to require
metadata.isFork === true && metadata.forkSlug && metadata.upstreamSlug, and if
either slug is missing avoid mutating contribution (or set a safe default) and
emit a warning/log entry so corrupted/partial metadata doesn't produce
incomplete fork state.

1784-1812: Potential edge case with legacy state files.

The pattern contribution.isFork ? contribution.upstreamSlug : undefined assumes that if isFork is true, upstreamSlug will always be present. For newly created contributions this is guaranteed, but contributions deserialized from older state files (before this PR) could have isFork: true without upstreamSlug.

The sync code at lines 2073-2077 partially mitigates this by syncing fork info from metadata files, but only when metadata.isFork && !contribution.isFork. Consider adding defensive validation:

 const readyResult = await markPRReady(
     contribution.localPath,
     contribution.draftPrNumber,
-    contribution.isFork ? contribution.upstreamSlug : undefined
+    contribution.isFork && contribution.upstreamSlug ? contribution.upstreamSlug : undefined
 );

This ensures that if upstreamSlug is missing, the operation falls back to the default repository rather than passing undefined explicitly.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main/ipc/handlers/symphony.ts` around lines 1784 - 1812,
contribution.isFork may be true while contribution.upstreamSlug is missing for
legacy state files; before calling markPRReady and postPRComment compute a safe
upstreamSlug value (e.g., let upstream = contribution.isFork &&
contribution.upstreamSlug ? contribution.upstreamSlug : undefined) and pass that
variable instead of the inline ternary, so markPRReady and postPRComment always
receive either a valid upstreamSlug or undefined; update calls to
markPRReady(contribution.localPath, contribution.draftPrNumber, upstream) and
postPRComment(contribution.localPath, contribution.draftPrNumber, commentStats,
upstream).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/main/services/symphony-runner.ts`:
- Around line 126-136: The git branch lookup using execFileNoThrow in
symphony-runner.ts can return a non-zero exitCode leaving branchName empty and
producing a malformed --head arg; update the branch retrieval logic (the call
assigned to branchResult and the derived branchName) to check
branchResult.exitCode and branchResult.stdout before using it, and if the
command fails or stdout is empty either throw or bail with a clear error (or
fallback to a safe default branch name) so you never push args.push('--head',
`${forkOwner}:${branchName}`) with an empty branch; ensure any thrown/returned
error is surfaced from the function that builds args.
- Around line 411-413: The current call builds closeArgs with '--delete-branch'
and may pass '--repo', upstreamSlug, then calls execFileNoThrow('gh', closeArgs,
localPath) which can silently fail to delete a forked head branch; change the
logic in the close/cleanup flow so that when upstreamSlug is present you do NOT
include '--delete-branch' (only call 'gh pr close' with the repo flag) and
instead either skip branch deletion or attempt an explicit, permission-aware
delete (e.g., only delete when the PR head owner matches the actor or after
checking maintainer-edit permission), and ensure failures from execFileNoThrow
are surfaced/logged (inspect closeResult stderr/status) so permission errors are
not ignored; update references: closeArgs, upstreamSlug, prNumber,
execFileNoThrow, localPath.

In `@src/main/utils/symphony-fork.ts`:
- Around line 88-117: After rewiring remotes in symphony-fork.ts the new origin
lacks refs/remotes/origin/HEAD so getDefaultBranch() can fall back to
main/master; update the logic around execFileNoThrow usage (referencing
renameResult, setUrlResult, addResult and the branch that adds origin) to run
"git remote set-head origin -a" after both the successful rename path (the else
branch where addResult is handled) and the set-url fallback path (where
setUrlResult is handled) to refresh origin/HEAD; also add a unit/integration
test that simulates a fork with a non-standard default branch (e.g., develop) to
assert getDefaultBranch() resolves the correct base after the remote rewrite.

---

Nitpick comments:
In `@src/main/ipc/handlers/symphony.ts`:
- Around line 2072-2077: The sync block that copies fork metadata (checking
metadata.isFork and writing to contribution.isFork, contribution.forkSlug,
contribution.upstreamSlug) must validate that metadata.forkSlug and
metadata.upstreamSlug are present and non-empty before assigning; update the
conditional in the sync logic to require metadata.isFork === true &&
metadata.forkSlug && metadata.upstreamSlug, and if either slug is missing avoid
mutating contribution (or set a safe default) and emit a warning/log entry so
corrupted/partial metadata doesn't produce incomplete fork state.
- Around line 1784-1812: contribution.isFork may be true while
contribution.upstreamSlug is missing for legacy state files; before calling
markPRReady and postPRComment compute a safe upstreamSlug value (e.g., let
upstream = contribution.isFork && contribution.upstreamSlug ?
contribution.upstreamSlug : undefined) and pass that variable instead of the
inline ternary, so markPRReady and postPRComment always receive either a valid
upstreamSlug or undefined; update calls to markPRReady(contribution.localPath,
contribution.draftPrNumber, upstream) and postPRComment(contribution.localPath,
contribution.draftPrNumber, commentStats, upstream).

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 0ed36d8f-9fe8-4337-bb47-640a636890b4

📥 Commits

Reviewing files that changed from the base of the PR and between 7acc502 and 42c649b.

📒 Files selected for processing (7)
  • src/__tests__/main/ipc/handlers/symphony.test.ts
  • src/__tests__/main/services/symphony-runner.test.ts
  • src/__tests__/main/utils/symphony-fork.test.ts
  • src/main/ipc/handlers/symphony.ts
  • src/main/services/symphony-runner.ts
  • src/main/utils/symphony-fork.ts
  • src/shared/symphony-types.ts

- Split --repo and --head guards in createDraftPR so --repo is set
  independently when upstreamSlug is provided
- Add branch name validation before building --head argument
- Remove --delete-branch from cancelContribution for fork PRs to avoid
  cross-fork permission failures
- Add git remote set-head origin -a after remote reconfiguration to
  ensure correct default branch resolution
- Validate forkSlug and upstreamSlug exist before syncing fork metadata
- Use safe upstreamSlug access in completion handler for legacy state
- Fix test mock comments and update tests for all behavioral changes
- Fix prettier formatting in all changed files

Co-Authored-By: Claude Opus 4.6 <[email protected]>
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/main/ipc/handlers/symphony.ts (1)

2056-2084: ⚠️ Potential issue | 🟠 Major

Fork metadata is persisted, but not reliably loaded back into ActiveContribution.

These sync paths only read metadata.json when draftPrNumber is missing. In the session flow, state can already know the PR number while still missing isFork/forkSlug/upstreamSlug, so symphony:complete later calls markPRReady and postPRComment without --repo for fork PRs. Please merge the fork fields whenever they are absent, independently of PR-number recovery.

Also applies to: 2250-2278, 2705-2709

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main/ipc/handlers/symphony.ts` around lines 2056 - 2084, The metadata
sync currently only applies fork fields when prCreated/draftPrNumber path runs;
change the logic so fork info is merged independently: after parsing metadata
(the JSON block that sets prCreated/draftPrNumber and prInfoSynced), always
check if metadata.isFork && metadata.forkSlug && metadata.upstreamSlug and if
contribution.isFork is falsy, then set contribution.isFork = metadata.isFork,
contribution.forkSlug = metadata.forkSlug, contribution.upstreamSlug =
metadata.upstreamSlug; apply the same independent-fork-merge change to the other
two sync sites that handle metadata (the similar blocks around the code that
sets draftPrNumber/prCreated/prInfoSynced at the other referenced locations).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/__tests__/main/ipc/handlers/symphony.test.ts`:
- Around line 5224-5345: The tests under "fork support" miss the upstream-only
metadata case; add a new it(...) similar to the two existing tests that uses
createValidMetadata({ upstreamSlug: 'owner/repo' }) but no forkSlug, mocks
fs.readFile and execFileNoThrow the same way, calls getCreateDraftPRHandler()
with contributionId 'contrib_draft_test', asserts result.success is true, then
finds the gh pr create call (via
vi.mocked(execFileNoThrow).mock.calls.find(...)) and verifies that the args
include --repo owner/repo while the --head value is just the branch name (i.e.,
does not contain ':'), thereby covering the legacy/partial-metadata branch.

In `@src/__tests__/main/services/symphony-runner.test.ts`:
- Around line 1381-1387: The test currently only asserts ensureForkSetup was
called with the right args but not that it ran after branch creation; update the
test to assert call order by locating the mock for the branch checkout step
(e.g., the mocked function that performs "git checkout -b" — find the mock used
in this test suite such as checkoutBranch, gitExec, or similar) and add an
assertion that ensureForkSetup was invoked after that checkout mock (use
standard Jest invocation order, e.g. compare
ensureForkSetup.mock.invocationCallOrder[0] >
checkoutMock.mock.invocationCallOrder[0], or use toHaveBeenCalledAfter if your
test utils provide it) so the test enforces the ordering when calling
startContribution.

In `@src/main/ipc/handlers/symphony.ts`:
- Around line 2605-2618: Call getDefaultBranch() to capture the upstream default
branch before calling ensureForkSetup(), because ensureForkSetup() rewrites
origin to point at the fork; specifically, invoke getDefaultBranch(localPath,
repoSlug) (or the existing getDefaultBranch call site) immediately before the
block that logs "Checking fork requirements" and stores its result in a variable
like upstreamDefaultBranch, then use upstreamDefaultBranch later when
constructing the PR --base value instead of relying on refs/remotes/origin/HEAD
after ensureForkSetup(); do not change ensureForkSetup() itself, just read and
store the upstream default branch prior to calling ensureForkSetup(localPath,
repoSlug).

In `@src/main/services/symphony-runner.ts`:
- Around line 416-423: The PR-close step builds closeArgs and calls
execFileNoThrow('gh', closeArgs, localPath) producing closeResult, but the code
currently logs errors and proceeds as if cancellation succeeded; change the flow
in the function that owns closeArgs/execFileNoThrow/closeResult (the PR
cancellation path) to check closeResult.success and when false immediately
return a failure result (e.g., { success: false } or throw) without deleting the
local checkout or continuing; ensure the existing log remains and include the
closeResult error/output in the returned failure context so the caller can
surface the cancellation failure to the user and retry cleanly.

---

Outside diff comments:
In `@src/main/ipc/handlers/symphony.ts`:
- Around line 2056-2084: The metadata sync currently only applies fork fields
when prCreated/draftPrNumber path runs; change the logic so fork info is merged
independently: after parsing metadata (the JSON block that sets
prCreated/draftPrNumber and prInfoSynced), always check if metadata.isFork &&
metadata.forkSlug && metadata.upstreamSlug and if contribution.isFork is falsy,
then set contribution.isFork = metadata.isFork, contribution.forkSlug =
metadata.forkSlug, contribution.upstreamSlug = metadata.upstreamSlug; apply the
same independent-fork-merge change to the other two sync sites that handle
metadata (the similar blocks around the code that sets
draftPrNumber/prCreated/prInfoSynced at the other referenced locations).

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 109b412d-55ad-4527-9d99-468fbd837395

📥 Commits

Reviewing files that changed from the base of the PR and between 42c649b and 84732bd.

📒 Files selected for processing (6)
  • src/__tests__/main/ipc/handlers/symphony.test.ts
  • src/__tests__/main/services/symphony-runner.test.ts
  • src/__tests__/main/utils/symphony-fork.test.ts
  • src/main/ipc/handlers/symphony.ts
  • src/main/services/symphony-runner.ts
  • src/main/utils/symphony-fork.ts

- Add upstream-only metadata test covering legacy/partial-metadata branch
- Assert ensureForkSetup call order (after branch creation, not just args)
- Capture upstream default branch before ensureForkSetup rewrites origin
- Return failure from cancelContribution when gh pr close fails
- Make fork info sync independent of draftPrNumber at both sync sites
- Handle upstreamSlug in deferred PR creation without requiring isFork

Co-Authored-By: Claude Opus 4.6 <[email protected]>
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
src/main/ipc/handlers/symphony.ts (2)

1784-1814: ⚠️ Potential issue | 🟠 Major

Backfill fork context before calling markPRReady() and postPRComment().

In the symphony:startContribution + registerActive() flow, ActiveContribution still is not populated with isFork / upstreamSlug. If the user completes before a sync pass repairs state, this block runs without the upstream repo context required for a cross-fork PR. Either persist those fields into state.active when the contribution is registered/updated, or load them from metadata.json here before invoking the GH commands.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main/ipc/handlers/symphony.ts` around lines 1784 - 1814, The
ActiveContribution may lack isFork/upstreamSlug when this code runs; before
calling markPRReady and postPRComment, backfill fork context into contribution
by loading the repo metadata (e.g., reading metadata.json under
contribution.localPath or using your existing metadata loader) and set
contribution.isFork and contribution.upstreamSlug accordingly (or derive
upstreamSlug into a local upstreamSlug variable if absent on contribution) so
the calls to markPRReady(...) and postPRComment(...) always get correct
upstreamFork context; ensure this backfill happens in the
symphony:startContribution/registerActive flow or immediately prior to invoking
markPRReady and postPRComment.

2242-2280: ⚠️ Potential issue | 🟠 Major

Carry forkSlug into PR recovery.

This step now restores forkSlug, but the recovery step below still calls discoverPRByBranch(contribution.repoSlug, contribution.branchName). For fork contributions that lost draftPrNumber, that lookup still searches with the upstream owner instead of forkOwner:branch, so the existing cross-fork PR is never rediscovered. The same fix is needed in checkPRStatuses().

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main/ipc/handlers/symphony.ts` around lines 2242 - 2280, The PR recovery
misses cross-fork lookups because discoverPRByBranch(contribution.repoSlug,
contribution.branchName) always uses the upstream repo owner; when you restore
contribution.forkSlug you must call discoverPRByBranch with the fork owner
instead of contribution.repoSlug (e.g., use contribution.forkSlug or construct
the fork repo slug) so it searches forkOwner:branch; update the same logic
inside checkPRStatuses() to prefer contribution.forkSlug when
contribution.isFork/forkSlug is present before calling discoverPRByBranch.
♻️ Duplicate comments (1)
src/main/ipc/handlers/symphony.ts (1)

2948-2963: ⚠️ Potential issue | 🟠 Major

Reuse the upstream base branch in the deferred PR path.

This handler now targets metadata.upstreamSlug, but baseBranch was still resolved earlier from the current origin. After ensureForkSetup() repoints origin at the fork, a fork with a different default branch can make the deferred rev-list / PR creation flow use the fork’s default instead of the upstream base. Persist the pre-fork branch from symphony:startContribution() metadata and use it here.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main/ipc/handlers/symphony.ts` around lines 2948 - 2963, The deferred PR
flow still uses the local variable baseBranch which may have been repointed to
the fork; replace that with the pre-fork/upstream base branch persisted in the
startContribution metadata (e.g. metadata.preForkBaseBranch or
metadata.upstreamBaseBranch) when calling createDraftPR: compute a
baseBranchToUse = metadata.preForkBaseBranch ?? baseBranch and pass
baseBranchToUse to createDraftPR (keep metaForkOwner and other args unchanged)
so the PR uses the original upstream base branch.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/__tests__/main/ipc/handlers/symphony.test.ts`:
- Around line 5224-5407: The tests only assert gh pr create flags but not the
preceding git push behavior; update each relevant test (those using
getCreateDraftPRHandler and mocking execFileNoThrow) to also find the git push
execFileNoThrow mock call (search mock.calls for call[0] === 'git' &&
call[1]?.[0] === 'push') and assert its args follow the fork-aware path: when
metadata.isFork / forkSlug is present, ensure the push refspec or remote
includes the fork owner (e.g., a refspec containing "chris:" or remote target
matching the fork), and when no fork/forkSlug or only upstreamSlug is present,
assert the push args do not include a fork owner prefix (no ':' in the pushed
refspec); use the same execFileNoThrow mock call inspection pattern as the
existing prCall assertions.
- Around line 2131-2155: The test asserts ensureForkSetup was called with
correct args but not that it ran after branch creation; update the test using
the mocks for execFileNoThrow and ensureForkSetup (and the getStartHandler
invocation) to assert call order: identify the execFileNoThrow call that
performs the git checkout/-b (check args.includes('checkout') or args[0] ===
'checkout') and assert its mock invocation occurs before ensureForkSetup's
invocation (e.g., compare mock.invocationCallOrder entries or compare positions
in mock.calls), and apply the same ordering assertion to the other similar test
covering the same flow where ensureForkSetup must run after branch creation.

---

Outside diff comments:
In `@src/main/ipc/handlers/symphony.ts`:
- Around line 1784-1814: The ActiveContribution may lack isFork/upstreamSlug
when this code runs; before calling markPRReady and postPRComment, backfill fork
context into contribution by loading the repo metadata (e.g., reading
metadata.json under contribution.localPath or using your existing metadata
loader) and set contribution.isFork and contribution.upstreamSlug accordingly
(or derive upstreamSlug into a local upstreamSlug variable if absent on
contribution) so the calls to markPRReady(...) and postPRComment(...) always get
correct upstreamFork context; ensure this backfill happens in the
symphony:startContribution/registerActive flow or immediately prior to invoking
markPRReady and postPRComment.
- Around line 2242-2280: The PR recovery misses cross-fork lookups because
discoverPRByBranch(contribution.repoSlug, contribution.branchName) always uses
the upstream repo owner; when you restore contribution.forkSlug you must call
discoverPRByBranch with the fork owner instead of contribution.repoSlug (e.g.,
use contribution.forkSlug or construct the fork repo slug) so it searches
forkOwner:branch; update the same logic inside checkPRStatuses() to prefer
contribution.forkSlug when contribution.isFork/forkSlug is present before
calling discoverPRByBranch.

---

Duplicate comments:
In `@src/main/ipc/handlers/symphony.ts`:
- Around line 2948-2963: The deferred PR flow still uses the local variable
baseBranch which may have been repointed to the fork; replace that with the
pre-fork/upstream base branch persisted in the startContribution metadata (e.g.
metadata.preForkBaseBranch or metadata.upstreamBaseBranch) when calling
createDraftPR: compute a baseBranchToUse = metadata.preForkBaseBranch ??
baseBranch and pass baseBranchToUse to createDraftPR (keep metaForkOwner and
other args unchanged) so the PR uses the original upstream base branch.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 31e0978a-4b28-4a06-8022-d3df66f169bf

📥 Commits

Reviewing files that changed from the base of the PR and between 84732bd and caab3fa.

📒 Files selected for processing (4)
  • src/__tests__/main/ipc/handlers/symphony.test.ts
  • src/__tests__/main/services/symphony-runner.test.ts
  • src/main/ipc/handlers/symphony.ts
  • src/main/services/symphony-runner.ts

- Fix discoverPRByBranch to use fork owner as head ref for cross-fork PRs
- Persist upstreamDefaultBranch in metadata so deferred PR creation uses
  correct base branch after fork setup reconfigures origin
- Add call order assertion: branch creation before ensureForkSetup

Co-Authored-By: Claude Opus 4.6 <[email protected]>
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (2)
src/__tests__/main/ipc/handlers/symphony.test.ts (2)

4654-4682: ⚠️ Potential issue | 🟡 Minor

Assert the call ordering here too.

This test only proves ensureForkSetup() was called, not that it ran after git checkout -b as the name promises. Reuse the invocation-order check from the sibling symphony:start fork test so this suite catches a reordered call.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/__tests__/main/ipc/handlers/symphony.test.ts` around lines 4654 - 4682,
The test only checks ensureForkSetup was called, not that it ran after the git
checkout; update the test that uses getStartContributionHandler,
vi.mocked(execFileNoThrow) and vi.mocked(ensureForkSetup) to also assert call
ordering: capture the invocation order of the execFileNoThrow call for the 'git'
'checkout' branch creation and compare it to ensureForkSetup's invocation order
(ensureForkSetup's invocation index is greater), so the test verifies
ensureForkSetup ran after the checkout; reference the mocked functions
execFileNoThrow, ensureForkSetup and the handler from
getStartContributionHandler and use their mock invocation metadata to perform
the ordering assertion.

5237-5419: ⚠️ Potential issue | 🟠 Major

Also assert the fork-aware git push path.

These cases only inspect gh pr create args. The Linux regression from #531 happens earlier if the branch is still pushed to upstream, so the suite can stay green while the real failure is back. Check the preceding git push call targets the fork when forkSlug exists and stays unprefixed otherwise.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/__tests__/main/ipc/handlers/symphony.test.ts` around lines 5237 - 5419,
Add assertions that verify the fork-aware git push call was made: find the
mocked execFileNoThrow call where call[0] === 'git' and call[1]?[0] === 'push'
and assert its args target the fork when metadata.forkSlug is present (metadata
created with forkSlug: 'chris/repo' — expect the push args to include the fork
owner prefix 'chris' or a ref like 'chris:branchName'), and assert it does not
include that fork prefix when metadata has no forkSlug (or only upstreamSlug);
update the three tests (the ones using createValidMetadata with fork info,
without fork info, and with only upstreamSlug) to locate the push call via
vi.mocked(execFileNoThrow).mock.calls and add the corresponding expectation on
the push call arguments to ensure push targets the fork in the fork case and the
plain branch/remote in the non-fork/upstream-only cases.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/__tests__/main/ipc/handlers/symphony.test.ts`:
- Around line 2190-2225: The test "should persist fork info in contribution when
fork is needed" currently verifies isFork, forkSlug, and upstreamSlug but omits
upstreamDefaultBranch; update the test fixtures and mocks so the fork flow
includes and persists upstreamDefaultBranch (e.g., have
vi.mocked(ensureForkSetup) resolve with upstreamDefaultBranch set to a value
different from origin/HEAD) then assert savedContrib.upstreamDefaultBranch
equals that value; ensure any other fork-related tests (the ones noted) receive
the same upstreamDefaultBranch in ensureForkSetup and include assertions that
the persisted state (from fs.writeFile inspected via writeStateCall) contains
upstreamDefaultBranch so the deferred gh pr create logic can rely on it.

---

Duplicate comments:
In `@src/__tests__/main/ipc/handlers/symphony.test.ts`:
- Around line 4654-4682: The test only checks ensureForkSetup was called, not
that it ran after the git checkout; update the test that uses
getStartContributionHandler, vi.mocked(execFileNoThrow) and
vi.mocked(ensureForkSetup) to also assert call ordering: capture the invocation
order of the execFileNoThrow call for the 'git' 'checkout' branch creation and
compare it to ensureForkSetup's invocation order (ensureForkSetup's invocation
index is greater), so the test verifies ensureForkSetup ran after the checkout;
reference the mocked functions execFileNoThrow, ensureForkSetup and the handler
from getStartContributionHandler and use their mock invocation metadata to
perform the ordering assertion.
- Around line 5237-5419: Add assertions that verify the fork-aware git push call
was made: find the mocked execFileNoThrow call where call[0] === 'git' and
call[1]?[0] === 'push' and assert its args target the fork when
metadata.forkSlug is present (metadata created with forkSlug: 'chris/repo' —
expect the push args to include the fork owner prefix 'chris' or a ref like
'chris:branchName'), and assert it does not include that fork prefix when
metadata has no forkSlug (or only upstreamSlug); update the three tests (the
ones using createValidMetadata with fork info, without fork info, and with only
upstreamSlug) to locate the push call via vi.mocked(execFileNoThrow).mock.calls
and add the corresponding expectation on the push call arguments to ensure push
targets the fork in the fork case and the plain branch/remote in the
non-fork/upstream-only cases.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: fe0335fd-8299-47ca-98f7-7738bc8fcb79

📥 Commits

Reviewing files that changed from the base of the PR and between caab3fa and 3f3e57c.

📒 Files selected for processing (2)
  • src/__tests__/main/ipc/handlers/symphony.test.ts
  • src/main/ipc/handlers/symphony.ts

…call ordering

- Assert upstreamDefaultBranch is persisted in startContribution fork metadata
- Verify ensureForkSetup runs after git checkout using invocationCallOrder
- Assert deferred createDraftPR uses persisted upstreamDefaultBranch as --base

Co-Authored-By: Claude Opus 4.6 <[email protected]>
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (1)
src/__tests__/main/ipc/handlers/symphony.test.ts (1)

2227-2265: ⚠️ Potential issue | 🟠 Major

Assert the fork-aware git push path too.

These cases still only prove the gh pr create flags. Issue #531 fails earlier if the branch is pushed to upstream instead of the fork, so the regression can come back while these tests stay green. Please inspect the preceding git push call and verify it targets the fork when forkSlug is present, and the non-fork path otherwise.

Also applies to: 5249-5436

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/__tests__/main/ipc/handlers/symphony.test.ts` around lines 2227 - 2265,
Add assertions that the mocked git push targets the fork when ensureForkSetup
returns isFork:true and targets upstream when not a fork: after calling handler,
find the push call from vi.mocked(execFileNoThrow).mock.calls where call[0] ===
'git' and call[1]?.[0] === 'push' and assert its args include the fork
remote/name derived from forkSlug (e.g., 'chris' or a 'chris:branch' ref) when
forkSlug is 'chris/repo'; also add a parallel test or branch check that when
ensureForkSetup resolves { isFork: false } the push args target the upstream
remote (e.g., 'origin' or no fork-prefixed ref). Ensure you reference the mocked
execFileNoThrow calls and the ensureForkSetup mock used in the test (and reuse
getStartHandler/validStartParams) so the push behavior is verified alongside the
gh pr create checks.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/__tests__/main/ipc/handlers/symphony.test.ts`:
- Around line 5318-5373: The test's check of the GitHub CLI `--head` behavior is
unsafe because it uses headIdx = prArgs.indexOf('--head') and then reads
prArgs[headIdx + 1] without ensuring headIdx !== -1; update the assertions in
the test that calls getCreateDraftPRHandler() (and the similar block around the
other test) to first assert that '--head' exists
(expect(headIdx).toBeGreaterThanOrEqual(0)) before validating the following
value, or—if the intended contract is that `--head` must be omitted for non-fork
metadata—assert that prArgs does not contain '--head'
(expect(prArgs).not.toContain('--head')) instead; adjust the checks near the
mocked execFileNoThrow/prCall/prArgs/headIdx references accordingly so they
reliably verify presence/absence and the branch-name format for `--head`.

---

Duplicate comments:
In `@src/__tests__/main/ipc/handlers/symphony.test.ts`:
- Around line 2227-2265: Add assertions that the mocked git push targets the
fork when ensureForkSetup returns isFork:true and targets upstream when not a
fork: after calling handler, find the push call from
vi.mocked(execFileNoThrow).mock.calls where call[0] === 'git' and call[1]?.[0]
=== 'push' and assert its args include the fork remote/name derived from
forkSlug (e.g., 'chris' or a 'chris:branch' ref) when forkSlug is 'chris/repo';
also add a parallel test or branch check that when ensureForkSetup resolves {
isFork: false } the push args target the upstream remote (e.g., 'origin' or no
fork-prefixed ref). Ensure you reference the mocked execFileNoThrow calls and
the ensureForkSetup mock used in the test (and reuse
getStartHandler/validStartParams) so the push behavior is verified alongside the
gh pr create checks.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 9a8f7c9e-9cd3-4002-b824-aacb62aa8966

📥 Commits

Reviewing files that changed from the base of the PR and between 3f3e57c and 6ab5f87.

📒 Files selected for processing (1)
  • src/__tests__/main/ipc/handlers/symphony.test.ts

…k tests

Ensure headIdx, repoIdx, and baseIdx are asserted >= 0 before accessing
the following array element, preventing silent false-passes if the flag
were ever missing from gh pr create args.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
@chr1syy
Copy link
Contributor Author

chr1syy commented Mar 8, 2026

@coderabbitai re-review please

@coderabbitai
Copy link

coderabbitai bot commented Mar 8, 2026

@chr1syy Sure! I'll kick off a fresh review of the PR now.

✅ Actions performed

Full review triggered.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/main/services/symphony-runner.ts (1)

396-403: ⚠️ Potential issue | 🟠 Major

Check gh pr view before returning success.

If gh pr view exits non-zero, this path still returns success: true with an empty prUrl, so the caller treats finalization as complete even though the upstream PR lookup failed.

Suggested fix
 const prInfoResult = await execFileNoThrow('gh', viewArgs, localPath);
+if (prInfoResult.exitCode !== 0) {
+	return { success: false, error: `Failed to fetch PR URL: ${prInfoResult.stderr}` };
+}
 
 return {
 	success: true,
 	prUrl: prInfoResult.stdout.trim(),
 };
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main/services/symphony-runner.ts` around lines 396 - 403, The current
return always sets success: true even when execFileNoThrow('gh', viewArgs,
localPath) fails; update the logic that builds the response after calling
execFileNoThrow (symbols: viewArgs, execFileNoThrow, prInfoResult, prUrl,
success) to check prInfoResult.exitCode/ code (or prInfoResult.stderr) and that
prInfoResult.stdout.trim() is non-empty before returning success: true; if the
gh command failed or produced empty output, return success: false and include
the error/stderr (or a clear message) instead of an empty prUrl so callers know
the PR lookup failed.
♻️ Duplicate comments (1)
src/__tests__/main/ipc/handlers/symphony.test.ts (1)

2227-2266: ⚠️ Potential issue | 🟠 Major

Assert the fork-aware git push path in the fork PR tests.

These cases only lock in gh pr create flags. Issue #531 breaks earlier when the branch is pushed to a remote the user cannot write to, so the regression can come back while these tests stay green.

What to assert
+const pushCall = vi.mocked(execFileNoThrow).mock.calls.find(
+	(call) => call[0] === 'git' && call[1]?.[0] === 'push'
+);
+expect(pushCall).toBeDefined();
+// fork case: assert the push targets the fork-aware remote/refspec
+// non-fork case: assert no fork-owner prefix is present

Also applies to: 5250-5441

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/__tests__/main/ipc/handlers/symphony.test.ts` around lines 2227 - 2266,
The test only asserts gh pr create flags but not that the branch was pushed to
the fork remote; add assertions that execFileNoThrow was invoked for the git
push path and that the push targeted the fork owner from ensureForkSetup
(forkSlug 'chris/...') rather than the upstream remote. Specifically, after
calling getStartHandler() check vi.mocked(execFileNoThrow).mock.calls for a call
where call[0] === 'git' and call[1]?.[0] === 'push' and assert the push args
include the fork remote or ref containing 'chris' and the branch name returned
by the mocked 'git rev-parse' ('symphony/issue-42-abc'), so the test verifies
the fork-aware git push behavior (references: getStartHandler, execFileNoThrow,
ensureForkSetup).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/main/ipc/handlers/symphony.ts`:
- Around line 2720-2725: The fork metadata (forkResult.isFork,
forkResult.forkSlug, repoSlug/upstreamSlug) is only being written to
metadata.json but not persisted into the in-memory state used by later handlers;
update the code paths that currently spread fork fields into the metadata object
(the block using upstreamDefaultBranch, isFork: forkResult.isFork,
...(forkResult.isFork && { forkSlug: forkResult.forkSlug, upstreamSlug: repoSlug
})) to also write the same fields into state.active.contribution (e.g. set
state.active.contribution.isFork = forkResult.isFork and when true set
state.active.contribution.forkSlug = forkResult.forkSlug and
state.active.contribution.upstreamSlug = repoSlug) so symphony:complete can read
them immediately; apply this change to the other similar spots mentioned (the
blocks around the other diffs at 2775-2782 and 2828-2834) and ensure any state
persistence function that writes metadata.json is accompanied by the
corresponding update to state.active before returning.

In `@src/main/utils/symphony-fork.ts`:
- Around line 88-120: The git commands that rewrite remotes and set HEAD (the
execFileNoThrow invocations for ['remote','rename','origin','upstream'],
['remote','set-url','origin',forkCloneUrl],
['remote','add','origin',forkCloneUrl'] and ['remote','set-head','origin','-a'])
are called without the expanded environment; update each execFileNoThrow call in
symphony-fork.ts to include the expanded env from getExpandedEnv() (pass it into
the execFileNoThrow options/env parameter) so those git child processes use the
same environment as the earlier gh calls and avoid resolution failures.
- Around line 93-106: The rename fallback currently runs for any failure of the
git remote rename operation; update the logic around renameResult (the result of
the git remote rename origin upstream executed via execFileNoThrow) to only
attempt the set-url fallback when stderr or exitCode indicates the specific
"remote upstream already exists" error (or an equivalent recognizable message),
otherwise surface/log the original error and return failure; keep using
logger.warn/error, still reference forkCloneUrl and repoPath for the set-url
call when the specific condition matches, and do not rewrite origin for
unrelated rename failures.
- Around line 42-51: The probe that checks push permission using
execFileNoThrow('gh', ['api', `repos/${repoSlug}`, '--jq', '.permissions.push'],
...) currently treats non-zero exit codes as "no push access" and continues to
the fork path; change this to fail fast instead: in the code that inspects
accessResult (the variable returned from execFileNoThrow), if
accessResult.exitCode !== 0 log an error with the accessResult.stdout/stderr and
either throw an Error or return a failing result rather than proceeding to
create/rewire a fork (update the block that currently checks
accessResult.exitCode === 0 && accessResult.stdout.trim() === 'true' to handle
the non-zero case explicitly), keeping logger.info(LOG_CONTEXT) usages for the
successful branch.

---

Outside diff comments:
In `@src/main/services/symphony-runner.ts`:
- Around line 396-403: The current return always sets success: true even when
execFileNoThrow('gh', viewArgs, localPath) fails; update the logic that builds
the response after calling execFileNoThrow (symbols: viewArgs, execFileNoThrow,
prInfoResult, prUrl, success) to check prInfoResult.exitCode/ code (or
prInfoResult.stderr) and that prInfoResult.stdout.trim() is non-empty before
returning success: true; if the gh command failed or produced empty output,
return success: false and include the error/stderr (or a clear message) instead
of an empty prUrl so callers know the PR lookup failed.

---

Duplicate comments:
In `@src/__tests__/main/ipc/handlers/symphony.test.ts`:
- Around line 2227-2266: The test only asserts gh pr create flags but not that
the branch was pushed to the fork remote; add assertions that execFileNoThrow
was invoked for the git push path and that the push targeted the fork owner from
ensureForkSetup (forkSlug 'chris/...') rather than the upstream remote.
Specifically, after calling getStartHandler() check
vi.mocked(execFileNoThrow).mock.calls for a call where call[0] === 'git' and
call[1]?.[0] === 'push' and assert the push args include the fork remote or ref
containing 'chris' and the branch name returned by the mocked 'git rev-parse'
('symphony/issue-42-abc'), so the test verifies the fork-aware git push behavior
(references: getStartHandler, execFileNoThrow, ensureForkSetup).

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 0db1d59c-f90f-41cf-8e70-879858e3f3b1

📥 Commits

Reviewing files that changed from the base of the PR and between 7acc502 and 319c9bf.

📒 Files selected for processing (7)
  • src/__tests__/main/ipc/handlers/symphony.test.ts
  • src/__tests__/main/services/symphony-runner.test.ts
  • src/__tests__/main/utils/symphony-fork.test.ts
  • src/main/ipc/handlers/symphony.ts
  • src/main/services/symphony-runner.ts
  • src/main/utils/symphony-fork.ts
  • src/shared/symphony-types.ts

Comment on lines +2720 to +2725
upstreamDefaultBranch,
isFork: forkResult.isFork,
...(forkResult.isFork && {
forkSlug: forkResult.forkSlug,
upstreamSlug: repoSlug,
}),
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Persist fork metadata into state.active, not just metadata.json.

In this workflow the fork fields are only written to metadata.json. symphony:complete later reads contribution.isFork / contribution.upstreamSlug from state.active to decide whether to add --repo, so a fork contribution can still be completed against the fork checkout until checkPRStatuses or syncContribution happens to backfill state.

Suggested fix
 					await fs.writeFile(
 						metadataPath,
 						JSON.stringify(
 							{
@@
 							2
 						)
 					);
@@
 						if (prResult.success) {
 							draftPrNumber = prResult.prNumber;
 							draftPrUrl = prResult.prUrl;
@@
 							await fs.writeFile(metadataPath, JSON.stringify(metaContent, null, 2));
 						}
 					}
+
+					const state = await readState(app);
+					const activeContribution = state.active.find((c) => c.id === contributionId);
+					if (activeContribution) {
+						activeContribution.isFork = forkResult.isFork;
+						if (forkResult.isFork) {
+							activeContribution.forkSlug = forkResult.forkSlug;
+							activeContribution.upstreamSlug = repoSlug;
+						}
+						activeContribution.draftPrNumber = draftPrNumber;
+						activeContribution.draftPrUrl = draftPrUrl;
+						await writeState(app, state);
+					}
 
 					// 6. Broadcast status update
 					const mainWindow = getMainWindow?.();

Also applies to: 2775-2782, 2828-2834

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main/ipc/handlers/symphony.ts` around lines 2720 - 2725, The fork
metadata (forkResult.isFork, forkResult.forkSlug, repoSlug/upstreamSlug) is only
being written to metadata.json but not persisted into the in-memory state used
by later handlers; update the code paths that currently spread fork fields into
the metadata object (the block using upstreamDefaultBranch, isFork:
forkResult.isFork, ...(forkResult.isFork && { forkSlug: forkResult.forkSlug,
upstreamSlug: repoSlug })) to also write the same fields into
state.active.contribution (e.g. set state.active.contribution.isFork =
forkResult.isFork and when true set state.active.contribution.forkSlug =
forkResult.forkSlug and state.active.contribution.upstreamSlug = repoSlug) so
symphony:complete can read them immediately; apply this change to the other
similar spots mentioned (the blocks around the other diffs at 2775-2782 and
2828-2834) and ensure any state persistence function that writes metadata.json
is accompanied by the corresponding update to state.active before returning.

Comment on lines +42 to +51
const accessResult = await execFileNoThrow(
'gh',
['api', `repos/${repoSlug}`, '--jq', '.permissions.push'],
undefined,
env
);
if (accessResult.exitCode === 0 && accessResult.stdout.trim() === 'true') {
logger.info('User has push access, no fork needed', LOG_CONTEXT);
return { isFork: false };
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Fail fast when the push-permission probe fails.

A non-zero gh api repos/${repoSlug} result currently falls through into the fork path. That can create or rewire a fork because of an unrelated API/auth/network failure instead of because push access was actually denied.

Possible fix
 	const accessResult = await execFileNoThrow(
 		'gh',
 		['api', `repos/${repoSlug}`, '--jq', '.permissions.push'],
 		undefined,
 		env
 	);
-	if (accessResult.exitCode === 0 && accessResult.stdout.trim() === 'true') {
+	if (accessResult.exitCode !== 0) {
+		logger.error('Failed to check push access', LOG_CONTEXT, { stderr: accessResult.stderr });
+		return { isFork: false, error: `Failed to check push access: ${accessResult.stderr}` };
+	}
+	if (accessResult.stdout.trim() === 'true') {
 		logger.info('User has push access, no fork needed', LOG_CONTEXT);
 		return { isFork: false };
 	}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const accessResult = await execFileNoThrow(
'gh',
['api', `repos/${repoSlug}`, '--jq', '.permissions.push'],
undefined,
env
);
if (accessResult.exitCode === 0 && accessResult.stdout.trim() === 'true') {
logger.info('User has push access, no fork needed', LOG_CONTEXT);
return { isFork: false };
}
const accessResult = await execFileNoThrow(
'gh',
['api', `repos/${repoSlug}`, '--jq', '.permissions.push'],
undefined,
env
);
if (accessResult.exitCode !== 0) {
logger.error('Failed to check push access', LOG_CONTEXT, { stderr: accessResult.stderr });
return { isFork: false, error: `Failed to check push access: ${accessResult.stderr}` };
}
if (accessResult.stdout.trim() === 'true') {
logger.info('User has push access, no fork needed', LOG_CONTEXT);
return { isFork: false };
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main/utils/symphony-fork.ts` around lines 42 - 51, The probe that checks
push permission using execFileNoThrow('gh', ['api', `repos/${repoSlug}`, '--jq',
'.permissions.push'], ...) currently treats non-zero exit codes as "no push
access" and continues to the fork path; change this to fail fast instead: in the
code that inspects accessResult (the variable returned from execFileNoThrow), if
accessResult.exitCode !== 0 log an error with the accessResult.stdout/stderr and
either throw an Error or return a failing result rather than proceeding to
create/rewire a fork (update the block that currently checks
accessResult.exitCode === 0 && accessResult.stdout.trim() === 'true' to handle
the non-zero case explicitly), keeping logger.info(LOG_CONTEXT) usages for the
successful branch.

Comment on lines +88 to +120
const renameResult = await execFileNoThrow(
'git',
['remote', 'rename', 'origin', 'upstream'],
repoPath
);
if (renameResult.exitCode !== 0) {
// Fallback: upstream already exists, just set origin URL
logger.warn('Could not rename origin to upstream, trying set-url fallback', LOG_CONTEXT, {
stderr: renameResult.stderr,
});
const setUrlResult = await execFileNoThrow(
'git',
['remote', 'set-url', 'origin', forkCloneUrl],
repoPath
);
if (setUrlResult.exitCode !== 0) {
logger.error('Failed to set origin URL', LOG_CONTEXT, { stderr: setUrlResult.stderr });
return { isFork: false, error: `Failed to reconfigure remotes: ${setUrlResult.stderr}` };
}
} else {
const addResult = await execFileNoThrow(
'git',
['remote', 'add', 'origin', forkCloneUrl],
repoPath
);
if (addResult.exitCode !== 0) {
logger.error('Failed to add origin remote', LOG_CONTEXT, { stderr: addResult.stderr });
return { isFork: false, error: `Failed to add origin remote: ${addResult.stderr}` };
}
}

// Set HEAD for origin so getDefaultBranch() works correctly with fork remotes
await execFileNoThrow('git', ['remote', 'set-head', 'origin', '-a'], repoPath);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Pass the expanded env to the git calls too.

getExpandedEnv() is prepared up front, but the remote rewrite and set-head commands run without it. In packaged Electron installs that can make fork setup fail with git resolution errors even though the earlier gh calls succeeded.

Possible fix
 	const renameResult = await execFileNoThrow(
 		'git',
 		['remote', 'rename', 'origin', 'upstream'],
-		repoPath
+		repoPath,
+		env
 	);
@@
 		const setUrlResult = await execFileNoThrow(
 			'git',
 			['remote', 'set-url', 'origin', forkCloneUrl],
-			repoPath
+			repoPath,
+			env
 		);
@@
 		const addResult = await execFileNoThrow(
 			'git',
 			['remote', 'add', 'origin', forkCloneUrl],
-			repoPath
+			repoPath,
+			env
 		);
@@
-	await execFileNoThrow('git', ['remote', 'set-head', 'origin', '-a'], repoPath);
+	await execFileNoThrow('git', ['remote', 'set-head', 'origin', '-a'], repoPath, env);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const renameResult = await execFileNoThrow(
'git',
['remote', 'rename', 'origin', 'upstream'],
repoPath
);
if (renameResult.exitCode !== 0) {
// Fallback: upstream already exists, just set origin URL
logger.warn('Could not rename origin to upstream, trying set-url fallback', LOG_CONTEXT, {
stderr: renameResult.stderr,
});
const setUrlResult = await execFileNoThrow(
'git',
['remote', 'set-url', 'origin', forkCloneUrl],
repoPath
);
if (setUrlResult.exitCode !== 0) {
logger.error('Failed to set origin URL', LOG_CONTEXT, { stderr: setUrlResult.stderr });
return { isFork: false, error: `Failed to reconfigure remotes: ${setUrlResult.stderr}` };
}
} else {
const addResult = await execFileNoThrow(
'git',
['remote', 'add', 'origin', forkCloneUrl],
repoPath
);
if (addResult.exitCode !== 0) {
logger.error('Failed to add origin remote', LOG_CONTEXT, { stderr: addResult.stderr });
return { isFork: false, error: `Failed to add origin remote: ${addResult.stderr}` };
}
}
// Set HEAD for origin so getDefaultBranch() works correctly with fork remotes
await execFileNoThrow('git', ['remote', 'set-head', 'origin', '-a'], repoPath);
const renameResult = await execFileNoThrow(
'git',
['remote', 'rename', 'origin', 'upstream'],
repoPath,
env
);
if (renameResult.exitCode !== 0) {
// Fallback: upstream already exists, just set origin URL
logger.warn('Could not rename origin to upstream, trying set-url fallback', LOG_CONTEXT, {
stderr: renameResult.stderr,
});
const setUrlResult = await execFileNoThrow(
'git',
['remote', 'set-url', 'origin', forkCloneUrl],
repoPath,
env
);
if (setUrlResult.exitCode !== 0) {
logger.error('Failed to set origin URL', LOG_CONTEXT, { stderr: setUrlResult.stderr });
return { isFork: false, error: `Failed to reconfigure remotes: ${setUrlResult.stderr}` };
}
} else {
const addResult = await execFileNoThrow(
'git',
['remote', 'add', 'origin', forkCloneUrl],
repoPath,
env
);
if (addResult.exitCode !== 0) {
logger.error('Failed to add origin remote', LOG_CONTEXT, { stderr: addResult.stderr });
return { isFork: false, error: `Failed to add origin remote: ${addResult.stderr}` };
}
}
// Set HEAD for origin so getDefaultBranch() works correctly with fork remotes
await execFileNoThrow('git', ['remote', 'set-head', 'origin', '-a'], repoPath, env);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main/utils/symphony-fork.ts` around lines 88 - 120, The git commands that
rewrite remotes and set HEAD (the execFileNoThrow invocations for
['remote','rename','origin','upstream'],
['remote','set-url','origin',forkCloneUrl],
['remote','add','origin',forkCloneUrl'] and ['remote','set-head','origin','-a'])
are called without the expanded environment; update each execFileNoThrow call in
symphony-fork.ts to include the expanded env from getExpandedEnv() (pass it into
the execFileNoThrow options/env parameter) so those git child processes use the
same environment as the earlier gh calls and avoid resolution failures.

Comment on lines +93 to +106
if (renameResult.exitCode !== 0) {
// Fallback: upstream already exists, just set origin URL
logger.warn('Could not rename origin to upstream, trying set-url fallback', LOG_CONTEXT, {
stderr: renameResult.stderr,
});
const setUrlResult = await execFileNoThrow(
'git',
['remote', 'set-url', 'origin', forkCloneUrl],
repoPath
);
if (setUrlResult.exitCode !== 0) {
logger.error('Failed to set origin URL', LOG_CONTEXT, { stderr: setUrlResult.stderr });
return { isFork: false, error: `Failed to reconfigure remotes: ${setUrlResult.stderr}` };
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Only use the set-url fallback for the expected upstream already exists case.

Any git remote rename origin upstream failure currently takes the fallback. If the rename failed for a different reason, this can still rewrite origin to the fork and return success without ever establishing a trustworthy upstream remote.

Possible fix
 	if (renameResult.exitCode !== 0) {
-		// Fallback: upstream already exists, just set origin URL
-		logger.warn('Could not rename origin to upstream, trying set-url fallback', LOG_CONTEXT, {
-			stderr: renameResult.stderr,
-		});
+		const canReuseExistingUpstream = /already exists/i.test(renameResult.stderr);
+		if (!canReuseExistingUpstream) {
+			logger.error('Failed to rename origin to upstream', LOG_CONTEXT, {
+				stderr: renameResult.stderr,
+			});
+			return { isFork: false, error: `Failed to reconfigure remotes: ${renameResult.stderr}` };
+		}
+		logger.warn('Could not rename origin to upstream, trying set-url fallback', LOG_CONTEXT, {
+			stderr: renameResult.stderr,
+		});
 		const setUrlResult = await execFileNoThrow(
 			'git',
 			['remote', 'set-url', 'origin', forkCloneUrl],
 			repoPath
 		);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (renameResult.exitCode !== 0) {
// Fallback: upstream already exists, just set origin URL
logger.warn('Could not rename origin to upstream, trying set-url fallback', LOG_CONTEXT, {
stderr: renameResult.stderr,
});
const setUrlResult = await execFileNoThrow(
'git',
['remote', 'set-url', 'origin', forkCloneUrl],
repoPath
);
if (setUrlResult.exitCode !== 0) {
logger.error('Failed to set origin URL', LOG_CONTEXT, { stderr: setUrlResult.stderr });
return { isFork: false, error: `Failed to reconfigure remotes: ${setUrlResult.stderr}` };
}
if (renameResult.exitCode !== 0) {
const canReuseExistingUpstream = /already exists/i.test(renameResult.stderr);
if (!canReuseExistingUpstream) {
logger.error('Failed to rename origin to upstream', LOG_CONTEXT, {
stderr: renameResult.stderr,
});
return { isFork: false, error: `Failed to reconfigure remotes: ${renameResult.stderr}` };
}
logger.warn('Could not rename origin to upstream, trying set-url fallback', LOG_CONTEXT, {
stderr: renameResult.stderr,
});
const setUrlResult = await execFileNoThrow(
'git',
['remote', 'set-url', 'origin', forkCloneUrl],
repoPath
);
if (setUrlResult.exitCode !== 0) {
logger.error('Failed to set origin URL', LOG_CONTEXT, { stderr: setUrlResult.stderr });
return { isFork: false, error: `Failed to reconfigure remotes: ${setUrlResult.stderr}` };
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main/utils/symphony-fork.ts` around lines 93 - 106, The rename fallback
currently runs for any failure of the git remote rename operation; update the
logic around renameResult (the result of the git remote rename origin upstream
executed via execFileNoThrow) to only attempt the set-url fallback when stderr
or exitCode indicates the specific "remote upstream already exists" error (or an
equivalent recognizable message), otherwise surface/log the original error and
return failure; keep using logger.warn/error, still reference forkCloneUrl and
repoPath for the set-url call when the specific condition matches, and do not
rewrite origin for unrelated rename failures.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Symphony: Cannot create Draft PR from fork on Linux (no push access to upstream)

1 participant