diff --git a/.changeset/fn-7426-gitlab-lifecycle.md b/.changeset/fn-7426-gitlab-lifecycle.md new file mode 100644 index 0000000000..ec473309a2 --- /dev/null +++ b/.changeset/fn-7426-gitlab-lifecycle.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Add GitLab comment and auto-close lifecycle actions for linked work items. +category: feature +dev: Uses GitLab REST notes and state-event APIs with configured self-managed instance URLs. diff --git a/docs/cli-reference.md b/docs/cli-reference.md index b9f52f74f5..76ac59a5db 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -649,7 +649,7 @@ Default behavior: PR title/body are AI-generated unless both `--title` and `--bo `fn task import` creates Fusion tasks from GitHub issues. If project or global GitHub tracking defaults are enabled, imported issue tasks are marked as tracked and the tracking hook links the source issue itself instead of opening a duplicate Fusion tracking issue. -`fn task import-gitlab` creates Fusion tasks from GitLab project issues, group issues, or project merge requests using the configured GitLab instance/API URL and access token (`read_api` or `api` scope). It uses the GitLab HTTP API only (no `glab` dependency), supports GitLab.com and self-managed instances, stores `gitlab_import` provenance plus `gitlabTracking` task metadata for dashboard badges/details, and skips duplicates by source URL/provenance. GitLab comment posting and remote auto-close are not part of this command yet. +`fn task import-gitlab` creates Fusion tasks from GitLab project issues, group issues, or project merge requests using the configured GitLab instance/API URL and access token (`read_api` or `api` scope for import; `api` for later comments/close actions). It uses the GitLab HTTP API only (no `glab` dependency), supports GitLab.com and self-managed instances, stores `gitlab_import` provenance plus `gitlabTracking` task metadata for dashboard badges/details, and skips duplicates by source URL/provenance. Imported GitLab tasks can post lifecycle comments and close/reopen source or tracking targets when project settings enable those side effects; group issues require backing project identity, and Fusion never merges GitLab merge requests. ```bash fn pr create FN-001 diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 7c4469ccc1..1b2fc90f74 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -1083,8 +1083,9 @@ Inspect task definition, logs, review feedback, comments, artifacts, workflow ou - After delete confirmations are complete, Task Detail closes immediately while the delete request finishes in the background; success and error outcomes still appear as toasts. - Eligible existing tasks (triage, todo, in-progress, in-review) expose a **GitHub tracking** section directly in Task Detail, even when tracking is currently disabled. - The GitHub tracking section now defaults to a compact summary row; use the disclosure arrow to expand linked-issue details plus tracking edit controls. -- Tasks linked to GitLab imports show a separate **GitLab tracking** section for GitLab.com and self-managed project issues, group issues, and merge requests. The section provides **Open in GitLab** and local **Unlink GitLab item** actions only; comment posting, remote close/delete, and auto-close behavior are reserved for later GitLab parity work. +- Tasks linked to GitLab imports show a separate **GitLab tracking** section for GitLab.com and self-managed project issues, group issues, and merge requests. The section provides **Open in GitLab** and local **Unlink GitLab item** actions; lifecycle side effects run in the background and appear as task-log entries such as `Posted GitLab tracking comment`, `Closed linked GitLab source issue`, or `Skipped closing GitLab merge request`. - GitLab stale state means Fusion is displaying the last persisted GitLab metadata after a sync/import refresh could not confirm a newer state; no GitLab token or secret is stored on the task. +- GitLab comment and close/reopen actions use the configured GitLab REST API base URL for GitLab.com or self-managed instances. Group-imported issues are updated only when Fusion has the concrete project identity plus IID, and merge requests are closed/reopened only for GitLab-supported states; Fusion never auto-merges a GitLab merge request. - Backstop reconciliation runs every 15 minutes to close tracked GitHub issues for soft-deleted and archived tasks even after restart; the sweep is paginated so large archive backlogs are eventually drained. - In shared task edit/create forms, GitHub Tracking appears at the bottom of **More options**, after **Workflow Steps**. - From this section you can explicitly enable/disable tracking and manage a per-task repo override (`owner/repo`). Clearing the override saves `null` and falls back to project/global defaults. diff --git a/docs/settings-reference.md b/docs/settings-reference.md index e21d2b2014..c2082cc6d9 100644 --- a/docs/settings-reference.md +++ b/docs/settings-reference.md @@ -594,11 +594,14 @@ Default notes: | `gitlabApiBaseUrl` | `string` | `undefined` (effective global fallback, then `/api/v4`) | Optional project GitLab REST API base URL. Blank/unset inherits global `gitlabApiBaseUrl`; if still unset, Fusion derives `/api/v4`, preserving self-managed path prefixes. Override only for API gateways with a different absolute HTTP(S) base URL. | | `gitlabAuthToken` | `string` | `undefined` | Project GitLab access token for later GitLab import/tracking/comment/close HTTP API tasks. Project value takes precedence over global `gitlabAuthToken`, then process `GITLAB_TOKEN`. The dashboard renders the field as a password input and trims values on save; blank clears the project override. Dashboard location: **Settings → Project → Merge → GitLab Authentication**. | | `gitlabAuthTokenType` | `"personal" \| "project" \| "group"` | `undefined` (effective `"personal"` when a token exists) | Project GitLab token family label. Supported values are personal access token, project access token, and group access token. Select the token family that matches the credential; unsupported values fail auth resolution with `invalid_token_type`. | +| `gitlabCommentOnDone` | `boolean` | `false` | When enabled, tasks imported from GitLab project issues, group-backed issues with project identity, or merge requests post a completion note when the task moves to `done`. Requires a GitLab token with write-capable API access (`api`) to the target project. | +| `gitlabCommentTemplate` | `string` | `undefined` | Optional GitLab source note template used by `gitlabCommentOnDone`. Supports `{taskId}` and `{taskTitle}` placeholders. If unset, Fusion uses the same concise completion message shape as GitHub source comments. | +| `gitlabCloseSourceIssueOnDone` | `boolean` | `false` | When enabled, source-imported GitLab project issues and merge requests close when the Fusion task moves to `done` and reopen when moved out of `done`, where GitLab supports `state_event`. Fusion never merges merge requests; merged MRs and group issues without backing `projectId`/`projectPath` plus IID are skipped with task-log diagnostics. | | `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**. | | `githubAuthMode` | `"gh-cli" \| "token"` | `"gh-cli"` | Project GitHub auth strategy used by tracking lifecycle integration. `"gh-cli"` requires an installed/authenticated `gh` CLI. `"token"` requires a non-empty `githubAuthToken` (or `GITHUB_TOKEN` env fallback). Tracking lifecycle auth is strict per selected mode (no cross-fallback). | | `githubAuthToken` | `string` | `undefined` | Optional project PAT used when `githubAuthMode` is `"token"` (takes precedence over server startup token for tracking flows). | -GitLab configuration examples: leave both URL fields blank for GitLab.com (`https://gitlab.com`, API `https://gitlab.com/api/v4`); set only `gitlabInstanceUrl=https://gitlab.example.com/gitlab` for a self-managed path-prefix install (API derives `https://gitlab.example.com/gitlab/api/v4`); set both URL fields when a self-managed API gateway differs from the web URL. GitLab auth uses access tokens over the GitLab REST API `PRIVATE-TOKEN` header; Fusion does not require or invoke `glab`. Supported token families are [personal access tokens](https://docs.gitlab.com/user/profile/personal_access_tokens/), [project access tokens](https://docs.gitlab.com/user/project/settings/project_access_tokens/), and [group access tokens](https://docs.gitlab.com/user/group/settings/group_access_tokens/). For later read-only GitLab issue/MR import and tracking reads, tokens need `read_api` or `api`; for later write-capable actions such as posting notes/comments and closing issues/MRs, tokens need `api`. Project and group access tokens are constrained to their associated resource and role membership, so the configured token must cover the target project or group. See GitLab REST authentication docs: . GitLab issue/MR import, tracking, comments, auto-close, Command Center signals, research/search providers, and star-prompt behavior remain deferred to later GitLab subtasks tracked from [GitLab Parity Inventory](./gitlab-parity-inventory.md). +GitLab configuration examples: leave both URL fields blank for GitLab.com (`https://gitlab.com`, API `https://gitlab.com/api/v4`); set only `gitlabInstanceUrl=https://gitlab.example.com/gitlab` for a self-managed path-prefix install (API derives `https://gitlab.example.com/gitlab/api/v4`); set both URL fields when a self-managed API gateway differs from the web URL. GitLab auth uses access tokens over the GitLab REST API `PRIVATE-TOKEN` header; Fusion does not require or invoke `glab`. Supported token families are [personal access tokens](https://docs.gitlab.com/user/profile/personal_access_tokens/), [project access tokens](https://docs.gitlab.com/user/project/settings/project_access_tokens/), and [group access tokens](https://docs.gitlab.com/user/group/settings/group_access_tokens/). GitLab issue/MR import and tracking reads need `read_api` or `api`; posting notes/comments and closing/reopening issues or MRs need `api`. Project and group access tokens are constrained to their associated resource and role membership, so the configured token must cover the target project or group. Lifecycle actions use the configured API base URL for GitLab.com and self-managed instances, URL-encode project path identifiers, and skip unsupported targets such as terminal merged merge requests or group issues missing concrete project identity. Command Center signals, research/search providers, and star-prompt behavior remain deferred to later GitLab subtasks tracked from [GitLab Parity Inventory](./gitlab-parity-inventory.md). | `autoCreatePr` | `boolean` | `false` | Auto-create PRs for completed tasks. | | `autoBackupEnabled` | `boolean` | `false` | Enable scheduled DB backups. | diff --git a/packages/core/src/settings-schema.ts b/packages/core/src/settings-schema.ts index aa85f80fe5..230235b2a9 100644 --- a/packages/core/src/settings-schema.ts +++ b/packages/core/src/settings-schema.ts @@ -505,6 +505,9 @@ export const DEFAULT_PROJECT_SETTINGS = { gitlabApiBaseUrl: undefined, gitlabAuthToken: undefined, gitlabAuthTokenType: undefined, + gitlabCommentOnDone: false, + gitlabCommentTemplate: undefined, + gitlabCloseSourceIssueOnDone: false, githubTrackingDedupEnabled: true, githubAuthMode: "gh-cli", githubAuthToken: undefined, diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 710a278140..f73c084db4 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -4347,6 +4347,16 @@ export interface ProjectSettings { gitlabAuthToken?: string; /** Project GitLab token type label. Defaults effectively to "personal" when a token exists and this is unset. */ gitlabAuthTokenType?: GitlabAuthTokenType; + /** + * FNXC:GitLabLifecycle 2026-07-02-00:00: + * GitLab comment and auto-close settings mirror GitHub lifecycle side effects but remain disabled by default and use the configured GitLab instance/API URL so GitLab.com and self-managed hosts behave consistently. + */ + /** When true, automatically post a comment to the originating GitLab issue or merge request when an imported task is moved to done. Default: false. */ + gitlabCommentOnDone?: boolean; + /** Optional template used for GitLab source comments posted on task completion. Supports `{taskId}` and `{taskTitle}` placeholders. */ + gitlabCommentTemplate?: string; + /** When true, automatically close/reopen linked source-imported GitLab issues or merge requests on task done/undone lifecycle moves. Default: false. */ + gitlabCloseSourceIssueOnDone?: boolean; /** When true, tracking issue creation searches open/closed repo issues for likely duplicates before opening a new issue. * Default: true (set false to opt out). */ githubTrackingDedupEnabled?: boolean; diff --git a/packages/dashboard/src/__tests__/gitlab-issue-comment.test.ts b/packages/dashboard/src/__tests__/gitlab-issue-comment.test.ts new file mode 100644 index 0000000000..c1697c1d4d --- /dev/null +++ b/packages/dashboard/src/__tests__/gitlab-issue-comment.test.ts @@ -0,0 +1,37 @@ +import { EventEmitter } from "node:events"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { GitLabIssueCommentService } from "../gitlab-issue-comment.js"; + +function jsonResponse(body: unknown, status = 200) { return new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } }); } +function store(settings: any = {}) { + const emitter = new EventEmitter(); + return Object.assign(emitter, { + getSettings: vi.fn().mockResolvedValue({ gitlabAuthToken: "token", gitlabInstanceUrl: "https://gitlab.example.com", gitlabCommentOnDone: true, ...settings }), + getGlobalSettingsStore: () => ({ getSettings: vi.fn().mockResolvedValue({}) }), + logEntry: vi.fn(), + }); +} +const task: any = { id: "FN-1", title: "Fix", sourceIssue: { provider: "gitlab", repository: "g/p", issueNumber: 2, url: "https://gitlab.example.com/g/p/-/issues/2" }, gitlabTracking: { item: { kind: "project_issue", instanceUrl: "https://gitlab.example.com", host: "gitlab.example.com", url: "https://gitlab.example.com/g/p/-/issues/2", projectPath: "g/p", iid: 2, title: "Fix", state: "opened", linkedAt: "now" } } }; + +describe("GitLabIssueCommentService", () => { + beforeEach(() => vi.unstubAllGlobals()); + it("posts source completion comments to GitLab project issues", async () => { + const fetchImpl = vi.fn().mockResolvedValue(jsonResponse({ id: 1 })); + vi.stubGlobal("fetch", fetchImpl); + const s = store(); + new GitLabIssueCommentService(s as any).start(); + s.emit("task:moved", { task, to: "done" }); + await vi.waitFor(() => expect(fetchImpl).toHaveBeenCalled()); + expect(fetchImpl.mock.calls[0][0]).toBe("https://gitlab.example.com/api/v4/projects/g%2Fp/issues/2/notes"); + expect(s.logEntry).toHaveBeenCalledWith("FN-1", "Posted GitLab issue completion comment", "g/p#2"); + }); + it("skips non-GitLab and incomplete source metadata", async () => { + const fetchImpl = vi.fn(); vi.stubGlobal("fetch", fetchImpl); + const s = store(); new GitLabIssueCommentService(s as any).start(); + s.emit("task:moved", { task: { ...task, sourceIssue: { provider: "github" } }, to: "done" }); + s.emit("task:moved", { task: { id: "FN-2", sourceIssue: { provider: "gitlab" }, gitlabTracking: { item: { kind: "group_issue", iid: 3 } } }, to: "done" }); + await new Promise((resolve) => setImmediate(resolve)); + expect(fetchImpl).not.toHaveBeenCalled(); + expect(s.logEntry).toHaveBeenCalledWith("FN-2", "Skipped GitLab source comment", "Linked GitLab source metadata is incomplete"); + }); +}); diff --git a/packages/dashboard/src/__tests__/gitlab-source-issue-close.test.ts b/packages/dashboard/src/__tests__/gitlab-source-issue-close.test.ts new file mode 100644 index 0000000000..3ad2e33469 --- /dev/null +++ b/packages/dashboard/src/__tests__/gitlab-source-issue-close.test.ts @@ -0,0 +1,29 @@ +import { EventEmitter } from "node:events"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { GitLabSourceIssueCloseService } from "../gitlab-source-issue-close.js"; + +function jsonResponse(body: unknown, status = 200) { return new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } }); } +function store(settings: any = {}) { const emitter = new EventEmitter(); return Object.assign(emitter, { getSettings: vi.fn().mockResolvedValue({ gitlabAuthToken: "token", gitlabInstanceUrl: "https://gitlab.example.com", gitlabCloseSourceIssueOnDone: true, ...settings }), getGlobalSettingsStore: () => ({ getSettings: vi.fn().mockResolvedValue({}) }), logEntry: vi.fn() }); } +function task(kind: "project_issue" | "merge_request" = "project_issue"): any { return { id: "FN-1", sourceIssue: { provider: "gitlab", repository: "g/p", issueNumber: 2, url: "url" }, gitlabTracking: { item: { kind, instanceUrl: "https://gitlab.example.com", host: "gitlab.example.com", url: "url", projectPath: "g/p", iid: 2, title: "T", state: "opened", linkedAt: "now" } } }; } + +describe("GitLabSourceIssueCloseService", () => { + beforeEach(() => vi.unstubAllGlobals()); + it("honors the close-source setting for GitLab issues", async () => { + const fetchImpl = vi.fn() + .mockResolvedValueOnce(jsonResponse({ iid: 2, title: "I", web_url: "url", state: "opened", labels: [] })) + .mockResolvedValueOnce(jsonResponse({ iid: 2, title: "I", web_url: "url", state: "closed", labels: [] })); + vi.stubGlobal("fetch", fetchImpl); const s = store(); new GitLabSourceIssueCloseService(s as any).start(); + s.emit("task:moved", { task: task(), from: "todo", to: "done" }); + await vi.waitFor(() => expect(fetchImpl).toHaveBeenCalledTimes(2)); + expect(s.logEntry).toHaveBeenCalledWith("FN-1", "Closed linked GitLab source issue", "g/p#2"); + }); + it("skips when disabled or non-GitLab", async () => { + const fetchImpl = vi.fn(); vi.stubGlobal("fetch", fetchImpl); + const disabled = store({ gitlabCloseSourceIssueOnDone: false }); new GitLabSourceIssueCloseService(disabled as any).start(); + disabled.emit("task:moved", { task: task(), from: "todo", to: "done" }); + const s = store(); new GitLabSourceIssueCloseService(s as any).start(); + s.emit("task:moved", { task: { ...task(), sourceIssue: { provider: "github" } }, from: "todo", to: "done" }); + await new Promise((resolve) => setImmediate(resolve)); + expect(fetchImpl).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/dashboard/src/__tests__/gitlab-tracking-comments.test.ts b/packages/dashboard/src/__tests__/gitlab-tracking-comments.test.ts new file mode 100644 index 0000000000..8d52b0f760 --- /dev/null +++ b/packages/dashboard/src/__tests__/gitlab-tracking-comments.test.ts @@ -0,0 +1,31 @@ +import { EventEmitter } from "node:events"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { GitLabTrackingCommentService, formatGitLabTrackingComment } from "../gitlab-tracking-comments.js"; + +function jsonResponse(body: unknown, status = 200) { return new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } }); } +function store() { const emitter = new EventEmitter(); return Object.assign(emitter, { getSettings: vi.fn().mockResolvedValue({ gitlabAuthToken: "token", gitlabInstanceUrl: "https://gitlab.example.com" }), getGlobalSettingsStore: () => ({ getSettings: vi.fn().mockResolvedValue({}) }), logEntry: vi.fn() }); } +function task(kind: "project_issue" | "group_issue" | "merge_request" = "merge_request"): any { return { id: "FN-1", title: "Ship", description: "Body", gitlabTracking: { item: { kind, instanceUrl: "https://gitlab.example.com", host: "gitlab.example.com", url: kind === "merge_request" ? "https://gitlab.example.com/g/p/-/merge_requests/5" : "https://gitlab.example.com/g/p/-/issues/5", projectPath: "g/p", iid: 5, title: "Ship", state: "opened", linkedAt: "now" } } }; } + +describe("GitLabTrackingCommentService", () => { + beforeEach(() => vi.unstubAllGlobals()); + it("formats in-progress and done status comments", () => { + expect(formatGitLabTrackingComment(task(), "in-progress")).toContain("🚧 In progress"); + expect(formatGitLabTrackingComment({ ...task(), branch: "fusion/FN-1", mergeDetails: { commitSha: "abcdef123", mergedAt: "today" } }, "done", "https://gitlab.example.com/g/p/-/merge_requests/5")).toContain("GitLab: https://gitlab.example.com/g/p/-/merge_requests/5"); + }); + it("posts comments to merge requests and group-backed project issues", async () => { + const fetchImpl = vi.fn().mockResolvedValue(jsonResponse({ id: 1 })); vi.stubGlobal("fetch", fetchImpl); + const s = store(); new GitLabTrackingCommentService(s as any).start(); + s.emit("task:moved", { task: task("merge_request"), from: "todo", to: "done" }); + s.emit("task:moved", { task: task("group_issue"), from: "todo", to: "in-progress" }); + await vi.waitFor(() => expect(fetchImpl).toHaveBeenCalledTimes(2)); + expect(fetchImpl.mock.calls[0][0]).toBe("https://gitlab.example.com/api/v4/projects/g%2Fp/merge_requests/5/notes"); + expect(fetchImpl.mock.calls[1][0]).toBe("https://gitlab.example.com/api/v4/projects/g%2Fp/issues/5/notes"); + expect(s.logEntry).toHaveBeenCalledWith("FN-1", "Posted GitLab tracking comment", "g/p!5 (done)"); + }); + it("skips missing auth without calling GitLab", async () => { + const s = store(); s.getSettings.mockResolvedValueOnce({ gitlabAuthToken: "" }); const fetchImpl = vi.fn(); vi.stubGlobal("fetch", fetchImpl); + new GitLabTrackingCommentService(s as any).start(); s.emit("task:moved", { task: task(), from: "todo", to: "done" }); + await vi.waitFor(() => expect(s.logEntry).toHaveBeenCalled()); + expect(fetchImpl).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/dashboard/src/__tests__/gitlab-tracking-state.test.ts b/packages/dashboard/src/__tests__/gitlab-tracking-state.test.ts new file mode 100644 index 0000000000..aad6350140 --- /dev/null +++ b/packages/dashboard/src/__tests__/gitlab-tracking-state.test.ts @@ -0,0 +1,42 @@ +import { EventEmitter } from "node:events"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { GitLabTrackingStateService } from "../gitlab-tracking-state.js"; + +function jsonResponse(body: unknown, status = 200) { return new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } }); } +function store() { const emitter = new EventEmitter(); return Object.assign(emitter, { getSettings: vi.fn().mockResolvedValue({ gitlabAuthToken: "token", gitlabInstanceUrl: "https://gitlab.example.com" }), getGlobalSettingsStore: () => ({ getSettings: vi.fn().mockResolvedValue({}) }), logEntry: vi.fn() }); } +function task(kind: "project_issue" | "group_issue" | "merge_request" = "project_issue"): any { return { id: "FN-1", gitlabTracking: { item: { kind, instanceUrl: "https://gitlab.example.com", host: "gitlab.example.com", url: "url", projectPath: "g/p", iid: 2, title: "T", state: "opened", linkedAt: "now" } } }; } + +describe("GitLabTrackingStateService", () => { + beforeEach(() => vi.unstubAllGlobals()); + it("closes and reopens linked project issues", async () => { + const fetchImpl = vi.fn() + .mockResolvedValueOnce(jsonResponse({ iid: 2, project_id: 7, title: "I", web_url: "url", state: "opened", labels: [] })) + .mockResolvedValueOnce(jsonResponse({ iid: 2, project_id: 7, title: "I", web_url: "url", state: "closed", labels: [] })) + .mockResolvedValueOnce(jsonResponse({ iid: 2, project_id: 7, title: "I", web_url: "url", state: "closed", labels: [] })) + .mockResolvedValueOnce(jsonResponse({ iid: 2, project_id: 7, title: "I", web_url: "url", state: "opened", labels: [] })); + vi.stubGlobal("fetch", fetchImpl); const s = store(); new GitLabTrackingStateService(s as any).start(); + s.emit("task:moved", { task: task(), from: "todo", to: "done" }); + await vi.waitFor(() => expect(fetchImpl).toHaveBeenCalledTimes(2)); + s.emit("task:moved", { task: task(), from: "done", to: "todo" }); + await vi.waitFor(() => expect(fetchImpl).toHaveBeenCalledTimes(4)); + expect(fetchImpl.mock.calls[1][0]).toBe("https://gitlab.example.com/api/v4/projects/g%2Fp/issues/2?state_event=close"); + expect(fetchImpl.mock.calls[3][0]).toBe("https://gitlab.example.com/api/v4/projects/g%2Fp/issues/2?state_event=reopen"); + }); + it("skips merged merge requests and incomplete group issue metadata", async () => { + const fetchImpl = vi.fn().mockResolvedValue(jsonResponse({ iid: 2, project_id: 7, title: "MR", web_url: "url", state: "merged", labels: [] })); + vi.stubGlobal("fetch", fetchImpl); const s = store(); new GitLabTrackingStateService(s as any).start(); + s.emit("task:moved", { task: task("merge_request"), from: "todo", to: "done" }); + s.emit("task:moved", { task: { id: "FN-2", gitlabTracking: { item: { kind: "group_issue", iid: 3 } } }, from: "todo", to: "done" }); + await vi.waitFor(() => expect(s.logEntry).toHaveBeenCalledWith("FN-1", "Skipped closing GitLab merge request", "g/p!2 is merged and cannot be auto-closed")); + expect(s.logEntry).toHaveBeenCalledWith("FN-2", "Failed to update GitLab tracking state", "Linked GitLab metadata is incomplete"); + }); + it("retries transient GitLab failures once", async () => { + const fetchImpl = vi.fn() + .mockResolvedValueOnce(jsonResponse({ iid: 2, title: "I", web_url: "url", state: "opened", labels: [] })) + .mockResolvedValueOnce(jsonResponse({ message: "oops" }, 502)) + .mockResolvedValueOnce(jsonResponse({ iid: 2, title: "I", web_url: "url", state: "closed", labels: [] })); + vi.stubGlobal("fetch", fetchImpl); const s = store(); new GitLabTrackingStateService(s as any).start(); + s.emit("task:moved", { task: task(), from: "todo", to: "done" }); + await vi.waitFor(() => expect(fetchImpl).toHaveBeenCalledTimes(3)); + }); +}); diff --git a/packages/dashboard/src/__tests__/gitlab.test.ts b/packages/dashboard/src/__tests__/gitlab.test.ts index fb8fb2049b..1927def686 100644 --- a/packages/dashboard/src/__tests__/gitlab.test.ts +++ b/packages/dashboard/src/__tests__/gitlab.test.ts @@ -31,6 +31,24 @@ describe("GitLabClient", () => { await expect(client.listProjectIssues("g/p", { limit: 1 })).rejects.toThrow("GitLab authentication failed"); }); + it("posts notes and state events for issues and merge requests", async () => { + const fetchImpl = vi.fn() + .mockResolvedValueOnce(jsonResponse({ id: 1 })) + .mockResolvedValueOnce(jsonResponse({ id: 2 })) + .mockResolvedValueOnce(jsonResponse({ iid: 2, project_id: 7, title: "Issue", web_url: "https://gitlab.example.com/g/p/-/issues/2", state: "closed", labels: [] })) + .mockResolvedValueOnce(jsonResponse({ iid: 4, project_id: 7, title: "MR", web_url: "https://gitlab.example.com/g/p/-/merge_requests/4", state: "closed", labels: [] })); + const client = new GitLabClient(auth, fetchImpl as any); + await client.commentOnProjectIssue("g/p", 2, "done"); + await client.commentOnMergeRequest(7, 4, "done"); + await client.setProjectIssueState("g/p", 2, "closed"); + await client.setMergeRequestState(7, 4, "closed"); + expect(fetchImpl.mock.calls[0][0]).toBe("https://gitlab.example.com/api/v4/projects/g%2Fp/issues/2/notes"); + expect(fetchImpl.mock.calls[0][1]).toMatchObject({ method: "POST", body: JSON.stringify({ body: "done" }) }); + expect(fetchImpl.mock.calls[1][0]).toBe("https://gitlab.example.com/api/v4/projects/7/merge_requests/4/notes"); + expect(fetchImpl.mock.calls[2][0]).toBe("https://gitlab.example.com/api/v4/projects/g%2Fp/issues/2?state_event=close"); + expect(fetchImpl.mock.calls[3][0]).toBe("https://gitlab.example.com/api/v4/projects/7/merge_requests/4?state_event=close"); + }); + it("normalizes project issues, group issues, and merge requests", async () => { const fetchImpl = vi.fn() .mockResolvedValueOnce(jsonResponse([{ id: 10, iid: 2, project_id: 7, title: "Issue", description: "Body", web_url: "https://gitlab.example.com/g/p/-/issues/2", state: "opened", labels: ["bug"], author: { username: "ana" }, user_notes_count: 3 }])) diff --git a/packages/dashboard/src/__tests__/register-git-github.gitlab-lifecycle.test.ts b/packages/dashboard/src/__tests__/register-git-github.gitlab-lifecycle.test.ts new file mode 100644 index 0000000000..b07967d5f2 --- /dev/null +++ b/packages/dashboard/src/__tests__/register-git-github.gitlab-lifecycle.test.ts @@ -0,0 +1,54 @@ +// @vitest-environment node + +import { EventEmitter } from "node:events"; +import express from "express"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { registerGitGitHubRoutes } from "../routes/register-git-github.js"; + +function jsonResponse(body: unknown, status = 200) { return new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } }); } +function createStore() { + const emitter = new EventEmitter(); + return Object.assign(emitter, { + getRootDir: vi.fn().mockReturnValue(process.cwd()), + getFusionDir: vi.fn().mockReturnValue(`${process.cwd()}/.fusion`), + getSettings: vi.fn().mockResolvedValue({ gitlabAuthToken: "token", gitlabInstanceUrl: "https://gitlab.example.com", gitlabCommentOnDone: true, gitlabCloseSourceIssueOnDone: true }), + getGlobalSettingsStore: () => ({ getSettings: vi.fn().mockResolvedValue({}) }), + logEntry: vi.fn().mockResolvedValue(undefined), + listTasks: vi.fn().mockResolvedValue([]), + listTasksForGithubTrackingReconcile: vi.fn().mockResolvedValue({ tasks: [], hasMore: false }), + updateTask: vi.fn().mockResolvedValue(undefined), + }); +} +function createContext(store: any, disposers: Array<() => void>) { + return { + router: express.Router(), store, options: {}, + getProjectContext: async () => ({ store, engine: undefined, projectId: undefined }), + getScopedStore: async () => store, + registerDispose: (fn: () => void) => { disposers.push(fn); }, + rethrowAsApiError: (error: unknown) => { throw error; }, + } as any; +} +const task: any = { id: "FN-1", title: "T", sourceIssue: { provider: "gitlab", repository: "g/p", issueNumber: 2, url: "url" }, gitlabTracking: { item: { kind: "project_issue", instanceUrl: "https://gitlab.example.com", host: "gitlab.example.com", url: "url", projectPath: "g/p", iid: 2, title: "T", state: "opened", linkedAt: "now" } } }; + +describe("registerGitGitHubRoutes GitLab lifecycle services", () => { + beforeEach(() => vi.useFakeTimers({ toFake: ["setInterval", "clearInterval", "setImmediate"] })); + afterEach(() => { vi.useRealTimers(); vi.unstubAllGlobals(); }); + it("starts GitLab lifecycle listeners and dispose removes them", async () => { + const fetchImpl = vi.fn() + .mockResolvedValueOnce(jsonResponse({ id: 1 })) + .mockResolvedValueOnce(jsonResponse({ id: 2 })) + .mockResolvedValueOnce(jsonResponse({ iid: 2, title: "I", web_url: "url", state: "opened", labels: [] })) + .mockResolvedValueOnce(jsonResponse({ iid: 2, title: "I", web_url: "url", state: "closed", labels: [] })) + .mockResolvedValueOnce(jsonResponse({ iid: 2, title: "I", web_url: "url", state: "closed", labels: [] })); + vi.stubGlobal("fetch", fetchImpl); + const store = createStore(); const disposers: Array<() => void> = []; + registerGitGitHubRoutes(createContext(store, disposers)); + store.emit("task:moved", { task, from: "todo", to: "done" }); + await vi.waitFor(() => expect(fetchImpl).toHaveBeenCalledTimes(5)); + expect(fetchImpl.mock.calls.filter(([url]) => String(url).endsWith("/notes"))).toHaveLength(2); + for (const dispose of disposers) dispose(); + store.emit("task:moved", { task, from: "done", to: "todo" }); + await Promise.resolve(); + expect(fetchImpl).toHaveBeenCalledTimes(5); + }); +}); diff --git a/packages/dashboard/src/gitlab-issue-comment.ts b/packages/dashboard/src/gitlab-issue-comment.ts new file mode 100644 index 0000000000..eb0f57ff17 --- /dev/null +++ b/packages/dashboard/src/gitlab-issue-comment.ts @@ -0,0 +1,62 @@ +import type { ProjectSettings, Task, TaskStore } from "@fusion/core"; +import { resolveGitLabClient, resolveGitLabTarget, safeLogGitLabEntry } from "./gitlab-lifecycle.js"; + +interface TaskMovedEvent { + task: Task; + to: string; +} + +export const DEFAULT_GITLAB_COMMENT_TEMPLATE = "✅ Task {taskId} ({taskTitle}) has been completed and resolved."; + +export class GitLabIssueCommentService { + private readonly store: TaskStore; + private readonly onTaskMoved = (event: TaskMovedEvent): void => { void this.handleTaskMoved(event); }; + private started = false; + + constructor(store: TaskStore) { + this.store = store; + } + + 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 { + if (event.to !== "done" || event.task.sourceIssue?.provider !== "gitlab") return; + const settings = await this.store.getSettings() as Pick; + if (settings.gitlabCommentOnDone !== true) return; + + const target = resolveGitLabTarget(event.task); + if (!target) { + await safeLogGitLabEntry(this.store, event.task.id, "Skipped GitLab source comment", "Linked GitLab source metadata is incomplete"); + return; + } + + const template = settings.gitlabCommentTemplate || DEFAULT_GITLAB_COMMENT_TEMPLATE; + const body = template.replaceAll("{taskId}", event.task.id).replaceAll("{taskTitle}", event.task.title ?? ""); + + try { + const resolved = await resolveGitLabClient(this.store); + if (!resolved.ok) { + await safeLogGitLabEntry(this.store, event.task.id, "Skipped GitLab source comment", resolved.message); + return; + } + if (target.kind === "merge_request") { + await resolved.client.commentOnMergeRequest(target.project, target.iid, body); + } else { + await resolved.client.commentOnProjectIssue(target.project, target.iid, body); + } + await safeLogGitLabEntry(this.store, event.task.id, "Posted GitLab issue completion comment", target.label); + } catch (error) { + await safeLogGitLabEntry(this.store, event.task.id, "Failed to post GitLab issue comment", error instanceof Error ? error.message : String(error)); + } + } +} diff --git a/packages/dashboard/src/gitlab-lifecycle.ts b/packages/dashboard/src/gitlab-lifecycle.ts new file mode 100644 index 0000000000..e571b68987 --- /dev/null +++ b/packages/dashboard/src/gitlab-lifecycle.ts @@ -0,0 +1,58 @@ +import type { GlobalSettings, ProjectSettings, Task, TaskGitLabTrackedItem, TaskStore } from "@fusion/core"; +import { GitLabClient } from "./gitlab.js"; +import { resolveGitlabAuth } from "./gitlab-auth.js"; + +export type GitLabLifecycleTarget = { + kind: "project_issue" | "group_issue" | "merge_request"; + project: string | number; + iid: number; + label: string; + url?: string; +}; + +export async function resolveGitLabClient(store: TaskStore): Promise<{ ok: true; client: GitLabClient } | { ok: false; message: string }> { + const projectSettings = await store.getSettings() as ProjectSettings; + const globalSettings = (await store.getGlobalSettingsStore?.()?.getSettings?.() ?? {}) as Partial; + const resolution = resolveGitlabAuth({ projectSettings, globalSettings }); + if (!resolution.ok) return { ok: false, message: resolution.message }; + return { ok: true, client: new GitLabClient(resolution.auth) }; +} + +export function resolveGitLabTarget(task: Pick): GitLabLifecycleTarget | null { + const item = task.gitlabTracking?.item; + if (item) return resolveGitLabTargetFromItem(item); + + const meta = task.source?.sourceMetadata && typeof task.source.sourceMetadata === "object" + ? task.source.sourceMetadata as Record + : undefined; + if (task.sourceIssue?.provider !== "gitlab" || !meta) return null; + const kind = meta.resourceType === "merge_request" ? "merge_request" : meta.resourceType === "group_issue" ? "group_issue" : "project_issue"; + const iid = typeof meta.iid === "number" ? meta.iid : task.sourceIssue.issueNumber; + const project = typeof meta.projectId === "number" ? meta.projectId : typeof meta.projectPath === "string" ? meta.projectPath : task.sourceIssue.repository; + if (!Number.isInteger(iid) || !project) return null; + return { kind, project, iid, label: formatGitLabTargetLabel(kind, project, iid), url: task.sourceIssue.url }; +} + +export function resolveGitLabTargetFromItem(item: TaskGitLabTrackedItem): GitLabLifecycleTarget | null { + const project = item.projectId ?? item.projectPath; + if (!project || !Number.isInteger(item.iid)) return null; + return { kind: item.kind, project, iid: item.iid, label: formatGitLabTargetLabel(item.kind, project, item.iid), url: item.url }; +} + +export function formatGitLabTargetLabel(kind: GitLabLifecycleTarget["kind"], project: string | number, iid: number): string { + const marker = kind === "merge_request" ? "!" : "#"; + return `${String(project)}${marker}${iid}`; +} + +export async function safeLogGitLabEntry(store: TaskStore, taskId: string, message: string, details: string): Promise { + try { + await store.logEntry(taskId, message, details); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + if (errorMessage.includes(`Task ${taskId} not found`)) { + console.warn(`[gitlab-lifecycle] Unable to write log entry for deleted task ${taskId}: ${message}`); + return; + } + throw error; + } +} diff --git a/packages/dashboard/src/gitlab-source-issue-close.ts b/packages/dashboard/src/gitlab-source-issue-close.ts new file mode 100644 index 0000000000..84a1d9ed57 --- /dev/null +++ b/packages/dashboard/src/gitlab-source-issue-close.ts @@ -0,0 +1,45 @@ +import type { ProjectSettings, Task, TaskStore } from "@fusion/core"; +import { resolveGitLabTarget, safeLogGitLabEntry } from "./gitlab-lifecycle.js"; +import { updateGitLabTargetState } from "./gitlab-tracking-state.js"; +import { decideIssueAction } from "./github-tracking-state.js"; + +interface TaskMovedEvent { task: Task; from: string; to: string; } + +export class GitLabSourceIssueCloseService { + private readonly defaultStore: TaskStore; + private readonly listeners = new Map 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 { + const settings = await store.getSettings() as Pick; + if (settings.gitlabCloseSourceIssueOnDone !== true) return; + if (event.task.sourceIssue?.provider !== "gitlab") return; + const decision = decideIssueAction(event.from, event.to); + if (!decision) return; + const target = resolveGitLabTarget(event.task); + if (!target) { + await safeLogGitLabEntry(store, event.task.id, "Skipped closing GitLab source issue", "Linked GitLab source metadata is incomplete"); + return; + } + await updateGitLabTargetState(store, event.task.id, target, decision.action === "close" ? "closed" : "opened", "source"); + } +} diff --git a/packages/dashboard/src/gitlab-tracking-comments.ts b/packages/dashboard/src/gitlab-tracking-comments.ts new file mode 100644 index 0000000000..5271c42a96 --- /dev/null +++ b/packages/dashboard/src/gitlab-tracking-comments.ts @@ -0,0 +1,77 @@ +import type { Task, TaskStore } from "@fusion/core"; +import { resolveGitLabClient, resolveGitLabTargetFromItem, safeLogGitLabEntry } from "./gitlab-lifecycle.js"; + +const COMMENT_MAX_LENGTH = 500; +const DONE_COMMENT_MAX_LENGTH = 2000; + +interface TaskMovedEvent { task: Task; from: string; to: string; } + +function clean(value: string): string { return value.replace(/\s+/g, " ").replace(/[[\]()]/g, "").trim(); } +function truncate(value: string, max: number): string { return value.length <= max ? value : `${value.slice(0, Math.max(0, max - 1)).trimEnd()}…`; } +function title(task: Pick, max: number): string { + const value = clean(task.title ?? "") || clean((task.description ?? "").split("\n", 1)[0] ?? "") || "Untitled task"; + return truncate(value, max); +} + +export function formatGitLabTrackingComment(task: Pick, transition: "in-progress" | "done", targetUrl?: string): string { + if (transition === "in-progress") { + const prefix = `Fusion task: ${task.id}\n\n`; + const stem = "🚧 In progress — work has started on “"; + const suffix = "”."; + return `${prefix}${stem}${title(task, COMMENT_MAX_LENGTH - prefix.length - stem.length - suffix.length)}${suffix}`; + } + const lines: string[] = []; + if (task.mergeDetails?.commitSha) lines.push(`Commit: ${task.mergeDetails.commitSha.slice(0, 7)}`); + if (task.branch) lines.push(`Branch: ${clean(task.branch)}`); + if (task.mergeDetails?.mergedAt) lines.push(`Merged: ${task.mergeDetails.mergedAt}`); + if (targetUrl) lines.push(`GitLab: ${targetUrl}`); + const prefix = `Fusion task: ${task.id}\n\n`; + const stem = "✅ Done — “"; + const suffix = "” is complete."; + const extra = lines.length ? `\n${lines.join("\n")}` : ""; + return `${prefix}${stem}${title(task, DONE_COMMENT_MAX_LENGTH - prefix.length - stem.length - suffix.length - extra.length)}${suffix}${extra}`; +} + +export class GitLabTrackingCommentService { + private readonly store: TaskStore; + private readonly onTaskMoved = (event: TaskMovedEvent): void => { void this.handleTaskMoved(event); }; + private started = false; + + constructor(store: TaskStore) { this.store = store; } + + 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 { + if (event.from === event.to || (event.to !== "in-progress" && event.to !== "done")) return; + const item = event.task.gitlabTracking?.item; + if (!item) return; + const target = resolveGitLabTargetFromItem(item); + if (!target) { + await safeLogGitLabEntry(this.store, event.task.id, "Failed to post GitLab tracking comment", "Linked GitLab metadata is incomplete"); + return; + } + const body = formatGitLabTrackingComment(event.task, event.to, event.to === "done" ? target.url : undefined); + try { + const resolved = await resolveGitLabClient(this.store); + if (!resolved.ok) { + await safeLogGitLabEntry(this.store, event.task.id, "Skipped GitLab tracking comment", resolved.message); + return; + } + if (target.kind === "merge_request") await resolved.client.commentOnMergeRequest(target.project, target.iid, body); + else await resolved.client.commentOnProjectIssue(target.project, target.iid, body); + await safeLogGitLabEntry(this.store, event.task.id, "Posted GitLab tracking comment", `${target.label} (${event.to})`); + } catch (error) { + await safeLogGitLabEntry(this.store, event.task.id, "Failed to post GitLab tracking comment", error instanceof Error ? error.message : String(error)); + } + } +} diff --git a/packages/dashboard/src/gitlab-tracking-state.ts b/packages/dashboard/src/gitlab-tracking-state.ts new file mode 100644 index 0000000000..adddbf373e --- /dev/null +++ b/packages/dashboard/src/gitlab-tracking-state.ts @@ -0,0 +1,97 @@ +import type { Task, TaskStore } from "@fusion/core"; +import { GitLabApiError } from "./gitlab.js"; +import { resolveGitLabClient, resolveGitLabTargetFromItem, safeLogGitLabEntry, type GitLabLifecycleTarget } from "./gitlab-lifecycle.js"; +import { decideIssueAction, delay } from "./github-tracking-state.js"; + +const TRANSIENT_RETRY_DELAY_MS = 25; + +interface TaskMovedEvent { task: Task; from: string; to: string; } + +export function isTransientGitLabError(error: unknown): boolean { + if (error instanceof GitLabApiError && error.status >= 500) return true; + if (!(error instanceof Error)) return false; + const message = error.message.toLowerCase(); + return message.includes("econn") || message.includes("timed out") || message.includes("socket hang up"); +} + +async function retryTransient(fn: () => Promise): Promise { + try { return await fn(); } catch (error) { + if (!isTransientGitLabError(error)) throw error; + await delay(TRANSIENT_RETRY_DELAY_MS); + return await fn(); + } +} + +export class GitLabTrackingStateService { + private readonly defaultStore: TaskStore; + private readonly listeners = new Map 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 { + const decision = decideIssueAction(event.from, event.to); + if (!decision) return; + const item = event.task.gitlabTracking?.item; + if (!item) return; + const target = resolveGitLabTargetFromItem(item); + if (!target) { + await safeLogGitLabEntry(store, event.task.id, "Failed to update GitLab tracking state", "Linked GitLab metadata is incomplete"); + return; + } + await updateGitLabTargetState(store, event.task.id, target, decision.action === "close" ? "closed" : "opened", "tracking"); + } +} + +export async function updateGitLabTargetState(store: TaskStore, taskId: string, target: GitLabLifecycleTarget, state: "opened" | "closed", source: "tracking" | "source"): Promise { + const action = state === "closed" ? "close" : "reopen"; + try { + const resolved = await resolveGitLabClient(store); + if (!resolved.ok) { + await safeLogGitLabEntry(store, taskId, `Skipped ${action === "close" ? "closing" : "reopening"} GitLab ${source} ${target.kind === "merge_request" ? "merge request" : "issue"}`, resolved.message); + return; + } + + if (target.kind === "merge_request") { + const existing = await resolved.client.getMergeRequest(target.project, target.iid); + if (existing.state === "merged") { + await safeLogGitLabEntry(store, taskId, "Skipped closing GitLab merge request", `${target.label} is merged and cannot be auto-closed`); + return; + } + if (existing.state === state) { + await safeLogGitLabEntry(store, taskId, `Skipped ${action === "close" ? "closing" : "reopening"} GitLab merge request`, `${target.label} already ${state}`); + return; + } + await retryTransient(() => resolved.client.setMergeRequestState(target.project, target.iid, state)); + await safeLogGitLabEntry(store, taskId, `${action === "close" ? "Closed" : "Reopened"} linked GitLab ${source} merge request`, target.label); + return; + } + + const existing = await resolved.client.getProjectIssue(target.project, target.iid); + if (existing.state === state) { + await safeLogGitLabEntry(store, taskId, `Skipped ${action === "close" ? "closing" : "reopening"} GitLab issue`, `${target.label} already ${state}`); + return; + } + await retryTransient(() => resolved.client.setProjectIssueState(target.project, target.iid, state)); + await safeLogGitLabEntry(store, taskId, `${action === "close" ? "Closed" : "Reopened"} linked GitLab ${source} issue`, target.label); + } catch (error) { + await safeLogGitLabEntry(store, taskId, `Failed to ${action} GitLab ${source} ${target.kind === "merge_request" ? "merge request" : "issue"}`, error instanceof Error ? error.message : String(error)); + } +} diff --git a/packages/dashboard/src/gitlab.ts b/packages/dashboard/src/gitlab.ts index d190699cb5..41a886e570 100644 --- a/packages/dashboard/src/gitlab.ts +++ b/packages/dashboard/src/gitlab.ts @@ -191,12 +191,14 @@ export class GitLabClient { headers: { Accept: "application/json", [this.auth.headerName]: this.auth.token, + ...(init?.body ? { "Content-Type": "application/json" } : {}), ...(init?.headers ?? {}), }, }); if (!response.ok) { throw new GitLabApiError(response.status, mapGitLabError(response.status)); } + if (response.status === 204) return undefined as T; return await response.json() as T; } @@ -240,6 +242,34 @@ export class GitLabClient { async getMergeRequest(project: string | number, iid: number): Promise { return normalizeMergeRequest(await this.request(`projects/${encodeGitLabPathId(project)}/merge_requests/${iid}`)); } + + /* + FNXC:GitLabLifecycle 2026-07-02-00:00: + GitLab lifecycle side effects must use REST notes and state_event APIs for GitLab.com and self-managed instances. Keep project identifiers URL-encoded, token auth header-based, and never introduce a local GitLab CLI dependency. + */ + async commentOnProjectIssue(project: string | number, iid: number, body: string): Promise { + await this.request(`projects/${encodeGitLabPathId(project)}/issues/${iid}/notes`, { + method: "POST", + body: JSON.stringify({ body }), + }); + } + + async commentOnMergeRequest(project: string | number, iid: number, body: string): Promise { + await this.request(`projects/${encodeGitLabPathId(project)}/merge_requests/${iid}/notes`, { + method: "POST", + body: JSON.stringify({ body }), + }); + } + + async setProjectIssueState(project: string | number, iid: number, state: "opened" | "closed"): Promise { + const params = new URLSearchParams({ state_event: state === "closed" ? "close" : "reopen" }); + return normalizeIssue(await this.request(`projects/${encodeGitLabPathId(project)}/issues/${iid}?${params.toString()}`, { method: "PUT" }), "project_issue"); + } + + async setMergeRequestState(project: string | number, iid: number, state: "opened" | "closed"): Promise { + const params = new URLSearchParams({ state_event: state === "closed" ? "close" : "reopen" }); + return normalizeMergeRequest(await this.request(`projects/${encodeGitLabPathId(project)}/merge_requests/${iid}?${params.toString()}`, { method: "PUT" })); + } } export function mapGitLabError(status: number): string { diff --git a/packages/dashboard/src/routes/register-git-github.ts b/packages/dashboard/src/routes/register-git-github.ts index 592f4aa4f2..55b765325b 100644 --- a/packages/dashboard/src/routes/register-git-github.ts +++ b/packages/dashboard/src/routes/register-git-github.ts @@ -43,6 +43,10 @@ import { GitHubTrackingCommentService } from "../github-tracking-comments.js"; import { GitHubTrackingStateService } from "../github-tracking-state.js"; import { GitHubTrackingReconciler, RECONCILE_SCAN_LIMIT } from "../github-tracking-reconciler.js"; import { GitHubSourceIssueCloseService } from "../github-source-issue-close.js"; +import { GitLabIssueCommentService } from "../gitlab-issue-comment.js"; +import { GitLabTrackingCommentService } from "../gitlab-tracking-comments.js"; +import { GitLabTrackingStateService } from "../gitlab-tracking-state.js"; +import { GitLabSourceIssueCloseService } from "../gitlab-source-issue-close.js"; import { KnowledgeIndexRefreshService } from "../knowledge-index-refresh.js"; import { githubRateLimiter } from "../github-poll.js"; import * as projectStoreResolver from "../project-store-resolver.js"; @@ -2534,6 +2538,18 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void { githubSourceIssueCloseService.start(); ctx.registerDispose(() => githubSourceIssueCloseService.stop()); + const gitlabIssueCommentService = new GitLabIssueCommentService(store); + gitlabIssueCommentService.start(); + ctx.registerDispose(() => gitlabIssueCommentService.stop()); + + const gitlabTrackingCommentService = new GitLabTrackingCommentService(store); + gitlabTrackingCommentService.start(); + ctx.registerDispose(() => gitlabTrackingCommentService.stop()); + + const gitlabSourceIssueCloseService = new GitLabSourceIssueCloseService(store); + gitlabSourceIssueCloseService.start(); + ctx.registerDispose(() => gitlabSourceIssueCloseService.stop()); + // U14 — incremental knowledge-index refresh on task completion. Listens for // task:moved → done and re-indexes just that task as a knowledge page. const knowledgeIndexRefreshService = new KnowledgeIndexRefreshService(store); @@ -2541,11 +2557,13 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void { ctx.registerDispose(() => knowledgeIndexRefreshService.stop()); const githubTrackingStateService = new GitHubTrackingStateService(store); + const gitlabTrackingStateService = new GitLabTrackingStateService(store); const githubTrackingReconciler = new GitHubTrackingReconciler(); const reconcileScheduledStores = new WeakSet(); const reconcileSweepOffsetByStore = new WeakMap(); const reconcileSweepInFlightByStore = new WeakMap(); githubTrackingStateService.start(); + gitlabTrackingStateService.start(); const runReconcileSweep = async (projectStore: TaskStore, options?: { startup?: boolean }) => { if (typeof (projectStore as Partial).listTasks !== "function" @@ -2590,6 +2608,8 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void { attachedStateStores.add(projectStore); githubTrackingStateService.attach(projectStore); githubSourceIssueCloseService.attach(projectStore); + gitlabTrackingStateService.attach(projectStore); + gitlabSourceIssueCloseService.attach(projectStore); // FNXC:Knowledge 2026-06-16-14:32: // Knowledge index refresh on task:moved→done must run for every registered project store, not just the primary. // Mirror the GitHubTrackingStateService/GitHubSourceIssueCloseService attach/detach lifecycle so non-primary @@ -2652,9 +2672,12 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void { for (const projectStore of attachedStateStores) { githubTrackingStateService.detach(projectStore); githubSourceIssueCloseService.detach(projectStore); + gitlabTrackingStateService.detach(projectStore); + gitlabSourceIssueCloseService.detach(projectStore); knowledgeIndexRefreshService.detach(projectStore); } githubTrackingStateService.stop(); + gitlabTrackingStateService.stop(); }); }