FN-5838: suppress stale integration-advance action prompts
Refine merge-advance status handling so Git Manager only requests action when the checkout is truly behind. - classify recent integration advances as reachable, orphaned, subsumed, or pending before marking them actionable - detect subsumed advances by comparing commit patch fingerprints from recent HEAD history - expose advance resolution metadata in API status payloads and route exports - update Git Manager UI to count only pending actionable advances, gate Sync Working Tree visibility, and allow dismissing handled orphaned/subsumed rows - expand dashboard tests and docs to cover stale false-positive scenarios and new resolution behavior Files changed: docs/dashboard-guide.md | 6 ++ packages/dashboard/app/api/legacy.ts | 1 + .../dashboard/app/components/GitManagerModal.tsx | 45 ++++++---- .../components/__tests__/GitManagerModal.test.tsx | 63 ++++++++++++++ .../dashboard/src/__tests__/routes-git.test.ts | 97 +++++++++++++++++++++- .../dashboard/src/routes/register-git-github.ts | 94 ++++++++++++++------- 6 files changed, 259 insertions(+), 47 deletions(-) Fusion-Task-Id: FN-5838 Fusion-Task-Lineage: 65d1317a-7618-4fff-9875-c845474888ed
This commit is contained in:
@@ -2708,6 +2708,7 @@ export interface GitStatus {
|
||||
advancedAt: string;
|
||||
autoSyncOutcome?: string;
|
||||
needsAction: boolean;
|
||||
resolution: "reachable" | "orphaned" | "subsumed" | "pending";
|
||||
}>;
|
||||
}
|
||||
|
||||
|
||||
@@ -1072,6 +1072,12 @@ function StatusPanel({
|
||||
syncing: boolean;
|
||||
}) {
|
||||
const [advancesHelpOpen, setAdvancesHelpOpen] = useState(false);
|
||||
const [dismissedAdvanceShas, setDismissedAdvanceShas] = useState<Set<string>>(new Set());
|
||||
const visibleAdvances = (status.recentMergeAdvances ?? []).filter((advance) => !dismissedAdvanceShas.has(advance.toSha));
|
||||
const actionableAdvances = visibleAdvances.filter((advance) => advance.resolution === "pending");
|
||||
const hasActionableAdvances = actionableAdvances.length > 0;
|
||||
const isHeadAlignedWithIntegration = status.aheadOfIntegration === 0 && status.behindIntegration === 0;
|
||||
const showSyncWorkingTree = hasActionableAdvances && !isHeadAlignedWithIntegration;
|
||||
return (
|
||||
<div className="gm-panel" data-testid="status-panel">
|
||||
<div className="gm-panel-header">
|
||||
@@ -1322,13 +1328,13 @@ function StatusPanel({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{(status.recentMergeAdvances ?? []).length > 0 && (
|
||||
{visibleAdvances.length > 0 && (
|
||||
<div className="gm-status-advances" data-testid="recent-merge-advances">
|
||||
<div className="gm-status-advances-header">
|
||||
<span>
|
||||
Recent integration-branch advances
|
||||
<span className="gm-status-sub">
|
||||
{" "}({(status.recentMergeAdvances ?? []).filter((a) => a.needsAction).length} need action)
|
||||
{" "}({actionableAdvances.length} need action)
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
@@ -1342,7 +1348,7 @@ function StatusPanel({
|
||||
<Info size={13} />
|
||||
</button>
|
||||
</span>
|
||||
{(status.recentMergeAdvances ?? []).some((a) => a.needsAction) && (
|
||||
{showSyncWorkingTree && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm"
|
||||
@@ -1365,22 +1371,17 @@ function StatusPanel({
|
||||
</p>
|
||||
<ul className="gm-status-advances-help-list">
|
||||
<li><code>clean-sync</code> / <code>synced-with-edits-restored</code> — working tree is in sync; nothing to do.</li>
|
||||
<li><code>off / not run</code> — auto-sync is disabled in Settings; the branch ref moved but your worktree didn't follow.</li>
|
||||
<li><code>stash-failed</code> / <code>would-conflict</code> / similar — auto-sync tried but couldn't reconcile (usually local edits collide with the new commit).</li>
|
||||
<li><code>reachable</code> / <code>subsumed</code> / <code>orphaned</code> — already handled (including history rewrites where equivalent content already landed or original SHAs disappeared).</li>
|
||||
<li><code>pending</code> + <code>off / not run</code> — auto-sync is disabled in Settings; the branch ref moved but your worktree didn't follow.</li>
|
||||
<li><code>pending</code> + <code>stash-failed</code> / <code>would-conflict</code> / similar — auto-sync tried but couldn't reconcile (usually local edits collide with the new commit).</li>
|
||||
</ul>
|
||||
<p>
|
||||
<strong>Fix:</strong> click <em>Sync working tree</em> to catch
|
||||
up now. Pure-local — it auto-stashes any uncommitted edits,
|
||||
hard-resets the worktree to match the local integration tip
|
||||
(the sha the merger advanced <code>refs/heads/{status.integrationBranch ?? "main"}</code> to),
|
||||
and restores your stash. Origin is not touched, so no unrelated
|
||||
remote work gets pulled in. To make this automatic going
|
||||
forward, enable <code>mergeAdvanceAutoSync</code> in Settings.
|
||||
<strong>Fix:</strong> Fusion only shows <em>Sync working tree</em> when at least one advance is genuinely <code>pending</code> and HEAD is not aligned with the integration tip. If entries are already handled (subsumed/orphaned/reachable), no sync action is offered.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<ul>
|
||||
{(status.recentMergeAdvances ?? []).map((advance) => (
|
||||
{visibleAdvances.map((advance) => (
|
||||
<li key={`${advance.taskId}-${advance.toSha}`} className={advance.needsAction ? "gm-advance-needs-action" : "gm-advance-handled"}>
|
||||
<code className="gm-hash">{advance.toSha.slice(0, 8)}</code>
|
||||
{" "}
|
||||
@@ -1395,8 +1396,24 @@ function StatusPanel({
|
||||
</span>
|
||||
)}
|
||||
<span className="gm-status-sub">
|
||||
{" "}· {new Date(advance.advancedAt).toLocaleTimeString()}
|
||||
{" "}· {new Date(advance.advancedAt).toLocaleTimeString()} · {advance.resolution}
|
||||
</span>
|
||||
{(advance.resolution === "orphaned" || advance.resolution === "subsumed") && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-xs"
|
||||
onClick={() => {
|
||||
setDismissedAdvanceShas((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.add(advance.toSha);
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
data-testid={`dismiss-advance-${advance.toSha}`}
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
@@ -3039,6 +3039,69 @@ describe("GitManagerModal", () => {
|
||||
});
|
||||
|
||||
|
||||
describe("recent merge advances panel", () => {
|
||||
it("hides sync CTA when advances are handled and head is aligned", async () => {
|
||||
(fetchGitStatus as any).mockResolvedValue({
|
||||
branch: "main",
|
||||
commit: "abc1234",
|
||||
isDirty: false,
|
||||
ahead: 0,
|
||||
behind: 0,
|
||||
integrationBranch: "main",
|
||||
aheadOfIntegration: 0,
|
||||
behindIntegration: 0,
|
||||
recentMergeAdvances: [
|
||||
{ taskId: "FN-1", fromSha: null, toSha: "a".repeat(40), advancedAt: new Date().toISOString(), needsAction: false, resolution: "orphaned" },
|
||||
{ taskId: "FN-2", fromSha: null, toSha: "b".repeat(40), advancedAt: new Date().toISOString(), needsAction: false, resolution: "subsumed" },
|
||||
],
|
||||
});
|
||||
render(<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />);
|
||||
await waitFor(() => expect(screen.getByTestId("recent-merge-advances")).toBeInTheDocument());
|
||||
expect(screen.getByText(/\(0 need action\)/i)).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("sync-working-tree-btn")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows sync CTA when pending advance exists", async () => {
|
||||
(fetchGitStatus as any).mockResolvedValue({
|
||||
branch: "main",
|
||||
commit: "abc1234",
|
||||
isDirty: false,
|
||||
ahead: 0,
|
||||
behind: 0,
|
||||
integrationBranch: "main",
|
||||
aheadOfIntegration: 1,
|
||||
behindIntegration: 0,
|
||||
recentMergeAdvances: [
|
||||
{ taskId: "FN-3", fromSha: null, toSha: "c".repeat(40), advancedAt: new Date().toISOString(), needsAction: true, resolution: "pending" },
|
||||
],
|
||||
});
|
||||
render(<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />);
|
||||
await waitFor(() => expect(screen.getByTestId("sync-working-tree-btn")).toBeInTheDocument());
|
||||
await userEvent.click(screen.getByTestId("sync-working-tree-btn"));
|
||||
});
|
||||
|
||||
it("dismisses orphaned/subsumed entries", async () => {
|
||||
const toSha = "d".repeat(40);
|
||||
(fetchGitStatus as any).mockResolvedValue({
|
||||
branch: "main",
|
||||
commit: "abc1234",
|
||||
isDirty: false,
|
||||
ahead: 0,
|
||||
behind: 0,
|
||||
integrationBranch: "main",
|
||||
aheadOfIntegration: 0,
|
||||
behindIntegration: 0,
|
||||
recentMergeAdvances: [
|
||||
{ taskId: "FN-4", fromSha: null, toSha, advancedAt: new Date().toISOString(), needsAction: false, resolution: "orphaned" },
|
||||
],
|
||||
});
|
||||
render(<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />);
|
||||
await waitFor(() => expect(screen.getByText("FN-4")).toBeInTheDocument());
|
||||
await userEvent.click(screen.getByTestId(`dismiss-advance-${toSha}`));
|
||||
expect(screen.queryByText("FN-4")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("CSS regression coverage", () => {
|
||||
it("includes remotes layout selectors and mobile rules", () => {
|
||||
const css = loadAllAppCss();
|
||||
|
||||
@@ -22,7 +22,7 @@ import type { TaskStore, TaskAttachment, Routine, RoutineCreateInput, RoutineUpd
|
||||
import type { TaskDetail } from "@fusion/core";
|
||||
import type { AuthStorageLike, ModelRegistryLike } from "../routes.js";
|
||||
import { __resetBatchImportRateLimiter, __setCreateFnAgentForRefine } from "../routes.js";
|
||||
import { pullGitBranch } from "../routes/register-git-github.js";
|
||||
import { collectRecentMergeAdvances, pullGitBranch } from "../routes/register-git-github.js";
|
||||
import * as agentGenerationModule from "../agent-generation.js";
|
||||
import { __resetPlanningState, __setCreateFnAgent, planningStreamManager } from "../planning.js";
|
||||
import * as planningModule from "../planning.js";
|
||||
@@ -1712,4 +1712,99 @@ describe("Workspace File Routes", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("collectRecentMergeAdvances", () => {
|
||||
function git(cwd: string, args: string[]): string {
|
||||
return execFileSync("git", ["-C", cwd, ...args], { encoding: "utf-8", stdio: "pipe" }).trim();
|
||||
}
|
||||
|
||||
function initRepo() {
|
||||
const repoDir = mkdtempSync(join(tmpdir(), "kb-advances-"));
|
||||
execFileSync("git", ["init", "--initial-branch=main", repoDir], { stdio: "pipe" });
|
||||
execFileSync("git", ["-C", repoDir, "config", "user.email", "kb-tests@example.com"], { stdio: "pipe" });
|
||||
execFileSync("git", ["-C", repoDir, "config", "user.name", "KB Tests"], { stdio: "pipe" });
|
||||
return repoDir;
|
||||
}
|
||||
|
||||
function commitFile(repoDir: string, file: string, content: string, message: string): string {
|
||||
writeFileSync(join(repoDir, file), content, "utf-8");
|
||||
execFileSync("git", ["-C", repoDir, "add", file], { stdio: "pipe" });
|
||||
execFileSync("git", ["-C", repoDir, "commit", "-m", message], { stdio: "pipe" });
|
||||
return git(repoDir, ["rev-parse", "HEAD"]);
|
||||
}
|
||||
|
||||
async function runWithAdvance(repoDir: string, toSha: string) {
|
||||
const headSha = git(repoDir, ["rev-parse", "HEAD"]);
|
||||
const fakeStore = {
|
||||
getRunAuditEvents: ({ mutationType }: { mutationType?: string }) => {
|
||||
if (mutationType === "merge:integration-ref-advance") {
|
||||
return [{ taskId: "FN-123", timestamp: new Date().toISOString(), metadata: { toSha, fromSha: null, succeeded: true } }];
|
||||
}
|
||||
return [];
|
||||
},
|
||||
} as unknown as TaskStore;
|
||||
return collectRecentMergeAdvances(fakeStore, repoDir, headSha);
|
||||
}
|
||||
|
||||
it("marks orphaned SHAs as handled", async () => {
|
||||
const repoDir = initRepo();
|
||||
try {
|
||||
const orphanSha = commitFile(repoDir, "a.txt", "one\n", "one");
|
||||
execFileSync("git", ["-C", repoDir, "checkout", "--orphan", "rewritten"], { stdio: "pipe" });
|
||||
execFileSync("git", ["-C", repoDir, "rm", "-rf", "."], { stdio: "pipe" });
|
||||
commitFile(repoDir, "a.txt", "two\n", "two");
|
||||
execFileSync("git", ["-C", repoDir, "branch", "-M", "main"], { stdio: "pipe" });
|
||||
execFileSync("git", ["-C", repoDir, "reflog", "expire", "--expire=now", "--all"], { stdio: "pipe" });
|
||||
execFileSync("git", ["-C", repoDir, "gc", "--prune=now"], { stdio: "pipe" });
|
||||
const result = await runWithAdvance(repoDir, orphanSha);
|
||||
expect(result?.[0]?.resolution).toBe("orphaned");
|
||||
expect(result?.[0]?.needsAction).toBe(false);
|
||||
} finally {
|
||||
rmSync(repoDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("marks subsumed equivalent content as handled", async () => {
|
||||
const repoDir = initRepo();
|
||||
try {
|
||||
const baseSha = commitFile(repoDir, "a.txt", "base\n", "base");
|
||||
const toSha = commitFile(repoDir, "a.txt", "base\nnew\n", "advance");
|
||||
execFileSync("git", ["-C", repoDir, "reset", "--hard", baseSha], { stdio: "pipe" });
|
||||
commitFile(repoDir, "a.txt", "base\nnew\n", "equivalent");
|
||||
const result = await runWithAdvance(repoDir, toSha);
|
||||
expect(result?.[0]?.resolution).toBe("subsumed");
|
||||
expect(result?.[0]?.needsAction).toBe(false);
|
||||
} finally {
|
||||
rmSync(repoDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps unresolved advances as pending", async () => {
|
||||
const repoDir = initRepo();
|
||||
try {
|
||||
const baseSha = commitFile(repoDir, "a.txt", "base\n", "base");
|
||||
const toSha = commitFile(repoDir, "a.txt", "base\nnew\n", "advance");
|
||||
execFileSync("git", ["-C", repoDir, "reset", "--hard", baseSha], { stdio: "pipe" });
|
||||
commitFile(repoDir, "a.txt", "base\nother\n", "different");
|
||||
const result = await runWithAdvance(repoDir, toSha);
|
||||
expect(result?.[0]?.resolution).toBe("pending");
|
||||
expect(result?.[0]?.needsAction).toBe(true);
|
||||
} finally {
|
||||
rmSync(repoDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("marks reachable ancestor as handled", async () => {
|
||||
const repoDir = initRepo();
|
||||
try {
|
||||
const toSha = commitFile(repoDir, "a.txt", "base\n", "base");
|
||||
commitFile(repoDir, "b.txt", "next\n", "next");
|
||||
const result = await runWithAdvance(repoDir, toSha);
|
||||
expect(result?.[0]?.resolution).toBe("reachable");
|
||||
expect(result?.[0]?.needsAction).toBe(false);
|
||||
} finally {
|
||||
rmSync(repoDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -443,6 +443,7 @@ export interface ExtendedGitStatus {
|
||||
advancedAt: string;
|
||||
autoSyncOutcome?: string;
|
||||
needsAction: boolean;
|
||||
resolution: "reachable" | "orphaned" | "subsumed" | "pending";
|
||||
}>;
|
||||
}
|
||||
|
||||
@@ -578,7 +579,21 @@ function canonicalForCompare(p: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
async function collectRecentMergeAdvances(
|
||||
async function getPatchFingerprint(cwd: string, sha: string): Promise<string | null> {
|
||||
try {
|
||||
const out = await runGitCommand(["show", sha, "--pretty=format:", "--patch", "--no-color"], cwd, 5_000);
|
||||
const normalized = out
|
||||
.split("\n")
|
||||
.filter((line) => !line.startsWith("index ") && !line.startsWith("@@ "))
|
||||
.join("\n")
|
||||
.trim();
|
||||
return normalized || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function collectRecentMergeAdvances(
|
||||
scopedStore: TaskStore & {
|
||||
getRunAuditEvents?: (filters: {
|
||||
taskId?: string;
|
||||
@@ -596,13 +611,6 @@ async function collectRecentMergeAdvances(
|
||||
mutationType: "merge:integration-ref-advance",
|
||||
limit: 10,
|
||||
});
|
||||
// Auto-sync events come in two flavors:
|
||||
// - per-advance: emit with `worktreePath` + `newSha`; pair by (taskId, newSha)
|
||||
// - early-failure (`outcome: "enumeration-failed"`): emitted by the merger
|
||||
// when worktree enumeration fails BEFORE any advance was processed, so
|
||||
// they carry NO `worktreePath` and NO `newSha`. We still want operators
|
||||
// to see these — pair them by taskId-only as a fallback so the matching
|
||||
// advance shows the actual reason instead of "no auto-sync record."
|
||||
const wantPath = canonicalForCompare(worktreePath);
|
||||
const autoSyncByAdvance = new Map<string, string>();
|
||||
const autoSyncByTaskFallback = new Map<string, string>();
|
||||
@@ -620,21 +628,17 @@ async function collectRecentMergeAdvances(
|
||||
const hasPath = typeof md.worktreePath === "string";
|
||||
const hasNewSha = typeof md.newSha === "string";
|
||||
if (hasPath && hasNewSha) {
|
||||
// Per-advance event for a specific worktree: only attribute to this
|
||||
// user's checkout when the canonicalized paths match.
|
||||
if (canonicalForCompare(md.worktreePath as string) !== wantPath) continue;
|
||||
const key = pairKey(tid, md.newSha as string);
|
||||
// Events are timestamp DESC; first occurrence is the freshest.
|
||||
if (!autoSyncByAdvance.has(key)) autoSyncByAdvance.set(key, md.outcome);
|
||||
} else if (!hasPath && !hasNewSha) {
|
||||
// Early-failure event (e.g. "enumeration-failed"): no per-worktree
|
||||
// attribution possible — apply to every advance for this task.
|
||||
if (!autoSyncByTaskFallback.has(tid)) autoSyncByTaskFallback.set(tid, md.outcome);
|
||||
}
|
||||
// Events with one of the two but not the other are malformed; skip.
|
||||
}
|
||||
const successOutcomes = new Set(["clean-sync", "synced-with-edits-restored"]);
|
||||
const out: NonNullable<ExtendedGitStatus["recentMergeAdvances"]> = [];
|
||||
const headPatchIds = new Set<string>();
|
||||
let headPatchIdsLoaded = false;
|
||||
for (const ev of advances) {
|
||||
const md = ev.metadata as { fromSha?: unknown; toSha?: unknown; succeeded?: unknown } | undefined;
|
||||
if (!md || typeof md !== "object") continue;
|
||||
@@ -642,30 +646,55 @@ async function collectRecentMergeAdvances(
|
||||
if (md.succeeded === false) continue;
|
||||
const tid = typeof ev.taskId === "string" ? ev.taskId : "";
|
||||
if (!tid) continue;
|
||||
const autoSyncOutcome =
|
||||
autoSyncByAdvance.get(pairKey(tid, md.toSha))
|
||||
?? autoSyncByTaskFallback.get(tid);
|
||||
// The worktree may already contain `toSha` — either because auto-sync
|
||||
// succeeded, the operator manually ran "Sync working tree" / pulled, or
|
||||
// they checked out a later commit by hand. In all those cases there's
|
||||
// nothing left to do, regardless of what the original auto-sync audit
|
||||
// event recorded. Treat reachability from HEAD as authoritative.
|
||||
let alreadyInHead = false;
|
||||
if (headSha) {
|
||||
if (headSha === md.toSha) {
|
||||
alreadyInHead = true;
|
||||
} else {
|
||||
const autoSyncOutcome = autoSyncByAdvance.get(pairKey(tid, md.toSha)) ?? autoSyncByTaskFallback.get(tid);
|
||||
|
||||
let resolution: "reachable" | "orphaned" | "subsumed" | "pending" = "pending";
|
||||
let toShaExists = true;
|
||||
if (headSha && headSha === md.toSha) {
|
||||
resolution = "reachable";
|
||||
} else if (headSha) {
|
||||
try {
|
||||
await runGitCommand(["cat-file", "-e", `${md.toSha}^{commit}`], worktreePath, 5_000);
|
||||
} catch {
|
||||
toShaExists = false;
|
||||
resolution = "orphaned";
|
||||
}
|
||||
|
||||
if (toShaExists && resolution === "pending") {
|
||||
try {
|
||||
await runGitCommand(["merge-base", "--is-ancestor", md.toSha, headSha], worktreePath, 5_000);
|
||||
alreadyInHead = true;
|
||||
resolution = "reachable";
|
||||
} catch {
|
||||
alreadyInHead = false;
|
||||
// continue
|
||||
}
|
||||
}
|
||||
|
||||
if (toShaExists && resolution === "pending") {
|
||||
const targetPatchId = await getPatchFingerprint(worktreePath, md.toSha);
|
||||
if (targetPatchId) {
|
||||
if (!headPatchIdsLoaded) {
|
||||
headPatchIdsLoaded = true;
|
||||
try {
|
||||
const commitsOut = (await runGitCommand(["log", "-n", "50", "--pretty=%H", headSha], worktreePath, 5_000)).trim();
|
||||
const commits = commitsOut ? commitsOut.split("\n").filter(Boolean) : [];
|
||||
for (const commitSha of commits) {
|
||||
const patchId = await getPatchFingerprint(worktreePath, commitSha);
|
||||
if (patchId) headPatchIds.add(patchId);
|
||||
}
|
||||
} catch {
|
||||
// degrade conservatively
|
||||
}
|
||||
}
|
||||
if (headPatchIds.has(targetPatchId)) {
|
||||
resolution = "subsumed";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const needsAction = alreadyInHead
|
||||
? false
|
||||
: (autoSyncOutcome === undefined || !successOutcomes.has(autoSyncOutcome));
|
||||
|
||||
const needsAction = resolution === "pending"
|
||||
&& (autoSyncOutcome === undefined || !successOutcomes.has(autoSyncOutcome));
|
||||
|
||||
out.push({
|
||||
taskId: tid,
|
||||
fromSha: typeof md.fromSha === "string" ? md.fromSha : null,
|
||||
@@ -673,6 +702,7 @@ async function collectRecentMergeAdvances(
|
||||
advancedAt: ev.timestamp,
|
||||
autoSyncOutcome,
|
||||
needsAction,
|
||||
resolution,
|
||||
});
|
||||
if (out.length >= 5) break;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user