From 7e98ff84d657c420971d7106cdf708f96fc8beb2 Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Tue, 13 Oct 2026 22:37:51 -0700 Subject: [PATCH 1/5] feat(issues): read and edit GitLab issues GitLab issues come through the same `glab api` the merge request reads use, so one login per host serves both. Listing is GitLab's own REST issues query paged by page number; the detail joins the issue, its notes (split into comments and the system notes GitLab writes as events), label events, closing and related merge requests, and award emoji read through one GraphQL query shaped like the merge request one. Co-Authored-By: Claude Fable 5.1 --- packages/source-control-gitlab/package.json | 13 + .../src/server/GitLabIssueTracker.test.ts | 221 +++++++ .../src/server/GitLabIssueTracker.ts | 585 ++++++++++++++++++ .../src/server/gitLabIssueJson.ts | 410 ++++++++++++ .../src/server/issueTrackerDriver.ts | 15 + pnpm-lock.yaml | 9 + 6 files changed, 1253 insertions(+) create mode 100644 packages/source-control-gitlab/src/server/GitLabIssueTracker.test.ts create mode 100644 packages/source-control-gitlab/src/server/GitLabIssueTracker.ts create mode 100644 packages/source-control-gitlab/src/server/gitLabIssueJson.ts create mode 100644 packages/source-control-gitlab/src/server/issueTrackerDriver.ts diff --git a/packages/source-control-gitlab/package.json b/packages/source-control-gitlab/package.json index aafefa73fb4b..0ebd55f06287 100644 --- a/packages/source-control-gitlab/package.json +++ b/packages/source-control-gitlab/package.json @@ -19,6 +19,18 @@ "types": "./src/server/GitLabCli.ts", "import": "./src/server/GitLabCli.ts" }, + "./server/gitLabIssueJson": { + "types": "./src/server/gitLabIssueJson.ts", + "import": "./src/server/gitLabIssueJson.ts" + }, + "./server/GitLabIssueTracker": { + "types": "./src/server/GitLabIssueTracker.ts", + "import": "./src/server/GitLabIssueTracker.ts" + }, + "./server/issueTrackerDriver": { + "types": "./src/server/issueTrackerDriver.ts", + "import": "./src/server/issueTrackerDriver.ts" + }, "./server/gitLabMergeRequestJson": { "types": "./src/server/gitLabMergeRequestJson.ts", "import": "./src/server/gitLabMergeRequestJson.ts" @@ -46,6 +58,7 @@ }, "dependencies": { "@t3tools/contracts": "workspace:*", + "@t3tools/issue-tracker-core": "workspace:*", "@t3tools/shared": "workspace:*", "@t3tools/source-control-core": "workspace:*", "effect": "catalog:" diff --git a/packages/source-control-gitlab/src/server/GitLabIssueTracker.test.ts b/packages/source-control-gitlab/src/server/GitLabIssueTracker.test.ts new file mode 100644 index 000000000000..3fbcde96f3b1 --- /dev/null +++ b/packages/source-control-gitlab/src/server/GitLabIssueTracker.test.ts @@ -0,0 +1,221 @@ +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as TestSourceControlHost from "@t3tools/source-control-testing/TestSourceControlHost"; + +import * as GitLabCli from "./GitLabCli.ts"; +import * as GitLabIssueTracker from "./GitLabIssueTracker.ts"; + +const encode = (value: unknown) => JSON.stringify(value); + +const ISSUE = { + iid: 42, + title: "Composer loses focus", + web_url: "https://gitlab.com/acme/web/-/issues/42", + description: "Typing in the composer drops focus after a paste.", + author: { id: 1, username: "octocat", name: "Octo Cat", avatar_url: "https://avatars/octocat" }, + state: "closed", + created_at: "2026-07-01T00:00:00Z", + updated_at: "2026-07-03T00:00:00Z", + closed_at: "2026-07-03T00:00:00Z", + assignees: [{ id: 2, username: "theo", name: null }], + labels: ["bug", " "], + user_notes_count: 3, +}; + +/** Every endpoint the detail read touches, answered by the path `glab api` is given. */ +function answer(path: string): string { + if (path === "user") return encode({ username: "julius" }); + if (path === "projects/acme%2Fweb/issues/42") return encode(ISSUE); + if (path.startsWith("projects/acme%2Fweb/issues/42/notes")) { + return encode([ + { + id: 7, + body: "Repro attached.", + author: { username: "julius" }, + created_at: "2026-07-02T00:00:00Z", + }, + { + id: 8, + body: "closed", + author: { username: "theo" }, + created_at: "2026-07-03T00:00:00Z", + system: true, + }, + { + id: 9, + body: "added ~7 label", + author: { username: "theo" }, + created_at: "2026-07-03T00:00:00Z", + system: true, + }, + ]); + } + if (path.startsWith("projects/acme%2Fweb/issues/42/resource_label_events")) { + return encode([ + { + id: 3, + action: "add", + created_at: "2026-07-01T01:00:00Z", + user: { username: "theo" }, + label: { name: "bug" }, + }, + ]); + } + if (path.startsWith("projects/acme%2Fweb/issues/42/closed_by")) { + return encode([ + { + iid: 50, + title: "fix(web): keep composer focus", + web_url: "https://gitlab.com/acme/web/-/merge_requests/50", + state: "merged", + references: { full: "acme/web!50" }, + }, + ]); + } + if (path.startsWith("projects/acme%2Fweb/issues/42/related_merge_requests")) { + return encode([ + { + iid: 50, + title: "fix(web): keep composer focus", + web_url: "https://gitlab.com/acme/web/-/merge_requests/50", + state: "merged", + references: { full: "acme/web!50" }, + }, + { + iid: 51, + title: "chore: mention", + web_url: "https://gitlab.com/acme/web/-/merge_requests/51", + state: "opened", + draft: true, + references: { full: "acme/web!51" }, + }, + ]); + } + if (path === "graphql") { + return encode({ + data: { + currentUser: { username: "julius" }, + project: { + mergeRequest: { + awardEmoji: { nodes: [{ name: "eyes", user: { username: "theo" } }] }, + notes: { + pageInfo: { hasNextPage: false }, + nodes: [ + { + id: "gid://gitlab/Note/7", + awardEmoji: { + nodes: [ + { name: "thumbsup", user: { username: "julius" } }, + { name: "thumbsup", user: { username: "theo" } }, + ], + }, + }, + ], + }, + }, + }, + }, + }); + } + throw new Error(`unexpected path ${path}`); +} + +function tracker(onExecute: (args: ReadonlyArray) => string) { + return GitLabIssueTracker.make.pipe( + Effect.provide( + Layer.mock(GitLabCli.GitLabCli)({ + execute: ({ args }) => Effect.succeed(TestSourceControlHost.processOutput(onExecute(args))), + }), + ), + ); +} + +describe("GitLabIssueTracker", () => { + it.effect("joins the detail's endpoints into the neutral issue shape", () => + Effect.gen(function* () { + const api = yield* tracker((args) => answer(args[1]!)); + const issue = yield* api.getIssue({ + cwd: "/w", + host: "gitlab.com", + scope: "acme/web", + key: "42", + }); + expect(issue).toMatchObject({ + key: "42", + displayKey: "#42", + state: { category: "closed", name: "closed" }, + author: { login: "octocat", name: "Octo Cat" }, + assignees: [{ login: "theo" }], + labels: [{ name: "bug", color: null }], + commentCount: 3, + viewer: "julius", + viewerPermissions: { setState: true, edit: true, labels: true, assignees: true }, + commentsCursor: null, + reactions: [{ content: "eyes", count: 1, actors: ["theo"], viewerHasReacted: false }], + }); + expect(issue.comments).toEqual([ + { + id: "7", + author: { login: "julius", name: null, avatarUrl: null }, + body: "Repro attached.", + createdAt: "2026-07-02T00:00:00Z", + url: null, + reactions: [{ content: "thumbs-up", count: 2, actors: ["theo"], viewerHasReacted: true }], + }, + ]); + // System notes become events; label notes are dropped in favour of the label events. + expect(issue.events.map((event) => [event.kind, event.detail])).toEqual([ + ["labeled", "bug"], + ["closed", null], + ]); + // A closing merge request is not repeated as a related one. + expect( + issue.linkedPullRequests.map((link) => [link.number, link.closesIssue, link.isDraft]), + ).toEqual([ + [50, true, false], + [51, false, true], + ]); + }), + ); + + it.effect("pages a listing by GitLab's page number and stops on a short page", () => + Effect.gen(function* () { + const paths: string[] = []; + const api = yield* tracker((args) => { + paths.push(args[1]!); + return encode([ISSUE]); + }); + const page = yield* api.listIssues({ + cwd: "/w", + host: "gitlab.com", + scope: "acme/web", + state: "open", + involvement: "assigned", + viewer: "julius", + limit: 1, + cursor: "3", + }); + expect(paths[0]).toBe( + "projects/acme%2Fweb/issues?state=opened&assignee_username=julius&order_by=updated_at&sort=desc&per_page=1&page=3", + ); + expect(page.items[0]?.key).toBe("42"); + expect(page.nextCursor).toBe("4"); + }), + ); + + it.effect("refuses a key that names no GitLab issue without a request", () => + Effect.gen(function* () { + let requests = 0; + const api = yield* tracker(() => { + requests += 1; + return "{}"; + }); + const error = yield* Effect.flip( + api.getIssue({ cwd: "/w", host: "gitlab.com", scope: "acme/web", key: "ENG-42" }), + ); + expect(error.reason).toBe("not-found"); + expect(requests).toBe(0); + }), + ); +}); diff --git a/packages/source-control-gitlab/src/server/GitLabIssueTracker.ts b/packages/source-control-gitlab/src/server/GitLabIssueTracker.ts new file mode 100644 index 000000000000..d83e739610d4 --- /dev/null +++ b/packages/source-control-gitlab/src/server/GitLabIssueTracker.ts @@ -0,0 +1,585 @@ +/** + * GitLab's issue tracker over `glab api`, which signs every request with the credentials the + * checkout's remote resolves to, the way the merge request reads do. + * + * GitLab pages by offset and reports no total, so every cursor here is a page number and a full + * page is the only sign that more exists. Reads that GitLab keeps on separate endpoints (notes, + * label events, linked merge requests, awards) are read one page each and joined into the detail. + * + * @module source-control-gitlab/server/GitLabIssueTracker + */ +import * as Effect from "effect/Effect"; +import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; +import { + IssueTrackerKind, + type IssueCapabilities, + type IssueInvolvement, + type IssueViewerPermissions, +} from "@t3tools/contracts"; +import { + IssueTrackerError, + type IssueTrackerApi, +} from "@t3tools/issue-tracker-core/server/IssueTracker"; + +import * as GitLabCli from "./GitLabCli.ts"; +import { + decodeCreatedIssueJson, + decodeIssueDetailJson, + decodeIssueListJson, + decodeIssueNotesJson, + decodeLabelEventsJson, + decodeLinkedMergeRequestsJson, + decodeProjectLabelsJson, + decodeProjectMembersJson, + decodeTemplateEntriesJson, + decodeTemplateJson, + ISSUE_AWARD_EMOJI_GRAPHQL_QUERY, + PAGE_SIZE, + type GitLabTemplateEntry, +} from "./gitLabIssueJson.ts"; +import { + decodeAwardEmojiJson, + decodeOwnAwardIdJson, + decodeViewerJson, + gitLabAwardName, +} from "./gitLabMergeRequestJson.ts"; + +const KIND = IssueTrackerKind.make("gitlab"); + +export const CAPABILITIES: IssueCapabilities = { + create: true, + edit: true, + comment: true, + labels: true, + assignees: true, + reactions: true, + templates: true, + linkedPullRequests: true, + events: true, + search: true, + states: [ + { category: "open", name: "open" }, + { category: "closed", name: "closed" }, + ], + // GitLab records nothing about why an issue was closed. + closeReasons: [], +}; + +/** + * Everything granted. GitLab's REST API reports the viewer's role on the project but never whether + * they opened this issue, and an issue's author may close, reopen and edit it whatever their role. + * Withholding those controls from the one person entitled to them is the worse mistake, so they + * are offered and GitLab explains any refusal itself. + */ +const VIEWER_PERMISSIONS: IssueViewerPermissions = { + setState: true, + comment: true, + edit: true, + labels: true, + assignees: true, +}; + +/** How many of a scope's templates are read in full; each one is a request of its own. */ +const TEMPLATE_LIMIT = 20; + +const encodeBody = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); + +/** The REST API addresses a project by its URL-encoded full path. */ +function projectPath(scope: string): string { + return encodeURIComponent(scope.trim()); +} + +/** An issue key is its iid; anything else names no GitLab issue. */ +function parseNumber(key: string): number | null { + return /^[1-9]\d*$/.test(key) ? Number(key) : null; +} + +/** A page cursor this tracker handed out, or null for anything it could not have issued. */ +function parsePage(cursor: string | undefined): number | null { + if (cursor === undefined) return 1; + return /^[1-9]\d{0,6}$/.test(cursor) ? Number(cursor) : null; +} + +function query(params: ReadonlyArray): string { + return params.map(([key, value]) => `${key}=${encodeURIComponent(value)}`).join("&"); +} + +function involvementParams(input: { + readonly involvement: IssueInvolvement; + readonly viewer: string; +}): ReadonlyArray { + switch (input.involvement) { + case "authored": + return [["author_username", input.viewer]]; + case "assigned": + return [["assignee_username", input.viewer]]; + // GitLab's `my_reaction_emoji` and `iteration` filters aside, nothing narrows to mentions, so + // the listing stays as wide as `all` rather than guessing. + case "mentioned": + case "all": + return []; + } +} + +function reasonFor(error: GitLabCli.GitLabCliError): IssueTrackerError["reason"] { + switch (error._tag) { + case "GitLabCliUnavailableError": + return "missing-tool"; + case "GitLabCliAuthenticationError": + return "unauthenticated"; + case "GitLabCliRateLimitError": + return "rate-limited"; + default: + return "failed"; + } +} + +export const make: Effect.Effect = Effect.gen( + function* () { + const gitlab = yield* GitLabCli.GitLabCli; + + const fail = (operation: string) => (error: GitLabCli.GitLabCliError) => + new IssueTrackerError({ + tracker: KIND, + operation, + reason: reasonFor(error), + detail: error.message, + cause: error, + }); + + const unreadable = (operation: string, cause: unknown) => + new IssueTrackerError({ + tracker: KIND, + operation, + reason: "failed", + detail: `GitLab returned an unreadable ${operation} response.`, + cause, + }); + + const notFound = (operation: string) => + new IssueTrackerError({ + tracker: KIND, + operation, + reason: "not-found", + detail: "GitLab could not find the requested issue.", + }); + + const refused = (operation: string, detail: string) => + new IssueTrackerError({ tracker: KIND, operation, reason: "failed", detail }); + + const api = (input: { + readonly cwd: string; + readonly operation: string; + readonly path: string; + readonly method?: string; + readonly body?: unknown; + }) => + gitlab + .execute({ + cwd: input.cwd, + args: [ + "api", + input.path, + ...(input.method === undefined ? [] : ["--method", input.method]), + // A JSON body from stdin: argv is visible in process listings, and `glab api --input` + // sends no Content-Type of its own, which GitLab answers with HTTP 415. + ...(input.body === undefined + ? [] + : ["--input", "-", "--header", "Content-Type: application/json"]), + ], + ...(input.body === undefined ? {} : { stdin: encodeBody(input.body) }), + }) + .pipe( + Effect.map((result) => result.stdout.trim()), + Effect.mapError(fail(input.operation)), + ); + + const read = (input: { + readonly cwd: string; + readonly operation: string; + readonly path: string; + readonly method?: string; + readonly body?: unknown; + readonly decode: (raw: string) => Result.Result; + }): Effect.Effect => + api(input).pipe( + Effect.flatMap((raw) => { + const decoded = input.decode(raw); + return Result.isSuccess(decoded) + ? Effect.succeed(decoded.success) + : Effect.fail(unreadable(input.operation, decoded.failure)); + }), + ); + + const write = (input: { + readonly cwd: string; + readonly operation: string; + readonly path: string; + readonly method: string; + readonly body?: unknown; + }) => api(input).pipe(Effect.asVoid); + + /** The signed-in account; an answer naming nobody is a failure, since every comparison needs a login. */ + const viewer = (input: { + readonly cwd: string; + readonly operation: string; + }): Effect.Effect => + read({ + cwd: input.cwd, + operation: input.operation, + path: "user", + decode: decodeViewerJson, + }).pipe( + Effect.filterOrFail( + (login) => login !== null, + () => refused(input.operation, "GitLab returned no username for the signed-in account."), + ), + ); + + /** The issue as REST addresses it, or a not-found for a key that names no iid. */ + const issuePath = ( + input: { readonly scope: string; readonly key: string }, + operation: string, + ): Effect.Effect<{ readonly number: number; readonly path: string }, IssueTrackerError> => { + const number = parseNumber(input.key); + return number === null + ? Effect.fail(notFound(operation)) + : Effect.succeed({ number, path: `projects/${projectPath(input.scope)}/issues/${number}` }); + }; + + /** GitLab names assignees by numeric id, which is what the candidates carry as `id`. */ + const assigneeIds = ( + operation: string, + assignees: ReadonlyArray, + ): Effect.Effect, IssueTrackerError> => { + const ids = assignees.map((id) => (/^[1-9]\d*$/.test(id) ? Number(id) : null)); + return ids.every((id) => id !== null) + ? Effect.succeed(ids) + : Effect.fail(refused(operation, "GitLab assignees are named by their user id.")); + }; + + const paged = (path: string, page: number) => + `${path}?${query([ + ["per_page", String(PAGE_SIZE)], + ["page", String(page)], + ])}`; + + const notesPage = (input: { + readonly cwd: string; + readonly path: string; + readonly page: number; + readonly operation: string; + }) => + read({ + cwd: input.cwd, + operation: input.operation, + path: `${input.path}/notes?${query([ + ["per_page", String(PAGE_SIZE)], + ["page", String(input.page)], + ["order_by", "created_at"], + ["sort", "asc"], + ])}`, + decode: decodeIssueNotesJson, + }).pipe( + Effect.map((notes) => ({ + ...notes, + nextCursor: notes.rawCount < PAGE_SIZE ? null : String(input.page + 1), + })), + ); + + const awards = (input: { + readonly cwd: string; + readonly scope: string; + readonly number: number; + readonly operation: string; + }) => + read({ + cwd: input.cwd, + operation: input.operation, + path: "graphql", + method: "POST", + body: { + query: ISSUE_AWARD_EMOJI_GRAPHQL_QUERY, + variables: { fullPath: input.scope.trim(), iid: String(input.number) }, + }, + decode: decodeAwardEmojiJson, + }); + + const linked = (input: { + readonly cwd: string; + readonly path: string; + readonly endpoint: "closed_by" | "related_merge_requests"; + }) => + read({ + cwd: input.cwd, + operation: "getIssue", + path: paged(`${input.path}/${input.endpoint}`, 1), + decode: (raw) => decodeLinkedMergeRequestsJson(raw, input.endpoint === "closed_by"), + }); + + const template = + (input: { readonly cwd: string; readonly scope: string }) => (entry: GitLabTemplateEntry) => + read({ + cwd: input.cwd, + operation: "listTemplates", + path: `projects/${projectPath(input.scope)}/templates/issues/${encodeURIComponent(entry.key)}`, + decode: (raw) => decodeTemplateJson(entry, raw), + }); + + return { + kind: KIND, + capabilities: CAPABILITIES, + + getViewer: (input) => viewer({ cwd: input.cwd, operation: "getViewer" }), + + listIssues: (input) => { + const page = parsePage(input.cursor); + if (page === null) { + return Effect.fail(refused("listIssues", "The cursor was not one GitLab handed out.")); + } + const perPage = Math.min(Math.max(input.limit, 1), PAGE_SIZE); + const text = input.query?.trim() ?? ""; + return read({ + cwd: input.cwd, + operation: "listIssues", + path: `projects/${projectPath(input.scope)}/issues?${query([ + ["state", input.state === "open" ? "opened" : input.state], + ...involvementParams(input), + // GitLab's own `search` matches title and description, URL-encoded like every other + // value here, so no text in it can become a parameter of its own. + ...(text.length === 0 ? [] : [["search", text] as const]), + ["order_by", "updated_at"], + ["sort", "desc"], + ["per_page", String(perPage)], + ["page", String(page)], + ])}`, + decode: decodeIssueListJson, + }).pipe( + Effect.map((list) => ({ + items: list.items, + nextCursor: list.rawCount < perPage ? null : String(page + 1), + })), + ); + }, + + getIssue: (input) => + issuePath(input, "getIssue").pipe( + Effect.flatMap(({ number, path }) => + Effect.all( + [ + read({ + cwd: input.cwd, + operation: "getIssue", + path, + decode: decodeIssueDetailJson, + }), + viewer({ cwd: input.cwd, operation: "getIssue" }), + notesPage({ cwd: input.cwd, path, page: 1, operation: "getIssue" }), + read({ + cwd: input.cwd, + operation: "getIssue", + path: paged(`${path}/resource_label_events`, 1), + decode: decodeLabelEventsJson, + }), + linked({ cwd: input.cwd, path, endpoint: "closed_by" }), + linked({ cwd: input.cwd, path, endpoint: "related_merge_requests" }), + awards({ cwd: input.cwd, scope: input.scope, number, operation: "getIssue" }), + ], + { concurrency: 4 }, + ), + ), + Effect.map(([issue, viewerLogin, notes, labelEvents, closing, related, awardPage]) => { + const closingKeys = new Set(closing.map((link) => `${link.repository}!${link.number}`)); + return { + ...issue, + viewer: viewerLogin, + viewerPermissions: VIEWER_PERMISSIONS, + reactions: awardPage.reactions, + comments: notes.comments.map((comment) => ({ + ...comment, + reactions: awardPage.reactionsByNoteId.get(comment.id) ?? [], + })), + commentsCursor: notes.nextCursor, + events: [...notes.events, ...labelEvents].sort((left, right) => + left.createdAt.localeCompare(right.createdAt), + ), + // A merge request that closes the issue outranks its sighting as a mere mention. + linkedPullRequests: [ + ...closing, + ...related.filter((link) => !closingKeys.has(`${link.repository}!${link.number}`)), + ], + }; + }), + ), + + getComments: (input) => { + const page = parsePage(input.cursor); + return page === null + ? Effect.fail(refused("getComments", "The cursor was not one GitLab handed out.")) + : issuePath(input, "getComments").pipe( + Effect.flatMap(({ path }) => + notesPage({ cwd: input.cwd, path, page, operation: "getComments" }), + ), + Effect.map((notes) => ({ comments: notes.comments, nextCursor: notes.nextCursor })), + ); + }, + + getViewerPermissions: () => Effect.succeed(VIEWER_PERMISSIONS), + + create: (input) => + assigneeIds("create", input.assignees).pipe( + Effect.flatMap((assignee_ids) => + read({ + cwd: input.cwd, + operation: "create", + path: `projects/${projectPath(input.scope)}/issues`, + method: "POST", + body: { + title: input.title, + description: input.body, + labels: input.labels.join(","), + assignee_ids, + }, + decode: decodeCreatedIssueJson, + }), + ), + Effect.map((issue) => ({ + key: String(issue.number), + displayKey: `#${issue.number}`, + url: issue.url, + })), + ), + + comment: (input) => + issuePath(input, "comment").pipe( + Effect.flatMap(({ path }) => + write({ + cwd: input.cwd, + operation: "comment", + path: `${path}/notes`, + method: "POST", + body: { body: input.body }, + }), + ), + ), + + update: (input) => + Effect.all([ + issuePath(input, "update"), + input.assignees === undefined ? Effect.void : assigneeIds("update", input.assignees), + ]).pipe( + Effect.flatMap(([{ path }, assignee_ids]) => + write({ + cwd: input.cwd, + operation: "update", + path, + method: "PUT", + body: { + ...(input.title === undefined ? {} : { title: input.title }), + ...(input.body === undefined ? {} : { description: input.body }), + ...(input.labels === undefined ? {} : { labels: input.labels.join(",") }), + ...(assignee_ids === undefined ? {} : { assignee_ids }), + }, + }), + ), + ), + + setState: (input) => + issuePath(input, "setState").pipe( + Effect.flatMap(({ path }) => + write({ + cwd: input.cwd, + operation: "setState", + path, + method: "PUT", + body: { state_event: input.state === "closed" ? "close" : "reopen" }, + }), + ), + ), + + setReaction: (input) => + issuePath(input, "setReaction").pipe( + Effect.flatMap(({ path }) => { + if (input.subjectId !== undefined && !/^[1-9]\d*$/.test(input.subjectId)) { + return Effect.fail(refused("setReaction", "The comment id names no GitLab note.")); + } + const subject = + input.subjectId === undefined + ? `${path}/award_emoji` + : `${path}/notes/${input.subjectId}/award_emoji`; + if (input.reacted) { + return write({ + cwd: input.cwd, + operation: "setReaction", + path: `${subject}?${query([["name", gitLabAwardName(input.content)]])}`, + method: "POST", + }); + } + // GitLab deletes an award by its id and takes no emoji name there, so the reader's own + // award of that name is looked up first. Nothing to delete is success. + return viewer({ cwd: input.cwd, operation: "setReaction" }).pipe( + Effect.flatMap((login) => + read({ + cwd: input.cwd, + operation: "setReaction", + path: paged(subject, 1), + decode: (raw) => + decodeOwnAwardIdJson(raw, { content: input.content, viewer: login }), + }), + ), + Effect.flatMap((own) => + own === null + ? Effect.void + : write({ + cwd: input.cwd, + operation: "setReaction", + path: `${subject}/${own}`, + method: "DELETE", + }), + ), + ); + }), + ), + + listTemplates: (input) => + read({ + cwd: input.cwd, + operation: "listTemplates", + path: `projects/${projectPath(input.scope)}/templates/issues`, + decode: decodeTemplateEntriesJson, + }).pipe( + Effect.flatMap((entries) => + Effect.forEach(entries.slice(0, TEMPLATE_LIMIT), template(input), { concurrency: 4 }), + ), + // GitLab has no switch that forbids an issue filed from nothing. + Effect.map((templates) => ({ templates, blankIssuesEnabled: true })), + ), + + listCandidates: (input) => + Effect.all( + [ + read({ + cwd: input.cwd, + operation: "listCandidates", + path: paged(`projects/${projectPath(input.scope)}/labels`, 1), + decode: decodeProjectLabelsJson, + }), + read({ + cwd: input.cwd, + operation: "listCandidates", + path: paged(`projects/${projectPath(input.scope)}/members/all`, 1), + decode: decodeProjectMembersJson, + }), + ], + { concurrency: 2 }, + ).pipe( + Effect.map(([labels, assignees]) => ({ + labels, + assignees, + truncated: labels.length >= PAGE_SIZE || assignees.length >= PAGE_SIZE, + })), + ), + }; + }, +); diff --git a/packages/source-control-gitlab/src/server/gitLabIssueJson.ts b/packages/source-control-gitlab/src/server/gitLabIssueJson.ts new file mode 100644 index 000000000000..6ff373a13617 --- /dev/null +++ b/packages/source-control-gitlab/src/server/gitLabIssueJson.ts @@ -0,0 +1,410 @@ +/** + * Decoders for GitLab issues as `glab api` answers them. REST enums are decoded as plain strings + * and normalized here, so a GitLab release that adds a state or a label action never fails a + * whole payload; a row that cannot be read is skipped rather than failing its page. + * + * @module source-control-gitlab/server/gitLabIssueJson + */ +import * as Exit from "effect/Exit"; +import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; +import type { + IssueActor, + IssueAssigneeCandidate, + IssueComment, + IssueEvent, + IssueEventKind, + IssueLabel, + IssueLabelCandidate, + IssueLinkedPullRequest, + IssueTemplate, + PullRequestState, +} from "@t3tools/contracts"; +import { decodeJsonResult } from "@t3tools/shared/schemaJson"; +import type { TrackerIssueSummary } from "@t3tools/issue-tracker-core/server/IssueTracker"; + +/** GitLab's ceiling on `per_page`, which is what every bounded read asks for. */ +export const PAGE_SIZE = 100; + +const RawUserSchema = Schema.Struct({ + /** GitLab writes an issue's assignees as numeric ids, so the id travels with the handle. */ + id: Schema.optional(Schema.Int), + username: Schema.String, + name: Schema.optional(Schema.NullOr(Schema.String)), + avatar_url: Schema.optional(Schema.NullOr(Schema.String)), +}); + +const RawIssueSchema = Schema.Struct({ + iid: Schema.Int, + title: Schema.String, + web_url: Schema.String, + description: Schema.optional(Schema.NullOr(Schema.String)), + author: Schema.optional(Schema.NullOr(RawUserSchema)), + state: Schema.optional(Schema.NullOr(Schema.String)), + created_at: Schema.String, + updated_at: Schema.String, + closed_at: Schema.optional(Schema.NullOr(Schema.String)), + assignees: Schema.optional(Schema.NullOr(Schema.Array(RawUserSchema))), + labels: Schema.optional(Schema.NullOr(Schema.Array(Schema.String))), + user_notes_count: Schema.optional(Schema.NullOr(Schema.Int)), +}); + +const RawNoteSchema = Schema.Struct({ + id: Schema.Int, + body: Schema.optional(Schema.NullOr(Schema.String)), + author: Schema.optional(Schema.NullOr(RawUserSchema)), + created_at: Schema.String, + /** True for the notes GitLab writes itself ("closed", "assigned to @x"), which are events. */ + system: Schema.optional(Schema.Boolean), +}); + +const RawLabelEventSchema = Schema.Struct({ + id: Schema.Int, + action: Schema.String, + created_at: Schema.String, + user: Schema.optional(Schema.NullOr(RawUserSchema)), + /** Null once the label itself has been deleted, which leaves the event with no subject. */ + label: Schema.optional(Schema.NullOr(Schema.Struct({ name: Schema.optional(Schema.String) }))), +}); + +const RawLinkedMergeRequestSchema = Schema.Struct({ + iid: Schema.Int, + title: Schema.String, + web_url: Schema.String, + state: Schema.optional(Schema.NullOr(Schema.String)), + draft: Schema.optional(Schema.Boolean), + work_in_progress: Schema.optional(Schema.Boolean), + /** `full` is `group/project!12`, which is the only place the merge request names its project. */ + references: Schema.optional( + Schema.NullOr(Schema.Struct({ full: Schema.optional(Schema.NullOr(Schema.String)) })), + ), +}); + +const RawProjectLabelSchema = Schema.Struct({ + name: Schema.String, + color: Schema.optional(Schema.NullOr(Schema.String)), + description: Schema.optional(Schema.NullOr(Schema.String)), +}); + +/** A row of `GET /projects/:id/templates/issues`, which names a template but carries none of it. */ +const RawTemplateEntrySchema = Schema.Struct({ + key: Schema.String, + name: Schema.optional(Schema.NullOr(Schema.String)), +}); + +const RawTemplateSchema = Schema.Struct({ content: Schema.optional(Schema.NullOr(Schema.String)) }); + +const decodeUnknownList = decodeJsonResult(Schema.Array(Schema.Unknown)); +const decodeIssue = decodeJsonResult(RawIssueSchema); +const decodeIssueEntry = Schema.decodeUnknownExit(RawIssueSchema); +const decodeNoteEntry = Schema.decodeUnknownExit(RawNoteSchema); +const decodeLabelEventEntry = Schema.decodeUnknownExit(RawLabelEventSchema); +const decodeLinkedMergeRequestEntry = Schema.decodeUnknownExit(RawLinkedMergeRequestSchema); +const decodeProjectLabelEntry = Schema.decodeUnknownExit(RawProjectLabelSchema); +const decodeUserEntry = Schema.decodeUnknownExit(RawUserSchema); +const decodeTemplateEntry = Schema.decodeUnknownExit(RawTemplateEntrySchema); +const decodeTemplate = decodeJsonResult(RawTemplateSchema); + +type DecodeFailure = + ReturnType extends Result.Result ? E : never; + +function trimmed(value: string | null | undefined): string | null { + const text = value?.trim() ?? ""; + return text.length > 0 ? text : null; +} + +function toActor(raw: typeof RawUserSchema.Type | null | undefined): IssueActor | null { + const login = trimmed(raw?.username); + return login === null + ? null + : { login, name: trimmed(raw?.name), avatarUrl: trimmed(raw?.avatar_url) }; +} + +/** GitLab returns label names only on an issue, so there is no colour to carry. */ +function toLabels(raw: ReadonlyArray | null | undefined): ReadonlyArray { + return (raw ?? []).flatMap((label) => { + const name = trimmed(label); + return name === null ? [] : [{ name, color: null }]; + }); +} + +function toSummary(raw: typeof RawIssueSchema.Type): TrackerIssueSummary { + return { + key: String(raw.iid), + displayKey: `#${raw.iid}`, + title: raw.title, + url: raw.web_url, + author: toActor(raw.author), + // GitLab has exactly the two states, calls the open one `opened`, and records no reason. + state: + raw.state?.trim().toLowerCase() === "closed" + ? { category: "closed", name: "closed" } + : { category: "open", name: "open" }, + createdAt: raw.created_at, + updatedAt: raw.updated_at, + closedAt: trimmed(raw.closed_at), + assignees: (raw.assignees ?? []).flatMap((user) => toActor(user) ?? []), + labels: toLabels(raw.labels), + commentCount: Math.max(0, raw.user_notes_count ?? 0), + }; +} + +interface GitLabIssueList { + readonly items: ReadonlyArray; + /** Rows GitLab returned, counted before decoding, so a skipped row cannot hide a next page. */ + readonly rawCount: number; +} + +export function decodeIssueListJson(raw: string): Result.Result { + return Result.map(decodeUnknownList(raw), (rows) => ({ + items: rows.flatMap((entry) => { + const issue = decodeIssueEntry(entry); + return Exit.isSuccess(issue) ? [toSummary(issue.value)] : []; + }), + rawCount: rows.length, + })); +} + +interface GitLabIssueDetail extends TrackerIssueSummary { + readonly body: string; +} + +export function decodeIssueDetailJson( + raw: string, +): Result.Result { + return Result.map(decodeIssue(raw), (issue) => ({ + ...toSummary(issue), + body: issue.description ?? "", + })); +} + +export function decodeCreatedIssueJson( + raw: string, +): Result.Result<{ readonly number: number; readonly url: string }, DecodeFailure> { + return Result.map(decodeIssue(raw), (issue) => ({ number: issue.iid, url: issue.web_url })); +} + +/** + * The events GitLab records as notes of its own. A label note names its labels by id ("added ~7 + * label"), which is why labellings come from `/resource_label_events` instead and are dropped here. + */ +function toSystemEvent( + body: string, +): { readonly kind: IssueEventKind; readonly detail: string | null } | null { + const text = body.trim(); + if (text === "closed") return { kind: "closed", detail: null }; + if (text === "reopened") return { kind: "reopened", detail: null }; + if (text.startsWith("assigned to ")) { + return { + kind: "assigned", + detail: trimmed(text.slice("assigned to ".length).replace(/^@/, "")), + }; + } + if (text.startsWith("unassigned ")) { + return { + kind: "unassigned", + detail: trimmed(text.slice("unassigned ".length).replace(/^@/, "")), + }; + } + if (text.startsWith("mentioned in ")) { + return { kind: "referenced", detail: trimmed(text.slice("mentioned in ".length)) }; + } + if (text.startsWith("changed title")) { + // GitLab marks the new title with its own inline-diff braces. + return { kind: "renamed", detail: trimmed(/\{\+(.+)\+\}/.exec(text)?.[1]) }; + } + return null; +} + +interface GitLabIssueNotes { + readonly comments: ReadonlyArray; + readonly events: ReadonlyArray; + readonly rawCount: number; +} + +/** One page of notes, split into what people wrote and what GitLab wrote about the issue. */ +export function decodeIssueNotesJson(raw: string): Result.Result { + return Result.map(decodeUnknownList(raw), (rows) => { + const comments: IssueComment[] = []; + const events: IssueEvent[] = []; + for (const entry of rows) { + const note = decodeNoteEntry(entry); + if (Exit.isFailure(note)) continue; + const value = note.value; + const body = value.body ?? ""; + if (value.system === true) { + const event = toSystemEvent(body); + if (event === null) continue; + events.push({ + // Notes and label events are numbered separately, so each carries where it came from. + id: `note-${value.id}`, + kind: event.kind, + actor: toActor(value.author), + createdAt: value.created_at, + detail: event.detail, + }); + continue; + } + comments.push({ + id: String(value.id), + author: toActor(value.author), + body, + createdAt: value.created_at, + url: null, + }); + } + return { comments, events, rawCount: rows.length }; + }); +} + +export function decodeLabelEventsJson( + raw: string, +): Result.Result, DecodeFailure> { + return Result.map(decodeUnknownList(raw), (rows) => + rows.flatMap((entry) => { + const event = decodeLabelEventEntry(entry); + if (Exit.isFailure(event)) return []; + const action = event.value.action.trim().toLowerCase(); + if (action !== "add" && action !== "remove") return []; + return [ + { + id: `label-${event.value.id}`, + kind: action === "add" ? ("labeled" as const) : ("unlabeled" as const), + actor: toActor(event.value.user), + createdAt: event.value.created_at, + detail: trimmed(event.value.label?.name), + }, + ]; + }), + ); +} + +function toPullRequestState(value: string | null | undefined): PullRequestState { + switch (value?.trim().toLowerCase()) { + case "merged": + return "merged"; + case "closed": + return "closed"; + default: + // `locked` is an open merge request whose discussion is locked. + return "open"; + } +} + +export function decodeLinkedMergeRequestsJson( + raw: string, + closesIssue: boolean, +): Result.Result, DecodeFailure> { + return Result.map(decodeUnknownList(raw), (rows) => + rows.flatMap((entry) => { + const mergeRequest = decodeLinkedMergeRequestEntry(entry); + if (Exit.isFailure(mergeRequest)) return []; + const value = mergeRequest.value; + const repository = trimmed(value.references?.full?.split("!")[0]); + const title = trimmed(value.title); + const url = trimmed(value.web_url); + if (repository === null || title === null || url === null || value.iid <= 0) return []; + return [ + { + repository, + number: value.iid, + title, + url, + state: toPullRequestState(value.state), + isDraft: value.draft ?? value.work_in_progress ?? false, + closesIssue, + }, + ]; + }), + ); +} + +export function decodeProjectLabelsJson( + raw: string, +): Result.Result, DecodeFailure> { + return Result.map(decodeUnknownList(raw), (rows) => + rows.flatMap((entry) => { + const label = decodeProjectLabelEntry(entry); + if (Exit.isFailure(label)) return []; + const name = trimmed(label.value.name); + return name === null + ? [] + : [ + { + name, + color: trimmed(label.value.color), + description: label.value.description ?? null, + }, + ]; + }), + ); +} + +/** + * The people with access to the project as assignees. GitLab addresses an assignment by numeric + * id and by nothing else, so a member it named no id for is dropped rather than offered under a + * handle the write would discard. + */ +export function decodeProjectMembersJson( + raw: string, +): Result.Result, DecodeFailure> { + return Result.map(decodeUnknownList(raw), (rows) => + rows.flatMap((entry) => { + const user = decodeUserEntry(entry); + if (Exit.isFailure(user) || user.value.id === undefined) return []; + const actor = toActor(user.value); + return actor === null ? [] : [{ ...actor, id: String(user.value.id) }]; + }), + ); +} + +export interface GitLabTemplateEntry { + readonly key: string; + readonly name: string; +} + +export function decodeTemplateEntriesJson( + raw: string, +): Result.Result, DecodeFailure> { + return Result.map(decodeUnknownList(raw), (rows) => + rows.flatMap((entry) => { + const template = decodeTemplateEntry(entry); + if (Exit.isFailure(template)) return []; + const key = trimmed(template.value.key); + return key === null ? [] : [{ key, name: trimmed(template.value.name) ?? key }]; + }), + ); +} + +/** One template's own answer is the markdown a description starts out as; GitLab adds nothing else. */ +export function decodeTemplateJson( + entry: GitLabTemplateEntry, + raw: string, +): Result.Result { + return Result.map(decodeTemplate(raw), (template) => ({ + key: entry.key, + name: entry.name, + about: "", + title: "", + body: template.content ?? "", + labels: [], + assignees: [], + })); +} + +/** + * Awards on the issue and on every note of it, in one read, shaped exactly like the merge request + * query so its decoder answers for both: `mergeRequest` is an alias for the issue field. + */ +export const ISSUE_AWARD_EMOJI_GRAPHQL_QUERY = `query($fullPath: ID!, $iid: String!, $cursor: String) { + currentUser { username } + project(fullPath: $fullPath) { + mergeRequest: issue(iid: $iid) { + awardEmoji { nodes { name user { username } } } + notes(first: ${PAGE_SIZE}, after: $cursor) { + pageInfo { hasNextPage endCursor } + nodes { id awardEmoji { nodes { name user { username } } } } + } + } + } +}`; diff --git a/packages/source-control-gitlab/src/server/issueTrackerDriver.ts b/packages/source-control-gitlab/src/server/issueTrackerDriver.ts new file mode 100644 index 000000000000..1470bf5bd23f --- /dev/null +++ b/packages/source-control-gitlab/src/server/issueTrackerDriver.ts @@ -0,0 +1,15 @@ +/** + * The GitLab issue tracker driver, registered beside the source control driver so the server's + * issue registry can list it without knowing the package. + * + * @module source-control-gitlab/server/issueTrackerDriver + */ +import { IssueTrackerKind } from "@t3tools/contracts"; +import { defineIssueTrackerDriver } from "@t3tools/issue-tracker-core/server/driver"; + +import * as GitLabIssueTracker from "./GitLabIssueTracker.ts"; + +export const driver = defineIssueTrackerDriver({ + kind: IssueTrackerKind.make("gitlab"), + make: GitLabIssueTracker.make, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 57482137904a..32b43f37eeef 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1582,6 +1582,9 @@ importers: '@t3tools/contracts': specifier: workspace:* version: link:../contracts + '@t3tools/issue-tracker-core': + specifier: workspace:* + version: link:../issue-tracker-core '@t3tools/shared': specifier: workspace:* version: link:../shared @@ -1666,6 +1669,9 @@ importers: '@t3tools/contracts': specifier: workspace:* version: link:../contracts + '@t3tools/issue-tracker-core': + specifier: workspace:* + version: link:../issue-tracker-core '@t3tools/provider-core': specifier: workspace:* version: link:../provider-core @@ -1765,6 +1771,9 @@ importers: '@t3tools/contracts': specifier: workspace:* version: link:../contracts + '@t3tools/issue-tracker-core': + specifier: workspace:* + version: link:../issue-tracker-core '@t3tools/shared': specifier: workspace:* version: link:../shared From 48753f60d51c74c5120d6dda407beec8158e2d96 Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Tue, 13 Oct 2026 22:37:54 -0700 Subject: [PATCH 2/5] feat(issues): read and edit Azure DevOps work items Work items belong to a project rather than a repository, so the git-host binding scopes Azure projects to `organization/project` out of the remote path, and every `az` call names the organization instead of detecting it. Listing is a WIQL query paged by a changed-date/id keyset cursor; states are the work item's own names mapped onto open and closed categories, with the stock process states offered as targets. Co-Authored-By: Claude Fable 5.1 --- apps/server/src/issue/IssueService.test.ts | 20 + apps/server/src/issue/IssueService.ts | 17 +- .../source-control-azure-devops/package.json | 13 + .../server/AzureDevOpsIssueTracker.test.ts | 195 ++++ .../src/server/AzureDevOpsIssueTracker.ts | 892 ++++++++++++++++++ .../src/server/azureDevOpsIssueJson.ts | 366 +++++++ .../src/server/issueTrackerDriver.ts | 15 + 7 files changed, 1517 insertions(+), 1 deletion(-) create mode 100644 packages/source-control-azure-devops/src/server/AzureDevOpsIssueTracker.test.ts create mode 100644 packages/source-control-azure-devops/src/server/AzureDevOpsIssueTracker.ts create mode 100644 packages/source-control-azure-devops/src/server/azureDevOpsIssueJson.ts create mode 100644 packages/source-control-azure-devops/src/server/issueTrackerDriver.ts diff --git a/apps/server/src/issue/IssueService.test.ts b/apps/server/src/issue/IssueService.test.ts index e52f3d142b38..1b44f0d7c8a7 100644 --- a/apps/server/src/issue/IssueService.test.ts +++ b/apps/server/src/issue/IssueService.test.ts @@ -132,6 +132,26 @@ function makeService(input: { ); } +describe("resolveGitHostBinding", () => { + it("scopes Azure DevOps work items to the project rather than the repository", () => { + expect( + IssueService.resolveGitHostBinding( + project({ + id: "p", + provider: "azure-devops", + host: "dev.azure.com", + repository: "acme/Web App/_git/web", + }), + ), + ).toEqual({ tracker: "azure-devops", host: "dev.azure.com", scope: "acme/Web App" }); + expect( + IssueService.resolveGitHostBinding( + project({ id: "p", provider: "azure-devops", host: "dev.azure.com", repository: "web" }), + ), + ).toBeNull(); + }); +}); + describe("IssueService", () => { it.effect("reads another repository on the same host but refuses to write to it", () => Effect.gen(function* () { diff --git a/apps/server/src/issue/IssueService.ts b/apps/server/src/issue/IssueService.ts index a3c168497039..0d4a06840afe 100644 --- a/apps/server/src/issue/IssueService.ts +++ b/apps/server/src/issue/IssueService.ts @@ -70,13 +70,28 @@ interface SupportedProject extends ProjectTrackerBinding { readonly api: IssueTrackerApi; } +/** + * Azure work items belong to the project, not the repository, so the scope is + * `organization/project` out of the remote's `organization/project/_git/repository` path. The + * source control selector answers only the repository name there, which addresses no work item. + */ +function azureDevOpsProjectScope(displayName: string | undefined): string | null { + const segments = (displayName ?? "").split("/").filter((part) => part.length > 0); + const marker = segments.indexOf("_git"); + const [organization, project] = marker === 2 ? segments : []; + return organization && project ? `${organization}/${project}` : null; +} + /** * The git-host path of resolution: the tracker is the host's own, named like the remote's * provider kind. Null for a project with no remote or a kind no tracker claims. */ function resolveGitHostBinding(project: OrchestrationProjectShell): ProjectTrackerBinding | null { const identity = project.repositoryIdentity; - const scope = sourceControlRepositorySelector(identity); + const scope = + identity?.provider === "azure-devops" + ? azureDevOpsProjectScope(identity.displayName) + : sourceControlRepositorySelector(identity); const kind = identity?.provider; if (!identity || kind === undefined || kind === "unknown" || scope === null) return null; return { diff --git a/packages/source-control-azure-devops/package.json b/packages/source-control-azure-devops/package.json index 456c9b1d0148..f19a2a2f0459 100644 --- a/packages/source-control-azure-devops/package.json +++ b/packages/source-control-azure-devops/package.json @@ -15,6 +15,14 @@ "types": "./src/server/azureDevOpsDiff.ts", "import": "./src/server/azureDevOpsDiff.ts" }, + "./server/azureDevOpsIssueJson": { + "types": "./src/server/azureDevOpsIssueJson.ts", + "import": "./src/server/azureDevOpsIssueJson.ts" + }, + "./server/AzureDevOpsIssueTracker": { + "types": "./src/server/AzureDevOpsIssueTracker.ts", + "import": "./src/server/AzureDevOpsIssueTracker.ts" + }, "./server/AzureDevOpsPullRequestCli": { "types": "./src/server/AzureDevOpsPullRequestCli.ts", "import": "./src/server/AzureDevOpsPullRequestCli.ts" @@ -38,6 +46,10 @@ "./server/driver": { "types": "./src/server/driver.ts", "import": "./src/server/driver.ts" + }, + "./server/issueTrackerDriver": { + "types": "./src/server/issueTrackerDriver.ts", + "import": "./src/server/issueTrackerDriver.ts" } }, "scripts": { @@ -46,6 +58,7 @@ }, "dependencies": { "@t3tools/contracts": "workspace:*", + "@t3tools/issue-tracker-core": "workspace:*", "@t3tools/shared": "workspace:*", "@t3tools/source-control-core": "workspace:*", "diff": "8.0.3", diff --git a/packages/source-control-azure-devops/src/server/AzureDevOpsIssueTracker.test.ts b/packages/source-control-azure-devops/src/server/AzureDevOpsIssueTracker.test.ts new file mode 100644 index 000000000000..d17c2a27ce68 --- /dev/null +++ b/packages/source-control-azure-devops/src/server/AzureDevOpsIssueTracker.test.ts @@ -0,0 +1,195 @@ +import { describe, expect, it } from "@effect/vitest"; +import * as NodeFileSystem from "@effect/platform-node/NodeFileSystem"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as TestSourceControlHost from "@t3tools/source-control-testing/TestSourceControlHost"; + +import * as AzureDevOpsCli from "./AzureDevOpsCli.ts"; +import * as AzureDevOpsIssueTracker from "./AzureDevOpsIssueTracker.ts"; + +const encode = (value: unknown) => JSON.stringify(value); + +const WORK_ITEM = { + id: 7, + url: "https://dev.azure.com/acme/_apis/wit/workItems/7", + fields: { + "System.Title": "Composer loses focus", + "System.State": "Resolved", + "System.CreatedDate": "2026-07-01T00:00:00Z", + "System.ChangedDate": "2026-07-03T00:00:00Z", + "Microsoft.VSTS.Common.ClosedDate": "2026-07-03T00:00:00Z", + "System.CreatedBy": { uniqueName: "octo@acme.test", displayName: "Octo Cat" }, + "System.AssignedTo": "Theo ", + "System.Tags": "bug; ui; bug", + "System.TeamProject": "Web App", + "System.CommentCount": 2, + "System.Description": "

Typing drops focus.

", + }, + relations: [ + { rel: "ArtifactLink", url: "vstfs:///Git/PullRequestId/p-guid%2Fr-guid%2F50" }, + { rel: "System.LinkTypes.Related", url: "https://dev.azure.com/acme/_apis/wit/workItems/8" }, + ], +}; + +/** Every `az` command the detail read runs, told apart by its first few arguments. */ +function answer(args: ReadonlyArray): string { + const resource = args[args.indexOf("--resource") + 1]; + if (args[0] === "boards") return encode(WORK_ITEM); + if (args[0] === "repos") { + return encode({ + pullRequestId: 50, + title: "fix(web): keep composer focus", + status: "completed", + isDraft: false, + sourceRefName: "refs/heads/fix", + targetRefName: "refs/heads/main", + creationDate: "2026-07-02T00:00:00Z", + closedDate: "2026-07-03T00:00:00Z", + mergeStatus: "succeeded", + repository: { + id: "r-guid", + name: "web", + webUrl: "https://dev.azure.com/acme/Web%20App/_git/web", + project: { id: "p-guid", name: "Web App" }, + }, + createdBy: { uniqueName: "theo@acme.test", displayName: "Theo" }, + description: "", + reviewers: [], + _links: { web: { href: "https://dev.azure.com/acme/Web%20App/_git/web/pullrequest/50" } }, + }); + } + if (resource === "comments") { + return encode({ + continuationToken: "next-token", + comments: [ + { + id: 1, + text: "Repro attached.", + createdDate: "2026-07-02T00:00:00Z", + createdBy: { uniqueName: "julius@acme.test", displayName: "Julius" }, + reactions: [ + { type: "like", count: 2, isCurrentUserEngaged: true }, + { type: "dislike", count: 0 }, + ], + }, + { id: 2, text: "gone", createdDate: "2026-07-02T00:00:00Z", isDeleted: true }, + ], + }); + } + if (resource === "updates") { + return encode({ + value: [ + { + id: 1, + revisedDate: "2026-07-01T00:00:00Z", + fields: { "System.Title": { newValue: "x" } }, + }, + { + id: 2, + revisedDate: "2026-07-03T00:00:00Z", + revisedBy: { uniqueName: "theo@acme.test" }, + fields: { + "System.State": { oldValue: "Active", newValue: "Resolved" }, + "System.Tags": { oldValue: "ui", newValue: "bug; ui" }, + }, + }, + ], + }); + } + throw new Error(`unexpected az invocation ${args.join(" ")}`); +} + +function tracker(onExecute: (args: ReadonlyArray) => string) { + return AzureDevOpsIssueTracker.make.pipe( + Effect.provide( + Layer.mergeAll( + NodeFileSystem.layer, + Layer.mock(AzureDevOpsCli.AzureDevOpsCli)({ + execute: ({ args }) => + Effect.succeed(TestSourceControlHost.processOutput(onExecute(args))), + }), + ), + ), + ); +} + +describe("AzureDevOpsIssueTracker", () => { + it.effect("maps a work item, its conversation and revisions to the neutral issue shape", () => + Effect.gen(function* () { + const seen: string[][] = []; + const api = yield* tracker((args) => { + seen.push([...args]); + return answer(args); + }); + const issue = yield* api.getIssue({ + cwd: "/w", + host: "dev.azure.com", + scope: "acme/Web App", + key: "7", + }); + // Every command names the organization rather than detecting it from the checkout. + for (const args of seen) { + expect(args).toContain("https://dev.azure.com/acme"); + } + expect(issue).toMatchObject({ + key: "7", + displayKey: "#7", + title: "Composer loses focus", + url: "https://dev.azure.com/acme/Web%20App/_workitems/edit/7", + // The project's own state name survives; the category is what the list filters by. + state: { category: "closed", name: "Resolved" }, + author: { login: "octo@acme.test", name: "Octo Cat" }, + assignees: [{ login: "Theo " }], + labels: [ + { name: "bug", color: null }, + { name: "ui", color: null }, + ], + commentCount: 2, + body: "

Typing drops focus.

", + commentsCursor: "next-token", + viewerPermissions: { setState: true, edit: true, labels: true, assignees: true }, + }); + expect(issue.comments).toEqual([ + { + id: "1", + author: { login: "julius@acme.test", name: "Julius", avatarUrl: null }, + body: "Repro attached.", + createdAt: "2026-07-02T00:00:00Z", + url: null, + reactions: [{ content: "thumbs-up", count: 2, actors: [], viewerHasReacted: true }], + }, + ]); + // The first revision is the creation; a state move across the category line is a close. + expect(issue.events.map((event) => [event.kind, event.detail])).toEqual([ + ["closed", "Resolved"], + ["labeled", "bug"], + ]); + expect(issue.linkedPullRequests).toEqual([ + { + repository: "Web App/web", + number: 50, + title: "fix(web): keep composer focus", + url: "https://dev.azure.com/acme/Web%20App/_git/web/pullrequest/50", + state: "merged", + isDraft: false, + closesIssue: false, + }, + ]); + }), + ); + + it.effect("refuses a scope that is not organization/project without a request", () => + Effect.gen(function* () { + let requests = 0; + const api = yield* tracker(() => { + requests += 1; + return "{}"; + }); + const error = yield* Effect.flip( + api.getIssue({ cwd: "/w", host: "dev.azure.com", scope: "web", key: "7" }), + ); + expect(error.reason).toBe("failed"); + expect(requests).toBe(0); + }), + ); +}); diff --git a/packages/source-control-azure-devops/src/server/AzureDevOpsIssueTracker.ts b/packages/source-control-azure-devops/src/server/AzureDevOpsIssueTracker.ts new file mode 100644 index 000000000000..1122ef4aafd6 --- /dev/null +++ b/packages/source-control-azure-devops/src/server/AzureDevOpsIssueTracker.ts @@ -0,0 +1,892 @@ +/** + * Azure DevOps work items as an issue tracker, over the same `az` the pull request reads use. + * + * Work items belong to a project rather than a repository, so the scope is `organization/project` + * and every call names the organization explicitly instead of detecting it from the checkout. + * Listing is a WIQL query with `timePrecision`, paged by a `(changedDate, id)` keyset cursor + * because WIQL has no offset; the rest of the reads are `az boards` commands and `wit` REST + * routes reached through `az devops invoke`, which signs in the way the extension does. + * + * @module source-control-azure-devops/server/AzureDevOpsIssueTracker + */ +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; +import { + IssueTrackerKind, + type IssueCapabilities, + type IssueInvolvement, + type IssueLinkedPullRequest, + type IssueViewerPermissions, +} from "@t3tools/contracts"; +import { + IssueTrackerError, + type IssueTrackerApi, +} from "@t3tools/issue-tracker-core/server/IssueTracker"; + +import * as AzureDevOpsCli from "./AzureDevOpsCli.ts"; +import { + azureDevOpsReactionType, + decodeCommentsJson, + decodeProfileJson, + decodeUpdatesJson, + decodeWorkItemJson, + decodeWorkItemsJson, + pullRequestNumberOf, + type AzureDevOpsWorkItem, +} from "./azureDevOpsIssueJson.ts"; +import { decodePullRequestJson } from "./azureDevOpsPullRequestJson.ts"; + +const KIND = IssueTrackerKind.make("azure-devops"); + +/** + * The states the stock Basic, Agile and Scrum processes carry, which is what a project that has + * not customised its workflow can be moved between. A project with states of its own refuses a + * name it lacks at write time, with Azure's own message. + */ +export const CAPABILITIES: IssueCapabilities = { + create: true, + edit: true, + comment: true, + labels: true, + assignees: true, + // On comments only; a work item itself takes none, and `setReaction` says so. + reactions: true, + // Azure has no issue templates. Its work item types are picked here, not by the reader. + templates: false, + linkedPullRequests: true, + events: true, + search: true, + states: [ + { category: "open", name: "New" }, + { category: "open", name: "To Do" }, + { category: "open", name: "Active" }, + { category: "open", name: "Doing" }, + { category: "closed", name: "Resolved" }, + { category: "closed", name: "Done" }, + { category: "closed", name: "Closed" }, + { category: "closed", name: "Removed" }, + ], + closeReasons: [], +}; + +/** + * Everything granted. Azure states no permission anywhere `az boards` reaches: the answer lives + * in the security namespaces, behind identity descriptors that would be several calls per work + * item to resolve. Controls stay live and a viewer who may not use one is told so by Azure. + */ +const VIEWER_PERMISSIONS: IssueViewerPermissions = { + setState: true, + comment: true, + edit: true, + labels: true, + assignees: true, +}; + +const REST_API_VERSION = "7.1"; +const PAGE_SIZE = 100; +/** Linked pull requests are read one `az` process each, so a work item's links are bounded. */ +const LINKED_PULL_REQUEST_LIMIT = 10; +/** Which work item type a new issue is filed as, in order of preference across the stock processes. */ +const CREATE_TYPES = ["Issue", "Bug", "Task", "User Story", "Product Backlog Item"]; + +const LIST_FIELDS = [ + "System.Id", + "System.Title", + "System.State", + "System.CreatedDate", + "System.ChangedDate", + "System.CreatedBy", + "System.AssignedTo", + "System.CommentCount", + "System.Tags", + "System.TeamProject", + "Microsoft.VSTS.Common.ClosedDate", +]; + +const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); +const decodeWiql = Schema.decodeUnknownResult( + Schema.Struct({ + workItems: Schema.optional(Schema.NullOr(Schema.Array(Schema.Struct({ id: Schema.Int })))), + }), +); +const decodeTypes = Schema.decodeUnknownResult( + Schema.Struct({ + value: Schema.Array( + Schema.Struct({ name: Schema.String, isDisabled: Schema.optional(Schema.Boolean) }), + ), + }), +); +const decodeTags = Schema.decodeUnknownResult( + Schema.Struct({ value: Schema.Array(Schema.Struct({ name: Schema.String })) }), +); +const decodeTeams = Schema.decodeUnknownResult(Schema.Array(Schema.Struct({ id: Schema.String }))); +const decodeMembers = Schema.decodeUnknownResult( + Schema.Array( + Schema.Struct({ + identity: Schema.Struct({ + uniqueName: Schema.String, + displayName: Schema.optional(Schema.NullOr(Schema.String)), + imageUrl: Schema.optional(Schema.NullOr(Schema.String)), + isContainer: Schema.optional(Schema.Boolean), + }), + }), + ), +); +const decodeUnknown = Schema.decodeUnknownResult(Schema.fromJsonString(Schema.Unknown)); + +/** The parts of an `organization/project` scope, as the organization URL and the project name. */ +function parseScope( + host: string, + scope: string, +): { readonly organization: string; readonly project: string } | null { + const parts = scope.split("/").flatMap((part) => part.trim() || []); + if (parts.length !== 2) return null; + const [organization, project] = parts as [string, string]; + return { + organization: host.toLowerCase().endsWith(".visualstudio.com") + ? `https://${host}` + : `https://${host}/${encodeURIComponent(organization)}`, + project, + }; +} + +function parseNumber(key: string): number | null { + return /^[1-9]\d{0,9}$/.test(key) ? Number(key) : null; +} + +/** WIQL string literals are single-quoted and escape a quote by doubling it. */ +function quoted(value: string): string { + return `'${value.replaceAll("'", "''")}'`; +} + +function involvementClause(involvement: IssueInvolvement): string { + switch (involvement) { + case "assigned": + return " AND [System.AssignedTo] = @Me"; + case "authored": + return " AND [System.CreatedBy] = @Me"; + // Azure records no mention of a person on a work item, so a mention filter has nothing to narrow by. + case "mentioned": + case "all": + return ""; + } +} + +function stateClause(state: "all" | "open" | "closed"): string { + const closed = "('Closed', 'Completed', 'Done', 'Removed', 'Resolved')"; + switch (state) { + case "open": + return ` AND [System.State] NOT IN ${closed}`; + case "closed": + return ` AND [System.State] IN ${closed}`; + case "all": + return ""; + } +} + +/** `|` of the last row handed over, which is where the next page starts. */ +function parseCursor(cursor: string): { readonly changedAt: string; readonly id: number } | null { + const match = /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z)\|([1-9]\d{0,9})$/.exec(cursor); + return match === null ? null : { changedAt: match[1]!, id: Number(match[2]) }; +} + +function reasonFor(error: AzureDevOpsCli.AzureDevOpsCliError): IssueTrackerError["reason"] { + switch (error._tag) { + case "AzureDevOpsCliUnavailableError": + return "missing-tool"; + case "AzureDevOpsCliAuthenticationError": + return "unauthenticated"; + case "AzureDevOpsCliRateLimitError": + return "rate-limited"; + case "AzureDevOpsPullRequestNotFoundError": + return "not-found"; + default: + return "failed"; + } +} + +export const make: Effect.Effect< + IssueTrackerApi, + never, + AzureDevOpsCli.AzureDevOpsCli | FileSystem.FileSystem +> = Effect.gen(function* () { + const azure = yield* AzureDevOpsCli.AzureDevOpsCli; + const fs = yield* FileSystem.FileSystem; + + const fail = (operation: string) => (error: AzureDevOpsCli.AzureDevOpsCliError) => + new IssueTrackerError({ + tracker: KIND, + operation, + reason: reasonFor(error), + detail: error.message, + cause: error, + }); + + const refused = (operation: string, detail: string, cause?: unknown) => + new IssueTrackerError({ + tracker: KIND, + operation, + reason: "failed", + detail, + ...(cause === undefined ? {} : { cause }), + }); + + const unreadable = (operation: string, cause: unknown) => + refused(operation, `Azure DevOps returned an unreadable ${operation} response.`, cause); + + const notFound = (operation: string) => + new IssueTrackerError({ + tracker: KIND, + operation, + reason: "not-found", + detail: "Azure DevOps could not find the requested work item.", + }); + + const scoped = ( + operation: string, + input: { readonly host: string; readonly scope: string }, + ): Effect.Effect< + { readonly organization: string; readonly project: string }, + IssueTrackerError + > => { + const parsed = parseScope(input.host, input.scope); + return parsed === null + ? Effect.fail(refused(operation, "An Azure DevOps scope is named `organization/project`.")) + : Effect.succeed(parsed); + }; + + const run = (input: { + readonly cwd: string; + readonly operation: string; + readonly args: ReadonlyArray; + }) => + azure + .execute({ cwd: input.cwd, args: [...input.args, "--only-show-errors", "--output", "json"] }) + .pipe( + Effect.map((result) => result.stdout.trim()), + Effect.mapError(fail(input.operation)), + ); + + const read =
(input: { + readonly cwd: string; + readonly operation: string; + readonly args: ReadonlyArray; + readonly decode: (raw: string) => Result.Result; + }): Effect.Effect => + run(input).pipe( + Effect.flatMap((raw) => { + const decoded = input.decode(raw); + return Result.isSuccess(decoded) + ? Effect.succeed(decoded.success) + : Effect.fail(unreadable(input.operation, decoded.failure)); + }), + ); + + const decodeAs = + (schema: (value: unknown) => Result.Result) => + (raw: string): Result.Result => + Result.flatMap(decodeUnknown(raw), schema); + + /** A `wit` REST route, addressed by resource and route parameters rather than by URL. */ + const invokeArgs = (input: { + readonly organization: string; + readonly project: string; + readonly resource: string; + readonly route?: ReadonlyArray; + readonly query?: ReadonlyArray; + readonly method?: string; + readonly apiVersion?: string; + }) => [ + "devops", + "invoke", + "--detect", + "false", + "--organization", + input.organization, + "--area", + "wit", + "--resource", + input.resource, + "--api-version", + input.apiVersion ?? REST_API_VERSION, + "--route-parameters", + `project=${input.project}`, + ...(input.route ?? []), + ...(input.query === undefined || input.query.length === 0 + ? [] + : ["--query-parameters", ...input.query]), + ...(input.method === undefined ? [] : ["--http-method", input.method]), + ]; + + /** A request with a body, which `az devops invoke` only takes from a file. */ + const invokeWithBody = (input: { + readonly cwd: string; + readonly operation: string; + readonly args: ReadonlyArray; + readonly body: unknown; + }) => + Effect.scoped( + Effect.gen(function* () { + const file = yield* fs + .makeTempFileScoped({ prefix: "t3-azure-issue-" }) + .pipe(Effect.mapError((cause) => refused(input.operation, cause.message, cause))); + yield* fs + .writeFileString(file, encodeJson(input.body)) + .pipe(Effect.mapError((cause) => refused(input.operation, cause.message, cause))); + return yield* run({ + cwd: input.cwd, + operation: input.operation, + args: [...input.args, "--in-file", file, "--media-type", "application/json"], + }); + }), + ); + + const workItem = (input: { + readonly cwd: string; + readonly operation: string; + readonly organization: string; + readonly number: number; + }): Effect.Effect => + read({ + cwd: input.cwd, + operation: input.operation, + args: [ + "boards", + "work-item", + "show", + "--detect", + "false", + "--organization", + input.organization, + "--id", + String(input.number), + "--expand", + "relations", + ], + decode: decodeWorkItemJson, + }).pipe( + Effect.filterOrFail( + (item) => item !== null, + () => notFound(input.operation), + ), + ); + + /** The work item as `az` addresses it, or a not-found for a key that names no id. */ + const target = ( + operation: string, + input: { readonly host: string; readonly scope: string; readonly key: string }, + ) => + scoped(operation, input).pipe( + Effect.flatMap((scope) => { + const number = parseNumber(input.key); + return number === null + ? Effect.fail(notFound(operation)) + : Effect.succeed({ ...scope, number }); + }), + ); + + const comments = (input: { + readonly cwd: string; + readonly operation: string; + readonly organization: string; + readonly project: string; + readonly number: number; + readonly cursor?: string | undefined; + }) => + read({ + cwd: input.cwd, + operation: input.operation, + args: invokeArgs({ + ...input, + resource: "comments", + route: [`workItemId=${input.number}`], + apiVersion: "7.1-preview", + query: [ + `$top=${PAGE_SIZE}`, + "order=asc", + "$expand=reactions", + "api-version=7.1-preview.4", + ...(input.cursor === undefined ? [] : [`continuationToken=${input.cursor}`]), + ], + }), + decode: decodeCommentsJson, + }); + + const linkedPullRequests = (input: { + readonly cwd: string; + readonly organization: string; + readonly item: AzureDevOpsWorkItem; + }): Effect.Effect, IssueTrackerError> => { + const numbers = [ + ...new Set(input.item.relations.flatMap((relation) => pullRequestNumberOf(relation) ?? [])), + ].slice(0, LINKED_PULL_REQUEST_LIMIT); + return Effect.forEach( + numbers, + (number) => + read({ + cwd: input.cwd, + operation: "getIssue", + args: [ + "repos", + "pr", + "show", + "--detect", + "false", + "--organization", + input.organization, + "--id", + String(number), + ], + decode: decodePullRequestJson, + }).pipe( + Effect.map((pullRequest) => + pullRequest === null || pullRequest.location === null + ? [] + : [ + { + repository: `${pullRequest.location.project}/${pullRequest.location.repository}`, + number: pullRequest.number, + title: pullRequest.title, + url: pullRequest.url, + state: pullRequest.state, + isDraft: pullRequest.isDraft, + // Azure links a pull request to a work item but never says whether completing + // it resolves the item; that lives in a completion option nothing reports. + closesIssue: false, + }, + ], + ), + // A link to a pull request this account cannot see is left out, not a failed detail. + Effect.catchIf( + (error) => error.reason === "not-found", + () => Effect.succeed([]), + ), + ), + { concurrency: 2 }, + ).pipe(Effect.map((links) => links.flat())); + }; + + const update = (input: { + readonly cwd: string; + readonly operation: string; + readonly organization: string; + readonly number: number; + readonly args: ReadonlyArray; + }) => + run({ + cwd: input.cwd, + operation: input.operation, + args: [ + "boards", + "work-item", + "update", + "--detect", + "false", + "--organization", + input.organization, + "--id", + String(input.number), + ...input.args, + ], + }).pipe(Effect.asVoid); + + /** Azure keeps tags as one `; `-joined field and takes exactly one assignee. */ + const fieldArgs = (input: { + readonly labels?: ReadonlyArray | undefined; + readonly assignees?: ReadonlyArray | undefined; + }): ReadonlyArray => { + const fields = [ + ...(input.labels === undefined ? [] : [`System.Tags=${input.labels.join("; ")}`]), + ...(input.assignees === undefined ? [] : [`System.AssignedTo=${input.assignees[0] ?? ""}`]), + ]; + return fields.length === 0 ? [] : ["--fields", ...fields]; + }; + + return { + kind: KIND, + capabilities: CAPABILITIES, + + // The profile the token belongs to, which is what WIQL's `@Me` resolves to. Its email is the + // `uniqueName` every identity on a work item carries, so the two compare. + getViewer: (input) => + read({ + cwd: input.cwd, + operation: "getViewer", + args: [ + "devops", + "invoke", + "--detect", + "true", + "--area", + "profile", + "--resource", + "profiles", + "--api-version", + REST_API_VERSION, + "--route-parameters", + "id=me", + ], + decode: decodeProfileJson, + }).pipe( + Effect.filterOrFail( + (login) => login !== null, + () => refused("getViewer", "Azure DevOps could not name the signed-in account."), + ), + ), + + listIssues: (input) => { + const cursor = input.cursor === undefined ? undefined : parseCursor(input.cursor); + if (cursor === null) { + return Effect.fail( + refused("listIssues", "The cursor was not one Azure DevOps handed out."), + ); + } + const limit = Math.min(Math.max(input.limit, 1), PAGE_SIZE); + const text = input.query?.trim() ?? ""; + return scoped("listIssues", input).pipe( + Effect.flatMap((scope) => + invokeWithBody({ + cwd: input.cwd, + operation: "listIssues", + args: invokeArgs({ + ...scope, + resource: "wiql", + method: "POST", + // One more than the page, which is how a next page reveals itself. + query: ["timePrecision=true", `$top=${limit + 1}`], + }), + body: { + query: + `SELECT [System.Id] FROM WorkItems WHERE [System.TeamProject] = ${quoted(scope.project)}` + + stateClause(input.state) + + involvementClause(input.involvement) + + (text.length === 0 ? "" : ` AND [System.Title] CONTAINS ${quoted(text)}`) + + (cursor === undefined + ? "" + : ` AND ([System.ChangedDate] < ${quoted(cursor.changedAt)} OR ([System.ChangedDate] = ${quoted(cursor.changedAt)} AND [System.Id] < ${cursor.id}))`) + + " ORDER BY [System.ChangedDate] DESC, [System.Id] DESC", + }, + }).pipe( + Effect.flatMap((raw) => { + const decoded = decodeAs(decodeWiql)(raw); + return Result.isSuccess(decoded) + ? Effect.succeed(decoded.success.workItems ?? []) + : Effect.fail(unreadable("listIssues", decoded.failure)); + }), + Effect.flatMap((ids) => + ids.length === 0 + ? Effect.succeed({ items: [], nextCursor: null }) + : read({ + cwd: input.cwd, + operation: "listIssues", + args: invokeArgs({ + ...scope, + resource: "workitems", + query: [ + `ids=${ids + .slice(0, limit) + .map((row) => row.id) + .join(",")}`, + `fields=${LIST_FIELDS.join(",")}`, + ], + }), + decode: decodeWorkItemsJson, + }).pipe( + Effect.map((items) => { + const last = items.at(-1); + return { + items, + nextCursor: + ids.length > limit && last !== undefined + ? `${last.updatedAt}|${last.key}` + : null, + }; + }), + ), + ), + ), + ), + ); + }, + + getIssue: (input) => + target("getIssue", input).pipe( + Effect.flatMap((scope) => + workItem({ ...scope, cwd: input.cwd, operation: "getIssue" }).pipe( + Effect.flatMap((item) => + Effect.all( + [ + comments({ ...scope, cwd: input.cwd, operation: "getIssue" }), + read({ + cwd: input.cwd, + operation: "getIssue", + args: invokeArgs({ + ...scope, + resource: "updates", + route: [`id=${scope.number}`], + query: [`$top=${PAGE_SIZE}`], + }), + decode: decodeUpdatesJson, + }), + linkedPullRequests({ cwd: input.cwd, organization: scope.organization, item }), + ], + { concurrency: 3 }, + ).pipe( + Effect.map(([conversation, events, links]) => ({ + ...item, + // Nothing cheap names the viewer here; the service already read it per host. + viewer: null, + viewerPermissions: VIEWER_PERMISSIONS, + comments: conversation.comments, + commentsCursor: conversation.nextCursor, + events, + linkedPullRequests: links, + })), + ), + ), + ), + ), + ), + + getComments: (input) => + target("getComments", input).pipe( + Effect.flatMap((scope) => + comments({ ...scope, cwd: input.cwd, operation: "getComments", cursor: input.cursor }), + ), + ), + + getViewerPermissions: () => Effect.succeed(VIEWER_PERMISSIONS), + + create: (input) => + scoped("create", input).pipe( + Effect.flatMap((scope) => + read({ + cwd: input.cwd, + operation: "create", + args: invokeArgs({ ...scope, resource: "workitemtypes" }), + decode: decodeAs(decodeTypes), + }).pipe( + Effect.flatMap((types) => { + const names = types.value.flatMap((type) => (type.isDisabled ? [] : [type.name])); + const type = CREATE_TYPES.find((name) => names.includes(name)) ?? names[0]; + return type === undefined + ? Effect.fail(refused("create", "The project offers no work item type to file.")) + : Effect.succeed(type); + }), + Effect.flatMap((type) => + read({ + cwd: input.cwd, + operation: "create", + args: [ + "boards", + "work-item", + "create", + "--detect", + "false", + "--organization", + scope.organization, + "--project", + scope.project, + "--type", + type, + "--title", + input.title, + ...(input.body.length === 0 ? [] : ["--description", input.body]), + ...fieldArgs({ + ...(input.labels.length === 0 ? {} : { labels: input.labels }), + ...(input.assignees.length === 0 ? {} : { assignees: input.assignees }), + }), + ], + decode: decodeWorkItemJson, + }), + ), + Effect.filterOrFail( + (item) => item !== null, + () => unreadable("create", "incomplete-response"), + ), + Effect.map((item) => ({ key: item.key, displayKey: item.displayKey, url: item.url })), + ), + ), + ), + + comment: (input) => + target("comment", input).pipe( + Effect.flatMap((scope) => + invokeWithBody({ + cwd: input.cwd, + operation: "comment", + args: invokeArgs({ + ...scope, + resource: "comments", + route: [`workItemId=${scope.number}`], + method: "POST", + apiVersion: "7.1-preview", + query: ["format=markdown", "api-version=7.1-preview.4"], + }), + body: { text: input.body }, + }), + ), + Effect.asVoid, + ), + + update: (input) => + target("update", input).pipe( + Effect.flatMap((scope) => + update({ + ...scope, + cwd: input.cwd, + operation: "update", + args: [ + ...(input.title === undefined ? [] : ["--title", input.title]), + ...(input.body === undefined ? [] : ["--description", input.body]), + ...fieldArgs(input), + ], + }), + ), + ), + + setState: (input) => + target("setState", input).pipe( + Effect.flatMap((scope) => + update({ + ...scope, + cwd: input.cwd, + operation: "setState", + args: ["--state", input.state], + }), + ), + ), + + setReaction: (input) => + target("setReaction", input).pipe( + Effect.flatMap((scope) => { + const type = azureDevOpsReactionType(input.content); + if (input.subjectId === undefined) { + return Effect.fail( + refused("setReaction", "Azure DevOps reactions go on comments only."), + ); + } + if (!/^[1-9]\d{0,9}$/.test(input.subjectId)) { + return Effect.fail( + refused("setReaction", "The comment id names no Azure DevOps comment."), + ); + } + if (type === null) { + return Effect.fail(refused("setReaction", "Azure DevOps has no such reaction.")); + } + return run({ + cwd: input.cwd, + operation: "setReaction", + args: invokeArgs({ + ...scope, + resource: "commentsReactions", + route: [ + `workItemId=${scope.number}`, + `commentId=${input.subjectId}`, + `reactionType=${type}`, + ], + method: input.reacted ? "PUT" : "DELETE", + apiVersion: "7.1-preview", + query: ["api-version=7.1-preview.1"], + }), + }).pipe(Effect.asVoid); + }), + ), + + listCandidates: (input) => + scoped("listCandidates", input).pipe( + Effect.flatMap((scope) => + Effect.all( + [ + read({ + cwd: input.cwd, + operation: "listCandidates", + args: invokeArgs({ ...scope, resource: "tags" }), + decode: decodeAs(decodeTags), + }).pipe( + Effect.map((tags) => + tags.value.map((tag) => ({ name: tag.name, color: null, description: null })), + ), + ), + // Nothing in `az boards` lists who a work item may go to; the project's teams are + // the nearest thing, read a handful at a time. + read({ + cwd: input.cwd, + operation: "listCandidates", + args: [ + "devops", + "team", + "list", + "--detect", + "false", + "--organization", + scope.organization, + "--project", + scope.project, + "--top", + "10", + ], + decode: decodeAs(decodeTeams), + }).pipe( + Effect.flatMap((teams) => + Effect.forEach( + teams, + (team) => + read({ + cwd: input.cwd, + operation: "listCandidates", + args: [ + "devops", + "team", + "list-member", + "--detect", + "false", + "--organization", + scope.organization, + "--project", + scope.project, + "--team", + team.id, + "--top", + String(PAGE_SIZE), + ], + decode: decodeAs(decodeMembers), + }), + { concurrency: 3 }, + ), + ), + Effect.map((members) => { + const byLogin = new Map< + string, + { id: string; login: string; name: string | null; avatarUrl: string | null } + >(); + for (const { identity } of members.flat()) { + const login = identity.uniqueName.trim(); + if ( + identity.isContainer || + login.length === 0 || + byLogin.has(login.toLowerCase()) + ) { + continue; + } + byLogin.set(login.toLowerCase(), { + id: login, + login, + name: identity.displayName?.trim() || null, + avatarUrl: identity.imageUrl?.trim() || null, + }); + } + return [...byLogin.values()]; + }), + ), + ], + { concurrency: 2 }, + ), + ), + // Teams are a sample of who can be assigned, so somebody missing is not ineligible. + Effect.map(([labels, assignees]) => ({ labels, assignees, truncated: true })), + ), + }; +}); diff --git a/packages/source-control-azure-devops/src/server/azureDevOpsIssueJson.ts b/packages/source-control-azure-devops/src/server/azureDevOpsIssueJson.ts new file mode 100644 index 000000000000..9ca14afbcebe --- /dev/null +++ b/packages/source-control-azure-devops/src/server/azureDevOpsIssueJson.ts @@ -0,0 +1,366 @@ +/** + * Decoders for Azure DevOps work items as `az boards` and the `wit` REST area answer them. Azure + * keeps everything under `fields`, keyed by reference name, and a project may add fields of its + * own, so only the ones every work item type carries are read. + * + * @module source-control-azure-devops/server/azureDevOpsIssueJson + */ +import * as Exit from "effect/Exit"; +import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; +import type { + IssueActor, + IssueComment, + IssueEvent, + IssueLabel, + IssueReaction, + IssueReactionContent, + IssueState, +} from "@t3tools/contracts"; +import { decodeJsonResult } from "@t3tools/shared/schemaJson"; +import type { TrackerIssueSummary } from "@t3tools/issue-tracker-core/server/IssueTracker"; + +/** + * The states Azure's stock processes treat as finished. A project may rename its own columns, so + * anything not named here reads as open: an item wrongly shown as open is one the reader can + * still close, while one wrongly shown as closed disappears from the list they were looking at. + */ +const CLOSED_STATES: ReadonlySet = new Set([ + "closed", + "done", + "removed", + "resolved", + "completed", +]); + +const Identity = Schema.Struct({ + displayName: Schema.optional(Schema.NullOr(Schema.String)), + uniqueName: Schema.optional(Schema.NullOr(Schema.String)), + imageUrl: Schema.optional(Schema.NullOr(Schema.String)), +}); + +/** WIQL answers an identity as an object; a project rule can leave a plain "Name " string. */ +const IdentityValue = Schema.NullOr(Schema.Union([Identity, Schema.String])); + +const Fields = Schema.Struct({ + "System.CommentCount": Schema.optional(Schema.NullOr(Schema.Int)), + "System.Title": Schema.optional(Schema.NullOr(Schema.String)), + "System.State": Schema.optional(Schema.NullOr(Schema.String)), + "System.CreatedDate": Schema.optional(Schema.NullOr(Schema.String)), + "System.ChangedDate": Schema.optional(Schema.NullOr(Schema.String)), + "Microsoft.VSTS.Common.ClosedDate": Schema.optional(Schema.NullOr(Schema.String)), + "System.Description": Schema.optional(Schema.NullOr(Schema.String)), + "System.Tags": Schema.optional(Schema.NullOr(Schema.String)), + "System.TeamProject": Schema.optional(Schema.NullOr(Schema.String)), + "System.CreatedBy": Schema.optional(IdentityValue), + "System.AssignedTo": Schema.optional(IdentityValue), +}); + +const Relation = Schema.Struct({ rel: Schema.String, url: Schema.String }); + +const WorkItem = Schema.Struct({ + id: Schema.Int, + url: Schema.optional(Schema.NullOr(Schema.String)), + fields: Schema.optional(Schema.NullOr(Fields)), + relations: Schema.optional(Schema.NullOr(Schema.Array(Relation))), +}); + +const decodeWorkItemEntry = Schema.decodeUnknownExit(WorkItem); +const decodeUnknownList = decodeJsonResult(Schema.NullOr(Schema.Array(Schema.Unknown))); +const decodeWorkItem = decodeJsonResult(WorkItem); + +type DecodeFailure = + ReturnType extends Result.Result ? E : never; + +function trimmed(value: string | null | undefined): string | null { + const text = value?.trim() ?? ""; + return text.length > 0 ? text : null; +} + +/** + * The organization link `az` returns addresses the REST resource, which is not a page anybody can + * open. The one a reader wants is the board's own item, which Azure spells this way. + */ +function browserUrl( + apiUrl: string | null | undefined, + id: number, + project: string | null, +): string | null { + if (apiUrl === null || apiUrl === undefined || project === null) return null; + const match = /^(https?:\/\/[^/]+\/[^/]+)\/(?:[^/]+\/)?_apis\/wit\/workItems\//i.exec(apiUrl); + return match === null ? null : `${match[1]}/${encodeURIComponent(project)}/_workitems/edit/${id}`; +} + +function toActor(value: typeof IdentityValue.Type | undefined): IssueActor | null { + if (value === null || value === undefined) return null; + if (typeof value === "string") { + const login = trimmed(value); + return login === null ? null : { login, name: null, avatarUrl: null }; + } + const login = trimmed(value.uniqueName) ?? trimmed(value.displayName); + return login === null + ? null + : { login, name: trimmed(value.displayName), avatarUrl: trimmed(value.imageUrl) }; +} + +function toIssueState(name: string | null | undefined): IssueState { + const state = trimmed(name) ?? "New"; + return { category: CLOSED_STATES.has(state.toLowerCase()) ? "closed" : "open", name: state }; +} + +/** Azure keeps tags as one `; `-separated string, with no colour anywhere. */ +function toLabels(tags: string | null | undefined): ReadonlyArray { + return [...new Set((tags ?? "").split(";").flatMap((tag) => trimmed(tag) ?? []))].map((name) => ({ + name, + color: null, + })); +} + +export interface AzureDevOpsWorkItem extends TrackerIssueSummary { + readonly body: string; + readonly project: string | null; + readonly relations: ReadonlyArray; +} + +/** Null for a row this cannot place: no title, no dates, or no project to build a link from. */ +function toWorkItem(raw: typeof WorkItem.Type): AzureDevOpsWorkItem | null { + const fields = raw.fields ?? {}; + const title = trimmed(fields["System.Title"]); + const createdAt = trimmed(fields["System.CreatedDate"]); + const updatedAt = trimmed(fields["System.ChangedDate"]) ?? createdAt; + const project = trimmed(fields["System.TeamProject"]); + const url = browserUrl(raw.url, raw.id, project); + if (raw.id <= 0 || title === null || createdAt === null || updatedAt === null || url === null) { + return null; + } + const assignee = toActor(fields["System.AssignedTo"]); + return { + key: String(raw.id), + displayKey: `#${raw.id}`, + title, + url, + author: toActor(fields["System.CreatedBy"]), + state: toIssueState(fields["System.State"]), + createdAt, + updatedAt, + closedAt: trimmed(fields["Microsoft.VSTS.Common.ClosedDate"]), + assignees: assignee === null ? [] : [assignee], + labels: toLabels(fields["System.Tags"]), + commentCount: Math.max(0, fields["System.CommentCount"] ?? 0), + body: fields["System.Description"] ?? "", + project, + relations: raw.relations ?? [], + }; +} + +/** A flat WIQL answer; a row this cannot place is skipped rather than failing the page. */ +export function decodeWorkItemsJson( + raw: string, +): Result.Result, DecodeFailure> { + return Result.map(decodeUnknownList(raw.trim().length === 0 ? "[]" : raw), (rows) => + (rows ?? []).flatMap((entry) => { + const item = decodeWorkItemEntry(entry); + if (Exit.isFailure(item)) return []; + return toWorkItem(item.value) ?? []; + }), + ); +} + +/** Null where az answered with a work item this cannot place, which the caller reports as such. */ +export function decodeWorkItemJson( + raw: string, +): Result.Result { + return Result.map(decodeWorkItem(raw), toWorkItem); +} + +/** Azure names comment reactions after its own icons; the six it has map onto the contract's. */ +const CONTENT_BY_REACTION: Readonly> = { + like: "thumbs-up", + dislike: "thumbs-down", + heart: "heart", + hooray: "hooray", + smile: "laugh", + confused: "confused", +}; + +export function azureDevOpsReactionType(content: IssueReactionContent): string | null { + return Object.entries(CONTENT_BY_REACTION).find(([, mapped]) => mapped === content)?.[0] ?? null; +} + +const Comment = Schema.Struct({ + id: Schema.Int, + text: Schema.optional(Schema.NullOr(Schema.String)), + createdDate: Schema.String, + createdBy: Schema.optional(Schema.NullOr(Identity)), + isDeleted: Schema.optional(Schema.Boolean), + reactions: Schema.optional( + Schema.NullOr( + Schema.Array( + Schema.Struct({ + type: Schema.String, + count: Schema.Int, + isCurrentUserEngaged: Schema.optional(Schema.Boolean), + }), + ), + ), + ), +}); + +const CommentsPage = Schema.Struct({ + comments: Schema.optional(Schema.NullOr(Schema.Array(Schema.Unknown))), + continuationToken: Schema.optional(Schema.NullOr(Schema.String)), +}); + +const decodeCommentsPage = decodeJsonResult(CommentsPage); +const decodeCommentEntry = Schema.decodeUnknownExit(Comment); + +function toReactions(raw: (typeof Comment.Type)["reactions"]): ReadonlyArray { + return (raw ?? []).flatMap((reaction) => { + const content = CONTENT_BY_REACTION[reaction.type.toLowerCase()]; + return content === undefined || reaction.count <= 0 + ? [] + : [ + { + content, + count: reaction.count, + // Azure counts reactions and says whether the reader is among them, but names nobody. + actors: [], + viewerHasReacted: reaction.isCurrentUserEngaged ?? false, + }, + ]; + }); +} + +export function decodeCommentsJson( + raw: string, +): Result.Result< + { readonly comments: ReadonlyArray; readonly nextCursor: string | null }, + DecodeFailure +> { + return Result.map(decodeCommentsPage(raw), (page) => ({ + comments: (page.comments ?? []).flatMap((entry) => { + const comment = decodeCommentEntry(entry); + if (Exit.isFailure(comment) || comment.value.isDeleted === true) return []; + return [ + { + id: String(comment.value.id), + author: toActor(comment.value.createdBy), + body: comment.value.text ?? "", + createdAt: comment.value.createdDate, + url: null, + reactions: toReactions(comment.value.reactions), + }, + ]; + }), + nextCursor: trimmed(page.continuationToken), + })); +} + +const FieldChange = Schema.Struct({ + oldValue: Schema.optional(Schema.Unknown), + newValue: Schema.optional(Schema.Unknown), +}); + +const Update = Schema.Struct({ + id: Schema.Int, + revisedDate: Schema.String, + revisedBy: Schema.optional(Schema.NullOr(Identity)), + fields: Schema.optional(Schema.NullOr(Schema.Record(Schema.String, FieldChange))), +}); + +const decodeUpdatesPage = decodeJsonResult( + Schema.Struct({ value: Schema.optional(Schema.NullOr(Schema.Array(Schema.Unknown))) }), +); +const decodeUpdateEntry = Schema.decodeUnknownExit(Update); +const decodeIdentityValue = Schema.decodeUnknownExit(IdentityValue); + +function identityLogin(value: unknown): string | null { + const decoded = decodeIdentityValue(value); + return Exit.isSuccess(decoded) ? (toActor(decoded.value)?.login ?? null) : null; +} + +/** + * The timeline, read out of each revision's field changes. Azure reads `9999-01-01` as the changed + * date of the newest revision, so the revision's own date is what every event carries. + */ +export function decodeUpdatesJson( + raw: string, +): Result.Result, DecodeFailure> { + return Result.map(decodeUpdatesPage(raw), (page) => + (page.value ?? []).flatMap((entry) => { + const update = decodeUpdateEntry(entry); + if (Exit.isFailure(update) || update.value.id === 1) return []; + const { id, revisedBy, revisedDate } = update.value; + const fields = update.value.fields ?? {}; + const events: IssueEvent[] = []; + const add = (kind: IssueEvent["kind"], detail: string | null) => + events.push({ + id: `update-${id}-${events.length}`, + kind, + actor: toActor(revisedBy), + createdAt: revisedDate, + detail, + }); + const state = fields["System.State"]; + if (typeof state?.oldValue === "string" && typeof state.newValue === "string") { + const was = toIssueState(state.oldValue); + const now = toIssueState(state.newValue); + add( + was.category === now.category + ? "state-changed" + : now.category === "closed" + ? "closed" + : "reopened", + now.name, + ); + } + const title = fields["System.Title"]; + if (typeof title?.oldValue === "string" && typeof title.newValue === "string") { + add("renamed", trimmed(title.newValue)); + } + const tags = fields["System.Tags"]; + if (tags !== undefined) { + const before = toLabels(typeof tags.oldValue === "string" ? tags.oldValue : null); + const after = toLabels(typeof tags.newValue === "string" ? tags.newValue : null); + for (const tag of before) + if (!after.some((label) => label.name === tag.name)) add("unlabeled", tag.name); + for (const tag of after) + if (!before.some((label) => label.name === tag.name)) add("labeled", tag.name); + } + const assigned = fields["System.AssignedTo"]; + if (assigned !== undefined) { + const before = identityLogin(assigned.oldValue); + const after = identityLogin(assigned.newValue); + if (before !== after) { + if (before !== null) add("unassigned", before); + if (after !== null) add("assigned", after); + } + } + return events; + }), + ); +} + +/** A relation that links a pull request, as Azure spells it: `vstfs:///Git/PullRequestId/{project}/{repo}/{id}`. */ +export function pullRequestNumberOf(relation: typeof Relation.Type): number | null { + if (relation.rel !== "ArtifactLink") return null; + const match = /^vstfs:\/\/\/Git\/PullRequestId\/(.+)$/.exec(relation.url); + if (match === null) return null; + const number = decodeURIComponent(match[1]!).split("/").at(-1) ?? ""; + return /^[1-9]\d{0,9}$/.test(number) ? Number(number) : null; +} + +const Profile = Schema.Struct({ + emailAddress: Schema.optional(Schema.NullOr(Schema.String)), + displayName: Schema.optional(Schema.NullOr(Schema.String)), +}); + +const decodeProfile = decodeJsonResult(Profile); + +/** `GET profile/profiles/me` names the signed-in account, which WIQL's `@Me` compares against. */ +export function decodeProfileJson(raw: string): Result.Result { + return Result.map( + decodeProfile(raw), + (profile) => trimmed(profile.emailAddress) ?? trimmed(profile.displayName), + ); +} diff --git a/packages/source-control-azure-devops/src/server/issueTrackerDriver.ts b/packages/source-control-azure-devops/src/server/issueTrackerDriver.ts new file mode 100644 index 000000000000..ae19557b4544 --- /dev/null +++ b/packages/source-control-azure-devops/src/server/issueTrackerDriver.ts @@ -0,0 +1,15 @@ +/** + * The Azure DevOps issue tracker driver, registered beside the source control driver so the + * server's issue registry can list it without knowing the package. + * + * @module source-control-azure-devops/server/issueTrackerDriver + */ +import { IssueTrackerKind } from "@t3tools/contracts"; +import { defineIssueTrackerDriver } from "@t3tools/issue-tracker-core/server/driver"; + +import * as AzureDevOpsIssueTracker from "./AzureDevOpsIssueTracker.ts"; + +export const driver = defineIssueTrackerDriver({ + kind: IssueTrackerKind.make("azure-devops"), + make: AzureDevOpsIssueTracker.make, +}); From cb3d0c56ecbc1b8130821a311f56215176c61202 Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Tue, 13 Oct 2026 22:37:56 -0700 Subject: [PATCH 3/5] feat(issues): read and edit Forgejo issues Forgejo issues go through `ForgejoCli.api`, which resolves the server, its port and the login from the checkout's remote the way the pull request reads do. Pages follow Forgejo's `Link` header as a page-number cursor; the timeline supplies events and the pull requests that cite the issue. Registers the GitLab, Azure DevOps and Forgejo drivers beside GitHub. Bitbucket registers none: its issue tracker is retired. Co-Authored-By: Claude Fable 5.1 --- apps/server/src/issue/builtInIssueTrackers.ts | 13 +- packages/source-control-forgejo/package.json | 13 + .../src/server/ForgejoIssueTracker.test.ts | 221 +++++++ .../src/server/ForgejoIssueTracker.ts | 602 ++++++++++++++++++ .../src/server/forgejoIssueJson.ts | 164 +++++ .../src/server/issueTrackerDriver.ts | 15 + 6 files changed, 1026 insertions(+), 2 deletions(-) create mode 100644 packages/source-control-forgejo/src/server/ForgejoIssueTracker.test.ts create mode 100644 packages/source-control-forgejo/src/server/ForgejoIssueTracker.ts create mode 100644 packages/source-control-forgejo/src/server/forgejoIssueJson.ts create mode 100644 packages/source-control-forgejo/src/server/issueTrackerDriver.ts diff --git a/apps/server/src/issue/builtInIssueTrackers.ts b/apps/server/src/issue/builtInIssueTrackers.ts index 6fccbcfd57e9..60208a60053f 100644 --- a/apps/server/src/issue/builtInIssueTrackers.ts +++ b/apps/server/src/issue/builtInIssueTrackers.ts @@ -1,17 +1,26 @@ /** * The issue tracker drivers this build ships with. Git hosts contribute theirs from their source * control package; a standalone tracker (Linear, Jira) adds its own package and a row here. A - * kind with no driver shows up as unsupported rather than missing. + * kind with no driver shows up as unsupported rather than missing: Bitbucket is one, since its + * issue tracker is retired on bitbucket.org. * * The services these drivers need are the same ones `sourceControl/builtInDrivers.ts` provides, * so the server reuses that layer rather than building a second `GitHubApi`. * * @module issue/builtInIssueTrackers */ +import * as AzureDevOpsIssueTrackerDriver from "@t3tools/source-control-azure-devops/server/issueTrackerDriver"; +import * as ForgejoIssueTrackerDriver from "@t3tools/source-control-forgejo/server/issueTrackerDriver"; import * as GitHubIssueTrackerDriver from "@t3tools/source-control-github/server/issueTrackerDriver"; +import * as GitLabIssueTrackerDriver from "@t3tools/source-control-gitlab/server/issueTrackerDriver"; import type { IssueTrackerDriver } from "@t3tools/issue-tracker-core/server/driver"; -const drivers = [GitHubIssueTrackerDriver.driver]; +const drivers = [ + GitHubIssueTrackerDriver.driver, + GitLabIssueTrackerDriver.driver, + AzureDevOpsIssueTrackerDriver.driver, + ForgejoIssueTrackerDriver.driver, +]; export type BuiltInIssueTrackersEnv = (typeof drivers)[number] extends IssueTrackerDriver ? R : never; diff --git a/packages/source-control-forgejo/package.json b/packages/source-control-forgejo/package.json index 5a2f1a747c90..2b286e839579 100644 --- a/packages/source-control-forgejo/package.json +++ b/packages/source-control-forgejo/package.json @@ -15,6 +15,14 @@ "types": "./src/server/ForgejoCli.ts", "import": "./src/server/ForgejoCli.ts" }, + "./server/forgejoIssueJson": { + "types": "./src/server/forgejoIssueJson.ts", + "import": "./src/server/forgejoIssueJson.ts" + }, + "./server/ForgejoIssueTracker": { + "types": "./src/server/ForgejoIssueTracker.ts", + "import": "./src/server/ForgejoIssueTracker.ts" + }, "./server/forgejoPullRequestJson": { "types": "./src/server/forgejoPullRequestJson.ts", "import": "./src/server/forgejoPullRequestJson.ts" @@ -30,6 +38,10 @@ "./server/ForgejoSourceControlProvider": { "types": "./src/server/ForgejoSourceControlProvider.ts", "import": "./src/server/ForgejoSourceControlProvider.ts" + }, + "./server/issueTrackerDriver": { + "types": "./src/server/issueTrackerDriver.ts", + "import": "./src/server/issueTrackerDriver.ts" } }, "scripts": { @@ -38,6 +50,7 @@ }, "dependencies": { "@t3tools/contracts": "workspace:*", + "@t3tools/issue-tracker-core": "workspace:*", "@t3tools/provider-core": "workspace:*", "@t3tools/shared": "workspace:*", "@t3tools/source-control-core": "workspace:*", diff --git a/packages/source-control-forgejo/src/server/ForgejoIssueTracker.test.ts b/packages/source-control-forgejo/src/server/ForgejoIssueTracker.test.ts new file mode 100644 index 000000000000..a81b0bcfebe6 --- /dev/null +++ b/packages/source-control-forgejo/src/server/ForgejoIssueTracker.test.ts @@ -0,0 +1,221 @@ +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as TestSourceControlHost from "@t3tools/source-control-testing/TestSourceControlHost"; + +import * as ForgejoCli from "./ForgejoCli.ts"; +import * as ForgejoIssueTracker from "./ForgejoIssueTracker.ts"; + +const encode = (value: unknown) => JSON.stringify(value); + +const ISSUE = { + number: 42, + title: "Composer loses focus", + body: "Typing drops focus.", + html_url: "https://forgejo.test:3000/acme/web/issues/42", + user: { login: "octocat", full_name: "Octo Cat", avatar_url: "" }, + state: "open", + created_at: "2026-07-01T00:00:00+02:00", + updated_at: "2026-07-03T00:00:00Z", + closed_at: null, + assignees: [{ login: "theo" }], + labels: [{ id: 3, name: "bug", color: "d73a4a" }], + comments: 1, + is_locked: true, +}; + +/** Every endpoint the detail read touches, answered by path; stderr carries the `Link` header. */ +function answer(path: string): { readonly stdout: string; readonly stderr?: string } { + const [route] = path.split("?"); + switch (route) { + case "user": + return { stdout: encode({ login: "julius" }) }; + case "repos/acme/web": + return { + stdout: encode({ full_name: "acme/web", permissions: { push: false, admin: false } }), + }; + case "repos/acme/web/issues/42": + return { stdout: encode(ISSUE) }; + case "repos/acme/web/issues/42/timeline": + return { + stdout: encode([ + { + id: 1, + type: "label", + body: "1", + user: { login: "theo" }, + created_at: "2026-07-01T01:00:00Z", + label: { id: 3, name: "bug" }, + }, + { + id: 2, + type: "comment", + body: "Repro attached.", + user: { login: "julius" }, + created_at: "2026-07-02T00:00:00Z", + }, + { + id: 3, + type: "pull_ref", + user: { login: "theo" }, + created_at: "2026-07-02T01:00:00Z", + ref_issue: { + number: 50, + html_url: "https://forgejo.test:3000/acme/web/pulls/50", + pull_request: { merged: false }, + }, + }, + { id: 4, type: "milestone", user: { login: "theo" }, created_at: "2026-07-02T02:00:00Z" }, + ]), + }; + case "repos/acme/web/issues/42/reactions": + return { stdout: encode([{ content: "eyes", user: { login: "theo" } }]) }; + case "repos/acme/web/issues/42/comments": + return { + stdout: encode([ + { + id: 7, + body: "Repro attached.", + user: { login: "julius" }, + created_at: "2026-07-02T00:00:00Z", + html_url: "https://forgejo.test:3000/acme/web/issues/42#issuecomment-7", + }, + ]), + stderr: + 'HTTP/1.1 200\nlink: ; rel="next"\n', + }; + case "repos/acme/web/issues/comments/7/reactions": + return { + stdout: encode([ + { content: "+1", user: { login: "julius" } }, + { content: "+1", user: { login: "theo" } }, + ]), + }; + case "repos/acme/web/pulls/50": + return { + stdout: encode({ + number: 50, + title: "fix(web): keep composer focus", + html_url: "https://forgejo.test:3000/acme/web/pulls/50", + state: "open", + merged: false, + draft: true, + }), + }; + default: + throw new Error(`unexpected path ${path}`); + } +} + +function tracker( + onApi: (input: ForgejoCli.ForgejoApiInput) => { + readonly stdout: string; + readonly stderr?: string; + }, +) { + return ForgejoIssueTracker.make.pipe( + Effect.provide( + Layer.mock(ForgejoCli.ForgejoCli)({ + api: (input) => { + const { stdout, stderr } = onApi(input); + return Effect.succeed( + TestSourceControlHost.processOutput(stdout, { stderr: stderr ?? "" }), + ); + }, + }), + ), + ); +} + +describe("ForgejoIssueTracker", () => { + it.effect("joins the issue, timeline and conversation into the neutral issue shape", () => + Effect.gen(function* () { + const hosts = new Set(); + const api = yield* tracker((input) => { + hosts.add(input.host); + return answer(input.path); + }); + const issue = yield* api.getIssue({ + cwd: "/w", + host: "forgejo.test:3000", + scope: "acme/web", + key: "42", + }); + // The custom host and port reach the CLI, which resolves the login for them. + expect([...hosts]).toEqual(["forgejo.test:3000"]); + expect(issue).toMatchObject({ + key: "42", + displayKey: "#42", + state: { category: "open", name: "open" }, + author: { login: "octocat", name: "Octo Cat", avatarUrl: null }, + createdAt: "2026-06-30T22:00:00.000Z", + labels: [{ name: "bug", color: "d73a4a" }], + viewer: "julius", + // No push and not the author: nothing but reading. Locked, so not even a comment. + viewerPermissions: { + setState: false, + comment: false, + edit: false, + labels: false, + assignees: false, + }, + reactions: [{ content: "eyes", count: 1, actors: ["theo"], viewerHasReacted: false }], + commentsCursor: "2", + }); + expect(issue.comments).toEqual([ + { + id: "7", + author: { login: "julius", name: null, avatarUrl: null }, + body: "Repro attached.", + createdAt: "2026-07-02T00:00:00.000Z", + url: "https://forgejo.test:3000/acme/web/issues/42#issuecomment-7", + reactions: [{ content: "thumbs-up", count: 2, actors: ["theo"], viewerHasReacted: true }], + }, + ]); + // Comments and milestones on the timeline are not events in the shared vocabulary. + expect(issue.events.map((event) => [event.kind, event.detail])).toEqual([ + ["labeled", "bug"], + ["referenced", "#50"], + ]); + expect(issue.linkedPullRequests).toEqual([ + { + repository: "acme/web", + number: 50, + title: "fix(web): keep composer focus", + url: "https://forgejo.test:3000/acme/web/pulls/50", + state: "open", + isDraft: true, + closesIssue: false, + }, + ]); + }), + ); + + it.effect("lists issues by page and leaves pull requests out of the rows", () => + Effect.gen(function* () { + const paths: string[] = []; + const api = yield* tracker((input) => { + paths.push(input.path); + return { + stdout: encode([ISSUE, { ...ISSUE, number: 43, pull_request: { merged: false } }]), + }; + }); + const page = yield* api.listIssues({ + cwd: "/w", + host: "forgejo.test:3000", + scope: "acme/web", + state: "open", + involvement: "authored", + viewer: "julius", + limit: 50, + cursor: "2", + }); + expect(paths).toEqual([ + "repos/acme/web/issues?type=issues&state=open&sort=recentupdate&created_by=julius&limit=50&page=2", + ]); + expect(page.items.map((item) => item.key)).toEqual(["42"]); + // No `Link` header at all means the server may still have more; a full page carries on. + expect(page.nextCursor).toBe("3"); + }), + ); +}); diff --git a/packages/source-control-forgejo/src/server/ForgejoIssueTracker.ts b/packages/source-control-forgejo/src/server/ForgejoIssueTracker.ts new file mode 100644 index 000000000000..0545ab28e899 --- /dev/null +++ b/packages/source-control-forgejo/src/server/ForgejoIssueTracker.ts @@ -0,0 +1,602 @@ +/** + * Forgejo's issue tracker, over the same `ForgejoCli.api` the pull request reads use: it resolves + * the server, its port and the login from the checkout's remote, so a self-hosted instance on a + * custom host is addressed the way its merge requests already are. + * + * Forgejo pages by number and announces a next page in a `Link` header, which the CLI surfaces + * on stderr; every cursor here is the next page number. + * + * @module source-control-forgejo/server/ForgejoIssueTracker + */ +import * as Effect from "effect/Effect"; +import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; +import { + IssueTrackerKind, + type IssueCapabilities, + type IssueLinkedPullRequest, + type IssueViewerPermissions, +} from "@t3tools/contracts"; +import { decodeJsonResult } from "@t3tools/shared/schemaJson"; +import { + IssueTrackerError, + type IssueTrackerApi, +} from "@t3tools/issue-tracker-core/server/IssueTracker"; + +import * as ForgejoCli from "./ForgejoCli.ts"; +import { + ForgejoIssue, + ForgejoIssueConfig, + ForgejoIssueRepository, + ForgejoIssueTemplate, + ForgejoTimelineEntry, + forgejoIssue, + forgejoIssueComment, + forgejoIssueEvent, + forgejoIssueTemplate, +} from "./forgejoIssueJson.ts"; +import { + FORGEJO_REACTIONS, + ForgejoComment, + ForgejoLabel, + ForgejoReaction, + ForgejoUser, + forgejoActor, + forgejoReactions, +} from "./forgejoPullRequestJson.ts"; + +const KIND = IssueTrackerKind.make("forgejo"); + +export const CAPABILITIES: IssueCapabilities = { + create: true, + edit: true, + comment: true, + labels: true, + assignees: true, + reactions: true, + templates: true, + linkedPullRequests: true, + events: true, + search: true, + states: [ + { category: "open", name: "open" }, + { category: "closed", name: "closed" }, + ], + closeReasons: [], +}; + +/** Forgejo's own ceiling on a page, which a self-hosted server may lower. */ +const PAGE_SIZE = 50; +/** Comment reactions are a request each, so only the first page's are read with the detail. */ +const COMMENT_REACTION_LIMIT = 50; + +const repoPath = (scope: string) => `repos/${scope.split("/").map(encodeURIComponent).join("/")}`; +const issuePath = (scope: string, number: number) => `${repoPath(scope)}/issues/${number}`; + +function parseNumber(key: string): number | null { + return /^[1-9]\d*$/.test(key) ? Number(key) : null; +} + +function parsePage(cursor: string | undefined): number | null { + if (cursor === undefined) return 1; + return /^[1-9]\d{0,6}$/.test(cursor) ? Number(cursor) : null; +} + +function reasonFor(error: ForgejoCli.ForgejoCliError): IssueTrackerError["reason"] { + switch (error.reason) { + case "missing-cli": + return "missing-tool"; + case "authentication": + return "unauthenticated"; + case "rate-limit": + return "rate-limited"; + case "not-found": + return "not-found"; + default: + return "failed"; + } +} + +/** + * What the signed-in account may do. Forgejo reports push access on the repository, and an + * issue's author may edit and close their own; an archived repository takes everything away, and + * a locked issue takes commenting away from anyone without push. + */ +function permissions( + repository: typeof ForgejoIssueRepository.Type, + issue: typeof ForgejoIssue.Type, + viewer: string, +): IssueViewerPermissions { + const active = !repository.archived; + const canWrite = repository.permissions?.push ?? false; + const canEdit = canWrite || issue.user?.login === viewer; + return { + setState: active && canEdit, + comment: active && (!issue.is_locked || canWrite), + edit: active && canEdit, + labels: active && canWrite, + assignees: active && canWrite, + }; +} + +export const make: Effect.Effect = Effect.gen( + function* () { + const cli = yield* ForgejoCli.ForgejoCli; + + const refused = (operation: string, detail: string, cause?: unknown) => + new IssueTrackerError({ + tracker: KIND, + operation, + reason: "failed", + detail, + ...(cause === undefined ? {} : { cause }), + }); + + const notFound = (operation: string) => + new IssueTrackerError({ + tracker: KIND, + operation, + reason: "not-found", + detail: "Forgejo could not find the requested issue.", + }); + + type Target = { readonly cwd: string; readonly host: string; readonly scope: string }; + + const request = ( + target: Target, + operation: string, + input: Omit, + ) => + cli.api({ cwd: target.cwd, repository: target.scope, host: target.host, ...input }).pipe( + Effect.mapError( + (error) => + new IssueTrackerError({ + tracker: KIND, + operation, + reason: reasonFor(error), + detail: error.detail, + cause: error, + }), + ), + ); + + const read = ( + target: Target, + operation: string, + input: Omit, + schema: Schema.Codec, + ): Effect.Effect => + request(target, operation, input).pipe( + Effect.flatMap((result) => { + if (result.stdoutTruncated) { + return Effect.fail(refused(operation, "Forgejo response exceeded the output limit.")); + } + const decoded = decodeJsonResult(schema)(result.stdout); + return Result.isSuccess(decoded) + ? Effect.succeed(decoded.success) + : Effect.fail( + refused(operation, "Forgejo returned an invalid response.", decoded.failure), + ); + }), + ); + + const readArray = ( + target: Target, + operation: string, + path: string, + schema: Schema.Codec, + ) => + read(target, operation, { path }, Schema.NullOr(Schema.Array(schema))).pipe( + Effect.map((rows) => rows ?? []), + ); + + /** One page of a listing, and whether Forgejo's `Link` header announced another. */ + const readPage = ( + target: Target, + operation: string, + path: string, + page: number, + schema: Schema.Codec, + ): Effect.Effect< + { readonly rows: ReadonlyArray; readonly nextCursor: string | null }, + IssueTrackerError + > => + request(target, operation, { + path: `${path}${path.includes("?") ? "&" : "?"}limit=${PAGE_SIZE}&page=${page}`, + }).pipe( + Effect.flatMap((result) => { + const decoded = decodeJsonResult(Schema.NullOr(Schema.Array(schema)))(result.stdout); + if (Result.isFailure(decoded)) { + return Effect.fail( + refused(operation, "Forgejo returned an invalid response.", decoded.failure), + ); + } + const rows = decoded.success ?? []; + const links = /^link:\s*(.*)$/im.exec(result.stderr)?.[1]; + const more = rows.length > 0 && (links === undefined || /rel="?next"?/i.test(links)); + return Effect.succeed({ rows, nextCursor: more ? String(page + 1) : null }); + }), + ); + + const write = ( + target: Target, + operation: string, + input: Omit, + ) => request(target, operation, input).pipe(Effect.asVoid); + + const getViewer = (target: Target, operation: string) => + read(target, operation, { path: "user" }, ForgejoUser).pipe(Effect.map((user) => user.login)); + + const getRepository = (target: Target, operation: string) => + read(target, operation, { path: repoPath(target.scope) }, ForgejoIssueRepository); + + /** The issue as the API addresses it, or a not-found for a key that names no number. */ + const issueTarget = ( + operation: string, + input: Target & { readonly key: string }, + ): Effect.Effect<{ readonly number: number; readonly path: string }, IssueTrackerError> => { + const number = parseNumber(input.key); + return number === null + ? Effect.fail(notFound(operation)) + : Effect.succeed({ number, path: issuePath(input.scope, number) }); + }; + + const getIssue = (target: Target, operation: string, path: string) => + read(target, operation, { path }, ForgejoIssue).pipe( + // The issues API answers for a pull request's number too; that is not an issue here. + Effect.filterOrFail( + (issue) => !issue.pull_request, + () => notFound(operation), + ), + ); + + const commentsPage = ( + target: Target, + operation: string, + path: string, + page: number, + viewer: string, + ) => + readPage(target, operation, `${path}/comments`, page, ForgejoComment).pipe( + Effect.flatMap(({ rows, nextCursor }) => + Effect.forEach( + rows.slice(0, COMMENT_REACTION_LIMIT), + (comment) => + readArray( + target, + operation, + `${repoPath(target.scope)}/issues/comments/${comment.id}/reactions`, + ForgejoReaction, + ).pipe( + Effect.map((reactions) => ({ + ...forgejoIssueComment(comment), + reactions: forgejoReactions(reactions, viewer), + })), + ), + { concurrency: 4 }, + ).pipe(Effect.map((comments) => ({ comments, nextCursor }))), + ), + ); + + /** Pull requests the timeline shows citing this issue, each read once for its state. */ + const linkedPullRequests = ( + target: Target, + timeline: ReadonlyArray, + ): Effect.Effect, IssueTrackerError> => { + const numbers = [ + ...new Set( + timeline.flatMap((entry) => + entry.type === "pull_ref" && entry.ref_issue?.pull_request + ? [entry.ref_issue.number] + : [], + ), + ), + ]; + return Effect.forEach( + numbers, + (number) => + read( + target, + "getIssue", + { path: `${repoPath(target.scope)}/pulls/${number}` }, + Schema.Struct({ + number: Schema.Int, + title: Schema.String, + html_url: Schema.String, + state: Schema.String, + merged: Schema.Boolean, + draft: Schema.optional(Schema.Boolean), + }), + ).pipe( + Effect.map((pull) => [ + { + repository: target.scope, + number: pull.number, + title: pull.title, + url: pull.html_url, + state: pull.merged + ? ("merged" as const) + : pull.state === "closed" + ? ("closed" as const) + : ("open" as const), + isDraft: pull.draft ?? false, + // Forgejo's timeline cites the pull request without saying whether it closes the issue. + closesIssue: false, + }, + ]), + Effect.catchIf( + (error) => error.reason === "not-found", + () => Effect.succeed([]), + ), + ), + { concurrency: 2 }, + ).pipe(Effect.map((links) => links.flat())); + }; + + return { + kind: KIND, + capabilities: CAPABILITIES, + + getViewer: (input) => getViewer({ ...input, scope: "" }, "getViewer"), + + listIssues: (input) => { + const page = parsePage(input.cursor); + if (page === null) { + return Effect.fail(refused("listIssues", "The cursor was not one Forgejo handed out.")); + } + const params = new URLSearchParams({ + type: "issues", + state: input.state, + sort: "recentupdate", + }); + const text = input.query?.trim() ?? ""; + if (text.length > 0) params.set("q", text); + const involvement = + input.involvement === "assigned" + ? "assigned_by" + : input.involvement === "authored" + ? "created_by" + : input.involvement === "mentioned" + ? "mentioned_by" + : null; + if (involvement !== null) params.set(involvement, input.viewer); + return readPage( + input, + "listIssues", + `${repoPath(input.scope)}/issues?${params}`, + page, + ForgejoIssue, + ).pipe( + Effect.map(({ rows, nextCursor }) => ({ + items: rows.flatMap((issue) => (issue.pull_request ? [] : [forgejoIssue(issue)])), + nextCursor, + })), + ); + }, + + getIssue: (input) => + issueTarget("getIssue", input).pipe( + Effect.flatMap(({ path }) => + Effect.all( + [ + getIssue(input, "getIssue", path), + getRepository(input, "getIssue"), + getViewer(input, "getIssue"), + readPage(input, "getIssue", `${path}/timeline`, 1, ForgejoTimelineEntry), + readArray(input, "getIssue", `${path}/reactions`, ForgejoReaction), + ], + { concurrency: 3 }, + ), + ), + Effect.flatMap(([issue, repository, viewer, timeline, reactions]) => + Effect.all([ + commentsPage(input, "getIssue", issuePath(input.scope, issue.number), 1, viewer), + linkedPullRequests(input, timeline.rows), + ]).pipe( + Effect.map(([conversation, links]) => ({ + ...forgejoIssue(issue), + body: issue.body ?? "", + viewer, + viewerPermissions: permissions(repository, issue, viewer), + reactions: forgejoReactions(reactions, viewer), + comments: conversation.comments, + commentsCursor: conversation.nextCursor, + events: timeline.rows.flatMap((entry) => forgejoIssueEvent(entry) ?? []), + linkedPullRequests: links, + })), + ), + ), + ), + + getComments: (input) => { + const page = parsePage(input.cursor); + return page === null + ? Effect.fail(refused("getComments", "The cursor was not one Forgejo handed out.")) + : Effect.all([issueTarget("getComments", input), getViewer(input, "getComments")]).pipe( + Effect.flatMap(([{ path }, viewer]) => + commentsPage(input, "getComments", path, page, viewer), + ), + ); + }, + + getViewerPermissions: (input) => + issueTarget("getViewerPermissions", input).pipe( + Effect.flatMap(({ path }) => + Effect.all( + [ + getIssue(input, "getViewerPermissions", path), + getRepository(input, "getViewerPermissions"), + getViewer(input, "getViewerPermissions"), + ], + { concurrency: 3 }, + ), + ), + Effect.map(([issue, repository, viewer]) => permissions(repository, issue, viewer)), + ), + + create: (input) => + // Labels are written by id, so the names are resolved against the repository's own first. + readArray( + input, + "create", + `${repoPath(input.scope)}/labels?limit=${PAGE_SIZE}`, + ForgejoLabel, + ).pipe( + Effect.flatMap((labels) => + read( + input, + "create", + { + path: `${repoPath(input.scope)}/issues`, + method: "POST", + body: { + title: input.title, + body: input.body, + labels: labels.flatMap((label) => + input.labels.includes(label.name) ? [label.id] : [], + ), + assignees: input.assignees, + }, + }, + ForgejoIssue, + ), + ), + Effect.map((issue) => ({ + key: String(issue.number), + displayKey: `#${issue.number}`, + url: issue.html_url, + })), + ), + + comment: (input) => + issueTarget("comment", input).pipe( + Effect.flatMap(({ path }) => + write(input, "comment", { + path: `${path}/comments`, + method: "POST", + body: { body: input.body }, + }), + ), + ), + + update: (input) => + issueTarget("update", input).pipe( + Effect.flatMap(({ path }) => + Effect.all([ + input.title === undefined && input.body === undefined && input.assignees === undefined + ? Effect.void + : write(input, "update", { + path, + method: "PATCH", + body: { + ...(input.title === undefined ? {} : { title: input.title }), + ...(input.body === undefined ? {} : { body: input.body }), + ...(input.assignees === undefined ? {} : { assignees: input.assignees }), + }, + }), + input.labels === undefined + ? Effect.void + : readArray( + input, + "update", + `${repoPath(input.scope)}/labels?limit=${PAGE_SIZE}`, + ForgejoLabel, + ).pipe( + Effect.flatMap((labels) => + write(input, "update", { + path: `${path}/labels`, + method: "PUT", + body: { + labels: labels.flatMap((label) => + input.labels!.includes(label.name) ? [label.id] : [], + ), + }, + }), + ), + ), + ]), + ), + Effect.asVoid, + ), + + setState: (input) => + issueTarget("setState", input).pipe( + Effect.flatMap(({ path }) => + write(input, "setState", { path, method: "PATCH", body: { state: input.state } }), + ), + ), + + setReaction: (input) => + issueTarget("setReaction", input).pipe( + Effect.flatMap(({ path }) => { + if (input.subjectId !== undefined && !/^[1-9]\d*$/.test(input.subjectId)) { + return Effect.fail( + refused("setReaction", "The comment id names no Forgejo comment."), + ); + } + return write(input, "setReaction", { + path: + input.subjectId === undefined + ? `${path}/reactions` + : `${repoPath(input.scope)}/issues/comments/${input.subjectId}/reactions`, + method: input.reacted ? "POST" : "DELETE", + body: { content: FORGEJO_REACTIONS[input.content] }, + }); + }), + ), + + listTemplates: (input) => + Effect.all( + [ + readArray( + input, + "listTemplates", + `${repoPath(input.scope)}/issue_templates`, + ForgejoIssueTemplate, + ), + // A repository with no config leaves the blank issue where Forgejo puts it. + read( + input, + "listTemplates", + { path: `${repoPath(input.scope)}/issue_config` }, + ForgejoIssueConfig, + ).pipe( + Effect.map((config) => config.blank_issues_enabled !== false), + Effect.catchIf( + (error) => error.reason === "not-found" || error.reason === "failed", + () => Effect.succeed(true), + ), + ), + ], + { concurrency: 2 }, + ).pipe( + Effect.map(([templates, blankIssuesEnabled]) => ({ + templates: templates.flatMap((template) => forgejoIssueTemplate(template) ?? []), + blankIssuesEnabled, + })), + ), + + listCandidates: (input) => + Effect.all( + [ + readPage(input, "listCandidates", `${repoPath(input.scope)}/labels`, 1, ForgejoLabel), + readPage(input, "listCandidates", `${repoPath(input.scope)}/assignees`, 1, ForgejoUser), + ], + { concurrency: 2 }, + ).pipe( + Effect.map(([labels, assignees]) => ({ + labels: labels.rows.map((label) => ({ + name: label.name, + color: label.color ?? null, + description: label.description ?? null, + })), + assignees: assignees.rows.flatMap((user) => { + const actor = forgejoActor(user); + return actor === null ? [] : [{ ...actor, id: actor.login }]; + }), + truncated: labels.nextCursor !== null || assignees.nextCursor !== null, + })), + ), + }; + }, +); diff --git a/packages/source-control-forgejo/src/server/forgejoIssueJson.ts b/packages/source-control-forgejo/src/server/forgejoIssueJson.ts new file mode 100644 index 000000000000..1a248f2b4c2c --- /dev/null +++ b/packages/source-control-forgejo/src/server/forgejoIssueJson.ts @@ -0,0 +1,164 @@ +/** + * Forgejo's issue shapes, beside the pull request ones they share users, labels and comments + * with. Gitea answers the same API, so a Gitea server reads through this too. + * + * @module source-control-forgejo/server/forgejoIssueJson + */ +import * as DateTime from "effect/DateTime"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import type { IssueComment, IssueEvent, IssueTemplate } from "@t3tools/contracts"; +import type { TrackerIssueSummary } from "@t3tools/issue-tracker-core/server/IssueTracker"; + +import { + ForgejoComment, + ForgejoLabel, + ForgejoRepository, + ForgejoUser, + forgejoActor, +} from "./forgejoPullRequestJson.ts"; + +export const ForgejoIssue = Schema.Struct({ + number: Schema.Int, + title: Schema.String, + body: Schema.optional(Schema.NullOr(Schema.String)), + html_url: Schema.String, + user: Schema.NullOr(ForgejoUser), + state: Schema.String, + created_at: Schema.String, + updated_at: Schema.String, + closed_at: Schema.optional(Schema.NullOr(Schema.String)), + assignees: Schema.optional(Schema.NullOr(Schema.Array(ForgejoUser))), + labels: Schema.optional(Schema.NullOr(Schema.Array(ForgejoLabel))), + comments: Schema.optional(Schema.Int), + is_locked: Schema.optional(Schema.Boolean), + /** Present on a row that is really a pull request, which the issue listing also returns. */ + pull_request: Schema.optional(Schema.NullOr(Schema.Struct({ merged: Schema.Boolean }))), +}); + +export const ForgejoIssueRepository = Schema.Struct({ + ...ForgejoRepository.fields, + has_issues: Schema.optional(Schema.Boolean), +}); + +/** One row of `/issues/{index}/timeline`, which is a comment with a type and a subject. */ +export const ForgejoTimelineEntry = Schema.Struct({ + id: Schema.Int, + type: Schema.String, + body: Schema.optional(Schema.NullOr(Schema.String)), + user: Schema.optional(Schema.NullOr(ForgejoUser)), + created_at: Schema.String, + html_url: Schema.optional(Schema.NullOr(Schema.String)), + assignee: Schema.optional(Schema.NullOr(ForgejoUser)), + removed_assignee: Schema.optional(Schema.Boolean), + new_title: Schema.optional(Schema.NullOr(Schema.String)), + label: Schema.optional(Schema.NullOr(ForgejoLabel)), + ref_issue: Schema.optional( + Schema.NullOr( + Schema.Struct({ + number: Schema.Int, + html_url: Schema.String, + pull_request: Schema.optional(Schema.NullOr(Schema.Struct({ merged: Schema.Boolean }))), + }), + ), + ), +}); + +export const ForgejoIssueTemplate = Schema.Struct({ + file_name: Schema.String, + name: Schema.optional(Schema.NullOr(Schema.String)), + about: Schema.optional(Schema.NullOr(Schema.String)), + title: Schema.optional(Schema.NullOr(Schema.String)), + content: Schema.optional(Schema.NullOr(Schema.String)), + labels: Schema.optional(Schema.NullOr(Schema.Array(Schema.String))), + assignees: Schema.optional(Schema.NullOr(Schema.Array(Schema.String))), +}); + +export const ForgejoIssueConfig = Schema.Struct({ + blank_issues_enabled: Schema.optional(Schema.Boolean), +}); + +function toIsoUtc(value: string): string { + return Option.match(DateTime.make(value), { onNone: () => value, onSome: DateTime.formatIso }); +} + +export function forgejoIssue(issue: typeof ForgejoIssue.Type): TrackerIssueSummary { + return { + key: String(issue.number), + displayKey: `#${issue.number}`, + title: issue.title, + url: issue.html_url, + author: forgejoActor(issue.user), + state: + issue.state === "closed" + ? { category: "closed", name: "closed" } + : { category: "open", name: "open" }, + createdAt: toIsoUtc(issue.created_at), + updatedAt: toIsoUtc(issue.updated_at), + closedAt: issue.closed_at ? toIsoUtc(issue.closed_at) : null, + assignees: (issue.assignees ?? []).flatMap((user) => forgejoActor(user) ?? []), + labels: (issue.labels ?? []).map((label) => ({ name: label.name, color: label.color ?? null })), + commentCount: Math.max(0, issue.comments ?? 0), + }; +} + +export function forgejoIssueComment(comment: typeof ForgejoComment.Type): IssueComment { + return { + id: String(comment.id), + author: forgejoActor(comment.user), + body: comment.body, + createdAt: toIsoUtc(comment.created_at), + url: comment.html_url || null, + }; +} + +/** Null for a timeline entry with no kind in the shared vocabulary, which is dropped. */ +export function forgejoIssueEvent(entry: typeof ForgejoTimelineEntry.Type): IssueEvent | null { + const event = (kind: IssueEvent["kind"], detail: string | null = null): IssueEvent => ({ + id: String(entry.id), + kind, + actor: forgejoActor(entry.user), + createdAt: toIsoUtc(entry.created_at), + detail, + }); + switch (entry.type) { + case "close": + return event("closed"); + case "reopen": + return event("reopened"); + case "assignees": + return event( + entry.removed_assignee ? "unassigned" : "assigned", + entry.assignee?.login || null, + ); + case "change_title": + return event("renamed", entry.new_title || null); + case "label": + // Forgejo writes `1` into the body of a labelling and nothing into a removal. + return event(entry.body === "1" ? "labeled" : "unlabeled", entry.label?.name || null); + case "issue_ref": + case "commit_ref": + case "comment_ref": + case "pull_ref": + return event("referenced", entry.ref_issue ? `#${entry.ref_issue.number}` : null); + default: + return null; + } +} + +/** Only markdown templates carry a body; a YAML form is a questionnaire this cannot flatten. */ +export function forgejoIssueTemplate( + template: typeof ForgejoIssueTemplate.Type, +): IssueTemplate | null { + const key = template.file_name.trim(); + if (key.length === 0 || !/\.md$/i.test(key)) return null; + return { + key, + name: template.name?.trim() || key, + about: template.about ?? "", + title: template.title ?? "", + body: template.content ?? "", + labels: template.labels ?? [], + assignees: template.assignees ?? [], + }; +} diff --git a/packages/source-control-forgejo/src/server/issueTrackerDriver.ts b/packages/source-control-forgejo/src/server/issueTrackerDriver.ts new file mode 100644 index 000000000000..59616fcff87c --- /dev/null +++ b/packages/source-control-forgejo/src/server/issueTrackerDriver.ts @@ -0,0 +1,15 @@ +/** + * The Forgejo issue tracker driver, registered beside the source control driver so the server's + * issue registry can list it without knowing the package. + * + * @module source-control-forgejo/server/issueTrackerDriver + */ +import { IssueTrackerKind } from "@t3tools/contracts"; +import { defineIssueTrackerDriver } from "@t3tools/issue-tracker-core/server/driver"; + +import * as ForgejoIssueTracker from "./ForgejoIssueTracker.ts"; + +export const driver = defineIssueTrackerDriver({ + kind: IssueTrackerKind.make("forgejo"), + make: ForgejoIssueTracker.make, +}); From d927a3b416467184a13d2eb387bb24b2e4f138fa Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Wed, 14 Oct 2026 01:16:27 -0700 Subject: [PATCH 4/5] chore(issues): drop unused exports and register new packages in CI lists Co-Authored-By: Claude Opus 5.5 --- apps/server/src/issue/IssueService.ts | 4 +++- .../src/server/AzureDevOpsIssueTracker.ts | 2 +- .../source-control-forgejo/src/server/ForgejoIssueTracker.ts | 2 +- .../source-control-gitlab/src/server/GitLabIssueTracker.ts | 2 +- 4 files changed, 6 insertions(+), 4 deletions(-) diff --git a/apps/server/src/issue/IssueService.ts b/apps/server/src/issue/IssueService.ts index 0d4a06840afe..b5c49353c533 100644 --- a/apps/server/src/issue/IssueService.ts +++ b/apps/server/src/issue/IssueService.ts @@ -86,7 +86,9 @@ function azureDevOpsProjectScope(displayName: string | undefined): string | null * The git-host path of resolution: the tracker is the host's own, named like the remote's * provider kind. Null for a project with no remote or a kind no tracker claims. */ -function resolveGitHostBinding(project: OrchestrationProjectShell): ProjectTrackerBinding | null { +export function resolveGitHostBinding( + project: OrchestrationProjectShell, +): ProjectTrackerBinding | null { const identity = project.repositoryIdentity; const scope = identity?.provider === "azure-devops" diff --git a/packages/source-control-azure-devops/src/server/AzureDevOpsIssueTracker.ts b/packages/source-control-azure-devops/src/server/AzureDevOpsIssueTracker.ts index 1122ef4aafd6..8911741d0fbb 100644 --- a/packages/source-control-azure-devops/src/server/AzureDevOpsIssueTracker.ts +++ b/packages/source-control-azure-devops/src/server/AzureDevOpsIssueTracker.ts @@ -45,7 +45,7 @@ const KIND = IssueTrackerKind.make("azure-devops"); * not customised its workflow can be moved between. A project with states of its own refuses a * name it lacks at write time, with Azure's own message. */ -export const CAPABILITIES: IssueCapabilities = { +const CAPABILITIES: IssueCapabilities = { create: true, edit: true, comment: true, diff --git a/packages/source-control-forgejo/src/server/ForgejoIssueTracker.ts b/packages/source-control-forgejo/src/server/ForgejoIssueTracker.ts index 0545ab28e899..4ff22550bca2 100644 --- a/packages/source-control-forgejo/src/server/ForgejoIssueTracker.ts +++ b/packages/source-control-forgejo/src/server/ForgejoIssueTracker.ts @@ -47,7 +47,7 @@ import { const KIND = IssueTrackerKind.make("forgejo"); -export const CAPABILITIES: IssueCapabilities = { +const CAPABILITIES: IssueCapabilities = { create: true, edit: true, comment: true, diff --git a/packages/source-control-gitlab/src/server/GitLabIssueTracker.ts b/packages/source-control-gitlab/src/server/GitLabIssueTracker.ts index d83e739610d4..d5a8819e67cc 100644 --- a/packages/source-control-gitlab/src/server/GitLabIssueTracker.ts +++ b/packages/source-control-gitlab/src/server/GitLabIssueTracker.ts @@ -47,7 +47,7 @@ import { const KIND = IssueTrackerKind.make("gitlab"); -export const CAPABILITIES: IssueCapabilities = { +const CAPABILITIES: IssueCapabilities = { create: true, edit: true, comment: true, From 6906f4a9c0d470f6a5a222bdd47ac0ca2a0d8a02 Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Thu, 22 Oct 2026 16:14:23 -0700 Subject: [PATCH 5/5] fix(issues): read Azure DevOps finished states from the project's process Custom processes name their own columns, so a fixed list of state names left a renamed finished state open forever and in the open filter. The tracker now reads each work item type's state categories and treats Resolved, Completed and Removed as closed, falling back to the stock names when none are reported. Bindings also key Azure projects rather than repositories, so two repositories of one project list its work items once. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/issue/IssueService.test.ts | 7 +- apps/server/src/issue/IssueService.ts | 7 +- .../server/AzureDevOpsIssueTracker.test.ts | 57 ++++++++++++++-- .../src/server/AzureDevOpsIssueTracker.ts | 68 +++++++++++++------ .../src/server/azureDevOpsIssueJson.ts | 51 ++++++++++---- 5 files changed, 150 insertions(+), 40 deletions(-) diff --git a/apps/server/src/issue/IssueService.test.ts b/apps/server/src/issue/IssueService.test.ts index 8732ababe72f..efb1b6d61cf8 100644 --- a/apps/server/src/issue/IssueService.test.ts +++ b/apps/server/src/issue/IssueService.test.ts @@ -158,7 +158,12 @@ describe("resolveGitHostBinding", () => { repository: "acme/Web App/_git/web", }), ), - ).toEqual({ tracker: "azure-devops", host: "dev.azure.com", scope: "acme/Web App" }); + ).toEqual({ + canonicalKey: "dev.azure.com/acme/web app", + tracker: "azure-devops", + host: "dev.azure.com", + scope: "acme/Web App", + }); expect( IssueService.resolveGitHostBinding( project({ id: "p", provider: "azure-devops", host: "dev.azure.com", repository: "web" }), diff --git a/apps/server/src/issue/IssueService.ts b/apps/server/src/issue/IssueService.ts index 4ee2444d1bcb..32d0560af9c6 100644 --- a/apps/server/src/issue/IssueService.ts +++ b/apps/server/src/issue/IssueService.ts @@ -90,10 +90,13 @@ export function resolveGitHostBinding( : sourceControlRepositorySelector(identity); const kind = identity?.provider; if (!identity || kind === undefined || kind === "unknown" || scope === null) return null; + const host = pullRequestHostOf(identity, SourceControlProviderKind.make(kind)); return { - canonicalKey: identity.canonicalKey, + // Every repository of an Azure DevOps project shares its work items. + canonicalKey: + kind === "azure-devops" ? `${host}/${scope}`.toLowerCase() : identity.canonicalKey, tracker: IssueTrackerKind.make(kind), - host: pullRequestHostOf(identity, SourceControlProviderKind.make(kind)), + host, scope, }; } diff --git a/packages/source-control-azure-devops/src/server/AzureDevOpsIssueTracker.test.ts b/packages/source-control-azure-devops/src/server/AzureDevOpsIssueTracker.test.ts index d17c2a27ce68..c35047aacb89 100644 --- a/packages/source-control-azure-devops/src/server/AzureDevOpsIssueTracker.test.ts +++ b/packages/source-control-azure-devops/src/server/AzureDevOpsIssueTracker.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "@effect/vitest"; import * as NodeFileSystem from "@effect/platform-node/NodeFileSystem"; import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as TestSourceControlHost from "@t3tools/source-control-testing/TestSourceControlHost"; @@ -14,7 +15,7 @@ const WORK_ITEM = { url: "https://dev.azure.com/acme/_apis/wit/workItems/7", fields: { "System.Title": "Composer loses focus", - "System.State": "Resolved", + "System.State": "Shipped", "System.CreatedDate": "2026-07-01T00:00:00Z", "System.ChangedDate": "2026-07-03T00:00:00Z", "Microsoft.VSTS.Common.ClosedDate": "2026-07-03T00:00:00Z", @@ -31,10 +32,24 @@ const WORK_ITEM = { ], }; +/** A custom process: "Shipped" is finished only because its category says so. */ +const WORK_ITEM_TYPES = { + value: [ + { + name: "Bug", + states: [ + { name: "Active", category: "InProgress" }, + { name: "Shipped", category: "Completed" }, + ], + }, + ], +}; + /** Every `az` command the detail read runs, told apart by its first few arguments. */ function answer(args: ReadonlyArray): string { const resource = args[args.indexOf("--resource") + 1]; if (args[0] === "boards") return encode(WORK_ITEM); + if (resource === "workitemtypes") return encode(WORK_ITEM_TYPES); if (args[0] === "repos") { return encode({ pullRequestId: 50, @@ -89,7 +104,7 @@ function answer(args: ReadonlyArray): string { revisedDate: "2026-07-03T00:00:00Z", revisedBy: { uniqueName: "theo@acme.test" }, fields: { - "System.State": { oldValue: "Active", newValue: "Resolved" }, + "System.State": { oldValue: "Active", newValue: "Shipped" }, "System.Tags": { oldValue: "ui", newValue: "bug; ui" }, }, }, @@ -137,7 +152,7 @@ describe("AzureDevOpsIssueTracker", () => { title: "Composer loses focus", url: "https://dev.azure.com/acme/Web%20App/_workitems/edit/7", // The project's own state name survives; the category is what the list filters by. - state: { category: "closed", name: "Resolved" }, + state: { category: "closed", name: "Shipped" }, author: { login: "octo@acme.test", name: "Octo Cat" }, assignees: [{ login: "Theo " }], labels: [ @@ -161,7 +176,7 @@ describe("AzureDevOpsIssueTracker", () => { ]); // The first revision is the creation; a state move across the category line is a close. expect(issue.events.map((event) => [event.kind, event.detail])).toEqual([ - ["closed", "Resolved"], + ["closed", "Shipped"], ["labeled", "bug"], ]); expect(issue.linkedPullRequests).toEqual([ @@ -178,6 +193,40 @@ describe("AzureDevOpsIssueTracker", () => { }), ); + it.effect("filters open work items by the project's own finished states", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const bodies: string[] = []; + const api = yield* AzureDevOpsIssueTracker.make.pipe( + Effect.provide( + Layer.mock(AzureDevOpsCli.AzureDevOpsCli)({ + execute: ({ args }) => + args[args.indexOf("--resource") + 1] === "wiql" + ? fs.readFileString(args[args.indexOf("--in-file") + 1]!).pipe( + Effect.orDie, + Effect.map((body) => { + bodies.push(body); + return TestSourceControlHost.processOutput(encode({ workItems: [] })); + }), + ) + : Effect.succeed(TestSourceControlHost.processOutput(answer(args))), + }), + ), + ); + yield* api.listIssues({ + cwd: "/w", + host: "dev.azure.com", + scope: "acme/Web App", + state: "open", + involvement: "all", + viewer: "octo@acme.test", + limit: 10, + }); + expect(bodies).toHaveLength(1); + expect(bodies[0]).toContain("AND [System.State] NOT IN ('shipped')"); + }).pipe(Effect.provide(NodeFileSystem.layer)), + ); + it.effect("refuses a scope that is not organization/project without a request", () => Effect.gen(function* () { let requests = 0; diff --git a/packages/source-control-azure-devops/src/server/AzureDevOpsIssueTracker.ts b/packages/source-control-azure-devops/src/server/AzureDevOpsIssueTracker.ts index ac6f397336a6..89d1810a37c8 100644 --- a/packages/source-control-azure-devops/src/server/AzureDevOpsIssueTracker.ts +++ b/packages/source-control-azure-devops/src/server/AzureDevOpsIssueTracker.ts @@ -28,6 +28,7 @@ import { import * as AzureDevOpsCli from "./AzureDevOpsCli.ts"; import { azureDevOpsReactionType, + closedStateNames, decodeCommentsJson, decodeProfileJson, decodeUpdatesJson, @@ -35,6 +36,7 @@ import { decodeWorkItemsJson, pullRequestNumberOf, type AzureDevOpsWorkItem, + type ClosedStates, } from "./azureDevOpsIssueJson.ts"; import { decodePullRequestJson } from "./azureDevOpsPullRequestJson.ts"; @@ -114,7 +116,13 @@ const decodeWiql = Schema.decodeUnknownResult( const decodeTypes = Schema.decodeUnknownResult( Schema.Struct({ value: Schema.Array( - Schema.Struct({ name: Schema.String, isDisabled: Schema.optional(Schema.Boolean) }), + Schema.Struct({ + name: Schema.String, + isDisabled: Schema.optional(Schema.Boolean), + states: Schema.optional( + Schema.Array(Schema.Struct({ name: Schema.String, category: Schema.String })), + ), + }), ), }), ); @@ -174,8 +182,8 @@ function involvementClause(involvement: IssueInvolvement): string { } } -function stateClause(state: "all" | "open" | "closed"): string { - const closed = "('Closed', 'Completed', 'Done', 'Removed', 'Resolved')"; +function stateClause(state: "all" | "open" | "closed", closedStates: ClosedStates): string { + const closed = `(${[...closedStates].map(quoted).join(", ")})`; switch (state) { case "open": return ` AND [System.State] NOT IN ${closed}`; @@ -343,11 +351,28 @@ export const make: Effect.Effect< }), ); + const workItemTypes = (input: { + readonly cwd: string; + readonly operation: string; + readonly organization: string; + readonly project: string; + }) => + read({ + cwd: input.cwd, + operation: input.operation, + args: invokeArgs({ ...input, resource: "workitemtypes" }), + decode: decodeAs(decodeTypes), + }).pipe(Effect.map((types) => types.value)); + + const closedStates = (input: Parameters[0]) => + workItemTypes(input).pipe(Effect.map(closedStateNames)); + const workItem = (input: { readonly cwd: string; readonly operation: string; readonly organization: string; readonly number: number; + readonly closed: ClosedStates; }): Effect.Effect => read({ cwd: input.cwd, @@ -365,7 +390,7 @@ export const make: Effect.Effect< "--expand", "relations", ], - decode: decodeWorkItemJson, + decode: (raw) => decodeWorkItemJson(raw, input.closed), }).pipe( Effect.filterOrFail( (item) => item !== null, @@ -547,6 +572,11 @@ export const make: Effect.Effect< const text = input.query?.trim() ?? ""; return scoped("listIssues", input).pipe( Effect.flatMap((scope) => + closedStates({ ...scope, cwd: input.cwd, operation: "listIssues" }).pipe( + Effect.map((closed) => ({ scope, closed })), + ), + ), + Effect.flatMap(({ scope, closed }) => invokeWithBody({ cwd: input.cwd, operation: "listIssues", @@ -560,7 +590,7 @@ export const make: Effect.Effect< body: { query: `SELECT [System.Id] FROM WorkItems WHERE [System.TeamProject] = ${quoted(scope.project)}` + - stateClause(input.state) + + stateClause(input.state, closed) + involvementClause(input.involvement) + (text.length === 0 ? "" : ` AND [System.Title] CONTAINS ${quoted(text)}`) + (cursor === undefined @@ -592,7 +622,7 @@ export const make: Effect.Effect< `fields=${LIST_FIELDS.join(",")}`, ], }), - decode: decodeWorkItemsJson, + decode: (raw) => decodeWorkItemsJson(raw, closed), }).pipe( Effect.map((items) => { const last = items.at(-1); @@ -614,8 +644,13 @@ export const make: Effect.Effect< getIssue: (input) => target("getIssue", input).pipe( Effect.flatMap((scope) => - workItem({ ...scope, cwd: input.cwd, operation: "getIssue" }).pipe( - Effect.flatMap((item) => + closedStates({ ...scope, cwd: input.cwd, operation: "getIssue" }).pipe( + Effect.flatMap((closed) => + workItem({ ...scope, cwd: input.cwd, operation: "getIssue", closed }).pipe( + Effect.map((item) => ({ item, closed })), + ), + ), + Effect.flatMap(({ item, closed }) => Effect.all( [ comments({ ...scope, cwd: input.cwd, operation: "getIssue" }), @@ -628,7 +663,7 @@ export const make: Effect.Effect< route: [`id=${scope.number}`], query: [`$top=${PAGE_SIZE}`], }), - decode: decodeUpdatesJson, + decode: (raw) => decodeUpdatesJson(raw, closed), }), linkedPullRequests({ cwd: input.cwd, organization: scope.organization, item }), ], @@ -660,20 +695,15 @@ export const make: Effect.Effect< create: (input) => scoped("create", input).pipe( Effect.flatMap((scope) => - read({ - cwd: input.cwd, - operation: "create", - args: invokeArgs({ ...scope, resource: "workitemtypes" }), - decode: decodeAs(decodeTypes), - }).pipe( + workItemTypes({ ...scope, cwd: input.cwd, operation: "create" }).pipe( Effect.flatMap((types) => { - const names = types.value.flatMap((type) => (type.isDisabled ? [] : [type.name])); + const names = types.flatMap((type) => (type.isDisabled ? [] : [type.name])); const type = CREATE_TYPES.find((name) => names.includes(name)) ?? names[0]; return type === undefined ? Effect.fail(refused("create", "The project offers no work item type to file.")) - : Effect.succeed(type); + : Effect.succeed({ type, closed: closedStateNames(types) }); }), - Effect.flatMap((type) => + Effect.flatMap(({ type, closed }) => read({ cwd: input.cwd, operation: "create", @@ -697,7 +727,7 @@ export const make: Effect.Effect< ...(input.assignees.length === 0 ? {} : { assignees: input.assignees }), }), ], - decode: decodeWorkItemJson, + decode: (raw) => decodeWorkItemJson(raw, closed), }), ), Effect.filterOrFail( diff --git a/packages/source-control-azure-devops/src/server/azureDevOpsIssueJson.ts b/packages/source-control-azure-devops/src/server/azureDevOpsIssueJson.ts index 9ca14afbcebe..5efe6c8c6df6 100644 --- a/packages/source-control-azure-devops/src/server/azureDevOpsIssueJson.ts +++ b/packages/source-control-azure-devops/src/server/azureDevOpsIssueJson.ts @@ -20,12 +20,11 @@ import type { import { decodeJsonResult } from "@t3tools/shared/schemaJson"; import type { TrackerIssueSummary } from "@t3tools/issue-tracker-core/server/IssueTracker"; -/** - * The states Azure's stock processes treat as finished. A project may rename its own columns, so - * anything not named here reads as open: an item wrongly shown as open is one the reader can - * still close, while one wrongly shown as closed disappears from the list they were looking at. - */ -const CLOSED_STATES: ReadonlySet = new Set([ +/** Lowercased names of the states a project treats as finished, read by `closedStateNames`. */ +export type ClosedStates = ReadonlySet; + +/** The finished states of Azure's stock processes, for a project whose types report none. */ +const STOCK_CLOSED_STATES: ClosedStates = new Set([ "closed", "done", "removed", @@ -33,6 +32,27 @@ const CLOSED_STATES: ReadonlySet = new Set([ "completed", ]); +const FINISHED_CATEGORIES: ReadonlySet = new Set(["Resolved", "Completed", "Removed"]); + +/** + * A project's finished states, from its work item types' state categories. Custom processes + * name their own columns, so the category, not the name, says whether one is finished. + */ +export function closedStateNames( + types: ReadonlyArray<{ + readonly states?: + | ReadonlyArray<{ readonly name: string; readonly category: string }> + | undefined; + }>, +): ClosedStates { + const names = types.flatMap((type) => + (type.states ?? []).flatMap((state) => + FINISHED_CATEGORIES.has(state.category) ? [state.name.toLowerCase()] : [], + ), + ); + return names.length === 0 ? STOCK_CLOSED_STATES : new Set(names); +} + const Identity = Schema.Struct({ displayName: Schema.optional(Schema.NullOr(Schema.String)), uniqueName: Schema.optional(Schema.NullOr(Schema.String)), @@ -103,9 +123,9 @@ function toActor(value: typeof IdentityValue.Type | undefined): IssueActor | nul : { login, name: trimmed(value.displayName), avatarUrl: trimmed(value.imageUrl) }; } -function toIssueState(name: string | null | undefined): IssueState { +function toIssueState(name: string | null | undefined, closed: ClosedStates): IssueState { const state = trimmed(name) ?? "New"; - return { category: CLOSED_STATES.has(state.toLowerCase()) ? "closed" : "open", name: state }; + return { category: closed.has(state.toLowerCase()) ? "closed" : "open", name: state }; } /** Azure keeps tags as one `; `-separated string, with no colour anywhere. */ @@ -123,7 +143,7 @@ export interface AzureDevOpsWorkItem extends TrackerIssueSummary { } /** Null for a row this cannot place: no title, no dates, or no project to build a link from. */ -function toWorkItem(raw: typeof WorkItem.Type): AzureDevOpsWorkItem | null { +function toWorkItem(raw: typeof WorkItem.Type, closed: ClosedStates): AzureDevOpsWorkItem | null { const fields = raw.fields ?? {}; const title = trimmed(fields["System.Title"]); const createdAt = trimmed(fields["System.CreatedDate"]); @@ -140,7 +160,7 @@ function toWorkItem(raw: typeof WorkItem.Type): AzureDevOpsWorkItem | null { title, url, author: toActor(fields["System.CreatedBy"]), - state: toIssueState(fields["System.State"]), + state: toIssueState(fields["System.State"], closed), createdAt, updatedAt, closedAt: trimmed(fields["Microsoft.VSTS.Common.ClosedDate"]), @@ -156,12 +176,13 @@ function toWorkItem(raw: typeof WorkItem.Type): AzureDevOpsWorkItem | null { /** A flat WIQL answer; a row this cannot place is skipped rather than failing the page. */ export function decodeWorkItemsJson( raw: string, + closed: ClosedStates, ): Result.Result, DecodeFailure> { return Result.map(decodeUnknownList(raw.trim().length === 0 ? "[]" : raw), (rows) => (rows ?? []).flatMap((entry) => { const item = decodeWorkItemEntry(entry); if (Exit.isFailure(item)) return []; - return toWorkItem(item.value) ?? []; + return toWorkItem(item.value, closed) ?? []; }), ); } @@ -169,8 +190,9 @@ export function decodeWorkItemsJson( /** Null where az answered with a work item this cannot place, which the caller reports as such. */ export function decodeWorkItemJson( raw: string, + closed: ClosedStates, ): Result.Result { - return Result.map(decodeWorkItem(raw), toWorkItem); + return Result.map(decodeWorkItem(raw), (item) => toWorkItem(item, closed)); } /** Azure names comment reactions after its own icons; the six it has map onto the contract's. */ @@ -285,6 +307,7 @@ function identityLogin(value: unknown): string | null { */ export function decodeUpdatesJson( raw: string, + closed: ClosedStates, ): Result.Result, DecodeFailure> { return Result.map(decodeUpdatesPage(raw), (page) => (page.value ?? []).flatMap((entry) => { @@ -303,8 +326,8 @@ export function decodeUpdatesJson( }); const state = fields["System.State"]; if (typeof state?.oldValue === "string" && typeof state.newValue === "string") { - const was = toIssueState(state.oldValue); - const now = toIssueState(state.newValue); + const was = toIssueState(state.oldValue, closed); + const now = toIssueState(state.newValue, closed); add( was.category === now.category ? "state-changed"