FN-7182: pause auto-merge for manual pull requests
Manual pull requests now pause automatic merge processing until human action closes or merges the PR. - Mark dashboard-created or linked pull requests with manual provenance and preserve it across PR refreshes and merges. - Exclude tasks with manual open or draft PRs from automatic merge processing while preserving pipeline PR automation. - Add regression coverage and document the manual PR handoff behavior. Files changed: .changeset/FN-7182-no-auto-merge-with-manual-pr.md | 7 ++ docs/architecture.md | 2 +- docs/dashboard-guide.md | 1 + packages/core/src/__tests__/task-helpers.test.ts | 49 ++++++++++++- packages/core/src/__tests__/task-merge.test.ts | 60 +++++++++++++++- packages/core/src/index.ts | 2 +- packages/core/src/task-helpers.ts | 13 ++++ packages/core/src/task-merge.ts | 10 ++- packages/core/src/types.ts | 6 ++ .../dashboard/src/__tests__/routes-auth.test.ts | 80 ++++++++++++++++++++++ .../dashboard/src/__tests__/routes-github.test.ts | 45 +++++++++++- .../dashboard/src/routes/register-git-github.ts | 20 +++++- 12 files changed, 285 insertions(+), 10 deletions(-) Fusion-Task-Id: FN-7182 Fusion-Task-Lineage: c415a9dc-43c1-4609-a032-367991e949a1 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/FN-7182-no-auto-merge-with-manual-pr.md
Normal file
7
.changeset/FN-7182-no-auto-merge-with-manual-pr.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": minor
|
||||
---
|
||||
|
||||
summary: Tasks with a manually-created open Pull Request are no longer auto-merged.
|
||||
category: feature
|
||||
dev: New PrInfo.manual flag set by POST /tasks/:id/pr/create; allowsAutoMergeProcessing now returns false when a task has an open manual PR (status === "open"), excluding it from the engine merge queue and self-healing sweeps until the human merges the PR. Pipeline (PR-merge-strategy) PRs are unaffected. FN-7182.
|
||||
@@ -1827,7 +1827,7 @@ This section preserves the detailed lifecycle/self-healing contracts that were f
|
||||
- **Empty-commit refusal + early empty-own-diff finalize (FN-5345/FN-5377)**: Fusion task worktrees install a `prepare-commit-msg` hook that refuses `git commit --allow-empty` and other zero-staged-diff commits, preventing verification-only tasks from manufacturing empty handoff commits that defeat the merger's no-op classifier. The hook allows legitimate empty-tree paths (amend, merge, squash, cherry-pick, revert, rebase). Amend detection tokenizes the parent process command line (`ps -o args=` with `/proc/$PPID/cmdline` fallback for Alpine/busybox) and stops at the first message-supplying flag (`-m`/`-F`/`--message`/`--file`) so a commit message containing the substring `--amend` cannot bypass the guard. In `aiMergeTask`, an early empty-own-diff fast-path runs BEFORE any reuse-handoff acquisition: when integration mode is `reuse-task-worktree`, the branch exists, `git rev-list --count <mergeTarget>..<branch>` is > 0, and `git diff --quiet <mergeBase>..<branch>` exits 0, the task auto-finalizes as no-op with `mergeDetails.noOpMerge: true` and emits `task:auto-recover-finalize-already-on-main` with `reason: "empty-own-diff-early-fast-path"`. The fast-path best-effort removes the stranded worktree (FN-4811 same-task/foreign-owner guard) and deletes the `fusion/<id>` branch so empty-own-diff residuals do not accumulate. This unsticks tasks where a stale empty handoff commit combined with drifted worktree↔branch mapping would otherwise wedge the handoff gate with `registered-branch-mismatch`. The explicit `cwd-integration-branch` mode is unchanged (`cwd-main` remains a deprecated alias normalized to it). `classifyOwnedLandedEvidence` also detects empty-own-diff (aheadCount > 0, zero net diff) and returns `proven-no-op` so downstream self-healing and post-handoff finalize paths benefit too. Additionally, merger's reuse-fallback path now consults `git worktree list --porcelain` before creating a new worktree: extant usable registrations of `fusion/<id>` are reused directly (rather than blindly `git worktree add -f` producing a duplicate registration), and stale registrations are pruned first. The direct-reuse shortcut is guarded by FN-4811 (refuses paths owned by a different task in `activeSessionRegistry`) and FN-4954 (skipped when `recycleWorktrees=true` with a pool attached, so `WorktreePool.acquire` lease bookkeeping stays consistent). Two audit subtypes — `merge:reuse-fallback-pruned-stale-registration` and `merge:reuse-fallback-reused-existing-registration` — replace the prior overloading of `merge:reuse-fallback-new-worktree` for these cases.
|
||||
- **Verified no-op/duplicate executor completion (FN-6275)**: explicit `fn_task_done` may complete with zero branch commits only when the summary starts with a recognized sentinel (`PREMISE STALE:`, `NO-OP:`, `NOOP:`, `DUPLICATE: FN-NNNN ...`, or `REDUNDANT:`) or the task already carries a no-commit contract. The sentinel only relaxes the `no_commits` invariant; `wrong_toplevel`, `wrong_branch`, pending-step/review refusals, and scope-leak guards still run. Accepted sentinel completions persist `noCommitsExpected: true`, write task-log audit details with marker kind/reason/raw summary/run/agent IDs, and add a task timeline activity so the no-code terminal path remains explainable. Ordinary zero-commit implementation completions without a leading sentinel are still refused.
|
||||
- **In-review branch-binding self-heal (FN-5083/FN-6695)**: `reconcile-in-review-branch-rebind` runs after `reconcile-task-worktree-metadata` and before `reclaim-stale-active-branches`. It restores `task.branch` (and clears `task.worktree` for fresh acquisition) for `in-review` tasks when exactly one case-insensitive `fusion/<id>` candidate branch has unique commits versus the integration base. Ambiguous candidates emit `task:auto-rebind-skipped` (`reason: "ambiguous-candidates"`) and are never auto-resolved. Unsafe metadata repair is also skipped with `task:auto-rebind-skipped`: `userPaused` preserves authoritative user intent, and `checkedOutBy` preserves live agent checkout ownership. Branch construction across executor/worktree-pool/worktree-acquisition/merger/self-healing canonicalizes to lowercase via `canonicalFusionBranchName`; `fn_task_done` wrong-branch checks now auto-canonicalize case-only mismatches and emit `branch:auto-canonicalize-case`.
|
||||
- **In-review is terminal-until-merged under `autoMerge: false` (FN-5147)**: when a project sets `settings.autoMerge: false`, `in-review` is the intended resting state until a human merges the PR. No lifecycle-mutating self-healing sweep (`reclaimSelfOwnedBranchConflicts`, `recoverGhostReviewTasks`, `recoverStaleIncompleteReviewTasks`, `recoverInterruptedMergingTasks`, `recoverStuckMergeDeadlocks`, `recoverMissingWorktreeReviewFailures`, `recoverPartialProgressNoTaskDoneFailures`, `recoverCompletionHandoffLimbo`, `recoverPostDoneNonContinuableWedge`, `recoverMergeableReviewTasks`, `recoverMergedReviewTasks`, `recoverAlreadyMergedReviewTasks`, `recoverOrphanOnlyScopeViolations`, `recoverForeignOnlyContaminatedInReviewTasks`, `recoverReviewTasksWithFailedPreMergeSteps`, `finalizeNoOpReviewTasks`, `surfaceInReviewStalls`, `surfaceInReviewStalled`) may move the task out of `in-review`, mark it `paused`/`failed`, or re-enqueue it for execution. Explicit per-task overrides are distinguished by `task.autoMergeProvenance: "user"`; ambiguous legacy rows stamped `autoMerge: true` by the pre-FN-6245 review-entry path are marked `"legacy-stamp"` once and surfaced in run-audit/logs, but are only cleared by the operator-driven `reconcileLegacyAutoMergeStamps({ apply: true })` action. Scoped FN-5819 exception: shared-group members (`branchContext.assignmentMode === "shared"`) are still allowed through the member→`branch_groups.branchName` integration step while `autoMerge` is off; this is a soft pre-integration only and does not permit shared-branch → default-branch promotion. RECONCILE-ONLY sweeps (branch rebind, blocker fan-out, stale-status clears, contamination metadata cleanup, attribution restore, PR refresh, misclassified-failure error clearing) continue to run.
|
||||
- **In-review is terminal-until-merged under `autoMerge: false` (FN-5147)**: when a project sets `settings.autoMerge: false`, `in-review` is the intended resting state until a human merges the PR. No lifecycle-mutating self-healing sweep (`reclaimSelfOwnedBranchConflicts`, `recoverGhostReviewTasks`, `recoverStaleIncompleteReviewTasks`, `recoverInterruptedMergingTasks`, `recoverStuckMergeDeadlocks`, `recoverMissingWorktreeReviewFailures`, `recoverPartialProgressNoTaskDoneFailures`, `recoverCompletionHandoffLimbo`, `recoverPostDoneNonContinuableWedge`, `recoverMergeableReviewTasks`, `recoverMergedReviewTasks`, `recoverAlreadyMergedReviewTasks`, `recoverOrphanOnlyScopeViolations`, `recoverForeignOnlyContaminatedInReviewTasks`, `recoverReviewTasksWithFailedPreMergeSteps`, `finalizeNoOpReviewTasks`, `surfaceInReviewStalls`, `surfaceInReviewStalled`) may move the task out of `in-review`, mark it `paused`/`failed`, or re-enqueue it for execution. Explicit per-task overrides are distinguished by `task.autoMergeProvenance: "user"`; ambiguous legacy rows stamped `autoMerge: true` by the pre-FN-6245 review-entry path are marked `"legacy-stamp"` once and surfaced in run-audit/logs, but are only cleared by the operator-driven `reconcileLegacyAutoMergeStamps({ apply: true })` action. Scoped FN-5819 exception: shared-group members (`branchContext.assignmentMode === "shared"`) are still allowed through the member→`branch_groups.branchName` integration step while `autoMerge` is off; this is a soft pre-integration only and does not permit shared-branch → default-branch promotion. FN-7182 applies the same human-gated treatment to an open `PrInfo.manual` PR created or linked from the dashboard **Create PR** action: automatic merge queues and self-healing stand down until the PR is closed/merged or handled manually, while pipeline-created PRs without `manual` remain auto-merge eligible. RECONCILE-ONLY sweeps (branch rebind, blocker fan-out, stale-status clears, contamination metadata cleanup, attribution restore, PR refresh, misclassified-failure error clearing) continue to run.
|
||||
- **Auto-merge integration-root default (FN-5279)**: direct auto-merge now defaults `mergeIntegrationWorktree` to `reuse-task-worktree`; merger must pass the reuse handoff gates or emit `merge:reuse-handoff-refused` and leave the task in `in-review` without silently falling back to `cwd-integration-branch` (`cwd-main` remains a deprecated alias normalized to that mode).
|
||||
- **Orphaned execution sweep is observation-only (FN-5337)**: `recoverOrphanedExecutions` only annotates stale in-progress candidates with `task:orphan-detected-no-action` and `[orphan-detected] ... no action (operator-decides)` logs. It must never move `in-progress`/`in-review` backward to `todo` or mutate lease/worktree metadata. Proof-based backward recovery remains exclusively in `recoverInProgressLimbo` (FN-5219), `RestartRecoveryCoordinator`, `recoverMissingWorktreeReviewFailures`, and explicit executor/merger failure paths. Reintroducing lifecycle mutation here requires hard git/session proof gating plus CEO+CTO+PM sign-off.
|
||||
- **Self-owned reclaim resume-limbo escalation (FN-5704)**: `reclaimSelfOwnedBranchConflicts` tracks `resumeLimboCount`, `resumeLimboTipSha`, and `resumeLimboStepSignature` for in-progress reclaim/unpause loops. If reclaim finds no progress (same tip, same step-status signature, and no active-session signal) for `MAX_NO_PROGRESS_RESUME_ATTEMPTS` consecutive sweeps, self-healing escalates by moving the task to `todo` with `preserveWorktree: true`, `preserveProgress: true`, and `preserveResumeState: true` instead of endlessly re-arming resume. Escalation emits `task:resume-limbo-escalated` run-audit metadata (`frozenTipSha`, `idleMs`, `resumeAttemptCount`, `currentStep`) and resets the limbo counter.
|
||||
|
||||
@@ -999,6 +999,7 @@ Inspect task definition, logs, review feedback, comments, artifacts, workflow ou
|
||||
- In `in-review`, pull-request controls/status (including stall badges) are in a dedicated **Pull Request** tab instead of the Definition tab.
|
||||
- In the task detail **Pull Request** tab, PR numbers open the linked pull request on GitHub when a PR URL is available.
|
||||
- Task Detail and list split-pane PR affordances follow the live project auto-merge setting: when auto-merge is off, manual **Create PR** / merge actions are shown; when it is on, the tab shows the automatic auto-merge hint unless a per-task override changes the effective behavior.
|
||||
- A PR created or linked with **Create PR** is treated as a manual handoff: while it remains open, Fusion excludes that task from automatic merge processing so the human can merge via GitHub or **Merge PR**.
|
||||
- The **Workflow** tab resolves the effective workflow for both explicitly selected and default-inherited tasks. Its overview, expandable graph preview, configured step details, and live step results refresh when switching tasks or projects without showing stale rows from the previous task.
|
||||
- The **Create Pull Request** modal now offers in-app remediation for every blocking preflight check. If `branchOnRemote` is false, use **Push branch to remote** and Fusion will publish `fusion/<task-id-lower>` to `origin` and refresh preflight. If `conflictsWithBase` is true, use **Resolve conflicts with AI** and Fusion will use an AI coding agent to resolve merge markers on the task branch, commit and push real merge changes, or report success without an empty commit when the selected base is already merged; preflight then refreshes so normal PR creation can continue once all checks pass.
|
||||
- The **Create Pull Request** modal is a floating pop-out like Plan Mission, New Task, and Automations: drag its header or resize from desktop edges/corners, while mobile keeps the full-screen dialog layout. Close it with **X**, **Cancel**, or **Escape**; stray clicks inside or outside the floating shell do not dismiss it.
|
||||
|
||||
@@ -1,5 +1,52 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getPrimaryPrInfo } from "../task-helpers.js";
|
||||
import { getPrimaryPrInfo, taskHasManualOpenPullRequest } from "../task-helpers.js";
|
||||
import type { PrInfo } from "../types.js";
|
||||
|
||||
function prInfo(overrides: Partial<PrInfo> = {}): PrInfo {
|
||||
return {
|
||||
url: "https://github.com/owner/repo/pull/1",
|
||||
number: 1,
|
||||
status: "open",
|
||||
title: "PR",
|
||||
headBranch: "fusion/fn-001",
|
||||
baseBranch: "main",
|
||||
commentCount: 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("taskHasManualOpenPullRequest", () => {
|
||||
it("returns true for a legacy single open manual PR", () => {
|
||||
expect(taskHasManualOpenPullRequest({ prInfo: prInfo({ manual: true }) })).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for a draft manual PR because it is still active", () => {
|
||||
expect(taskHasManualOpenPullRequest({ prInfo: prInfo({ manual: true, status: "draft" }) })).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for closed or merged manual PRs", () => {
|
||||
expect(taskHasManualOpenPullRequest({ prInfo: prInfo({ manual: true, status: "closed" }) })).toBe(false);
|
||||
expect(taskHasManualOpenPullRequest({ prInfo: prInfo({ manual: true, status: "merged" }) })).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for a non-manual open PR", () => {
|
||||
expect(taskHasManualOpenPullRequest({ prInfo: prInfo() })).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when no PR data is present", () => {
|
||||
expect(taskHasManualOpenPullRequest({})).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true when any multi-PR entry is open and manual", () => {
|
||||
expect(taskHasManualOpenPullRequest({
|
||||
prInfos: [
|
||||
prInfo({ number: 1, status: "closed", manual: true }),
|
||||
prInfo({ number: 2, manual: false }),
|
||||
prInfo({ number: 3, manual: true }),
|
||||
],
|
||||
})).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getPrimaryPrInfo", () => {
|
||||
it("returns prInfo when only legacy field is set", () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import type { StepStatus } from "../types.js";
|
||||
import type { PrInfo, StepStatus } from "../types.js";
|
||||
import {
|
||||
BLOCKING_TASK_STATUSES,
|
||||
HARD_BLOCKING_TASK_STATUSES,
|
||||
@@ -29,6 +29,19 @@ const baseCompletionTask = {
|
||||
blockedBy: undefined as string | undefined,
|
||||
};
|
||||
|
||||
function prInfo(overrides: Partial<PrInfo> = {}): PrInfo {
|
||||
return {
|
||||
url: "https://github.com/owner/repo/pull/1",
|
||||
number: 1,
|
||||
status: "open",
|
||||
title: "PR",
|
||||
headBranch: "fusion/fn-001",
|
||||
baseBranch: "main",
|
||||
commentCount: 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("resolveEffectiveAutoMerge", () => {
|
||||
it("prefers explicit true over global false", () => {
|
||||
expect(resolveEffectiveAutoMerge({ autoMerge: true }, { autoMerge: false })).toBe(true);
|
||||
@@ -82,6 +95,51 @@ describe("allowsAutoMergeProcessing", () => {
|
||||
expect(allowsAutoMergeProcessing({ autoMerge: true }, { autoMerge: true })).toBe(true);
|
||||
expect(allowsAutoMergeProcessing({ autoMerge: false }, { autoMerge: true })).toBe(true);
|
||||
});
|
||||
|
||||
it("blocks an open or draft manually-created PR even when global auto-merge is on", () => {
|
||||
expect(allowsAutoMergeProcessing({
|
||||
autoMerge: undefined,
|
||||
prInfos: [prInfo({ manual: true })],
|
||||
}, { autoMerge: true })).toBe(false);
|
||||
expect(allowsAutoMergeProcessing({
|
||||
autoMerge: undefined,
|
||||
prInfos: [prInfo({ manual: true, status: "draft" })],
|
||||
}, { autoMerge: true })).toBe(false);
|
||||
});
|
||||
|
||||
it("does not block once a manually-created PR is closed or merged", () => {
|
||||
expect(allowsAutoMergeProcessing({
|
||||
autoMerge: undefined,
|
||||
prInfos: [prInfo({ manual: true, status: "merged" })],
|
||||
}, { autoMerge: true })).toBe(true);
|
||||
expect(allowsAutoMergeProcessing({
|
||||
autoMerge: undefined,
|
||||
prInfos: [prInfo({ manual: true, status: "closed" })],
|
||||
}, { autoMerge: true })).toBe(true);
|
||||
});
|
||||
|
||||
it("does not block a pipeline-created open PR without the manual flag", () => {
|
||||
expect(allowsAutoMergeProcessing({
|
||||
autoMerge: undefined,
|
||||
prInfos: [prInfo()],
|
||||
}, { autoMerge: true })).toBe(true);
|
||||
});
|
||||
|
||||
it("preserves no-PR and explicit override behavior", () => {
|
||||
expect(allowsAutoMergeProcessing({ autoMerge: undefined }, { autoMerge: true })).toBe(true);
|
||||
expect(allowsAutoMergeProcessing({ autoMerge: true }, { autoMerge: false })).toBe(true);
|
||||
});
|
||||
|
||||
it("checks the legacy single prInfo and multi prInfos shapes", () => {
|
||||
expect(allowsAutoMergeProcessing({
|
||||
autoMerge: undefined,
|
||||
prInfo: prInfo({ manual: true }),
|
||||
}, { autoMerge: true })).toBe(false);
|
||||
expect(allowsAutoMergeProcessing({
|
||||
autoMerge: undefined,
|
||||
prInfos: [prInfo({ number: 1 }), prInfo({ number: 2, manual: true })],
|
||||
}, { autoMerge: true })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveEffectiveGroupAutoMerge", () => {
|
||||
|
||||
@@ -712,7 +712,6 @@ export {
|
||||
hasTitleIdDrift,
|
||||
normalizeTitleForTaskId,
|
||||
} from "./task-title-id-drift.js";
|
||||
export { getPrimaryPrInfo } from "./task-helpers.js";
|
||||
export {
|
||||
IN_REVIEW_STALL_DEADLOCK_PAUSE_REASON,
|
||||
MANUAL_RETRY_RESET_COUNTER_KEYS,
|
||||
@@ -807,6 +806,7 @@ export type {
|
||||
DependencyBlockedTodoReport,
|
||||
DependencyBlockedTodoReportContext,
|
||||
} from "./dependency-blocked-todo-report.js";
|
||||
export { getPrimaryPrInfo, taskHasManualOpenPullRequest } from "./task-helpers.js";
|
||||
export {
|
||||
getTaskMergeBlocker,
|
||||
getTaskHardMergeBlocker,
|
||||
|
||||
@@ -3,3 +3,16 @@ import type { PrInfo, Task } from "./types.js";
|
||||
export function getPrimaryPrInfo(task: Pick<Task, "prInfo" | "prInfos">): PrInfo | undefined {
|
||||
return task.prInfos?.[0] ?? task.prInfo;
|
||||
}
|
||||
|
||||
/**
|
||||
* FNXC:PrAutoMergeGate 2026-06-28-00:33:
|
||||
* FN-7182: active in-review rows keep `prInfo`/`prInfos` even in slim task listings; only archived snapshots strip PR payloads.
|
||||
* This makes the manual-open-PR handoff check safe for project-engine and self-healing slim sweeps while supporting legacy single-PR and multi-PR shapes.
|
||||
*
|
||||
* FNXC:PrAutoMergeGate 2026-06-28-01:39:
|
||||
* A draft PR is still an active human handoff: GitHub has not closed or merged it, and Fusion must not auto-process the task around it.
|
||||
*/
|
||||
export function taskHasManualOpenPullRequest(task: Pick<Task, "prInfo" | "prInfos">): boolean {
|
||||
const prs = task.prInfos ?? (task.prInfo ? [task.prInfo] : []);
|
||||
return prs.some((pr) => pr.manual === true && (pr.status === "open" || pr.status === "draft"));
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { taskHasManualOpenPullRequest } from "./task-helpers.js";
|
||||
import type { BranchGroup, Settings, Task, WorkflowStepResult } from "./types.js";
|
||||
|
||||
export interface MergeTargetResolution {
|
||||
@@ -58,12 +59,17 @@ export function resolveEffectiveAutoMerge(
|
||||
* reconciled separately. Distinct from
|
||||
* `resolveEffectiveAutoMerge`, which resolves the effective boolean and would
|
||||
* (incorrectly for processing gates) starve the manual-required parking path.
|
||||
*
|
||||
* FNXC:PrAutoMergeGate 2026-06-28-00:33:
|
||||
* FN-7182: a dashboard-created open PR is a human handoff, so exclude it from all automatic merge processing and self-healing recovery until the human merges or closes the PR.
|
||||
* This mirrors the `autoMerge:false` in-review gate while preserving manual Merge PR/manual done paths and pipeline PRs without `manual: true`.
|
||||
* Shared-branch member integration still bypasses this function via `allowInReviewMergeProcessing(... ) || isSharedBranchGroupMemberIntegration(task)`, so a manual PR on a shared member can still be integrated to its group branch; group-to-default promotion remains gated separately.
|
||||
*/
|
||||
export function allowsAutoMergeProcessing(
|
||||
task: Pick<Task, "autoMerge">,
|
||||
task: Pick<Task, "autoMerge" | "prInfo" | "prInfos">,
|
||||
settings: Pick<Settings, "autoMerge">,
|
||||
): boolean {
|
||||
return settings.autoMerge !== false || task.autoMerge === true;
|
||||
return (settings.autoMerge !== false || task.autoMerge === true) && !taskHasManualOpenPullRequest(task);
|
||||
}
|
||||
|
||||
// Resolves group → default-branch PROMOTION auto-merge. See resolveEffectiveAutoMerge for the per-task member→group-integration step; the two are distinct and must not be conflated.
|
||||
|
||||
@@ -954,6 +954,12 @@ export interface PrInfo {
|
||||
commentCount: number;
|
||||
isDraft?: boolean;
|
||||
draft?: boolean;
|
||||
/**
|
||||
* FNXC:PrAutoMergeGate 2026-06-28-00:33:
|
||||
* FN-7182: `true` means this PR was created or linked by the dashboard Create PR action as an explicit human handoff.
|
||||
* Pipeline PR-merge-strategy PRs leave this unset so automatic PR-mode merging keeps working. PrInfo is persisted as JSON, so this provenance flag needs no SQLite migration.
|
||||
*/
|
||||
manual?: boolean;
|
||||
autoMergeOnGreen?: boolean;
|
||||
autoMergeStrategy?: "merge" | "squash" | "rebase";
|
||||
lastMergeError?: string;
|
||||
|
||||
@@ -3174,12 +3174,45 @@ describe("Pause/Unpause endpoints", () => {
|
||||
expect(findSpy).toHaveBeenCalledWith(expect.objectContaining({ head: "fusion/fn-001", state: "all" }));
|
||||
expect(createSpy).not.toHaveBeenCalled();
|
||||
expect(pushSpy).not.toHaveBeenCalledWith(["push", "-u", "origin", "fusion/fn-001"], expect.anything(), expect.anything());
|
||||
expect(store.updatePrInfo).toHaveBeenCalledWith("FN-001", expect.objectContaining({ number: 77, manual: true }));
|
||||
expect(res.body).toEqual(expect.objectContaining({ number: 77, manual: true }));
|
||||
expect(store.logEntry).toHaveBeenCalledWith("FN-001", "Linked existing PR", "PR #77: https://github.com/owner/repo/pull/77");
|
||||
|
||||
if (originalEnv) process.env.GITHUB_REPOSITORY = originalEnv;
|
||||
else delete process.env.GITHUB_REPOSITORY;
|
||||
});
|
||||
|
||||
it("stores linked PRs with manual provenance when appending to an existing PR list", async () => {
|
||||
const originalEnv = process.env.GITHUB_REPOSITORY;
|
||||
process.env.GITHUB_REPOSITORY = "owner/repo";
|
||||
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
...mockInReviewTask,
|
||||
prInfo: mockPrInfo,
|
||||
prInfos: [mockPrInfo],
|
||||
});
|
||||
const existingPr = { ...mockPrInfo, number: 88, url: "https://github.com/owner/repo/pull/88" };
|
||||
vi.spyOn(GitHubClient.prototype, "findPrForBranch").mockResolvedValue(existingPr);
|
||||
const createSpy = vi.spyOn(GitHubClient.prototype, "createPr").mockResolvedValue(mockPrInfo);
|
||||
const pushSpy = vi.spyOn(resolveDiffBaseModule, "runGitCommand").mockResolvedValue("ok");
|
||||
|
||||
const res = await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/tasks/KB-001/pr/create",
|
||||
JSON.stringify({ title: "Test PR", body: "Test body" }),
|
||||
{ "Content-Type": "application/json" }
|
||||
);
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(createSpy).not.toHaveBeenCalled();
|
||||
expect(pushSpy).not.toHaveBeenCalledWith(["push", "-u", "origin", "fusion/fn-001"], expect.anything(), expect.anything());
|
||||
expect(store.addPrInfo).toHaveBeenCalledWith("FN-001", expect.objectContaining({ number: 88, manual: true }));
|
||||
expect(res.body).toEqual(expect.objectContaining({ number: 88, manual: true }));
|
||||
|
||||
if (originalEnv) process.env.GITHUB_REPOSITORY = originalEnv;
|
||||
else delete process.env.GITHUB_REPOSITORY;
|
||||
});
|
||||
|
||||
it("pushes the task branch before creating a PR when no existing PR is found", async () => {
|
||||
const originalEnv = process.env.GITHUB_REPOSITORY;
|
||||
process.env.GITHUB_REPOSITORY = "owner/repo";
|
||||
@@ -3200,6 +3233,8 @@ describe("Pause/Unpause endpoints", () => {
|
||||
expect(findSpy).toHaveBeenCalled();
|
||||
expect(pushSpy).toHaveBeenCalledWith(["push", "-u", "origin", "fusion/fn-001"], "/fake/root", 60_000);
|
||||
expect(createSpy).toHaveBeenCalledWith(expect.objectContaining({ head: "fusion/fn-001" }));
|
||||
expect(store.updatePrInfo).toHaveBeenCalledWith("FN-001", expect.objectContaining({ number: 42, manual: true }));
|
||||
expect(res.body).toEqual(expect.objectContaining({ number: 42, manual: true }));
|
||||
expect(store.logEntry).toHaveBeenCalledWith("FN-001", "Created PR", "PR #42: https://github.com/owner/repo/pull/42");
|
||||
|
||||
if (originalEnv) process.env.GITHUB_REPOSITORY = originalEnv;
|
||||
@@ -3666,6 +3701,51 @@ describe("Pause/Unpause endpoints", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves manual PR provenance during batch status refresh", async () => {
|
||||
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
...FAKE_TASK_DETAIL,
|
||||
id: "FN-003",
|
||||
updatedAt: new Date(Date.now() - 10 * 60 * 1000).toISOString(),
|
||||
prInfo: {
|
||||
url: "https://github.com/owner/repo/pull/43",
|
||||
number: 43,
|
||||
status: "open" as const,
|
||||
title: "PR 43",
|
||||
headBranch: "feature/43",
|
||||
baseBranch: "main",
|
||||
commentCount: 0,
|
||||
manual: true,
|
||||
},
|
||||
});
|
||||
|
||||
vi.spyOn(GitHubClient.prototype, "getBatchPrStatus").mockResolvedValue(new Map([
|
||||
[43, {
|
||||
url: "https://github.com/owner/repo/pull/43",
|
||||
number: 43,
|
||||
status: "open",
|
||||
title: "PR 43 refreshed",
|
||||
headBranch: "feature/43",
|
||||
baseBranch: "main",
|
||||
commentCount: 2,
|
||||
}],
|
||||
]));
|
||||
|
||||
const res = await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/github/batch/status",
|
||||
JSON.stringify({ taskIds: ["FN-003"] }),
|
||||
{ "Content-Type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.results["FN-003"].prInfo.manual).toBe(true);
|
||||
expect(store.updatePrInfo).toHaveBeenCalledWith(
|
||||
"FN-003",
|
||||
expect.objectContaining({ number: 43, manual: true, lastCheckedAt: expect.any(String) }),
|
||||
);
|
||||
});
|
||||
|
||||
it("handles partial failures without dropping successful results", async () => {
|
||||
(store.getTask as ReturnType<typeof vi.fn>)
|
||||
.mockResolvedValueOnce({
|
||||
|
||||
@@ -2634,10 +2634,47 @@ describe("PR conflict refresh + reclaim routes", () => {
|
||||
const res = await REQUEST(buildApp(), "POST", `/api/tasks/${task.id}/pr/create`, JSON.stringify({ title: "New PR", body: "New PR body" }), { "content-type": "application/json" });
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(store.addPrInfo).toHaveBeenCalledWith(task.id, expect.objectContaining({ number: 901 }));
|
||||
expect(store.addPrInfo).toHaveBeenCalledWith(task.id, expect.objectContaining({ number: 901, manual: true }));
|
||||
expect(store.updatePrInfo).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("create-PR stores freshly created PRs as manual handoffs", async () => {
|
||||
process.env.GITHUB_REPOSITORY = "owner/repo";
|
||||
const task = { ...FAKE_TASK_DETAIL, id: "FN-901", column: "in-review", prInfo: undefined, prInfos: undefined };
|
||||
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue(task);
|
||||
|
||||
vi.spyOn(GitHubClient.prototype, "findPrForBranch").mockResolvedValue(null);
|
||||
vi.spyOn(GitHubClient.prototype, "createPr").mockResolvedValue({
|
||||
url: "https://github.com/owner/repo/pull/902",
|
||||
number: 902,
|
||||
status: "open",
|
||||
title: "New",
|
||||
headBranch: "fusion/fn-901",
|
||||
baseBranch: "main",
|
||||
commentCount: 0,
|
||||
} as any);
|
||||
mockExecFile.mockImplementationOnce((file, argsOrCb, maybeOptions, maybeCb) => {
|
||||
const cb =
|
||||
typeof maybeCb === "function"
|
||||
? maybeCb
|
||||
: typeof maybeOptions === "function"
|
||||
? maybeOptions
|
||||
: typeof argsOrCb === "function"
|
||||
? argsOrCb
|
||||
: null;
|
||||
expect(file).toBe("git");
|
||||
expect(Array.isArray(argsOrCb) ? argsOrCb : []).toEqual(["push", "-u", "origin", "fusion/fn-901"]);
|
||||
if (cb) queueMicrotask(() => cb(null, { stdout: "", stderr: "" }));
|
||||
return undefined as never;
|
||||
});
|
||||
|
||||
const res = await REQUEST(buildApp(), "POST", `/api/tasks/${task.id}/pr/create`, JSON.stringify({ title: "New PR", body: "New PR body" }), { "content-type": "application/json" });
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(store.updatePrInfo).toHaveBeenCalledWith(task.id, expect.objectContaining({ number: 902, manual: true }));
|
||||
expect(store.addPrInfo).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("queues conflict reclaim during refresh when mergeable is conflicting", async () => {
|
||||
const task = {
|
||||
...FAKE_TASK_DETAIL,
|
||||
@@ -2707,6 +2744,7 @@ describe("PR conflict refresh + reclaim routes", () => {
|
||||
headBranch: "fusion/fn-901",
|
||||
baseBranch: "main",
|
||||
commentCount: 0,
|
||||
manual: true,
|
||||
},
|
||||
};
|
||||
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue(task);
|
||||
@@ -2731,6 +2769,11 @@ describe("PR conflict refresh + reclaim routes", () => {
|
||||
const res = await REQUEST(buildApp({ engine } as any), "POST", `/api/tasks/${task.id}/pr/refresh`, JSON.stringify({}), { "content-type": "application/json" });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.conflictReclaimQueued).toBe(false);
|
||||
expect(store.updatePrInfoByNumber).toHaveBeenCalledWith(
|
||||
task.id,
|
||||
task.prInfo.number,
|
||||
expect.objectContaining({ manual: true }),
|
||||
);
|
||||
expect(reclaimSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
@@ -2299,6 +2299,7 @@ async function mergeTaskPr(
|
||||
...mergedPrInfo,
|
||||
autoMergeOnGreen: task.prInfo.autoMergeOnGreen,
|
||||
autoMergeStrategy: task.prInfo.autoMergeStrategy,
|
||||
manual: task.prInfo.manual,
|
||||
lastMergeError: undefined,
|
||||
lastMergeErrorAt: undefined,
|
||||
draft: mergedPrInfo.draft ?? mergedPrInfo.isDraft,
|
||||
@@ -2402,6 +2403,7 @@ export async function refreshPrInBackground(
|
||||
autoMergeStrategy: prior?.autoMergeStrategy,
|
||||
lastMergeError: prior?.lastMergeError,
|
||||
lastMergeErrorAt: prior?.lastMergeErrorAt,
|
||||
manual: prior?.manual,
|
||||
draft: mergeStatus.prInfo.draft ?? mergeStatus.prInfo.isDraft,
|
||||
lastCheckedAt: new Date().toISOString(),
|
||||
lastReviewDecision: reviewSnapshot.decision,
|
||||
@@ -4634,7 +4636,11 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
|
||||
for (const match of matchingTasks) {
|
||||
if (match.resourceType === "pr") {
|
||||
const current = match.current as PrInfo;
|
||||
const next = { ...(badgeData as Omit<PrInfo, "lastCheckedAt">), lastCheckedAt: checkedAt };
|
||||
/*
|
||||
FNXC:PrAutoMergeGate 2026-06-28-01:39:
|
||||
FN-7182: GitHub status refresh payloads do not carry Fusion provenance. Preserve `manual` so a badge refresh cannot erase the human PR handoff and re-enable auto-merge.
|
||||
*/
|
||||
const next: PrInfo = { ...current, ...(badgeData as Omit<PrInfo, "lastCheckedAt">), manual: current.manual, lastCheckedAt: checkedAt };
|
||||
const changed = hasPrBadgeFieldsChanged(current, badgeData as Omit<PrInfo, "lastCheckedAt">);
|
||||
if (changed || current.lastCheckedAt !== checkedAt) {
|
||||
await scopedStore.updatePrInfo(match.id, next);
|
||||
@@ -4827,7 +4833,9 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
|
||||
}
|
||||
|
||||
const updatedPrInfo: PrInfo = {
|
||||
...task.prInfo,
|
||||
...prInfo,
|
||||
manual: task.prInfo.manual,
|
||||
lastCheckedAt: refreshedAt,
|
||||
};
|
||||
await scopedStore.updatePrInfo(taskId, updatedPrInfo);
|
||||
@@ -4939,10 +4947,10 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
|
||||
|
||||
let prInfo: PrInfo;
|
||||
if (existingPr) {
|
||||
prInfo = existingPr;
|
||||
prInfo = { ...existingPr, manual: true };
|
||||
} else {
|
||||
await runGitCommand(["push", "-u", "origin", branchName], scopedStore.getRootDir(), 60_000);
|
||||
prInfo = await client.createPr({
|
||||
const createdPrInfo = await client.createPr({
|
||||
owner,
|
||||
repo,
|
||||
title: prTitle,
|
||||
@@ -4950,8 +4958,13 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
|
||||
head: branchName,
|
||||
base,
|
||||
});
|
||||
prInfo = { ...createdPrInfo, manual: true };
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:PrAutoMergeGate 2026-06-28-00:33:
|
||||
FN-7182: Create PR is an explicit human handoff. Persist `manual: true` only for dashboard-created/linked PRs so automatic merge processing stands down while manual Merge PR and PR monitor completion still work.
|
||||
*/
|
||||
// Store PR info
|
||||
if (existingPrs.length > 0) {
|
||||
await scopedStore.addPrInfo(task.id, prInfo);
|
||||
@@ -5442,6 +5455,7 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
|
||||
autoMergeStrategy: priorPr.autoMergeStrategy,
|
||||
lastMergeError: priorPr.lastMergeError,
|
||||
lastMergeErrorAt: priorPr.lastMergeErrorAt,
|
||||
manual: priorPr.manual,
|
||||
draft: mergeStatus.prInfo.draft ?? mergeStatus.prInfo.isDraft,
|
||||
lastCheckedAt: new Date().toISOString(),
|
||||
lastReviewDecision: reviewSnapshot.decision,
|
||||
|
||||
Reference in New Issue
Block a user