Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
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
25 changes: 25 additions & 0 deletions apps/server/src/issue/IssueService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,31 @@ function makeService(input: {
);
}

describe("resolveGitHostBinding", () => {
it("scopes Azure DevOps work items to the project rather than the repository", () => {
expect(
IssueService.resolveGitHostBinding(
project({
id: "p",
provider: "azure-devops",
host: "dev.azure.com",
repository: "acme/Web App/_git/web",
}),
),
).toEqual({
canonicalKey: "dev.azure.com/acme/web app",
tracker: "azure-devops",
host: "dev.azure.com",
scope: "acme/Web App",
});
expect(
IssueService.resolveGitHostBinding(
project({ id: "p", provider: "azure-devops", host: "dev.azure.com", repository: "web" }),
),
).toBeNull();
});
});

describe("IssueService", () => {
it.effect("reads another repository on the same host but refuses to write to it", () =>
Effect.gen(function* () {
Expand Down
28 changes: 24 additions & 4 deletions apps/server/src/issue/IssueService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,19 +74,39 @@ interface SupportedProject extends ProjectTrackerBinding {
readonly api: IssueTrackerApi;
}

/**
* Azure work items belong to the project, not the repository, so the scope is
* `organization/project` out of the remote's `organization/project/_git/repository` path. The
* source control selector answers only the repository name there, which addresses no work item.
*/
function azureDevOpsProjectScope(displayName: string | undefined): string | null {
const segments = (displayName ?? "").split("/").filter((part) => part.length > 0);
const marker = segments.indexOf("_git");
const [organization, project] = marker === 2 ? segments : [];
return organization && project ? `${organization}/${project}` : null;
}

/**
* The git-host path of resolution: the tracker is the host's own, named like the remote's
* provider kind. Null for a project with no remote or a kind no tracker claims.
*/
function resolveGitHostBinding(project: OrchestrationProjectShell): ProjectTrackerBinding | null {
export function resolveGitHostBinding(
project: OrchestrationProjectShell,
): ProjectTrackerBinding | null {
const identity = project.repositoryIdentity;
const scope = sourceControlRepositorySelector(identity);
const scope =
identity?.provider === "azure-devops"
? azureDevOpsProjectScope(identity.displayName)
: sourceControlRepositorySelector(identity);
const kind = identity?.provider;
if (!identity || kind === undefined || kind === "unknown" || scope === null) return null;
const host = pullRequestHostOf(identity, SourceControlProviderKind.make(kind));
return {
canonicalKey: identity.canonicalKey,
// Every repository of an Azure DevOps project shares its work items.
canonicalKey:
kind === "azure-devops" ? `${host}/${scope}`.toLowerCase() : identity.canonicalKey,
tracker: IssueTrackerKind.make(kind),
host: pullRequestHostOf(identity, SourceControlProviderKind.make(kind)),
host,
scope,
};
}
Expand Down
10 changes: 9 additions & 1 deletion apps/server/src/issue/builtInIssueTrackers.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,15 @@
import * as AzureDevOpsIssueTrackerDriver from "@t3tools/source-control-azure-devops/server/issueTrackerDriver";
import * as ForgejoIssueTrackerDriver from "@t3tools/source-control-forgejo/server/issueTrackerDriver";
import * as GitHubIssueTrackerDriver from "@t3tools/source-control-github/server/issueTrackerDriver";
import * as GitLabIssueTrackerDriver from "@t3tools/source-control-gitlab/server/issueTrackerDriver";
import type { IssueTrackerDriver } from "@t3tools/issue-tracker-core/server/driver";

const drivers = [GitHubIssueTrackerDriver.driver];
const drivers = [
GitHubIssueTrackerDriver.driver,
GitLabIssueTrackerDriver.driver,
AzureDevOpsIssueTrackerDriver.driver,
ForgejoIssueTrackerDriver.driver,
];

export type BuiltInIssueTrackersEnv =
(typeof drivers)[number] extends IssueTrackerDriver<infer R> ? R : never;
Expand Down
13 changes: 13 additions & 0 deletions packages/source-control-azure-devops/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,14 @@
"types": "./src/server/azureDevOpsDiff.ts",
"import": "./src/server/azureDevOpsDiff.ts"
},
"./server/azureDevOpsIssueJson": {
"types": "./src/server/azureDevOpsIssueJson.ts",
"import": "./src/server/azureDevOpsIssueJson.ts"
},
"./server/AzureDevOpsIssueTracker": {
"types": "./src/server/AzureDevOpsIssueTracker.ts",
"import": "./src/server/AzureDevOpsIssueTracker.ts"
},
"./server/AzureDevOpsPullRequestCli": {
"types": "./src/server/AzureDevOpsPullRequestCli.ts",
"import": "./src/server/AzureDevOpsPullRequestCli.ts"
Expand All @@ -38,6 +46,10 @@
"./server/driver": {
"types": "./src/server/driver.ts",
"import": "./src/server/driver.ts"
},
"./server/issueTrackerDriver": {
"types": "./src/server/issueTrackerDriver.ts",
"import": "./src/server/issueTrackerDriver.ts"
}
},
"scripts": {
Expand All @@ -46,6 +58,7 @@
},
"dependencies": {
"@t3tools/contracts": "workspace:*",
"@t3tools/issue-tracker-core": "workspace:*",
"@t3tools/shared": "workspace:*",
"@t3tools/source-control-core": "workspace:*",
"diff": "8.0.3",
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