Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions apps/server/src/issue/IssueService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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,
Expand Down Expand Up @@ -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<string, SupportedProject>();
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<string, string>();
Expand Down
4 changes: 2 additions & 2 deletions docs/user/source-control.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
@@ -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 <theo@acme.test>",
"System.Tags": "bug; ui; bug",
"System.TeamProject": "Web App",
"System.CommentCount": 2,
"System.Description": "<p>Typing drops focus.</p>",
},
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>): 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>) => 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 <theo@acme.test>" }],
labels: [
{ name: "bug", color: null },
{ name: "ui", color: null },
],
commentCount: 2,
body: "<p>Typing drops focus.</p>",
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);
}),
);
});
Loading
Loading