Skip to content

fix(cli): fall back to PR when default branch is protected#2201

Merged
waynesun09 merged 5 commits into
mainfrom
fix-protected-branch-fallback
Jun 15, 2026
Merged

fix(cli): fall back to PR when default branch is protected#2201
waynesun09 merged 5 commits into
mainfrom
fix-protected-branch-fallback

Conversation

@waynesun09

@waynesun09 waynesun09 commented Jun 12, 2026

Copy link
Copy Markdown
Contributor

Fixes #1689

Summary

When fullsend admin install or fullsend github setup encounters a protected default branch (422 on ref update), the CLI now falls back to creating a feature branch (fullsend/scaffold-install) and opening a PR with the scaffold files instead of failing with an opaque error.

Covers both install paths:

  • Per-repo (fullsend github setup owner/repo, fullsend admin install owner/repo) — fallback in applyPerRepoScaffold
  • Per-org (fullsend admin install org) — fallback in WorkflowsLayer.Install() for the .fullsend config repo

Changes:

  • Adds ErrBranchProtected sentinel error and CommitFilesToBranch to forge.Client interface
  • Refactors CommitFiles in the GitHub client to detect 422 at the ref-update step as a branch protection failure
  • Adds PR-based fallback in both per-repo and per-org install paths
  • Variables and secrets are set regardless of which path is taken (they don't depend on scaffold files being merged)
  • Idempotent: re-runs tolerate existing branches and PRs gracefully

Test plan

  • go test ./internal/forge/... ./internal/cli/... ./internal/layers/... -count=1 — all pass
  • 8 unit tests for per-repo fallback: happy path, existing branch, duplicate PR, vars/secrets independence, branch creation failure, commit failure, PR creation failure, branch up-to-date
  • 7 unit tests for per-org fallback: happy path, existing branch, branch creation failure, commit failure, PR creation failure, duplicate PR, branch up-to-date
  • E2E tests pass

@github-actions

github-actions Bot commented Jun 12, 2026

Copy link
Copy Markdown

E2E tests are running

Authorization passed for this commit. See the E2E Tests workflow for results.

@github-actions

github-actions Bot commented Jun 12, 2026

Copy link
Copy Markdown

Site preview

Preview: https://24e8c353-site.fullsend-ai.workers.dev

Commit: b4c2edb879211d813387b07b37ccc5d42a812349

@fullsend-ai-review

Copy link
Copy Markdown

🤖 Review · Started 1:12 AM UTC
Commit: 9be2798 · View workflow run →

@codecov

codecov Bot commented Jun 12, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 68.86792% with 33 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
internal/forge/fake.go 0.00% 18 Missing ⚠️
internal/forge/github/github.go 73.07% 7 Missing ⚠️
internal/forge/forge.go 0.00% 4 Missing ⚠️
internal/layers/commit.go 90.00% 3 Missing and 1 partial ⚠️

📢 Thoughts on this report? Let us know!

@waynesun09 waynesun09 force-pushed the fix-protected-branch-fallback branch from 9be2798 to cc39241 Compare June 12, 2026 01:14
@waynesun09 waynesun09 added the ok-to-test Allow e2e CI to run after maintainer review (must be re-applied after each push) label Jun 12, 2026
@fullsend-ai-review

fullsend-ai-review Bot commented Jun 12, 2026

Copy link
Copy Markdown

🤖 Finished Review · ✅ Success · Started 1:17 AM UTC · Completed 1:29 AM UTC
Commit: cc39241 · View workflow run →

@fullsend-ai-review

fullsend-ai-review Bot commented Jun 12, 2026

Copy link
Copy Markdown

Review

Findings

Low

  • [sentinel collision] internal/forge/github/github.go:79APIError.Unwrap() now returns forge.ErrAlreadyExists for every 422 whose message contains "already exists". This follows the existing pattern where all 404s become forge.ErrNotFound, but future code branching on IsAlreadyExists for unrelated API calls may get unexpected matches. The heuristic is broad — any 422 response mentioning "already exists" in any error detail will match.

  • [race condition / branch staleness] internal/layers/commit.go:24 — When CreateBranch returns ErrAlreadyExists (from a previous failed run), the existing branch may be based on an old commit of the default branch. CommitFilesToBranch commits on top of potentially stale content. The diff now documents this explicitly. Practical impact is limited since scaffold files are idempotent boilerplate and the PR requires human review before merging.

  • [fail-open] internal/forge/github/github.go:785isBranchProtectionError uses substring matching on GitHub API error messages ("protected", "required status", "required review", "rule violation") to detect branch protection 422 errors. A non-protection 422 error whose message contains these substrings could be misclassified, triggering the PR-creation fallback. In practice, GitHub's structured error messages make accidental collisions unlikely, the call site is narrowly scoped to PATCH /git/refs, and the fallback path is safe (creates a PR requiring human review).

  • [interface-expansion-scope] internal/forge/forge.go:430CommitFilesToBranch expands the forge.Client interface. The new method is a natural complement to CommitFiles, shares the same implementation via commitFilesTo, and the ADR diff documents it. The interface is internal/, so this is a minor design observation.

  • [error-classification-consistency] internal/forge/github/github.go:583isBranchProtectionError conflates legacy branch protection rules and newer repository rulesets under a single sentinel. Currently there is exactly one caller (commitFilesTo) and both produce the same desired behavior (fall back to PR). If a future caller needs distinction, the code can be refactored then.

  • [doc-comment-consistency] internal/forge/github/github.go:596 — The helper function isAlreadyExistsError lacks a doc comment, while the similar function isBranchProtectionError has a three-sentence doc comment. Both are new functions added in this PR.
    Remediation: Add a doc comment to isAlreadyExistsError matching the format of isBranchProtectionError.

  • [abstraction-naming-consistency] internal/layers/commit.goCommitScaffoldFiles couples the function to "scaffold" terminology. The naming accurately reflects current usage but could limit future reuse if the fallback pattern is needed elsewhere.

  • [documentation-drift-risk] docs/guides/dev/cli-internals.md — Protected-branch fallback is documented in both cli-internals.md (dev guide) and installation.md (user guide). If the implementation changes, both must be updated.

  • [incomplete-documentation] docs/reference/github-setup.md — The github-setup reference does not document the protected-branch fallback. The installation.md reference already covers it and github-setup.md cross-references installation.md, so the omission is minor.

Info

  • [test-coupling] internal/forge/fake.go:148CommitFilesChanged field controls return values for both CommitFiles and CommitFilesToBranch. The diff adds a clear explanatory comment documenting why a single field suffices.

  • [authorization] internal/layers/commit.go:26 — The fallback path uses a hardcoded branch name (fullsend/scaffold-install). Concurrent install operations on the same repo would race on the same branch, but the code handles ErrAlreadyExists for both branch creation and PR creation, making re-runs idempotent. Pre-creation by an attacker requires push access and the resulting PR still requires human review.

Previous run

Review

Findings

Low

  • [sentinel collision] internal/forge/github/github.go:79APIError.Unwrap() now returns forge.ErrAlreadyExists for every 422 whose message contains "already exists". This follows the existing pattern where all 404s become forge.ErrNotFound, but future code branching on IsAlreadyExists for unrelated API calls may get unexpected matches. The heuristic is broad — any 422 response mentioning "already exists" in any error detail will match.

  • [fail-open] internal/forge/github/github.go:775isBranchProtectionError uses substring matching on GitHub API error messages ("protected", "required status", "required review") to detect branch protection 422 errors. A non-protection 422 error whose message contains these substrings could be misclassified, triggering the PR-creation fallback. In practice, GitHub's structured error messages make accidental collisions unlikely, and the fallback path is safe (creates a PR requiring human review).

  • [race condition / branch staleness] internal/layers/commit.go:24 — When CreateBranch returns ErrAlreadyExists (from a previous failed run), the existing branch may be based on an old commit of the default branch. CommitFilesToBranch commits on top of potentially stale content. Practical impact is limited since scaffold files are idempotent boilerplate and the PR requires human review before merging.

  • [test-coupling] internal/forge/fake.go:143CommitFilesChanged field controls return values for both CommitFiles and CommitFilesToBranch. This hidden coupling could lead to confusing test behavior if a test sets the field intending to affect only one method.

  • [doc-comment-consistency] internal/forge/github/github.go:554 — The helper function isAlreadyExistsError lacks a doc comment, while the similar function isBranchProtectionError has a two-sentence doc comment explaining its purpose.
    Remediation: Add a doc comment to isAlreadyExistsError matching the format of isBranchProtectionError.

Info

  • [authorization] internal/layers/commit.go — The fallback path uses a hardcoded branch name (fullsend/scaffold-install). Concurrent install operations would race on the same branch. Pre-creation by an attacker requires push access and the resulting PR still requires human review.
Previous run (2)

Review

Findings

Low

  • [sentinel collision] internal/forge/github/github.go:79APIError.Unwrap() now returns forge.ErrAlreadyExists for every 422 whose message contains "already exists". This follows the existing pattern where all 404s become forge.ErrNotFound, but future code branching on IsAlreadyExists for unrelated API calls may get unexpected matches. The heuristic is broad — any 422 response mentioning "already exists" in any error detail will match.

  • [fail-open] internal/forge/github/github.goisBranchProtectionError uses substring matching on GitHub API error messages ("protected", "required status", "required review") to detect branch protection 422 errors. A non-protection 422 error whose message contains these substrings could be misclassified, triggering the PR-creation fallback. In practice, GitHub's structured error messages make accidental collisions unlikely, and the fallback path is safe (creates a PR requiring human review).

  • [race condition / branch staleness] internal/layers/commit.go:24 — When CreateBranch returns ErrAlreadyExists (from a previous failed run), the existing branch may be based on an old commit of the default branch. CommitFilesToBranch commits on top of potentially stale content. Practical impact is limited since scaffold files are idempotent boilerplate and the PR requires human review before merging.

  • [test-coupling] internal/forge/fake.go:143CommitFilesChanged field controls return values for both CommitFiles and CommitFilesToBranch. This hidden coupling could lead to confusing test behavior if a test sets the field intending to affect only one method.

  • [doc-comment-consistency] internal/forge/forge.go — The new interface method CommitFilesToBranch has a brief doc comment, while the existing CommitFiles method has an extensive multi-sentence comment explaining idempotency and return value semantics.
    Remediation: Expand the doc comment for CommitFilesToBranch to match the documentation depth of CommitFiles.

  • [error-handling-idiom] internal/forge/github/github.go — The PR uses two approaches for error translation: string matching in unexported helpers (isBranchProtectionError, isAlreadyExistsError) and sentinel errors in the Unwrap() method. This follows the existing codebase pattern (isTransientStatus, isValidationErrorForField).

Info

  • [authorization] internal/layers/commit.go — The fallback path uses a hardcoded branch name (fullsend/scaffold-install). Concurrent install operations would race on the same branch. Pre-creation by an attacker requires push access and the resulting PR still requires human review.

  • [commit-message-accuracy] PR title uses fix(cli) but this introduces new behavior (PR-based fallback) rather than fixing broken existing behavior. Debatable since it addresses the inability to install on protected branches (issue install: fall back to PR when default branch push is rejected #1689).

Previous run (3)

Review

Findings

Medium

  • [stale-doc] docs/ADRs/0005-forge-abstraction-layer.md — ADR-0005 documents the forge.Client interface abstraction but does not acknowledge the new CommitFilesToBranch method or the ErrBranchProtected/ErrAlreadyExists sentinels added in this PR. While the ADR already anticipates interface growth, a brief annotation noting these additions would help future forge implementers.
    Remediation: Add a note to ADR-0005 acknowledging the new sentinels and interface method.

  • [stale-doc] docs/guides/dev/cli-internals.md — Phase 5 documentation describes scaffold file deployment as "pushes workflows" without covering the new fallback behavior when the default branch is protected. Developers referencing this guide may not understand the PR-creation path.
    Remediation: Add a note to Phase 5 describing the protected branch fallback behavior.

  • [stale-doc] docs/reference/installation.md — The installation guide does not mention what happens when the default branch is protected during installation. Users will see PR creation instead of direct pushes, which could cause confusion without documentation.
    Remediation: Add a note explaining that if the default branch has branch protection enabled, the installer creates a PR for scaffold files instead of committing directly.

Low

  • [fail-open] internal/forge/github/github.goisBranchProtectionError checks for substrings "protected", "required status", "required review" in 422 error messages. A non-protection 422 error whose message happens to contain these substrings could be misclassified, triggering the PR-creation fallback. In practice, GitHub's error messages are structured enough that this is unlikely, and the fallback path is safe (creates a PR requiring human review).
    Remediation: Add a negative test case verifying that a generic 422 (e.g., "SHA was not a valid commit") is NOT mapped to ErrBranchProtected.

  • [error handling / sentinel collision] internal/forge/github/github.goAPIError.Unwrap() now returns forge.ErrAlreadyExists for every 422 whose message contains "already exists". This follows the existing pattern where all 404s become forge.ErrNotFound, but future code branching on IsAlreadyExists for unrelated API calls may get unexpected matches.
    Remediation: Consider narrowing the Unwrap check to specific Resource/Field pairs, or document the broad matching behavior.

  • [error-handling-idiom] internal/forge/github/github.go — The PR introduces unexported helpers (isBranchProtectionError, isAlreadyExistsError) that do string matching to translate GitHub-specific error messages into forge-level sentinels. This is the correct layer for the translation, but the two approaches (string matching in helpers vs sentinels in Unwrap) could be unified for consistency.

  • [race condition / branch staleness] internal/layers/commit.go:24 — When CreateBranch returns ErrAlreadyExists (from a previous failed run), the existing branch may be based on an old commit of the default branch. CommitFilesToBranch commits on top of potentially stale content, and the resulting PR may have merge conflicts or contain outdated base content. Practical impact is limited since scaffold files are idempotent boilerplate and the PR requires human review before merging.

  • [authorization] internal/layers/commit.go — The PR fallback path uses a hardcoded branch name (fullsend/scaffold-install). Concurrent install operations targeting the same repo would race on the same branch. An attacker with push access could pre-create the branch with malicious content, though this requires elevated access and the PR still requires human review.

  • [interface-consistency] internal/forge/forge.goCommitFilesToBranch is added to the Client interface with a brief doc comment. While many interface methods lack detailed documentation, CommitFiles (which this method mirrors) has extensive comments explaining atomicity, idempotency, and return value semantics.

  • [test-coupling] internal/forge/fake.goCommitFilesChanged field controls return values for both CommitFiles and CommitFilesToBranch. This hidden coupling could lead to confusing test behavior if a test sets the field intending to affect only one method.

Info

  • [commit-message-accuracy] PR title uses fix(cli) but this introduces new behavior (automatic PR fallback when encountering protected branches) rather than fixing broken existing behavior. Per COMMITS.md, fix is for bug fixes while feat covers new user-facing functionality. However, this is debatable since it addresses the inability to install on protected branches (issue install: fall back to PR when default branch push is rejected #1689).

  • [scope-alignment] The PR adds CommitFilesToBranch to forge.Client and creates layers/commit.go. These are necessary implementation details of the fallback feature — you cannot create a PR without committing to a non-default branch, and extracting shared logic avoids duplication between admin.go and workflows.go.

  • [forge-abstraction-alignment] internal/forge/forge.goErrBranchProtected is a forge-neutral sentinel for a concept that exists across major forges (GitHub branch protection, GitLab protected branches). The assumption that other forges have equivalent semantics is reasonable.

Previous run (4)

Review

Findings

Medium

  • [fail-open] internal/forge/github/github.go:757 — All 422 responses on the ref-update PATCH are mapped to ErrBranchProtected, but GitHub returns 422 for multiple distinct conditions beyond branch protection (invalid SHA, fast-forward conflict, nonexistent ref, other validation failures). Non-protection 422 errors would silently trigger the PR-creation fallback, masking the real error behind a path the operator may not investigate.
    Remediation: Inspect the 422 response body (APIError.Message or APIError.Errors) for branch-protection-specific signals (e.g., "protected branch" in the error message) before wrapping as ErrBranchProtected. Only return the sentinel when the response clearly indicates branch protection.

Low

  • [race condition / branch staleness] internal/layers/commit.go:24 — When CreateBranch returns "already exists" (from a previous failed run), the existing branch may be based on an old commit of the default branch. CommitFilesToBranch commits on top of potentially stale content, and the resulting PR may have merge conflicts or contain outdated base content. Practical impact is limited since scaffold files are idempotent boilerplate and the PR requires human review before merging.
    Remediation: When the scaffold branch already exists, delete and recreate it from the current default branch HEAD, or reset its ref before committing.

  • [error-handling-idiom] internal/layers/commit.go:26 — Error checking uses strings.Contains(err.Error(), "already exists") for both CreateBranch (line 26) and CreateChangeProposal (line 42) errors instead of typed error comparison. The codebase uses errors.Is() with sentinel errors for error classification (ErrNotFound, ErrBranchProtected). There is existing precedent for this pattern in e2e/admin/lock.go:252, but it is inconsistent with the sentinel pattern introduced in this same PR.
    Remediation: Define forge.ErrAlreadyExists sentinel and use errors.Is() checks, following the ErrBranchProtected/IsBranchProtected pattern.

  • [authorization] internal/layers/commit.go — The PR fallback path uses a hardcoded branch name (fullsend/scaffold-install). Concurrent install operations targeting the same repo would race on the same branch. An attacker with push access could pre-create the branch with malicious content, though this requires elevated access and the PR still requires human review before merging.

Info

  • [commit-message-accuracy] PR title uses fix(cli) but this introduces new behavior (automatic PR fallback when encountering protected branches) rather than fixing broken existing behavior. Per COMMITS.md, fix is for bug fixes while feat covers new user-facing functionality. However, this is debatable since it addresses the inability to install on protected branches (issue install: fall back to PR when default branch push is rejected #1689).
    Remediation: Consider changing PR title to feat(cli): fall back to PR when default branch is protected.
Previous run (5)

Review

Findings

Medium

  • [fail-open] internal/forge/github/github.go:757 — All 422 responses on the ref-update PATCH are mapped to ErrBranchProtected, but GitHub returns 422 for multiple distinct conditions (invalid SHA, nonexistent ref, other validation failures). Non-protection 422 errors would silently trigger the PR-creation fallback, masking the real error behind a path the operator may not investigate.
    Remediation: Inspect the 422 response body (APIError.Message or APIError.Errors) for branch-protection-specific signals (e.g., "protected branch") before wrapping as ErrBranchProtected. Only return the sentinel when the response clearly indicates branch protection.

  • [race condition / branch staleness] internal/cli/admin.go:1035 — When CreateBranch returns "already exists" (from a previous failed run), the existing branch may be based on an old commit of the default branch. CommitFilesToBranch commits on top of potentially stale content, and the resulting PR may have merge conflicts or include unintended diff with the current default branch.
    Remediation: When the scaffold branch already exists, delete and recreate it from the current default branch HEAD, or reset its ref before committing, so the scaffold PR always has a clean diff.

  • [error-handling-idiom] internal/cli/admin.go:1035 — Error checking uses strings.Contains(branchErr.Error(), "already exists") instead of typed error comparison. The codebase uses errors.Is() for error classification (e.g., ErrNotFound, and this PR's own ErrBranchProtected/IsBranchProtected). The same fragile pattern appears for CreateChangeProposal errors at line 1056.
    Remediation: Define forge.ErrAlreadyExists sentinel and use errors.Is() checks, following the ErrBranchProtected/IsBranchProtected pattern introduced in this PR.

  • [commit-message-accuracy] PR title uses fix(cli) but this introduces new behavior (automatic PR fallback when encountering protected branches) rather than fixing broken existing behavior. Per COMMITS.md, fix is for bug fixes visible to users while feat covers new user-facing functionality. The previous behavior was an unsupported scenario (opaque error), and this PR adds a new code path to handle it gracefully.
    Remediation: Change PR title to feat(cli): fall back to PR when default branch is protected.

Low

  • [error handling / test coupling] internal/forge/fake.go:143 — The FakeClient shares CommitFilesChanged between CommitFiles and CommitFilesToBranch. This coupling is fragile — if the FakeClient implementation order changes, tests could break in subtle ways. Current tests work because CommitFiles returns the error before checking the flag.

  • [incomplete fallback] internal/cli/admin.go:1048 — When the fallback creates a PR, variables and secrets are set immediately before the PR is merged. If the PR is closed without merging, the repo has fullsend vars/secrets but no workflow files. However, vars/secrets without workflow files are inert and re-running the install command is idempotent.

  • [interface-expansion-scope] internal/forge/forge.goCommitFilesToBranch is added to forge.Client alongside CreateFileOnBranch and CreateOrUpdateFileOnBranch. A godoc comment explaining when to use each branch-writing method (atomic multi-file via Git Trees API vs. single-file via Contents API) would help future contributors.

  • [naming-consistency] internal/cli/admin.go:1033 — Branch name "fullsend/scaffold-install" is defined as a block-scoped constant. Other similar constants (forge.ConfigRepoName, forge.PerRepoGuardVar) are at package or file level for discoverability.

Info

  • [scope-completeness] The PR handles protected branches for per-repo install only. Per-org install flows that commit to the .fullsend config repo could encounter the same issue, though the config repo is a separate repo less likely to have branch protection.

  • [documentation-clarity] internal/cli/admin.go:1026 — The new PR creation flow doesn't document what happens when the scaffold branch already exists with outdated content. See the branch-staleness finding above.

  • [interface-expansion] internal/forge/forge.goCommitFilesToBranch expands the forge API surface. All forge.Client implementations must be updated. The internal/ path signals this is not for external consumption.

  • [error-contract-expansion] internal/forge/forge.go — New exported error symbols (ErrBranchProtected, IsBranchProtected) follow the existing ErrNotFound/IsNotFound pattern. Backward compatible.

  • [interface-compatibility] internal/forge/forge.go — Change is backward compatible for consumers but is a compile-time breaking change for implementers of forge.Client. The internal/ path makes this acceptable.

Previous run (6)

Review

Findings

Medium

  • [fail-open] internal/forge/github/github.go — All 422 responses on the ref update PATCH are mapped to ErrBranchProtected, but GitHub returns 422 for multiple distinct conditions (invalid SHA, nonexistent ref, other validation failures). Non-protection 422 errors would silently trigger the PR-creation fallback, masking the real error behind a path the operator may not investigate.
    Remediation: Inspect the 422 response body for branch-protection-specific signals (e.g., "protected branch" in the error message or a specific error code). Only wrap as ErrBranchProtected when the response clearly indicates branch protection.

  • [error-handling-idiom] internal/cli/admin.go:1038 — Error checking uses strings.Contains(branchErr.Error(), "already exists") instead of typed error comparison. The codebase uses errors.Is() for error classification (e.g., ErrNotFound, and this PR's own ErrBranchProtected). While there is precedent in e2e/admin/lock.go:252, the inconsistency with the sentinel pattern introduced in this same PR is notable. The same fragile pattern appears for CreateChangeProposal errors.
    Remediation: Define forge.ErrAlreadyExists sentinel and use errors.Is() checks, following the ErrBranchProtected/IsBranchProtected pattern introduced in this PR.

  • [race condition / branch staleness] internal/cli/admin.go:1035 — When CreateBranch returns "already exists" (from a previous failed run), the existing branch may be based on an old commit of the default branch. CommitFilesToBranch commits on top of potentially stale content, and the resulting PR may have merge conflicts or include unintended diff with the current default branch.
    Remediation: When the branch already exists, consider force-updating its ref to the current default branch HEAD before committing, or document that the scaffold PR may require a manual rebase if the default branch has diverged.

  • [interface-expansion-scope] internal/forge/forge.goCommitFilesToBranch is added to forge.Client, which already has CreateFileOnBranch and CreateOrUpdateFileOnBranch. The relationship between these three branch-writing methods needs clarification to avoid interface bloat (the interface already has 40+ methods).
    Remediation: Clarify whether existing methods could be composed for the same result. If CommitFilesToBranch is necessary, document its relationship to existing methods in the interface comments.

  • [error handling / edge case] internal/cli/admin.go:1048 — The FakeClient shares CommitFilesChanged between CommitFiles and CommitFilesToBranch. In TestApplyPerRepoScaffold_ProtectedBranch_BranchUpToDate, the interaction between Errors["CommitFiles"] and CommitFilesChanged means the shared control only affects CommitFilesToBranch (since CommitFiles returns the error before checking the flag). This coupling is fragile and should be verified or separated.
    Remediation: Consider adding a separate CommitFilesToBranchChanged field or explicitly documenting that CommitFilesChanged controls both methods.

Low

  • [naming-convention] internal/cli/admin.go:1033 — Branch name "fullsend/scaffold-install" is a magic string literal. The codebase uses package-level constants for similar values (DefaultMintURL, forge.ConfigRepoName, etc.).

  • [naming-convention] internal/cli/admin.go:1048 — Message "Merge the PR to activate fullsend workflows" lacks specificity about which PR. Consider fmt.Sprintf("Merge PR #%d to activate fullsend workflows", proposal.Number).

  • [test-inadequate] internal/cli/admin_test.go:2079TestApplyPerRepoScaffold_ProtectedBranch_ExistingBranch asserts CommittedFilesToBranch length but does not verify the branch name or message fields.

  • [code-organization] internal/cli/admin.go:1024 — Variable commitMsg is extracted but only used once; the rest of the function inlines fmt.Sprintf() for messages.

Info

  • [commit-message-accuracy] PR title uses fix(cli) but this introduces new behavior (automatic PR fallback) rather than fixing broken behavior. Consider whether feat(cli) is more accurate per COMMITS.md.

  • [scope-completeness] The PR handles protected branches for per-repo install only. Per-org install flows that commit to the .fullsend config repo could hit the same issue — inconsistent handling would create confusing UX.

  • [interface-expansion] CommitFilesToBranch expands the forge API surface to allow writes to arbitrary branches. No injection risk in this specific usage since the branch name is a constant.

Previous run

Review

Findings

Low

  • [sentinel collision] internal/forge/github/github.go:79APIError.Unwrap() now returns forge.ErrAlreadyExists for every 422 whose message contains "already exists". This follows the existing pattern where all 404s become forge.ErrNotFound, but future code branching on IsAlreadyExists for unrelated API calls may get unexpected matches. The heuristic is broad — any 422 response mentioning "already exists" in any error detail will match.

  • [fail-open] internal/forge/github/github.go:775isBranchProtectionError uses substring matching on GitHub API error messages ("protected", "required status", "required review") to detect branch protection 422 errors. A non-protection 422 error whose message contains these substrings could be misclassified, triggering the PR-creation fallback. In practice, GitHub's structured error messages make accidental collisions unlikely, and the fallback path is safe (creates a PR requiring human review).

  • [race condition / branch staleness] internal/layers/commit.go:24 — When CreateBranch returns ErrAlreadyExists (from a previous failed run), the existing branch may be based on an old commit of the default branch. CommitFilesToBranch commits on top of potentially stale content. Practical impact is limited since scaffold files are idempotent boilerplate and the PR requires human review before merging.

  • [test-coupling] internal/forge/fake.go:143CommitFilesChanged field controls return values for both CommitFiles and CommitFilesToBranch. This hidden coupling could lead to confusing test behavior if a test sets the field intending to affect only one method.

  • [doc-comment-consistency] internal/forge/github/github.go:554 — The helper function isAlreadyExistsError lacks a doc comment, while the similar function isBranchProtectionError has a two-sentence doc comment explaining its purpose.
    Remediation: Add a doc comment to isAlreadyExistsError matching the format of isBranchProtectionError.

Info

  • [authorization] internal/layers/commit.go — The fallback path uses a hardcoded branch name (fullsend/scaffold-install). Concurrent install operations would race on the same branch. Pre-creation by an attacker requires push access and the resulting PR still requires human review.
Previous run (2)

Review

Findings

Low

  • [sentinel collision] internal/forge/github/github.go:79APIError.Unwrap() now returns forge.ErrAlreadyExists for every 422 whose message contains "already exists". This follows the existing pattern where all 404s become forge.ErrNotFound, but future code branching on IsAlreadyExists for unrelated API calls may get unexpected matches. The heuristic is broad — any 422 response mentioning "already exists" in any error detail will match.

  • [fail-open] internal/forge/github/github.goisBranchProtectionError uses substring matching on GitHub API error messages ("protected", "required status", "required review") to detect branch protection 422 errors. A non-protection 422 error whose message contains these substrings could be misclassified, triggering the PR-creation fallback. In practice, GitHub's structured error messages make accidental collisions unlikely, and the fallback path is safe (creates a PR requiring human review).

  • [race condition / branch staleness] internal/layers/commit.go:24 — When CreateBranch returns ErrAlreadyExists (from a previous failed run), the existing branch may be based on an old commit of the default branch. CommitFilesToBranch commits on top of potentially stale content. Practical impact is limited since scaffold files are idempotent boilerplate and the PR requires human review before merging.

  • [test-coupling] internal/forge/fake.go:143CommitFilesChanged field controls return values for both CommitFiles and CommitFilesToBranch. This hidden coupling could lead to confusing test behavior if a test sets the field intending to affect only one method.

  • [doc-comment-consistency] internal/forge/forge.go — The new interface method CommitFilesToBranch has a brief doc comment, while the existing CommitFiles method has an extensive multi-sentence comment explaining idempotency and return value semantics.
    Remediation: Expand the doc comment for CommitFilesToBranch to match the documentation depth of CommitFiles.

  • [error-handling-idiom] internal/forge/github/github.go — The PR uses two approaches for error translation: string matching in unexported helpers (isBranchProtectionError, isAlreadyExistsError) and sentinel errors in the Unwrap() method. This follows the existing codebase pattern (isTransientStatus, isValidationErrorForField).

Info

  • [authorization] internal/layers/commit.go — The fallback path uses a hardcoded branch name (fullsend/scaffold-install). Concurrent install operations would race on the same branch. Pre-creation by an attacker requires push access and the resulting PR still requires human review.

  • [commit-message-accuracy] PR title uses fix(cli) but this introduces new behavior (PR-based fallback) rather than fixing broken existing behavior. Debatable since it addresses the inability to install on protected branches (issue install: fall back to PR when default branch push is rejected #1689).

Previous run (3)

Review

Findings

Medium

  • [stale-doc] docs/ADRs/0005-forge-abstraction-layer.md — ADR-0005 documents the forge.Client interface abstraction but does not acknowledge the new CommitFilesToBranch method or the ErrBranchProtected/ErrAlreadyExists sentinels added in this PR. While the ADR already anticipates interface growth, a brief annotation noting these additions would help future forge implementers.
    Remediation: Add a note to ADR-0005 acknowledging the new sentinels and interface method.

  • [stale-doc] docs/guides/dev/cli-internals.md — Phase 5 documentation describes scaffold file deployment as "pushes workflows" without covering the new fallback behavior when the default branch is protected. Developers referencing this guide may not understand the PR-creation path.
    Remediation: Add a note to Phase 5 describing the protected branch fallback behavior.

  • [stale-doc] docs/reference/installation.md — The installation guide does not mention what happens when the default branch is protected during installation. Users will see PR creation instead of direct pushes, which could cause confusion without documentation.
    Remediation: Add a note explaining that if the default branch has branch protection enabled, the installer creates a PR for scaffold files instead of committing directly.

Low

  • [fail-open] internal/forge/github/github.goisBranchProtectionError checks for substrings "protected", "required status", "required review" in 422 error messages. A non-protection 422 error whose message happens to contain these substrings could be misclassified, triggering the PR-creation fallback. In practice, GitHub's error messages are structured enough that this is unlikely, and the fallback path is safe (creates a PR requiring human review).
    Remediation: Add a negative test case verifying that a generic 422 (e.g., "SHA was not a valid commit") is NOT mapped to ErrBranchProtected.

  • [error handling / sentinel collision] internal/forge/github/github.goAPIError.Unwrap() now returns forge.ErrAlreadyExists for every 422 whose message contains "already exists". This follows the existing pattern where all 404s become forge.ErrNotFound, but future code branching on IsAlreadyExists for unrelated API calls may get unexpected matches.
    Remediation: Consider narrowing the Unwrap check to specific Resource/Field pairs, or document the broad matching behavior.

  • [error-handling-idiom] internal/forge/github/github.go — The PR introduces unexported helpers (isBranchProtectionError, isAlreadyExistsError) that do string matching to translate GitHub-specific error messages into forge-level sentinels. This is the correct layer for the translation, but the two approaches (string matching in helpers vs sentinels in Unwrap) could be unified for consistency.

  • [race condition / branch staleness] internal/layers/commit.go:24 — When CreateBranch returns ErrAlreadyExists (from a previous failed run), the existing branch may be based on an old commit of the default branch. CommitFilesToBranch commits on top of potentially stale content. Practical impact is limited since scaffold files are idempotent boilerplate and the PR requires human review before merging.

  • [authorization] internal/layers/commit.go — The PR fallback path uses a hardcoded branch name (fullsend/scaffold-install). Concurrent install operations targeting the same repo would race on the same branch. An attacker with push access could pre-create the branch with malicious content, though this requires elevated access and the PR still requires human review.

  • [interface-consistency] internal/forge/forge.goCommitFilesToBranch is added to the Client interface with a brief doc comment. While many interface methods lack detailed documentation, CommitFiles (which this method mirrors) has extensive comments explaining atomicity, idempotency, and return value semantics.

  • [test-coupling] internal/forge/fake.goCommitFilesChanged field controls return values for both CommitFiles and CommitFilesToBranch. This hidden coupling could lead to confusing test behavior if a test sets the field intending to affect only one method.

Info

  • [commit-message-accuracy] PR title uses fix(cli) but this introduces new behavior (automatic PR fallback when encountering protected branches) rather than fixing broken existing behavior. Per COMMITS.md, fix is for bug fixes while feat covers new user-facing functionality. However, this is debatable since it addresses the inability to install on protected branches (issue install: fall back to PR when default branch push is rejected #1689).

  • [scope-alignment] The PR adds CommitFilesToBranch to forge.Client and creates layers/commit.go. These are necessary implementation details of the fallback feature — you cannot create a PR without committing to a non-default branch, and extracting shared logic avoids duplication between admin.go and workflows.go.

  • [forge-abstraction-alignment] internal/forge/forge.goErrBranchProtected is a forge-neutral sentinel for a concept that exists across major forges (GitHub branch protection, GitLab protected branches). The assumption that other forges have equivalent semantics is reasonable.

Previous run (4)

Review

Findings

Medium

  • [fail-open] internal/forge/github/github.go:757 — All 422 responses on the ref-update PATCH are mapped to ErrBranchProtected, but GitHub returns 422 for multiple distinct conditions beyond branch protection (invalid SHA, fast-forward conflict, nonexistent ref, other validation failures). Non-protection 422 errors would silently trigger the PR-creation fallback, masking the real error behind a path the operator may not investigate.
    Remediation: Inspect the 422 response body (APIError.Message or APIError.Errors) for branch-protection-specific signals (e.g., "protected branch" in the error message) before wrapping as ErrBranchProtected. Only return the sentinel when the response clearly indicates branch protection.

Low

  • [race condition / branch staleness] internal/layers/commit.go:24 — When CreateBranch returns "already exists" (from a previous failed run), the existing branch may be based on an old commit of the default branch. CommitFilesToBranch commits on top of potentially stale content, and the resulting PR may have merge conflicts or contain outdated base content. Practical impact is limited since scaffold files are idempotent boilerplate and the PR requires human review before merging.
    Remediation: When the scaffold branch already exists, delete and recreate it from the current default branch HEAD, or reset its ref before committing.

  • [error-handling-idiom] internal/layers/commit.go:26 — Error checking uses strings.Contains(err.Error(), "already exists") for both CreateBranch (line 26) and CreateChangeProposal (line 42) errors instead of typed error comparison. The codebase uses errors.Is() with sentinel errors for error classification (ErrNotFound, ErrBranchProtected). There is existing precedent for this pattern in e2e/admin/lock.go:252, but it is inconsistent with the sentinel pattern introduced in this same PR.
    Remediation: Define forge.ErrAlreadyExists sentinel and use errors.Is() checks, following the ErrBranchProtected/IsBranchProtected pattern.

  • [authorization] internal/layers/commit.go — The PR fallback path uses a hardcoded branch name (fullsend/scaffold-install). Concurrent install operations targeting the same repo would race on the same branch. An attacker with push access could pre-create the branch with malicious content, though this requires elevated access and the PR still requires human review before merging.

Info

  • [commit-message-accuracy] PR title uses fix(cli) but this introduces new behavior (automatic PR fallback when encountering protected branches) rather than fixing broken existing behavior. Per COMMITS.md, fix is for bug fixes while feat covers new user-facing functionality. However, this is debatable since it addresses the inability to install on protected branches (issue install: fall back to PR when default branch push is rejected #1689).
    Remediation: Consider changing PR title to feat(cli): fall back to PR when default branch is protected.
Previous run (5)

Review

Findings

Medium

  • [fail-open] internal/forge/github/github.go:757 — All 422 responses on the ref-update PATCH are mapped to ErrBranchProtected, but GitHub returns 422 for multiple distinct conditions (invalid SHA, nonexistent ref, other validation failures). Non-protection 422 errors would silently trigger the PR-creation fallback, masking the real error behind a path the operator may not investigate.
    Remediation: Inspect the 422 response body (APIError.Message or APIError.Errors) for branch-protection-specific signals (e.g., "protected branch") before wrapping as ErrBranchProtected. Only return the sentinel when the response clearly indicates branch protection.

  • [race condition / branch staleness] internal/cli/admin.go:1035 — When CreateBranch returns "already exists" (from a previous failed run), the existing branch may be based on an old commit of the default branch. CommitFilesToBranch commits on top of potentially stale content, and the resulting PR may have merge conflicts or include unintended diff with the current default branch.
    Remediation: When the scaffold branch already exists, delete and recreate it from the current default branch HEAD, or reset its ref before committing, so the scaffold PR always has a clean diff.

  • [error-handling-idiom] internal/cli/admin.go:1035 — Error checking uses strings.Contains(branchErr.Error(), "already exists") instead of typed error comparison. The codebase uses errors.Is() for error classification (e.g., ErrNotFound, and this PR's own ErrBranchProtected/IsBranchProtected). The same fragile pattern appears for CreateChangeProposal errors at line 1056.
    Remediation: Define forge.ErrAlreadyExists sentinel and use errors.Is() checks, following the ErrBranchProtected/IsBranchProtected pattern introduced in this PR.

  • [commit-message-accuracy] PR title uses fix(cli) but this introduces new behavior (automatic PR fallback when encountering protected branches) rather than fixing broken existing behavior. Per COMMITS.md, fix is for bug fixes visible to users while feat covers new user-facing functionality. The previous behavior was an unsupported scenario (opaque error), and this PR adds a new code path to handle it gracefully.
    Remediation: Change PR title to feat(cli): fall back to PR when default branch is protected.

Low

  • [error handling / test coupling] internal/forge/fake.go:143 — The FakeClient shares CommitFilesChanged between CommitFiles and CommitFilesToBranch. This coupling is fragile — if the FakeClient implementation order changes, tests could break in subtle ways. Current tests work because CommitFiles returns the error before checking the flag.

  • [incomplete fallback] internal/cli/admin.go:1048 — When the fallback creates a PR, variables and secrets are set immediately before the PR is merged. If the PR is closed without merging, the repo has fullsend vars/secrets but no workflow files. However, vars/secrets without workflow files are inert and re-running the install command is idempotent.

  • [interface-expansion-scope] internal/forge/forge.goCommitFilesToBranch is added to forge.Client alongside CreateFileOnBranch and CreateOrUpdateFileOnBranch. A godoc comment explaining when to use each branch-writing method (atomic multi-file via Git Trees API vs. single-file via Contents API) would help future contributors.

  • [naming-consistency] internal/cli/admin.go:1033 — Branch name "fullsend/scaffold-install" is defined as a block-scoped constant. Other similar constants (forge.ConfigRepoName, forge.PerRepoGuardVar) are at package or file level for discoverability.

Info

  • [scope-completeness] The PR handles protected branches for per-repo install only. Per-org install flows that commit to the .fullsend config repo could encounter the same issue, though the config repo is a separate repo less likely to have branch protection.

  • [documentation-clarity] internal/cli/admin.go:1026 — The new PR creation flow doesn't document what happens when the scaffold branch already exists with outdated content. See the branch-staleness finding above.

  • [interface-expansion] internal/forge/forge.goCommitFilesToBranch expands the forge API surface. All forge.Client implementations must be updated. The internal/ path signals this is not for external consumption.

  • [error-contract-expansion] internal/forge/forge.go — New exported error symbols (ErrBranchProtected, IsBranchProtected) follow the existing ErrNotFound/IsNotFound pattern. Backward compatible.

  • [interface-compatibility] internal/forge/forge.go — Change is backward compatible for consumers but is a compile-time breaking change for implementers of forge.Client. The internal/ path makes this acceptable.

Previous run (6)

Review

Findings

Medium

  • [fail-open] internal/forge/github/github.go — All 422 responses on the ref update PATCH are mapped to ErrBranchProtected, but GitHub returns 422 for multiple distinct conditions (invalid SHA, nonexistent ref, other validation failures). Non-protection 422 errors would silently trigger the PR-creation fallback, masking the real error behind a path the operator may not investigate.
    Remediation: Inspect the 422 response body for branch-protection-specific signals (e.g., "protected branch" in the error message or a specific error code). Only wrap as ErrBranchProtected when the response clearly indicates branch protection.

  • [error-handling-idiom] internal/cli/admin.go:1038 — Error checking uses strings.Contains(branchErr.Error(), "already exists") instead of typed error comparison. The codebase uses errors.Is() for error classification (e.g., ErrNotFound, and this PR's own ErrBranchProtected). While there is precedent in e2e/admin/lock.go:252, the inconsistency with the sentinel pattern introduced in this same PR is notable. The same fragile pattern appears for CreateChangeProposal errors.
    Remediation: Define forge.ErrAlreadyExists sentinel and use errors.Is() checks, following the ErrBranchProtected/IsBranchProtected pattern introduced in this PR.

  • [race condition / branch staleness] internal/cli/admin.go:1035 — When CreateBranch returns "already exists" (from a previous failed run), the existing branch may be based on an old commit of the default branch. CommitFilesToBranch commits on top of potentially stale content, and the resulting PR may have merge conflicts or include unintended diff with the current default branch.
    Remediation: When the branch already exists, consider force-updating its ref to the current default branch HEAD before committing, or document that the scaffold PR may require a manual rebase if the default branch has diverged.

  • [interface-expansion-scope] internal/forge/forge.goCommitFilesToBranch is added to forge.Client, which already has CreateFileOnBranch and CreateOrUpdateFileOnBranch. The relationship between these three branch-writing methods needs clarification to avoid interface bloat (the interface already has 40+ methods).
    Remediation: Clarify whether existing methods could be composed for the same result. If CommitFilesToBranch is necessary, document its relationship to existing methods in the interface comments.

  • [error handling / edge case] internal/cli/admin.go:1048 — The FakeClient shares CommitFilesChanged between CommitFiles and CommitFilesToBranch. In TestApplyPerRepoScaffold_ProtectedBranch_BranchUpToDate, the interaction between Errors["CommitFiles"] and CommitFilesChanged means the shared control only affects CommitFilesToBranch (since CommitFiles returns the error before checking the flag). This coupling is fragile and should be verified or separated.
    Remediation: Consider adding a separate CommitFilesToBranchChanged field or explicitly documenting that CommitFilesChanged controls both methods.

Low

  • [naming-convention] internal/cli/admin.go:1033 — Branch name "fullsend/scaffold-install" is a magic string literal. The codebase uses package-level constants for similar values (DefaultMintURL, forge.ConfigRepoName, etc.).

  • [naming-convention] internal/cli/admin.go:1048 — Message "Merge the PR to activate fullsend workflows" lacks specificity about which PR. Consider fmt.Sprintf("Merge PR #%d to activate fullsend workflows", proposal.Number).

  • [test-inadequate] internal/cli/admin_test.go:2079TestApplyPerRepoScaffold_ProtectedBranch_ExistingBranch asserts CommittedFilesToBranch length but does not verify the branch name or message fields.

  • [code-organization] internal/cli/admin.go:1024 — Variable commitMsg is extracted but only used once; the rest of the function inlines fmt.Sprintf() for messages.

Info

  • [commit-message-accuracy] PR title uses fix(cli) but this introduces new behavior (automatic PR fallback) rather than fixing broken behavior. Consider whether feat(cli) is more accurate per COMMITS.md.

  • [scope-completeness] The PR handles protected branches for per-repo install only. Per-org install flows that commit to the .fullsend config repo could hit the same issue — inconsistent handling would create confusing UX.

  • [interface-expansion] CommitFilesToBranch expands the forge API surface to allow writes to arbitrary branches. No injection risk in this specific usage since the branch name is a constant.

@fullsend-ai-review fullsend-ai-review Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

See the review comment for full details.

Comment thread internal/cli/admin.go Outdated
if !strings.Contains(branchErr.Error(), "already exists") {
printer.StepFail("Failed to create scaffold branch")
return fmt.Errorf("creating scaffold branch: %w", branchErr)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[medium] error-handling-idiom

Error checking uses strings.Contains for already exists instead of typed error comparison. The codebase uses errors.Is() and this same PR introduces the ErrBranchProtected sentinel pattern, making the inconsistency notable.

Suggested fix: Define forge.ErrAlreadyExists sentinel and use errors.Is() checks, following the ErrBranchProtected pattern introduced in this PR.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Acknowledged — an ErrAlreadyExists sentinel would be cleaner. However, this is a broader refactor: CreateBranch returns 422 with "Reference already exists" and CreateChangeProposal returns 422 with "A pull request already exists" — both would need the sentinel, and the forge interface is forge-agnostic (can't depend on github.APIError). The string check is pragmatic for this PR. Filed as a follow-up improvement.

Comment thread internal/cli/admin.go Outdated

const scaffoldBranch = "fullsend/scaffold-install"
if branchErr := client.CreateBranch(ctx, owner, repo, scaffoldBranch); branchErr != nil {
if !strings.Contains(branchErr.Error(), "already exists") {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[medium] race condition / branch staleness

When CreateBranch returns already exists from a previous failed run, the existing branch may be based on an old commit. CommitFilesToBranch commits on top of potentially stale content, causing merge conflicts or unintended diff.

Suggested fix: When the branch already exists, force-update its ref to the current default branch HEAD before committing, or document that the scaffold PR may require manual rebase.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fair point. In practice, the scaffold branch is created fresh from the default branch HEAD and only contains fullsend scaffold files (no user content), so staleness is low-risk — the PR diff will still be correct even if the base has advanced, and GitHub handles the merge. A force-update would require a new UpdateBranchRef forge method which is out of scope here. The idempotency guard (branchCommitted == false → skip PR creation) handles the common re-run case where files haven't changed.

Comment thread internal/cli/admin.go Outdated
}

if branchCommitted {
prBody := fmt.Sprintf("This PR adds the fullsend scaffold files for per-repo installation.\n\n"+

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[medium] error handling / edge case

FakeClient shares CommitFilesChanged between CommitFiles and CommitFilesToBranch. The BranchUpToDate test relies on this shared control but the coupling is fragile and should be verified or separated.

Suggested fix: Consider adding a separate CommitFilesToBranchChanged field or documenting that CommitFilesChanged controls both methods.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Accepted tradeoff. The shared CommitFilesChanged knob is intentional — both methods have the same idempotency semantics (compare content, return whether anything changed). Adding a separate field would add test API surface for no practical benefit since no test needs them to differ. The BranchUpToDate test name documents this coupling.

@waynesun09 waynesun09 force-pushed the fix-protected-branch-fallback branch from cc39241 to b7c4ac9 Compare June 12, 2026 01:43
@github-actions github-actions Bot removed the ok-to-test Allow e2e CI to run after maintainer review (must be re-applied after each push) label Jun 12, 2026
@fullsend-ai-review

fullsend-ai-review Bot commented Jun 12, 2026

Copy link
Copy Markdown

🤖 Finished Review · ✅ Success · Started 1:46 AM UTC · Completed 1:58 AM UTC
Commit: b7c4ac9 · View workflow run →

@fullsend-ai-review fullsend-ai-review Bot added the requires-manual-review Review requires human judgment label Jun 12, 2026
@rh-hemartin

Copy link
Copy Markdown
Member

Closes #1689

@waynesun09 waynesun09 added the ok-to-test Allow e2e CI to run after maintainer review (must be re-applied after each push) label Jun 12, 2026
@waynesun09 waynesun09 force-pushed the fix-protected-branch-fallback branch from b7c4ac9 to 2aa22c4 Compare June 12, 2026 12:35
@github-actions github-actions Bot removed the ok-to-test Allow e2e CI to run after maintainer review (must be re-applied after each push) label Jun 12, 2026
@fullsend-ai-review

fullsend-ai-review Bot commented Jun 12, 2026

Copy link
Copy Markdown

🤖 Agent run interrupted (process terminated)
Commit: 3fa02b3 · View workflow run →

@waynesun09 waynesun09 force-pushed the fix-protected-branch-fallback branch from 2aa22c4 to c03bd36 Compare June 12, 2026 12:47
@waynesun09 waynesun09 added the ok-to-test Allow e2e CI to run after maintainer review (must be re-applied after each push) label Jun 12, 2026
@fullsend-ai-review

fullsend-ai-review Bot commented Jun 12, 2026

Copy link
Copy Markdown

🤖 Finished Review · ✅ Success · Started 12:51 PM UTC · Completed 1:03 PM UTC
Commit: c03bd36 · View workflow run →

@fullsend-ai-review fullsend-ai-review Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

See the review comment for full details.

}

// 8. Update branch ref to point to new commit.
// 7. Update branch ref to point to new commit.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[medium] fail-open

All 422 responses on the ref-update PATCH are mapped to ErrBranchProtected, but GitHub returns 422 for multiple distinct conditions beyond branch protection (invalid SHA, fast-forward conflict, nonexistent ref, other validation failures). Non-protection 422 errors would silently trigger the PR-creation fallback, masking the real error.

Suggested fix: Inspect the 422 response body (APIError.Message or APIError.Errors) for branch-protection-specific signals before wrapping as ErrBranchProtected. Only return the sentinel when the response clearly indicates branch protection.

Comment thread internal/layers/commit.go Outdated

const scaffoldBranch = "fullsend/scaffold-install"
if branchErr := client.CreateBranch(ctx, owner, repo, scaffoldBranch); branchErr != nil {
if !strings.Contains(branchErr.Error(), "already exists") {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[low] race condition / branch staleness

When CreateBranch returns already exists (from a previous failed run), the existing branch may be based on an old commit of the default branch. CommitFilesToBranch commits on top of potentially stale content.

Suggested fix: When the scaffold branch already exists, delete and recreate it from the current default branch HEAD.

Comment thread internal/layers/commit.go
if branchErr := client.CreateBranch(ctx, owner, repo, scaffoldBranch); branchErr != nil {
if !strings.Contains(branchErr.Error(), "already exists") {
printer.StepFail("Failed to create scaffold branch")
return fmt.Errorf("creating scaffold branch: %w", branchErr)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[low] error-handling-idiom

Error checking uses strings.Contains(err.Error(), already exists) for both CreateBranch and CreateChangeProposal errors instead of typed error comparison with sentinel errors.

Suggested fix: Define forge.ErrAlreadyExists sentinel and use errors.Is() checks.

@fullsend-ai-review fullsend-ai-review Bot added requires-manual-review Review requires human judgment and removed requires-manual-review Review requires human judgment labels Jun 12, 2026
@waynesun09 waynesun09 removed the ok-to-test Allow e2e CI to run after maintainer review (must be re-applied after each push) label Jun 12, 2026
@fullsend-ai-review

fullsend-ai-review Bot commented Jun 15, 2026

Copy link
Copy Markdown

🤖 Finished Review · ✅ Success · Started 7:36 PM UTC · Completed 7:48 PM UTC
Commit: ccd0470 · View workflow run →

if e.StatusCode == http.StatusNotFound {
return forge.ErrNotFound
}
if e.StatusCode == http.StatusUnprocessableEntity && isAlreadyExistsError(e) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[low] sentinel collision

APIError.Unwrap() now returns forge.ErrAlreadyExists for every 422 whose message contains 'already exists'. Future code branching on IsAlreadyExists for unrelated API calls may get unexpected matches.

Comment thread internal/layers/commit.go
const scaffoldBranch = "fullsend/scaffold-install"
if branchErr := client.CreateBranch(ctx, owner, repo, scaffoldBranch); branchErr != nil {
if !forge.IsAlreadyExists(branchErr) {
printer.StepFail("Failed to create scaffold branch")

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[low] race condition / branch staleness

When CreateBranch returns ErrAlreadyExists, the existing branch may be based on an old commit. Practical impact is limited since scaffold files are idempotent boilerplate and the PR requires human review.

@fullsend-ai-review fullsend-ai-review Bot added ready-for-merge All reviewers approved — ready to merge and removed requires-manual-review Review requires human judgment labels Jun 15, 2026

@ralphbean ralphbean left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

LGTM. Two minor notes inline.


// isBranchProtectionError checks whether a 422 APIError indicates branch
// protection rather than another validation failure (e.g. non-fast-forward).
func isBranchProtectionError(apiErr *APIError) bool {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

[moderate, non-blocking] isBranchProtectionError and isAlreadyExistsError don't have direct unit tests — the tests in admin_test.go and workflows_test.go exercise FakeClient with pre-wrapped sentinels, so they never validate that a real GitHub API error gets classified correctly.

Table-driven tests with realistic payloads (including negative cases like non-fast-forward 422s) would make these heuristics easier to maintain. Something like:

func TestIsBranchProtectionError(t *testing.T) {
    tests := []struct{ name string; apiErr *APIError; want bool }{
        {"protection", &APIError{StatusCode: 422, Message: "protected branch"}, true},
        {"non-ff", &APIError{StatusCode: 422, Message: "Update is not a fast forward"}, false},
    }
    // ...
}

if e.StatusCode == http.StatusNotFound {
return forge.ErrNotFound
}
if e.StatusCode == http.StatusUnprocessableEntity && isAlreadyExistsError(e) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

[minor, non-blocking] isAlreadyExistsError is wired into Unwrap() globally — any 422 from any endpoint whose message contains "already exists" now unwraps to ErrAlreadyExists. Contrast with ErrBranchProtected, which is scoped to the commitFilesTo call site. That asymmetry is correct for ErrBranchProtected since it needs context, but worth a brief doc comment here noting the design choice so future readers don't wonder about it.

No existing callers check for ErrAlreadyExists, so no immediate behavioral change. If a future endpoint returns a 422 with "already exists" for a different reason (e.g. SHA conflict), the consequence is graceful degradation rather than a hard failure.

Test isBranchProtectionError, isAlreadyExistsError, and APIError.Unwrap
against realistic GitHub API payloads including protected branch push
rejections, required status checks, required reviews, reference
already-exists, PR already-exists (custom code), non-fast-forward
(negative case), and unrelated validation errors.

Assisted-by: Claude
Signed-off-by: Wayne Sun <gsun@redhat.com>
@fullsend-ai-review

fullsend-ai-review Bot commented Jun 15, 2026

Copy link
Copy Markdown

🤖 Finished Review · ✅ Success · Started 8:37 PM UTC · Completed 8:48 PM UTC
Commit: 4043f1d · View workflow run →

if e.StatusCode == http.StatusNotFound {
return forge.ErrNotFound
}
if e.StatusCode == http.StatusUnprocessableEntity && isAlreadyExistsError(e) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[low] sentinel collision

APIError.Unwrap() now returns forge.ErrAlreadyExists for every 422 whose message contains "already exists". This follows the existing pattern where all 404s become forge.ErrNotFound, but future code branching on IsAlreadyExists for unrelated API calls may get unexpected matches.

}
refUpdateResp.Body.Close()

return true, nil

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[low] fail-open

isBranchProtectionError uses substring matching on GitHub API error messages to detect branch protection 422 errors. A non-protection 422 error whose message contains these substrings could be misclassified, triggering the PR-creation fallback. The fallback path is safe (creates a PR requiring human review).

Comment thread internal/layers/commit.go
const scaffoldBranch = "fullsend/scaffold-install"
if branchErr := client.CreateBranch(ctx, owner, repo, scaffoldBranch); branchErr != nil {
if !forge.IsAlreadyExists(branchErr) {
printer.StepFail("Failed to create scaffold branch")

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[low] race condition / branch staleness

When CreateBranch returns ErrAlreadyExists (from a previous failed run), the existing branch may be based on an old commit of the default branch. CommitFilesToBranch commits on top of potentially stale content. Practical impact is limited since scaffold files are idempotent boilerplate and the PR requires human review.

@fullsend-ai-review fullsend-ai-review Bot added ready-for-merge All reviewers approved — ready to merge and removed ready-for-merge All reviewers approved — ready to merge labels Jun 15, 2026
@fullsend-ai-review

fullsend-ai-review Bot commented Jun 15, 2026

Copy link
Copy Markdown

🤖 Review · ⚠️ Cancelled · Started 8:54 PM UTC · Ended 9:00 PM UTC
Commit: 4e21a60 · View workflow run →

- Add "rule violation" to isBranchProtectionError for GitHub rulesets
- Always attempt PR creation in CommitScaffoldFiles for crash recovery
- Add comment explaining intentional Unwrap asymmetry for ErrBranchProtected
- Correct ADR-0005 sentinel error wrapping description
- Add ruleset violation test case and scaffold-branch-also-protected tests
- Fix BranchUpToDate tests to inject ErrAlreadyExists for re-run scenario

Assisted-by: Claude
Signed-off-by: Wayne Sun <gsun@redhat.com>
@fullsend-ai-review

fullsend-ai-review Bot commented Jun 15, 2026

Copy link
Copy Markdown

🤖 Finished Review · ✅ Success · Started 9:04 PM UTC · Completed 9:20 PM UTC
Commit: b4c2edb · View workflow run →

@waynesun09 waynesun09 added the ok-to-test Allow e2e CI to run after maintainer review (must be re-applied after each push) label Jun 15, 2026
@fullsend-ai-review fullsend-ai-review Bot added ready-for-merge All reviewers approved — ready to merge and removed ready-for-merge All reviewers approved — ready to merge labels Jun 15, 2026
@waynesun09 waynesun09 added this pull request to the merge queue Jun 15, 2026
Merged via the queue into main with commit 8622e54 Jun 15, 2026
15 of 16 checks passed
@waynesun09 waynesun09 deleted the fix-protected-branch-fallback branch June 15, 2026 23:32
@fullsend-ai-retro

fullsend-ai-retro Bot commented Jun 15, 2026

Copy link
Copy Markdown

🤖 Finished Retro · ✅ Success · Started 11:37 PM UTC · Completed 11:47 PM UTC
Commit: b4c2edb · View workflow run →

@fullsend-ai-retro

Copy link
Copy Markdown

Retro Analysis: PR #2201 — Protected Branch Fallback

Timeline

  • June 12 01:09 — PR opened by waynesun09 with initial commit adding ErrBranchProtected sentinel and PR-based fallback for protected default branches.
  • June 12 01:29 — First review agent run: CHANGES_REQUESTED with 3 medium findings (fail-open on 422 mapping, race condition on stale branches, error-handling idiom using strings.Contains).
  • June 12 01:37 — Author responded to all 3 findings within 8 minutes, explaining design tradeoffs.
  • June 12 13:02 — Second review run re-raised similar findings at reduced severity, but did not acknowledge author responses.
  • June 15 — Author pushed 4 additional commits: tightened error handling with ErrAlreadyExists sentinel (Add problem areas: Tekton pipeline review, migration path, multi-tenancy #2), added documentation (docs: Add codebase context problem document and trim CLAUDE.md #3), added table-driven tests (Use AI to help formalise intent after rapid local prototyping #4, responding to human reviewer feedback), and improved ruleset handling (docs: add agent infrastructure problem document #5).
  • June 15 19:48 — Review agent approved (all findings reduced to Low/Info).
  • June 15 19:58 — Human reviewer ralphbean approved with two minor non-blocking inline notes.
  • June 15 20:48–21:20 — Two more review agent approval runs triggered (likely by the bot's own approval events or pushes).
  • June 15 23:32 — PR merged.

Assessment

What went well:

  • The initial review caught real issues — the fail-open 422 mapping was a genuine concern that the author addressed in commit 2.
  • The human review added targeted value (table-driven test suggestion led to commit 4).
  • Review agent correctly lowered severity as issues were addressed across commits.
  • The PR improved substantially through the review process (5 commits addressing real feedback).

Improvement opportunities identified (all covered by existing issues):

  1. Repeated findings across review runs — The same concerns (sentinel collision, branch staleness, fail-open, error-handling idiom) appeared in 7+ review iterations. Even after being addressed, they kept surfacing at lower severity. This is tracked by existing issues including Review agent should not regenerate unchanged inline comments on re-reviews #1285 (don't regenerate unchanged inline comments), Review agent should not re-request changes for unchanged findings #1500 (don't re-request changes for unchanged findings), Review comment should state whether findings changed since prior review #1155 (state whether findings changed), and Pass prior review findings to follow-up reviews for targeted verification #1552 (pass prior findings to follow-up reviews).

  2. Author responses not acknowledged — The author responded to all 3 initial findings within minutes, but the next review run did not reference or resolve those responses. Tracked by Review agent should honor explicit author dismissals of findings #1672 (honor explicit author dismissals), Review agent: incorporate existing human and third-party bot reviews into assessment #664 (incorporate existing human and bot reviews), and Review agent: resolve inline comments from previous reviews on re-review #956 (resolve inline comments from previous reviews).

  3. Excessive review dispatches — 10 shim dispatches and 4+ review runs on June 15 alone, with some cancelled. The review agent's own approvals appeared to trigger new dispatches. Extensively tracked by dispatch: filter bot-triggered pull_request_review events to prevent review self-triggering loop #1271 (filter bot-triggered review events), Skip review dispatch when HEAD SHA was already reviewed and approved #963 (skip dispatch for already-reviewed SHA), Deduplicate review dispatches for the same HEAD SHA #1452 (deduplicate dispatches for same SHA), Add concurrency group to dispatch-review job in fullsend.yaml #981 (add concurrency group), and Debounce review dispatch on rapid synchronize events #1014 (debounce rapid synchronize events).

Conclusion

No new proposals filed — all identified improvement areas are well-covered by existing open issues. The core themes (repeated findings, dispatch deduplication, author response awareness) have 20+ open issues between them. The most impactful existing issues to prioritize for reducing the noise seen on this PR are #1271 (prevent review self-triggering loop), #1552 (pass prior findings to follow-up reviews), and #1672 (honor author dismissals).

@rh-hemartin rh-hemartin mentioned this pull request Jun 18, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ok-to-test Allow e2e CI to run after maintainer review (must be re-applied after each push) ready-for-merge All reviewers approved — ready to merge

Projects

None yet

Development

Successfully merging this pull request may close these issues.

install: fall back to PR when default branch push is rejected

3 participants