From dd135450308224bea17c4389a6451d7dd1c43bfb Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Fri, 13 Nov 2026 23:21:29 -0800 Subject: [PATCH] feat(issues): add GitLab, Azure DevOps, and Forgejo issue trackers Each host's driver now provides its issue tracker over the CLI its pull request reads already use. Azure DevOps keeps work items per project, so its definition says where a repository's issues live and the service reads each project once. Co-Authored-By: Claude Fable 5.2 --- apps/server/src/issue/IssueService.ts | 8 +- docs/user/source-control.md | 4 +- .../server/AzureDevOpsIssueTracker.test.ts | 244 +++++ .../src/server/AzureDevOpsIssueTracker.ts | 914 ++++++++++++++++++ .../src/server/azureDevOpsIssueJson.ts | 389 ++++++++ .../src/server/driver.ts | 2 + .../src/shared/definition.test.ts | 10 + .../src/shared/definition.ts | 9 + .../src/server/ForgejoIssueTracker.test.ts | 221 +++++ .../src/server/ForgejoIssueTracker.ts | 581 +++++++++++ .../src/server/driver.ts | 2 + .../src/server/forgejoIssueJson.ts | 164 ++++ .../src/server/GitLabIssueTracker.test.ts | 221 +++++ .../src/server/GitLabIssueTracker.ts | 578 +++++++++++ .../src/server/driver.ts | 2 + .../src/server/gitLabIssueJson.ts | 410 ++++++++ 16 files changed, 3753 insertions(+), 6 deletions(-) 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-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-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 diff --git a/apps/server/src/issue/IssueService.ts b/apps/server/src/issue/IssueService.ts index 4b1a9a1167432..c317ea47f1849 100644 --- a/apps/server/src/issue/IssueService.ts +++ b/apps/server/src/issue/IssueService.ts @@ -60,7 +60,6 @@ function trackerDetail(error: IssueTrackerError): string { /** One project's resolved tracker: which driver, where it is served from, and its container. */ interface ProjectTrackerBinding { - readonly canonicalKey: string; readonly tracker: IssueTrackerKind; readonly host: string; readonly scope: string; @@ -82,7 +81,6 @@ function resolveGitHostBinding(project: OrchestrationProjectShell): ProjectTrack const kind = identity?.provider; if (!identity || kind === undefined || kind === "unknown" || scope === null) return null; return { - canonicalKey: identity.canonicalKey, tracker: IssueTrackerKind.make(kind), host: sourceControlDefinitions.changeRequestHost(identity, kind), scope, @@ -244,10 +242,12 @@ const make = Effect.gen(function* () { const list: IssueService["Service"]["list"] = Effect.fn("IssueService.list")(function* (input) { const { supported, unimplemented } = yield* listProjects(input); const limit = input.limit ?? DEFAULT_LIST_LIMIT; - // Worktrees of one repository are separate projects; read each scope once. + // Worktrees of one repository are separate projects, and every repository of an Azure + // DevOps project shares its work items; read each scope once. const scopes = new Map(); for (const project of supported) { - if (!scopes.has(project.canonicalKey)) scopes.set(project.canonicalKey, project); + const key = listCursorKey(project.host, project.scope).toLowerCase(); + if (!scopes.has(key)) scopes.set(key, project); } const continuing = input.cursors; const viewers = new Map(); diff --git a/docs/user/source-control.md b/docs/user/source-control.md index e7e78be82d293..57f8992f4098e 100644 --- a/docs/user/source-control.md +++ b/docs/user/source-control.md @@ -352,8 +352,8 @@ out from the matching organization and repository. ## Issues -The Issues page lists the issues of every project whose remote is on a supported tracker -(GitHub today), grouped per tracker account. Open it from the sidebar's utility row or with +The Issues page lists the issues of every project whose remote is on GitHub, GitLab, Azure +DevOps or Forgejo, grouped per tracker account. Open it from the sidebar's utility row or with **Open issues** in the command palette. Filter by open or closed, by your involvement (assigned, opened by you, mentioning you), and by tracker; the search box asks the tracker itself where it can search. Press **Load more** at the end of a group to read further back. 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 0000000000000..c35047aacb89f --- /dev/null +++ b/packages/source-control-azure-devops/src/server/AzureDevOpsIssueTracker.test.ts @@ -0,0 +1,244 @@ +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"; + +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": "Shipped", + "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" }, + ], +}; + +/** 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, + 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: "Shipped" }, + "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: "Shipped" }, + 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", "Shipped"], + ["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("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; + 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 0000000000000..39ce9975a3654 --- /dev/null +++ b/packages/source-control-azure-devops/src/server/AzureDevOpsIssueTracker.ts @@ -0,0 +1,914 @@ +/** + * Azure DevOps work items as an issue tracker, over `az`. Work items belong to a project, so the + * scope is `organization/project` and every call names the organization. Listing is a WIQL + * query paged by a `(changedDate, id)` keyset cursor, because WIQL has no offset. + */ +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/source-control-core/server/IssueTracker"; + +import * as AzureDevOpsCli from "./AzureDevOpsCli.ts"; +import { + azureDevOpsReactionType, + closedStateNames, + decodeCommentsJson, + decodeProfileJson, + decodeUpdatesJson, + decodeWorkItemJson, + decodeWorkItemsJson, + pullRequestNumberOf, + type AzureDevOpsWorkItem, + type ClosedStates, +} 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. + */ +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), + states: Schema.optional( + Schema.Array(Schema.Struct({ name: Schema.String, category: Schema.String })), + ), + }), + ), + }), +); +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", closedStates: ClosedStates): string { + const closed = `(${[...closedStates].map(quoted).join(", ")})`; + 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 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, + operation: input.operation, + args: [ + "boards", + "work-item", + "show", + "--detect", + "false", + "--organization", + input.organization, + "--id", + String(input.number), + "--expand", + "relations", + ], + decode: (raw) => decodeWorkItemJson(raw, input.closed), + }).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) => + closedStates({ ...scope, cwd: input.cwd, operation: "listIssues" }).pipe( + Effect.map((closed) => ({ scope, closed })), + ), + ), + Effect.flatMap(({ scope, closed }) => + 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, closed) + + 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: (raw) => decodeWorkItemsJson(raw, closed), + }).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) => + 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" }), + read({ + cwd: input.cwd, + operation: "getIssue", + args: invokeArgs({ + ...scope, + resource: "updates", + route: [`id=${scope.number}`], + query: [`$top=${PAGE_SIZE}`], + }), + decode: (raw) => decodeUpdatesJson(raw, closed), + }), + 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 }), + ), + ), + + create: (input) => + scoped("create", input).pipe( + Effect.flatMap((scope) => + workItemTypes({ ...scope, cwd: input.cwd, operation: "create" }).pipe( + Effect.flatMap((types) => { + 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, closed: closedStateNames(types) }); + }), + Effect.flatMap(({ type, closed }) => + 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: (raw) => decodeWorkItemJson(raw, closed), + }), + ), + 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 0000000000000..ee78c4b1233d2 --- /dev/null +++ b/packages/source-control-azure-devops/src/server/azureDevOpsIssueJson.ts @@ -0,0 +1,389 @@ +/** + * 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/source-control-core/server/IssueTracker"; + +/** 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", + "resolved", + "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)), + 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, closed: ClosedStates): IssueState { + const state = trimmed(name) ?? "New"; + return { category: closed.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, closed: ClosedStates): 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"], closed), + 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, + 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, closed) ?? []; + }), + ); +} + +/** 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), (item) => toWorkItem(item, closed)); +} + +/** 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, + closed: ClosedStates, +): 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, closed); + const now = toIssueState(state.newValue, closed); + 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/driver.ts b/packages/source-control-azure-devops/src/server/driver.ts index ac1f1a53855a3..3bf929b2f3436 100644 --- a/packages/source-control-azure-devops/src/server/driver.ts +++ b/packages/source-control-azure-devops/src/server/driver.ts @@ -9,6 +9,7 @@ import { definition } from "../shared/definition.ts"; import * as AzureDevOpsCli from "./AzureDevOpsCli.ts"; import * as AzureDevOpsPullRequestCli from "./AzureDevOpsPullRequestCli.ts"; +import * as AzureDevOpsIssueTracker from "./AzureDevOpsIssueTracker.ts"; import * as AzureDevOpsPullRequestProvider from "./AzureDevOpsPullRequestProvider.ts"; import * as AzureDevOpsSourceControlProvider from "./AzureDevOpsSourceControlProvider.ts"; @@ -18,6 +19,7 @@ const makeInstance = Effect.all({ sourceControl: AzureDevOpsSourceControlProvider.make, discovery: Effect.succeed(AzureDevOpsSourceControlProvider.discovery), pullRequests: AzureDevOpsPullRequestProvider.make, + issues: AzureDevOpsIssueTracker.make, }); export const driver = defineSourceControlDriver({ diff --git a/packages/source-control-azure-devops/src/shared/definition.test.ts b/packages/source-control-azure-devops/src/shared/definition.test.ts index ed7744f434ee0..5c65e8517f43a 100644 --- a/packages/source-control-azure-devops/src/shared/definition.test.ts +++ b/packages/source-control-azure-devops/src/shared/definition.test.ts @@ -252,3 +252,13 @@ describe("Azure DevOps repository selector", () => { ).toBe("checkout (eu)"); }); }); + +describe("Azure DevOps issue scope", () => { + it("places work items in the project rather than the repository", () => { + const kind = SourceControlProviderKind.make("azure-devops"); + expect( + definition.issueScope?.({ provider: kind, displayName: "acme/Web%20App/_git/web" }), + ).toBe("acme/Web App"); + expect(definition.issueScope?.({ provider: kind, displayName: "web" })).toBeNull(); + }); +}); diff --git a/packages/source-control-azure-devops/src/shared/definition.ts b/packages/source-control-azure-devops/src/shared/definition.ts index c894be875ca23..8383c002aa3ed 100644 --- a/packages/source-control-azure-devops/src/shared/definition.ts +++ b/packages/source-control-azure-devops/src/shared/definition.ts @@ -153,6 +153,15 @@ export const definition = defineSourceControl({ const name = identity.name || segments.at(-1); return name ? decodeUrlPathSegment(name) : null; }, + // Work items belong to the project: `organization/project` out of the remote's + // `organization/project/_git/repository`. + issueScope: (identity) => { + const segments = (identity.displayName ?? "").split("/").filter((part) => part !== ""); + const [organization, project] = segments.indexOf("_git") === 2 ? segments : []; + return organization && project + ? `${decodeUrlPathSegment(organization)}/${decodeUrlPathSegment(project)}` + : null; + }, repositoryKeyFromRemote, canonicalRepositoryKey, isChangeRequestInRepository: (identity, link) => { 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 0000000000000..a81b0bcfebe6d --- /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 0000000000000..0d9a190fd519a --- /dev/null +++ b/packages/source-control-forgejo/src/server/ForgejoIssueTracker.ts @@ -0,0 +1,581 @@ +/** + * Forgejo's issue tracker over `ForgejoCli.api`, which resolves the server and login from the + * checkout's remote. Forgejo pages by number and announces a next page in a `Link` header the + * CLI surfaces on stderr, so every cursor here is the next page number. + */ +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/source-control-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"); + +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: Effect.fn("ForgejoIssueTracker.listIssues")(function* (input) { + const page = parsePage(input.cursor); + if (page === null) { + return yield* 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); + const { rows, nextCursor } = yield* readPage( + input, + "listIssues", + `${repoPath(input.scope)}/issues?${params}`, + page, + ForgejoIssue, + ); + return { + 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), + ), + ); + }, + + 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/driver.ts b/packages/source-control-forgejo/src/server/driver.ts index b935b35eb7331..0c87217beb02a 100644 --- a/packages/source-control-forgejo/src/server/driver.ts +++ b/packages/source-control-forgejo/src/server/driver.ts @@ -7,6 +7,7 @@ import * as Effect from "effect/Effect"; import { definition } from "../shared/definition.ts"; import * as ForgejoCli from "./ForgejoCli.ts"; +import * as ForgejoIssueTracker from "./ForgejoIssueTracker.ts"; import * as ForgejoPullRequestProvider from "./ForgejoPullRequestProvider.ts"; import * as ForgejoSourceControlProvider from "./ForgejoSourceControlProvider.ts"; @@ -16,6 +17,7 @@ const makeInstance = Effect.all({ sourceControl: ForgejoSourceControlProvider.make, discovery: ForgejoSourceControlProvider.makeDiscovery, pullRequests: ForgejoPullRequestProvider.make, + issues: ForgejoIssueTracker.make, }); export const driver = defineSourceControlDriver({ 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 0000000000000..4e2f9cf208ef1 --- /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/source-control-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-gitlab/src/server/GitLabIssueTracker.test.ts b/packages/source-control-gitlab/src/server/GitLabIssueTracker.test.ts new file mode 100644 index 0000000000000..3fbcde96f3b14 --- /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 0000000000000..e650ef7de9f61 --- /dev/null +++ b/packages/source-control-gitlab/src/server/GitLabIssueTracker.ts @@ -0,0 +1,578 @@ +/** + * GitLab's issue tracker over `glab api`. GitLab pages by offset and reports no total, so every + * cursor is a page number and a full page is the only sign that more exists. Notes, label + * events, linked merge requests and awards are separate endpoints, joined into the detail. + */ +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/source-control-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"); + +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 })), + ); + }, + + 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/driver.ts b/packages/source-control-gitlab/src/server/driver.ts index 1773bfd4bdae7..2c71e348547f5 100644 --- a/packages/source-control-gitlab/src/server/driver.ts +++ b/packages/source-control-gitlab/src/server/driver.ts @@ -9,6 +9,7 @@ import { definition } from "../shared/definition.ts"; import * as GitLabCli from "./GitLabCli.ts"; import * as GitLabPullRequestCli from "./GitLabPullRequestCli.ts"; +import * as GitLabIssueTracker from "./GitLabIssueTracker.ts"; import * as GitLabPullRequestProvider from "./GitLabPullRequestProvider.ts"; import * as GitLabSourceControlProvider from "./GitLabSourceControlProvider.ts"; @@ -18,6 +19,7 @@ const makeInstance = Effect.all({ sourceControl: GitLabSourceControlProvider.make, discovery: Effect.succeed(GitLabSourceControlProvider.discovery), pullRequests: GitLabPullRequestProvider.make, + issues: GitLabIssueTracker.make, }); export const driver = defineSourceControlDriver({ 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 0000000000000..cb57813078b84 --- /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/source-control-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 } } } } + } + } + } +}`;