feat(FN-3873): add GitHub tracking state service for issue close/reopen lif

Added a GitHub tracking state service (`GitHubTrackingStateService`) and `setIssueState` client method to support closing and reopening tracked issues, with test coverage for both the client and the service, wired into the GitHub routes, and documented in the architecture docs.

Fusion-Task-Id: FN-3873
This commit is contained in:
Fusion
2026-05-10 02:39:01 -07:00
committed by gsxdsm
parent ebab75ec53
commit 44502572fc
8 changed files with 608 additions and 0 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Fusion now closes the linked GitHub tracking issue when a tracked task moves to done, and reopens it when the task moves back to an active column. Done → archived leaves the issue closed. Failures are recorded in the task activity log and never block the move.

View File

@@ -1244,6 +1244,8 @@ When Fusion does create a tracking issue, it formats the title as `[FN-XXXX] Tas
When a tracked task later moves to `in-progress` or `done`, Fusion posts one short lifecycle comment on the linked tracking issue. These comments include the Fusion task ID as plain text (`Fusion task: FN-XXXX`) and never link back to the Fusion app. No comment is posted for any other transition. When a tracked task later moves to `in-progress` or `done`, Fusion posts one short lifecycle comment on the linked tracking issue. These comments include the Fusion task ID as plain text (`Fusion task: FN-XXXX`) and never link back to the Fusion app. No comment is posted for any other transition.
When a tracked task transitions into `done`, Fusion closes the linked GitHub issue with `state_reason: completed`. When a task transitions out of `done` into any active column (`triage`, `todo`, `in-progress`, `in-review`), Fusion reopens it with `state_reason: reopened`. Moves from `done` to `archived` leave the issue closed. Tasks without `githubTracking.enabled` or without a linked issue are unaffected, and GitHub failures are logged to task activity without blocking the move.
### Worktree model ### Worktree model
- Each active task runs in isolated worktree under `.worktrees/*` - Each active task runs in isolated worktree under `.worktrees/*`
- Executor creates branches like `fusion/{task-id}` (`executor.ts`) - Executor creates branches like `fusion/{task-id}` (`executor.ts`)

View File

@@ -0,0 +1,165 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { GitHubClient } from "../github.js";
vi.mock("@fusion/core", async () => {
const actual = await vi.importActual<typeof import("@fusion/core")>("@fusion/core");
return {
...actual,
isGhAvailable: vi.fn(),
isGhAuthenticated: vi.fn(),
runGh: vi.fn(),
getGhErrorMessage: vi.fn((err) => err instanceof Error ? err.message : String(err)),
};
});
import {
getGhErrorMessage,
isGhAuthenticated,
isGhAvailable,
runGh,
} from "@fusion/core";
const mockIsGhAvailable = vi.mocked(isGhAvailable);
const mockIsGhAuthenticated = vi.mocked(isGhAuthenticated);
const mockRunGh = vi.mocked(runGh);
const mockGetGhErrorMessage = vi.mocked(getGhErrorMessage);
describe("GitHubClient.setIssueState", () => {
beforeEach(() => {
vi.clearAllMocks();
mockIsGhAvailable.mockReturnValue(true);
mockIsGhAuthenticated.mockReturnValue(true);
mockGetGhErrorMessage.mockImplementation((err) => err instanceof Error ? err.message : String(err));
});
it("uses gh issue close with reason when authenticated", async () => {
const client = new GitHubClient();
await client.setIssueState("owner", "repo", 123, "closed", "completed");
expect(mockRunGh).toHaveBeenCalledWith([
"issue",
"close",
"123",
"--repo",
"owner/repo",
"--reason",
"completed",
]);
});
it("uses gh issue close without reason when no reason provided", async () => {
const client = new GitHubClient();
await client.setIssueState("owner", "repo", 123, "closed");
expect(mockRunGh).toHaveBeenCalledWith([
"issue",
"close",
"123",
"--repo",
"owner/repo",
]);
});
it("uses gh issue reopen when opening and ignores reason", async () => {
const client = new GitHubClient();
await client.setIssueState("owner", "repo", 123, "open", "reopened");
expect(mockRunGh).toHaveBeenCalledWith([
"issue",
"reopen",
"123",
"--repo",
"owner/repo",
]);
});
it("uses REST when gh auth unavailable and token exists for closed state", async () => {
mockIsGhAvailable.mockReturnValue(false);
const client = new GitHubClient("ghp_token");
const fetchSpy = vi.spyOn(client, "fetchThrottled").mockResolvedValue({ success: true, data: { id: 1, state: "closed" } });
await client.setIssueState("owner", "repo", 123, "closed", "completed");
expect(fetchSpy).toHaveBeenCalledWith(
"https://api.github.com/repos/owner/repo/issues/123",
{
method: "PATCH",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ state: "closed", state_reason: "completed" }),
},
);
});
it("uses REST for reopen with reopened reason", async () => {
mockIsGhAvailable.mockReturnValue(false);
const client = new GitHubClient("ghp_token");
const fetchSpy = vi.spyOn(client, "fetchThrottled").mockResolvedValue({ success: true, data: { id: 1, state: "open" } });
await client.setIssueState("owner", "repo", 123, "open", "reopened");
expect(fetchSpy).toHaveBeenCalledWith(
"https://api.github.com/repos/owner/repo/issues/123",
expect.objectContaining({
body: JSON.stringify({ state: "open", state_reason: "reopened" }),
}),
);
});
it("omits state_reason when undefined on REST path", async () => {
mockIsGhAvailable.mockReturnValue(false);
const client = new GitHubClient("ghp_token");
const fetchSpy = vi.spyOn(client, "fetchThrottled").mockResolvedValue({ success: true, data: { id: 1, state: "open" } });
await client.setIssueState("owner", "repo", 123, "open");
const call = fetchSpy.mock.calls[0];
const body = call?.[1]?.body;
expect(body).toBeDefined();
expect(JSON.parse(String(body))).toEqual({ state: "open" });
});
it("falls back to REST when gh command throws and token exists", async () => {
mockRunGh.mockImplementation(() => {
throw new Error("gh failed");
});
const client = new GitHubClient("ghp_token");
const fetchSpy = vi.spyOn(client, "fetchThrottled").mockResolvedValue({ success: true, data: { id: 1, state: "closed" } });
await client.setIssueState("owner", "repo", 123, "closed", "completed");
expect(fetchSpy).toHaveBeenCalled();
});
it("throws wrapped gh error when gh command fails and no token", async () => {
mockRunGh.mockImplementation(() => {
throw new Error("gh failed");
});
const client = new GitHubClient();
await expect(client.setIssueState("owner", "repo", 123, "closed", "completed")).rejects.toThrow("gh failed");
expect(mockGetGhErrorMessage).toHaveBeenCalled();
});
it("throws explicit message when gh auth unavailable and no token", async () => {
mockIsGhAvailable.mockReturnValue(false);
mockIsGhAuthenticated.mockReturnValue(false);
const client = new GitHubClient();
await expect(client.setIssueState("owner", "repo", 123, "closed", "completed")).rejects.toThrow(
"GitHub CLI (gh) is not available or not authenticated, and no GITHUB_TOKEN provided.",
);
});
it("throws REST error message when PATCH fails", async () => {
mockIsGhAvailable.mockReturnValue(false);
const client = new GitHubClient("ghp_token");
vi.spyOn(client, "fetchThrottled").mockResolvedValue({ success: false, error: "rate limited" });
await expect(client.setIssueState("owner", "repo", 123, "closed", "completed")).rejects.toThrow("rate limited");
});
});

View File

@@ -0,0 +1,261 @@
import { EventEmitter } from "node:events";
import { beforeEach, describe, expect, it, vi, type Mock } from "vitest";
import type { TaskStore } from "@fusion/core";
import { decideIssueAction, GitHubTrackingStateService } from "../github-tracking-state.js";
const { mockSetIssueState } = vi.hoisted(() => ({
mockSetIssueState: vi.fn(),
}));
vi.mock("../github.js", () => ({
GitHubClient: vi.fn().mockImplementation(() => ({
setIssueState: (...args: unknown[]) => mockSetIssueState(...args),
})),
}));
class MockStore extends EventEmitter {
logEntry: Mock;
constructor() {
super();
this.logEntry = vi.fn().mockResolvedValue(undefined);
}
}
function createTask(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return {
id: "FN-1",
githubTracking: {
enabled: true,
issue: {
owner: "owner",
repo: "repo",
number: 42,
url: "https://github.com/owner/repo/issues/42",
createdAt: "2026-01-01T00:00:00.000Z",
},
},
...overrides,
};
}
async function flushAsync(): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, 0));
}
describe("decideIssueAction", () => {
const columns = ["triage", "todo", "in-progress", "in-review", "done", "archived"] as const;
const activeColumns = ["triage", "todo", "in-progress", "in-review"] as const;
it.each(columns.filter((from) => from !== "done"))("returns close for %s -> done", (from) => {
expect(decideIssueAction(from, "done")).toEqual({ action: "close", stateReason: "completed" });
});
it.each(activeColumns)("returns reopen for done -> %s", (to) => {
expect(decideIssueAction("done", to)).toEqual({ action: "reopen", stateReason: "reopened" });
});
it("returns null for done -> archived", () => {
expect(decideIssueAction("done", "archived")).toBeNull();
});
it.each([
["triage", "todo"],
["todo", "in-progress"],
["in-progress", "in-review"],
["in-review", "archived"],
["done", "done"],
["archived", "archived"],
] as const)("returns null for %s -> %s", (from, to) => {
expect(decideIssueAction(from, to)).toBeNull();
});
});
describe("GitHubTrackingStateService", () => {
let store: MockStore;
let service: GitHubTrackingStateService;
let tokenValue: string;
let tokenCalls: number;
beforeEach(() => {
vi.clearAllMocks();
store = new MockStore();
tokenValue = "ghp_test";
tokenCalls = 0;
service = new GitHubTrackingStateService(store as unknown as TaskStore, () => {
tokenCalls += 1;
return tokenValue;
});
});
it("start/stop are idempotent", async () => {
service.start();
service.start();
store.emit("task:moved", { task: createTask(), from: "triage", to: "done" });
await flushAsync();
expect(mockSetIssueState).toHaveBeenCalledTimes(1);
service.stop();
service.stop();
store.emit("task:moved", { task: createTask(), from: "done", to: "todo" });
await flushAsync();
expect(mockSetIssueState).toHaveBeenCalledTimes(1);
});
it("closes on triage -> done and logs success", async () => {
service.start();
store.emit("task:moved", { task: createTask(), from: "triage", to: "done" });
await flushAsync();
expect(mockSetIssueState).toHaveBeenCalledWith("owner", "repo", 42, "closed", "completed");
expect(store.logEntry).toHaveBeenCalledWith("FN-1", "Closed linked GitHub tracking issue", "owner/repo#42");
});
it("closes on archived -> done", async () => {
service.start();
store.emit("task:moved", { task: createTask(), from: "archived", to: "done" });
await flushAsync();
expect(mockSetIssueState).toHaveBeenCalledWith("owner", "repo", 42, "closed", "completed");
});
it.each(["todo", "triage", "in-progress", "in-review"] as const)("reopens on done -> %s", async (to) => {
service.start();
store.emit("task:moved", { task: createTask(), from: "done", to });
await flushAsync();
expect(mockSetIssueState).toHaveBeenCalledWith("owner", "repo", 42, "open", "reopened");
expect(store.logEntry).toHaveBeenCalledWith("FN-1", "Reopened linked GitHub tracking issue", "owner/repo#42");
});
it("does nothing for done -> archived", async () => {
service.start();
store.emit("task:moved", { task: createTask(), from: "done", to: "archived" });
await flushAsync();
expect(mockSetIssueState).not.toHaveBeenCalled();
expect(store.logEntry).not.toHaveBeenCalled();
});
it("does nothing for non-done transitions", async () => {
service.start();
store.emit("task:moved", { task: createTask(), from: "todo", to: "in-progress" });
await flushAsync();
expect(mockSetIssueState).not.toHaveBeenCalled();
});
it("ignores disabled tracking", async () => {
service.start();
store.emit("task:moved", {
task: createTask({ githubTracking: { enabled: false } }),
from: "todo",
to: "done",
});
await flushAsync();
expect(mockSetIssueState).not.toHaveBeenCalled();
});
it("ignores missing linked issue", async () => {
service.start();
store.emit("task:moved", {
task: createTask({ githubTracking: { enabled: true } }),
from: "todo",
to: "done",
});
await flushAsync();
expect(mockSetIssueState).not.toHaveBeenCalled();
});
it("logs incomplete metadata", async () => {
service.start();
store.emit("task:moved", {
task: createTask({
githubTracking: {
enabled: true,
issue: {
owner: "",
repo: "repo",
number: 42,
},
},
}),
from: "todo",
to: "done",
});
await flushAsync();
expect(mockSetIssueState).not.toHaveBeenCalled();
expect(store.logEntry).toHaveBeenCalledWith(
"FN-1",
"Failed to update GitHub tracking issue state",
"Linked issue metadata is incomplete",
);
});
it("swallows close failures and keeps listener alive", async () => {
service.start();
mockSetIssueState.mockRejectedValueOnce(new Error("close failed"));
expect(() => {
store.emit("task:moved", { task: createTask(), from: "todo", to: "done" });
}).not.toThrow();
await flushAsync();
expect(store.logEntry).toHaveBeenCalledWith("FN-1", "Failed to close GitHub tracking issue", "close failed");
mockSetIssueState.mockResolvedValueOnce(undefined);
store.emit("task:moved", { task: createTask(), from: "done", to: "todo" });
await flushAsync();
expect(mockSetIssueState).toHaveBeenCalledTimes(2);
});
it("swallows reopen failures", async () => {
service.start();
mockSetIssueState.mockRejectedValueOnce(new Error("reopen failed"));
store.emit("task:moved", { task: createTask(), from: "done", to: "todo" });
await flushAsync();
expect(store.logEntry).toHaveBeenCalledWith("FN-1", "Failed to reopen GitHub tracking issue", "reopen failed");
});
it("invokes token thunk per call", async () => {
service.start();
tokenValue = "ghp_1";
store.emit("task:moved", { task: createTask(), from: "todo", to: "done" });
tokenValue = "ghp_2";
store.emit("task:moved", { task: createTask(), from: "done", to: "todo" });
await flushAsync();
expect(mockSetIssueState).toHaveBeenCalledTimes(2);
expect(tokenCalls).toBe(2);
});
it("emits close then reopen in order", async () => {
service.start();
store.emit("task:moved", { task: createTask(), from: "triage", to: "done" });
store.emit("task:moved", { task: createTask(), from: "done", to: "todo" });
await flushAsync();
expect(mockSetIssueState).toHaveBeenCalledTimes(2);
expect(mockSetIssueState).toHaveBeenNthCalledWith(1, "owner", "repo", 42, "closed", "completed");
expect(mockSetIssueState).toHaveBeenNthCalledWith(2, "owner", "repo", 42, "open", "reopened");
});
});

View File

@@ -0,0 +1,117 @@
import type { TaskStore } from "@fusion/core";
import { GitHubClient } from "./github.js";
type Column = "triage" | "todo" | "in-progress" | "in-review" | "done" | "archived";
interface TaskMovedEvent {
task: {
id: string;
githubTracking?: {
enabled?: boolean;
issue?: {
owner?: string;
repo?: string;
number?: number;
url?: string;
htmlUrl?: string;
createdAt?: string;
};
};
};
from: Column;
to: Column;
}
export function decideIssueAction(
from: Column,
to: Column,
): { action: "close" | "reopen"; stateReason: "completed" | "reopened" } | null {
if (to === "done" && from !== "done") {
return { action: "close", stateReason: "completed" };
}
if (from === "done" && to !== "done" && to !== "archived") {
return { action: "reopen", stateReason: "reopened" };
}
return null;
}
export class GitHubTrackingStateService {
private readonly store: TaskStore;
private readonly getGitHubToken: () => string | undefined;
private readonly onTaskMoved = (event: TaskMovedEvent): void => {
void this.handleTaskMoved(event);
};
private started = false;
constructor(store: TaskStore, getGitHubToken?: () => string | undefined) {
this.store = store;
this.getGitHubToken = getGitHubToken ?? (() => process.env.GITHUB_TOKEN);
}
start(): void {
if (this.started) return;
this.started = true;
this.store.on("task:moved", this.onTaskMoved);
}
stop(): void {
if (!this.started) return;
this.started = false;
this.store.off("task:moved", this.onTaskMoved);
}
private async handleTaskMoved(event: TaskMovedEvent): Promise<void> {
const decision = decideIssueAction(event.from, event.to);
if (!decision) {
return;
}
if (event.task.githubTracking?.enabled !== true) {
return;
}
const issue = event.task.githubTracking?.issue;
if (!issue) {
return;
}
const { owner, repo, number } = issue;
if (!owner || !repo || !number) {
await this.store.logEntry(
event.task.id,
"Failed to update GitHub tracking issue state",
"Linked issue metadata is incomplete",
);
return;
}
const client = new GitHubClient(this.getGitHubToken());
try {
await client.setIssueState(
owner,
repo,
number,
decision.action === "close" ? "closed" : "open",
decision.stateReason,
);
await this.store.logEntry(
event.task.id,
decision.action === "close"
? "Closed linked GitHub tracking issue"
: "Reopened linked GitHub tracking issue",
`${owner}/${repo}#${number}`,
);
} catch (err) {
await this.store.logEntry(
event.task.id,
decision.action === "close"
? "Failed to close GitHub tracking issue"
: "Failed to reopen GitHub tracking issue",
err instanceof Error ? err.message : String(err),
);
}
}
}

View File

@@ -1348,6 +1348,55 @@ export class GitHubClient {
} }
} }
async setIssueState(
owner: string,
repo: string,
issueNumber: number,
state: "open" | "closed",
stateReason?: "completed" | "not_planned" | "reopened",
): Promise<void> {
if (this.hasGhAuth()) {
try {
const command = state === "closed" ? "close" : "reopen";
const args = ["issue", command, String(issueNumber), "--repo", `${owner}/${repo}`];
if (state === "closed" && (stateReason === "completed" || stateReason === "not_planned")) {
args.push("--reason", stateReason);
}
runGh(args);
return;
} catch (err) {
if (!this.token) {
throw new Error(getGhErrorMessage(err));
}
}
}
if (!this.token) {
throw new Error("GitHub CLI (gh) is not available or not authenticated, and no GITHUB_TOKEN provided.");
}
const url = `${this.baseUrl}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues/${issueNumber}`;
const payload: { state: "open" | "closed"; state_reason?: "completed" | "not_planned" | "reopened" } = { state };
if (stateReason !== undefined) {
payload.state_reason = stateReason;
}
const result = await this.fetchThrottled<{ id: number; state: string }>(
url,
{
method: "PATCH",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
},
);
if (!result.success) {
throw new Error(result.error ?? "Failed to update GitHub issue state");
}
}
/** /**
* Fetch current issue status using gh CLI if available, otherwise REST API. * Fetch current issue status using gh CLI if available, otherwise REST API.
* Returns null if the issue is not found or is a pull request. * Returns null if the issue is not found or is a pull request.

View File

@@ -17,6 +17,7 @@ export { rateLimit, RATE_LIMITS, type RateLimitOptions } from "./rate-limit.js";
export { GitHubPollingService, type GitHubPollingServiceOptions, type TaskWatchInput, type WatchedBadgeType } from "./github-poll.js"; export { GitHubPollingService, type GitHubPollingServiceOptions, type TaskWatchInput, type WatchedBadgeType } from "./github-poll.js";
export { GitHubIssueCommentService, DEFAULT_COMMENT_TEMPLATE } from "./github-issue-comment.js"; export { GitHubIssueCommentService, DEFAULT_COMMENT_TEMPLATE } from "./github-issue-comment.js";
export { GitHubTrackingCommentService, formatTrackingComment } from "./github-tracking-comments.js"; export { GitHubTrackingCommentService, formatTrackingComment } from "./github-tracking-comments.js";
export { GitHubTrackingStateService, decideIssueAction } from "./github-tracking-state.js";
export { getCliPackageVersion, resolveCliPackageVersionInfo, type CliPackageVersionInfo } from "./cli-package-version.js"; export { getCliPackageVersion, resolveCliPackageVersionInfo, type CliPackageVersionInfo } from "./cli-package-version.js";
export { export {
ApiError, ApiError,

View File

@@ -22,6 +22,7 @@ import {
import { GitHubClient, parseBadgeUrl } from "../github.js"; import { GitHubClient, parseBadgeUrl } from "../github.js";
import { GitHubIssueCommentService } from "../github-issue-comment.js"; import { GitHubIssueCommentService } from "../github-issue-comment.js";
import { GitHubTrackingCommentService } from "../github-tracking-comments.js"; import { GitHubTrackingCommentService } from "../github-tracking-comments.js";
import { GitHubTrackingStateService } from "../github-tracking-state.js";
import { githubRateLimiter } from "../github-poll.js"; import { githubRateLimiter } from "../github-poll.js";
import { import {
classifyWebhookEvent, classifyWebhookEvent,
@@ -1112,6 +1113,13 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
); );
githubTrackingCommentService.start(); githubTrackingCommentService.start();
ctx.registerDispose(() => githubTrackingCommentService.stop()); ctx.registerDispose(() => githubTrackingCommentService.stop());
const githubTrackingStateService = new GitHubTrackingStateService(
store,
() => ctx.options?.githubToken ?? process.env.GITHUB_TOKEN,
);
githubTrackingStateService.start();
ctx.registerDispose(() => githubTrackingStateService.stop());
} }
/** /**