Recovery: Re-land (Close source-imported GitHub issues on ta
This commit is contained in:
5
.changeset/FN-5631-source-issue-close.md
Normal file
5
.changeset/FN-5631-source-issue-close.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
FN-5631 re-lands FN-5616 to add an opt-in `githubCloseSourceIssueOnDone` setting that closes source-imported GitHub issues when linked tasks are completed, including startup reconciliation for previously missed closes.
|
||||||
@@ -1538,7 +1538,7 @@ When a tracked task later moves to `in-progress` or `done`, Fusion posts one sho
|
|||||||
|
|
||||||
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`. When a tracked task is permanently deleted, Fusion closes the linked GitHub issue with `state_reason: not_planned` (or deletes it when explicitly requested). Delete-path outcomes emit a `github-issue:action` store event payload (`{ taskId, action, owner, repo, number, outcome, error? }`) so success/failure remains observable even after the task row is gone and task activity logs are unwritable. 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.
|
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`. When a tracked task is permanently deleted, Fusion closes the linked GitHub issue with `state_reason: not_planned` (or deletes it when explicitly requested). Delete-path outcomes emit a `github-issue:action` store event payload (`{ taskId, action, owner, repo, number, outcome, error? }`) so success/failure remains observable even after the task row is gone and task activity logs are unwritable. 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.
|
||||||
|
|
||||||
The GitHub tracking state listener now attaches to every registered project store (including projects registered after startup), and each store gets a one-time asynchronous startup reconciliation sweep. That sweep scans bounded done tasks with tracking enabled and closes any linked GitHub issue still open, so missed/momentary failures are caught up without blocking server boot.
|
The GitHub tracking state listener now attaches to every registered project store (including projects registered after startup), and each store gets a one-time asynchronous startup reconciliation sweep. That sweep scans bounded done tasks with tracking enabled and closes any linked GitHub issue still open, so missed/momentary failures are caught up without blocking server boot. Source-imported GitHub issues can also be auto-closed when `githubCloseSourceIssueOnDone === true`: `GitHubSourceIssueCloseService` listens for `task:moved` transitions into `done` and closes open `task.sourceIssue` links, while `GitHubTrackingReconciler.reconcileSourceIssues` performs a parallel startup sweep over done tasks with GitHub source metadata to close any source issues still open.
|
||||||
|
|
||||||
### Worktree model
|
### Worktree model
|
||||||
- Each active task runs in isolated worktree under `.worktrees/*`
|
- Each active task runs in isolated worktree under `.worktrees/*`
|
||||||
|
|||||||
@@ -371,6 +371,7 @@ Default notes:
|
|||||||
| `autoUpdatePrStatus` | `boolean` | `false` | Auto-refresh PR status badges. |
|
| `autoUpdatePrStatus` | `boolean` | `false` | Auto-refresh PR status badges. |
|
||||||
| `githubCommentOnDone` | `boolean` | `false` | When enabled, tasks imported from GitHub issues post a completion comment to the source issue when the task moves to `done`. |
|
| `githubCommentOnDone` | `boolean` | `false` | When enabled, tasks imported from GitHub issues post a completion comment to the source issue when the task moves to `done`. |
|
||||||
| `githubCommentTemplate` | `string` | `undefined` | Optional issue comment template used by `githubCommentOnDone`. Supports `{taskId}` and `{taskTitle}` placeholders. If unset, Fusion uses a default completion message. |
|
| `githubCommentTemplate` | `string` | `undefined` | Optional issue comment template used by `githubCommentOnDone`. Supports `{taskId}` and `{taskTitle}` placeholders. If unset, Fusion uses a default completion message. |
|
||||||
|
| `githubCloseSourceIssueOnDone` | `boolean` | `false` | When enabled, source-imported GitHub issues are automatically closed with `state_reason: completed` when the Fusion task moves to `done`. A startup reconciliation sweep also closes missed open source issues on boot. |
|
||||||
| `githubTrackingEnabledByDefault` | `boolean` | `false` | Project-level default for enabling issue tracking on new tasks. When this is false, the Quick Entry GitHub toggle is disabled until tracking is enabled in Settings. |
|
| `githubTrackingEnabledByDefault` | `boolean` | `false` | Project-level default for enabling issue tracking on new tasks. When this is false, the Quick Entry GitHub toggle is disabled until tracking is enabled in Settings. |
|
||||||
| `githubTrackingDefaultRepo` | `string` | `undefined` | Project default issue-tracking repo (`owner/repo`) used before global fallback for tracked task creation (precedence: task override → project default → global default). In Settings UI this is a detected-remote dropdown with a Custom fallback for manual entry. This key is dual-scope: project saves go through `PUT /api/settings` (Settings → General → GitHub Tracking) while global saves go through `PUT /api/settings/global` (Settings → Global General). |
|
| `githubTrackingDefaultRepo` | `string` | `undefined` | Project default issue-tracking repo (`owner/repo`) used before global fallback for tracked task creation (precedence: task override → project default → global default). In Settings UI this is a detected-remote dropdown with a Custom fallback for manual entry. This key is dual-scope: project saves go through `PUT /api/settings` (Settings → General → GitHub Tracking) while global saves go through `PUT /api/settings/global` (Settings → Global General). |
|
||||||
| `githubTrackingDedupEnabled` | `boolean` | `true` | When enabled, tracking issue creation searches open and closed repo issues for likely duplicates before opening a new issue (gh CLI search first, with REST search fallback). Set `false` to skip dedup and always create a new issue when tracking is enabled. Dashboard location: **Settings → Project → General → GitHub Tracking**. |
|
| `githubTrackingDedupEnabled` | `boolean` | `true` | When enabled, tracking issue creation searches open and closed repo issues for likely duplicates before opening a new issue (gh CLI search first, with REST search fallback). Set `false` to skip dedup and always create a new issue when tracking is enabled. Dashboard location: **Settings → Project → General → GitHub Tracking**. |
|
||||||
|
|||||||
@@ -656,6 +656,7 @@ Recovery/backfill guidance:
|
|||||||
Import issues:
|
Import issues:
|
||||||
|
|
||||||
- GitHub-imported tasks retain typed source issue metadata (`sourceIssue.provider/repository/externalIssueId/issueNumber/url`), which executor and merger flows use to include `Ref: owner/repo#N` in commit bodies.
|
- GitHub-imported tasks retain typed source issue metadata (`sourceIssue.provider/repository/externalIssueId/issueNumber/url`), which executor and merger flows use to include `Ref: owner/repo#N` in commit bodies.
|
||||||
|
- When `githubCloseSourceIssueOnDone` is enabled (default: `false`), Fusion also closes linked source-imported GitHub issues with `state_reason: completed` when the task moves into `done`. On startup, a bounded reconciliation sweep checks done tasks and closes any still-open source issue links that were missed due to transient failures.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
fn task import owner/repo --labels bug --limit 20
|
fn task import owner/repo --labels bug --limit 20
|
||||||
|
|||||||
@@ -69,6 +69,11 @@ describe("settings defaults invariants", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps github source issue auto-close disabled by default", () => {
|
||||||
|
expect(DEFAULT_PROJECT_SETTINGS.githubCloseSourceIssueOnDone).toBe(false);
|
||||||
|
expect("githubCloseSourceIssueOnDone" in DEFAULT_GLOBAL_SETTINGS).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
describe("recycleWorktrees default", () => {
|
describe("recycleWorktrees default", () => {
|
||||||
it("keeps recycleWorktrees explicitly false in project defaults", () => {
|
it("keeps recycleWorktrees explicitly false in project defaults", () => {
|
||||||
expect(DEFAULT_PROJECT_SETTINGS.recycleWorktrees).toBe(false);
|
expect(DEFAULT_PROJECT_SETTINGS.recycleWorktrees).toBe(false);
|
||||||
|
|||||||
@@ -342,6 +342,7 @@ export const DEFAULT_PROJECT_SETTINGS = {
|
|||||||
autoUpdatePrStatus: false,
|
autoUpdatePrStatus: false,
|
||||||
githubCommentOnDone: false,
|
githubCommentOnDone: false,
|
||||||
githubCommentTemplate: undefined,
|
githubCommentTemplate: undefined,
|
||||||
|
githubCloseSourceIssueOnDone: false,
|
||||||
githubTrackingEnabledByDefault: false,
|
githubTrackingEnabledByDefault: false,
|
||||||
githubTrackingDefaultRepo: undefined,
|
githubTrackingDefaultRepo: undefined,
|
||||||
githubTrackingDedupEnabled: true,
|
githubTrackingDedupEnabled: true,
|
||||||
|
|||||||
@@ -3333,6 +3333,9 @@ export interface ProjectSettings {
|
|||||||
/** Optional template used for GitHub issue comments posted on task completion.
|
/** Optional template used for GitHub issue comments posted on task completion.
|
||||||
* Supports `{taskId}` and `{taskTitle}` placeholders. */
|
* Supports `{taskId}` and `{taskTitle}` placeholders. */
|
||||||
githubCommentTemplate?: string;
|
githubCommentTemplate?: string;
|
||||||
|
/** When true, automatically close linked source-imported GitHub issues
|
||||||
|
* when a task moves to done. Default: false. */
|
||||||
|
githubCloseSourceIssueOnDone?: boolean;
|
||||||
/** When true, new tasks default GitHub tracking to enabled for this project (FN-3868).
|
/** When true, new tasks default GitHub tracking to enabled for this project (FN-3868).
|
||||||
* Default: false. */
|
* Default: false. */
|
||||||
githubTrackingEnabledByDefault?: boolean;
|
githubTrackingEnabledByDefault?: boolean;
|
||||||
|
|||||||
@@ -0,0 +1,180 @@
|
|||||||
|
import { EventEmitter } from "node:events";
|
||||||
|
import { beforeEach, describe, expect, it, vi, type Mock } from "vitest";
|
||||||
|
import type { TaskStore } from "@fusion/core";
|
||||||
|
import { GitHubSourceIssueCloseService } from "../github-source-issue-close.js";
|
||||||
|
|
||||||
|
const { mockSetIssueState, mockGetIssue } = vi.hoisted(() => ({
|
||||||
|
mockSetIssueState: vi.fn(),
|
||||||
|
mockGetIssue: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { mockResolveGithubTrackingAuth } = vi.hoisted(() => ({
|
||||||
|
mockResolveGithubTrackingAuth: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../github.js", () => ({
|
||||||
|
GitHubClient: vi.fn().mockImplementation(() => ({
|
||||||
|
setIssueState: (...args: unknown[]) => mockSetIssueState(...args),
|
||||||
|
getIssue: (...args: unknown[]) => mockGetIssue(...args),
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../github-auth.js", () => ({
|
||||||
|
resolveGithubTrackingAuth: (...args: unknown[]) => mockResolveGithubTrackingAuth(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
class MockStore extends EventEmitter {
|
||||||
|
logEntry: Mock;
|
||||||
|
getSettings: Mock;
|
||||||
|
getGlobalSettingsStore: Mock;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.logEntry = vi.fn().mockResolvedValue(undefined);
|
||||||
|
this.getSettings = vi.fn().mockResolvedValue({ githubCloseSourceIssueOnDone: true, githubAuthMode: "token", githubAuthToken: "ghp_test" });
|
||||||
|
this.getGlobalSettingsStore = vi.fn(() => ({ getSettings: vi.fn().mockResolvedValue({}) }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createEvent(overrides: Record<string, unknown> = {}) {
|
||||||
|
return {
|
||||||
|
task: {
|
||||||
|
id: "FN-1",
|
||||||
|
sourceIssue: { provider: "github", repository: "owner/repo", issueNumber: 42 },
|
||||||
|
...overrides,
|
||||||
|
},
|
||||||
|
from: "todo",
|
||||||
|
to: "done",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function flushAsync(): Promise<void> {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("GitHubSourceIssueCloseService", () => {
|
||||||
|
let store: MockStore;
|
||||||
|
let service: GitHubSourceIssueCloseService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
store = new MockStore();
|
||||||
|
service = new GitHubSourceIssueCloseService(store as unknown as TaskStore);
|
||||||
|
mockResolveGithubTrackingAuth.mockReturnValue({ ok: true, auth: { mode: "token", token: "ghp_test" } });
|
||||||
|
mockGetIssue.mockResolvedValue({ state: "open" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does nothing when setting is disabled", async () => {
|
||||||
|
store.getSettings.mockResolvedValueOnce({ githubCloseSourceIssueOnDone: false });
|
||||||
|
service.start();
|
||||||
|
store.emit("task:moved", createEvent());
|
||||||
|
await flushAsync();
|
||||||
|
expect(mockGetIssue).not.toHaveBeenCalled();
|
||||||
|
expect(mockSetIssueState).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores missing sourceIssue", async () => {
|
||||||
|
service.start();
|
||||||
|
store.emit("task:moved", createEvent({ sourceIssue: undefined }));
|
||||||
|
await flushAsync();
|
||||||
|
expect(mockSetIssueState).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores non-github provider", async () => {
|
||||||
|
service.start();
|
||||||
|
store.emit("task:moved", createEvent({ sourceIssue: { provider: "jira", repository: "owner/repo", issueNumber: 42 } }));
|
||||||
|
await flushAsync();
|
||||||
|
expect(mockSetIssueState).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("logs malformed repository", async () => {
|
||||||
|
service.start();
|
||||||
|
store.emit("task:moved", createEvent({ sourceIssue: { provider: "github", repository: "bad", issueNumber: 42 } }));
|
||||||
|
await flushAsync();
|
||||||
|
expect(mockSetIssueState).not.toHaveBeenCalled();
|
||||||
|
expect(store.logEntry).toHaveBeenCalledWith("FN-1", "Failed to close linked GitHub source issue", "Invalid GitHub source issue metadata: bad#42");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("short-circuits when already closed", async () => {
|
||||||
|
service.start();
|
||||||
|
mockGetIssue.mockResolvedValueOnce({ state: "closed" });
|
||||||
|
store.emit("task:moved", createEvent());
|
||||||
|
await flushAsync();
|
||||||
|
expect(mockSetIssueState).not.toHaveBeenCalled();
|
||||||
|
expect(store.logEntry).toHaveBeenCalledWith("FN-1", "Skipped closing GitHub source issue - issue not found or already closed", "owner/repo#42");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips when source issue is missing from GitHub", async () => {
|
||||||
|
service.start();
|
||||||
|
mockGetIssue.mockResolvedValueOnce(null);
|
||||||
|
store.emit("task:moved", createEvent());
|
||||||
|
await flushAsync();
|
||||||
|
expect(mockSetIssueState).not.toHaveBeenCalled();
|
||||||
|
expect(store.logEntry).toHaveBeenCalledWith("FN-1", "Skipped closing GitHub source issue - issue not found or already closed", "owner/repo#42");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("closes open github source issue", async () => {
|
||||||
|
service.start();
|
||||||
|
store.emit("task:moved", createEvent());
|
||||||
|
await flushAsync();
|
||||||
|
expect(mockSetIssueState).toHaveBeenCalledWith("owner", "repo", 42, "closed", "completed");
|
||||||
|
expect(store.logEntry).toHaveBeenCalledWith("FN-1", "Closed linked GitHub source issue", "owner/repo#42");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retries transient close failures once", async () => {
|
||||||
|
service.start();
|
||||||
|
mockSetIssueState.mockRejectedValueOnce(new Error("ECONNRESET"));
|
||||||
|
mockSetIssueState.mockResolvedValueOnce(undefined);
|
||||||
|
store.emit("task:moved", createEvent());
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
expect(mockSetIssueState).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("logs non-transient close failures", async () => {
|
||||||
|
service.start();
|
||||||
|
mockSetIssueState.mockRejectedValueOnce(new Error("bad request"));
|
||||||
|
store.emit("task:moved", createEvent());
|
||||||
|
await flushAsync();
|
||||||
|
expect(store.logEntry).toHaveBeenCalledWith("FN-1", "Failed to close linked GitHub source issue", "bad request");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("no-ops when to is not done", async () => {
|
||||||
|
service.start();
|
||||||
|
store.emit("task:moved", { ...createEvent(), to: "in-review" });
|
||||||
|
await flushAsync();
|
||||||
|
expect(mockSetIssueState).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("no-ops when from is done", async () => {
|
||||||
|
service.start();
|
||||||
|
store.emit("task:moved", { ...createEvent(), from: "done", to: "done" });
|
||||||
|
await flushAsync();
|
||||||
|
expect(mockSetIssueState).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips and logs when auth resolution fails", async () => {
|
||||||
|
mockResolveGithubTrackingAuth.mockReturnValueOnce({ ok: false, message: "no auth" });
|
||||||
|
service.start();
|
||||||
|
store.emit("task:moved", createEvent());
|
||||||
|
await flushAsync();
|
||||||
|
expect(mockSetIssueState).not.toHaveBeenCalled();
|
||||||
|
expect(store.logEntry).toHaveBeenCalledWith("FN-1", "Skipped closing GitHub source issue", "no auth");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("attach adds listener for additional project store", async () => {
|
||||||
|
const lateStore = new MockStore();
|
||||||
|
service.start();
|
||||||
|
service.attach(lateStore as unknown as TaskStore);
|
||||||
|
lateStore.emit("task:moved", createEvent());
|
||||||
|
await flushAsync();
|
||||||
|
expect(mockSetIssueState).toHaveBeenCalledWith("owner", "repo", 42, "closed", "completed");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detach removes listeners", async () => {
|
||||||
|
service.start();
|
||||||
|
service.detach(store as unknown as TaskStore);
|
||||||
|
store.emit("task:moved", createEvent());
|
||||||
|
await flushAsync();
|
||||||
|
expect(mockSetIssueState).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import type { TaskStore } from "@fusion/core";
|
||||||
|
import { GitHubTrackingReconciler } from "../github-tracking-reconciler.js";
|
||||||
|
|
||||||
|
const { mockGetIssue, mockSetIssueState } = vi.hoisted(() => ({
|
||||||
|
mockGetIssue: vi.fn(),
|
||||||
|
mockSetIssueState: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { mockResolveGithubTrackingAuth } = vi.hoisted(() => ({
|
||||||
|
mockResolveGithubTrackingAuth: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../github.js", () => ({
|
||||||
|
GitHubClient: vi.fn().mockImplementation(() => ({
|
||||||
|
getIssue: (...args: unknown[]) => mockGetIssue(...args),
|
||||||
|
setIssueState: (...args: unknown[]) => mockSetIssueState(...args),
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../github-auth.js", () => ({
|
||||||
|
resolveGithubTrackingAuth: (...args: unknown[]) => mockResolveGithubTrackingAuth(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
function createStore(listTasks: Array<Record<string, unknown>>, settings: Record<string, unknown> = { githubCloseSourceIssueOnDone: true, githubAuthMode: "token", githubAuthToken: "ghp_test" }): TaskStore {
|
||||||
|
return {
|
||||||
|
listTasks: vi.fn().mockResolvedValue(listTasks),
|
||||||
|
listTasksForGithubTrackingReconcile: vi.fn().mockResolvedValue([]),
|
||||||
|
getSettings: vi.fn().mockResolvedValue(settings),
|
||||||
|
getGlobalSettingsStore: vi.fn(() => ({ getSettings: vi.fn().mockResolvedValue({}) })),
|
||||||
|
logEntry: vi.fn().mockResolvedValue(undefined),
|
||||||
|
} as unknown as TaskStore;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("GitHubTrackingReconciler.reconcileSourceIssues", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mockResolveGithubTrackingAuth.mockReturnValue({ ok: true, auth: { mode: "token", token: "ghp_test" } });
|
||||||
|
mockGetIssue.mockResolvedValue({ state: "open" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("short-circuits when setting disabled", async () => {
|
||||||
|
const store = createStore([{ id: "FN-1", status: "done", sourceIssue: { provider: "github", repository: "o/r", issueNumber: 1 } }], { githubCloseSourceIssueOnDone: false });
|
||||||
|
const result = await new GitHubTrackingReconciler().reconcileSourceIssues(store);
|
||||||
|
expect(result).toEqual({ scanned: 1, closed: 0, skipped: 1, errors: 0 });
|
||||||
|
expect(mockSetIssueState).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("closes open source issues", async () => {
|
||||||
|
const store = createStore([{ id: "FN-1", status: "done", sourceIssue: { provider: "github", repository: "owner/repo", issueNumber: 4 } }]);
|
||||||
|
const result = await new GitHubTrackingReconciler().reconcileSourceIssues(store);
|
||||||
|
expect(mockSetIssueState).toHaveBeenCalledWith("owner", "repo", 4, "closed", "completed");
|
||||||
|
expect(result.closed).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips already-closed source issues", async () => {
|
||||||
|
mockGetIssue.mockResolvedValueOnce({ state: "closed" });
|
||||||
|
const store = createStore([{ id: "FN-1", status: "done", sourceIssue: { provider: "github", repository: "owner/repo", issueNumber: 4 } }]);
|
||||||
|
const result = await new GitHubTrackingReconciler().reconcileSourceIssues(store);
|
||||||
|
expect(result.skipped).toBe(1);
|
||||||
|
expect(mockSetIssueState).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips source issues missing from GitHub", async () => {
|
||||||
|
mockGetIssue.mockResolvedValueOnce(null);
|
||||||
|
const store = createStore([{ id: "FN-12", status: "done", sourceIssue: { provider: "github", repository: "owner/repo", issueNumber: 12 } }]);
|
||||||
|
const result = await new GitHubTrackingReconciler().reconcileSourceIssues(store);
|
||||||
|
expect(result.skipped).toBe(1);
|
||||||
|
expect(result.errors).toBe(0);
|
||||||
|
expect(mockSetIssueState).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores non-done tasks and tasks without sourceIssue", async () => {
|
||||||
|
const store = createStore([
|
||||||
|
{ id: "FN-1", status: "todo", sourceIssue: { provider: "github", repository: "owner/repo", issueNumber: 1 } },
|
||||||
|
{ id: "FN-2", status: "done" },
|
||||||
|
{ id: "FN-3", status: "done", sourceIssue: { provider: "jira", repository: "x/y", issueNumber: 3 } },
|
||||||
|
]);
|
||||||
|
const result = await new GitHubTrackingReconciler().reconcileSourceIssues(store);
|
||||||
|
expect(result).toEqual({ scanned: 0, closed: 0, skipped: 0, errors: 0 });
|
||||||
|
expect(mockGetIssue).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("counts errors and logs on getIssue failure", async () => {
|
||||||
|
mockGetIssue.mockRejectedValueOnce(new Error("boom"));
|
||||||
|
const store = createStore([{ id: "FN-9", status: "done", sourceIssue: { provider: "github", repository: "owner/repo", issueNumber: 9 } }]);
|
||||||
|
const result = await new GitHubTrackingReconciler().reconcileSourceIssues(store);
|
||||||
|
expect(result.errors).toBe(1);
|
||||||
|
expect((store.logEntry as any)).toHaveBeenCalledWith("FN-9", "Failed to reconcile GitHub source issue", "boom");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("counts errors and logs on setIssueState failure", async () => {
|
||||||
|
mockSetIssueState.mockRejectedValueOnce(new Error("write failed"));
|
||||||
|
const store = createStore([{ id: "FN-10", status: "done", sourceIssue: { provider: "github", repository: "owner/repo", issueNumber: 10 } }]);
|
||||||
|
const result = await new GitHubTrackingReconciler().reconcileSourceIssues(store);
|
||||||
|
expect(result.errors).toBe(1);
|
||||||
|
expect((store.logEntry as any)).toHaveBeenCalledWith("FN-10", "Failed to reconcile GitHub source issue", "write failed");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips and logs when auth resolution fails", async () => {
|
||||||
|
mockResolveGithubTrackingAuth.mockReturnValueOnce({ ok: false, message: "no auth" });
|
||||||
|
const store = createStore([{ id: "FN-11", status: "done", sourceIssue: { provider: "github", repository: "owner/repo", issueNumber: 11 } }]);
|
||||||
|
const result = await new GitHubTrackingReconciler().reconcileSourceIssues(store);
|
||||||
|
expect(result.skipped).toBe(1);
|
||||||
|
expect((store.logEntry as any)).toHaveBeenCalledWith("FN-11", "Skipped GitHub source issue reconciliation", "no auth");
|
||||||
|
expect(mockSetIssueState).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
138
packages/dashboard/src/github-source-issue-close.ts
Normal file
138
packages/dashboard/src/github-source-issue-close.ts
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
import type { GlobalSettings, ProjectSettings, TaskStore } from "@fusion/core";
|
||||||
|
import { resolveGithubTrackingAuth } from "./github-auth.js";
|
||||||
|
import { GitHubClient } from "./github.js";
|
||||||
|
import { delay, isTransientGitHubError } from "./github-tracking-state.js";
|
||||||
|
|
||||||
|
type Column = "triage" | "todo" | "in-progress" | "in-review" | "done" | "archived";
|
||||||
|
|
||||||
|
interface TaskMovedEvent {
|
||||||
|
task: {
|
||||||
|
id: string;
|
||||||
|
sourceIssue?: {
|
||||||
|
provider?: string;
|
||||||
|
repository?: string;
|
||||||
|
issueNumber?: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
from: Column;
|
||||||
|
to: Column;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class GitHubSourceIssueCloseService {
|
||||||
|
private readonly defaultStore: TaskStore;
|
||||||
|
private readonly listeners = new Map<TaskStore, { onTaskMoved: (event: TaskMovedEvent) => void }>();
|
||||||
|
private started = false;
|
||||||
|
|
||||||
|
constructor(store: TaskStore) {
|
||||||
|
this.defaultStore = store;
|
||||||
|
}
|
||||||
|
|
||||||
|
start(): void {
|
||||||
|
if (this.started) return;
|
||||||
|
this.started = true;
|
||||||
|
this.attach(this.defaultStore);
|
||||||
|
}
|
||||||
|
|
||||||
|
stop(): void {
|
||||||
|
if (!this.started) return;
|
||||||
|
this.started = false;
|
||||||
|
for (const store of this.listeners.keys()) {
|
||||||
|
this.detach(store);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
attach(store: TaskStore): void {
|
||||||
|
if (this.listeners.has(store)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const onTaskMoved = (event: TaskMovedEvent): void => {
|
||||||
|
void this.handleTaskMoved(store, event);
|
||||||
|
};
|
||||||
|
this.listeners.set(store, { onTaskMoved });
|
||||||
|
|
||||||
|
if (this.started) {
|
||||||
|
store.on("task:moved", onTaskMoved);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
detach(store: TaskStore): void {
|
||||||
|
const handlers = this.listeners.get(store);
|
||||||
|
if (!handlers) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
store.off("task:moved", handlers.onTaskMoved);
|
||||||
|
this.listeners.delete(store);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleTaskMoved(store: TaskStore, event: TaskMovedEvent): Promise<void> {
|
||||||
|
if (event.to !== "done" || event.from === "done") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const settings = ((await store.getSettings()) ?? {}) as Pick<ProjectSettings, "githubCloseSourceIssueOnDone" | "githubAuthMode" | "githubAuthToken">;
|
||||||
|
if (settings.githubCloseSourceIssueOnDone !== true) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceIssue = event.task.sourceIssue;
|
||||||
|
if (!sourceIssue || sourceIssue.provider !== "github") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const repository = sourceIssue.repository ?? "";
|
||||||
|
const [owner, repo] = repository.split("/");
|
||||||
|
const issueNumber = sourceIssue.issueNumber;
|
||||||
|
if (!owner || !repo || !Number.isInteger(issueNumber)) {
|
||||||
|
await store.logEntry(
|
||||||
|
event.task.id,
|
||||||
|
"Failed to close linked GitHub source issue",
|
||||||
|
`Invalid GitHub source issue metadata: ${repository}#${String(issueNumber)}`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const issueNumberValue = issueNumber as number;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const globalSettings = (await store.getGlobalSettingsStore?.()?.getSettings?.() ?? {}) as Pick<GlobalSettings, never>;
|
||||||
|
const resolution = resolveGithubTrackingAuth({ projectSettings: settings, globalSettings });
|
||||||
|
if (!resolution.ok) {
|
||||||
|
await store.logEntry(event.task.id, "Skipped closing GitHub source issue", resolution.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = resolution.auth.mode === "token"
|
||||||
|
? new GitHubClient({ token: resolution.auth.token, forceMode: "token" })
|
||||||
|
: new GitHubClient({ forceMode: "gh-cli" });
|
||||||
|
|
||||||
|
const existing = await client.getIssue(owner, repo, issueNumberValue);
|
||||||
|
if (!existing || existing.state === "closed") {
|
||||||
|
await store.logEntry(event.task.id, "Skipped closing GitHub source issue - issue not found or already closed", `${owner}/${repo}#${issueNumberValue}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeIssue = async () => {
|
||||||
|
await client.setIssueState(owner, repo, issueNumberValue, "closed", "completed");
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await closeIssue();
|
||||||
|
} catch (error) {
|
||||||
|
if (!isTransientGitHubError(error)) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
await delay(25);
|
||||||
|
await closeIssue();
|
||||||
|
}
|
||||||
|
|
||||||
|
await store.logEntry(event.task.id, "Closed linked GitHub source issue", `${owner}/${repo}#${issueNumberValue}`);
|
||||||
|
} catch (error) {
|
||||||
|
await store.logEntry(
|
||||||
|
event.task.id,
|
||||||
|
"Failed to close linked GitHub source issue",
|
||||||
|
error instanceof Error ? error.message : String(error),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -59,6 +59,67 @@ export class GitHubTrackingReconciler {
|
|||||||
return { scanned: tasks.length, closed, skipped, errors };
|
return { scanned: tasks.length, closed, skipped, errors };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async reconcileSourceIssues(store: TaskStore): Promise<{ scanned: number; closed: number; skipped: number; errors: number }> {
|
||||||
|
const listedTasks = await store.listTasks({ slim: false, includeArchived: false });
|
||||||
|
const tasks = (Array.isArray(listedTasks) ? listedTasks : [])
|
||||||
|
.filter((task) => task.status === "done" && task.sourceIssue?.provider === "github")
|
||||||
|
.slice(0, RECONCILE_SCAN_LIMIT);
|
||||||
|
|
||||||
|
const projectSettings = ((await store.getSettings()) ?? {}) as Pick<ProjectSettings, "githubCloseSourceIssueOnDone" | "githubAuthMode" | "githubAuthToken">;
|
||||||
|
if (projectSettings.githubCloseSourceIssueOnDone !== true) {
|
||||||
|
return { scanned: tasks.length, closed: 0, skipped: tasks.length, errors: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const globalSettings = (await store.getGlobalSettingsStore?.()?.getSettings?.() ?? {}) as Pick<GlobalSettings, never>;
|
||||||
|
const resolution = resolveGithubTrackingAuth({ projectSettings, globalSettings });
|
||||||
|
if (!resolution.ok) {
|
||||||
|
for (const task of tasks) {
|
||||||
|
await store.logEntry(task.id, "Skipped GitHub source issue reconciliation", resolution.message);
|
||||||
|
}
|
||||||
|
return { scanned: tasks.length, closed: 0, skipped: tasks.length, errors: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = resolution.auth.mode === "token"
|
||||||
|
? new GitHubClient({ token: resolution.auth.token, forceMode: "token" })
|
||||||
|
: new GitHubClient({ forceMode: "gh-cli" });
|
||||||
|
|
||||||
|
let closed = 0;
|
||||||
|
let skipped = 0;
|
||||||
|
let errors = 0;
|
||||||
|
|
||||||
|
await runWithConcurrencyLimit(tasks, RECONCILE_CONCURRENCY_LIMIT, async (task) => {
|
||||||
|
const sourceIssue = task.sourceIssue;
|
||||||
|
const repository = sourceIssue?.repository ?? "";
|
||||||
|
const [owner, repo] = repository.split("/");
|
||||||
|
const issueNumber = sourceIssue?.issueNumber;
|
||||||
|
if (!owner || !repo || !Number.isInteger(issueNumber)) {
|
||||||
|
skipped += 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const issueNumberValue = issueNumber as number;
|
||||||
|
try {
|
||||||
|
const linkedIssue = await client.getIssue(owner, repo, issueNumberValue);
|
||||||
|
if (!linkedIssue || linkedIssue.state === "closed") {
|
||||||
|
skipped += 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.setIssueState(owner, repo, issueNumberValue, "closed", "completed");
|
||||||
|
closed += 1;
|
||||||
|
} catch (error) {
|
||||||
|
errors += 1;
|
||||||
|
await store.logEntry(
|
||||||
|
task.id,
|
||||||
|
"Failed to reconcile GitHub source issue",
|
||||||
|
error instanceof Error ? error.message : String(error),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return { scanned: tasks.length, closed, skipped, errors };
|
||||||
|
}
|
||||||
|
|
||||||
async reconcileDeletedAndArchived(store: TaskStore): Promise<{ scanned: number; closed: number; skipped: number; errors: number }> {
|
async reconcileDeletedAndArchived(store: TaskStore): Promise<{ scanned: number; closed: number; skipped: number; errors: number }> {
|
||||||
const listedTasks = await store.listTasksForGithubTrackingReconcile();
|
const listedTasks = await store.listTasksForGithubTrackingReconcile();
|
||||||
const tasks = (Array.isArray(listedTasks) ? listedTasks : []).slice(0, RECONCILE_SCAN_LIMIT);
|
const tasks = (Array.isArray(listedTasks) ? listedTasks : []).slice(0, RECONCILE_SCAN_LIMIT);
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ export function decideIssueAction(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isTransientGitHubError(error: unknown): boolean {
|
export function isTransientGitHubError(error: unknown): boolean {
|
||||||
if (!(error instanceof Error)) {
|
if (!(error instanceof Error)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -58,7 +58,7 @@ function isTransientGitHubError(error: unknown): boolean {
|
|||||||
|| message.includes("socket hang up");
|
|| message.includes("socket hang up");
|
||||||
}
|
}
|
||||||
|
|
||||||
async function delay(ms: number): Promise<void> {
|
export async function delay(ms: number): Promise<void> {
|
||||||
await new Promise((resolve) => setTimeout(resolve, ms));
|
await new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ export {
|
|||||||
export { rateLimit, RATE_LIMITS, type RateLimitOptions } from "./rate-limit.js";
|
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 { GitHubSourceIssueCloseService } from "./github-source-issue-close.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 { GitHubTrackingStateService, decideIssueAction } from "./github-tracking-state.js";
|
||||||
export { GitHubTrackingReconciler, RECONCILE_CONCURRENCY_LIMIT, RECONCILE_SCAN_LIMIT } from "./github-tracking-reconciler.js";
|
export { GitHubTrackingReconciler, RECONCILE_CONCURRENCY_LIMIT, RECONCILE_SCAN_LIMIT } from "./github-tracking-reconciler.js";
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ 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 { GitHubTrackingStateService } from "../github-tracking-state.js";
|
||||||
import { GitHubTrackingReconciler } from "../github-tracking-reconciler.js";
|
import { GitHubTrackingReconciler } from "../github-tracking-reconciler.js";
|
||||||
|
import { GitHubSourceIssueCloseService } from "../github-source-issue-close.js";
|
||||||
import { githubRateLimiter } from "../github-poll.js";
|
import { githubRateLimiter } from "../github-poll.js";
|
||||||
import * as projectStoreResolver from "../project-store-resolver.js";
|
import * as projectStoreResolver from "../project-store-resolver.js";
|
||||||
import { generatePrMetadata } from "../pr-metadata-generator.js";
|
import { generatePrMetadata } from "../pr-metadata-generator.js";
|
||||||
@@ -2345,6 +2346,10 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
|
|||||||
githubTrackingCommentService.start();
|
githubTrackingCommentService.start();
|
||||||
ctx.registerDispose(() => githubTrackingCommentService.stop());
|
ctx.registerDispose(() => githubTrackingCommentService.stop());
|
||||||
|
|
||||||
|
const githubSourceIssueCloseService = new GitHubSourceIssueCloseService(store);
|
||||||
|
githubSourceIssueCloseService.start();
|
||||||
|
ctx.registerDispose(() => githubSourceIssueCloseService.stop());
|
||||||
|
|
||||||
const githubTrackingStateService = new GitHubTrackingStateService(store);
|
const githubTrackingStateService = new GitHubTrackingStateService(store);
|
||||||
const githubTrackingReconciler = new GitHubTrackingReconciler();
|
const githubTrackingReconciler = new GitHubTrackingReconciler();
|
||||||
const reconcileScheduledStores = new WeakSet<TaskStore>();
|
const reconcileScheduledStores = new WeakSet<TaskStore>();
|
||||||
@@ -2357,6 +2362,7 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
|
|||||||
}
|
}
|
||||||
attachedStateStores.add(projectStore);
|
attachedStateStores.add(projectStore);
|
||||||
githubTrackingStateService.attach(projectStore);
|
githubTrackingStateService.attach(projectStore);
|
||||||
|
githubSourceIssueCloseService.attach(projectStore);
|
||||||
|
|
||||||
if (!reconcileScheduledStores.has(projectStore)) {
|
if (!reconcileScheduledStores.has(projectStore)) {
|
||||||
reconcileScheduledStores.add(projectStore);
|
reconcileScheduledStores.add(projectStore);
|
||||||
@@ -2369,6 +2375,7 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
|
|||||||
}
|
}
|
||||||
void githubTrackingReconciler.reconcile(projectStore).catch(() => {});
|
void githubTrackingReconciler.reconcile(projectStore).catch(() => {});
|
||||||
void githubTrackingReconciler.reconcileDeletedAndArchived(projectStore).catch(() => {});
|
void githubTrackingReconciler.reconcileDeletedAndArchived(projectStore).catch(() => {});
|
||||||
|
void githubTrackingReconciler.reconcileSourceIssues(projectStore).catch(() => {});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -2412,6 +2419,7 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
|
|||||||
unsubscribeProjectStoreRegistration();
|
unsubscribeProjectStoreRegistration();
|
||||||
for (const projectStore of attachedStateStores) {
|
for (const projectStore of attachedStateStores) {
|
||||||
githubTrackingStateService.detach(projectStore);
|
githubTrackingStateService.detach(projectStore);
|
||||||
|
githubSourceIssueCloseService.detach(projectStore);
|
||||||
}
|
}
|
||||||
githubTrackingStateService.stop();
|
githubTrackingStateService.stop();
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user