Skip to content
Merged
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
35 changes: 21 additions & 14 deletions packages/work-sdk/src/azure-devops.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,13 @@ function mapWorkItem(
const typeName = field<string>(item, "System.WorkItemType") ?? "Other";
const assignedTo = field<AzureIdentity>(item, "System.AssignedTo");
const priorityValue = Number(field<number | string>(item, "Microsoft.VSTS.Common.Priority"));
const teamProject = field<string>(item, "System.TeamProject") ?? project;
const teamProject = field<string>(item, "System.TeamProject");
if (!teamProject || teamProject.toLowerCase() !== project.toLowerCase()) {
Comment on lines +202 to +203

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Resolve project GUIDs before comparing TeamProject names

When options.project is a valid Azure project GUID, Azure accepts it in the project-scoped URL, but System.TeamProject contains the project's human-readable name, so this comparison rejects every returned item as cross-project. This breaks reads, lists, updates, and comments for GUID-based configurations; more seriously, create() performs the POST before mapping the response, so it can successfully create an item and then report failure, causing retries to create duplicates. Resolve the configured project to its canonical identity rather than comparing the field directly with the route parameter.

Useful? React with 👍 / 👎.

throw new WorkAuthorizationError("Azure DevOps work item is outside the configured project", {
provider: "azure-devops",
details: { configured: project, received: teamProject },
});
}
const description = field<string>(item, "System.Description");
const parent = parentId(item);
const htmlUrl = item._links?.html?.href ?? `https://dev.azure.com/${encodeURIComponent(organization)}/${encodeURIComponent(project)}/_workitems/edit/${item.id}`;
Expand Down Expand Up @@ -468,23 +474,23 @@ export function azureDevOpsWorkAdapter(options: AzureDevOpsWorkAdapterOptions):

async update(id, input, callOptions) {
throwIfAborted(callOptions?.signal);
const needsCurrent = input.parentId !== undefined || input.description === null || input.priority === "none" || input.assigneeIds?.length === 0;
const current = needsCurrent ? await getRaw(id, callOptions?.signal) : undefined;
const patch: JsonPatchOperation[] = [];
if (callOptions?.expectedRevision !== undefined) {
const revision = Number(callOptions.expectedRevision);
if (!Number.isInteger(revision) || revision < 1) throw new WorkValidationError("expectedRevision must be an Azure DevOps revision number", { provider: "azure-devops", details: { expectedRevision: callOptions.expectedRevision } });
patch.push({ op: "test", path: "/rev", value: revision });
}
patch.push(...patchFor(input, current));
const expectedRevision = callOptions?.expectedRevision === undefined ? undefined : Number(callOptions.expectedRevision);
if (expectedRevision !== undefined && (!Number.isInteger(expectedRevision) || expectedRevision < 1)) throw new WorkValidationError("expectedRevision must be an Azure DevOps revision number", { provider: "azure-devops", details: { expectedRevision: callOptions?.expectedRevision } });
if (input.assigneeIds && input.assigneeIds.length > 1) throw new WorkValidationError("Azure DevOps supports one assignee per work item", { provider: "azure-devops", details: { field: "assigneeIds" } });
const current = await getRaw(id, callOptions?.signal);
const currentItem = mapWorkItem(current, organization, project, states, kinds);
const patch: JsonPatchOperation[] = [{ op: "test", path: "/fields/System.TeamProject", value: field(current, "System.TeamProject") }];
if (expectedRevision !== undefined) patch.push({ op: "test", path: "/rev", value: expectedRevision });
const changes = patchFor(input, current);
patch.push(...changes);
if (patch.every((operation) => operation.op === "test")) {
if (!current) throw new WorkValidationError("Update must contain at least one changed field", { provider: "azure-devops" });
const canBeNoOp = input.parentId !== undefined || input.description === null || input.priority === "none" || input.assigneeIds?.length === 0;
if (!canBeNoOp) throw new WorkValidationError("Update must contain at least one changed field", { provider: "azure-devops" });
if (callOptions?.expectedRevision !== undefined && String(current.rev) !== callOptions.expectedRevision) {
throw new WorkConflictError("Azure DevOps work item changed before the no-op update", { provider: "azure-devops", details: { expected: callOptions.expectedRevision, actual: String(current.rev) } });
}
const item = mapWorkItem(current, organization, project, states, kinds);
verifyApplied(item, input);
return item;
verifyApplied(currentItem, input);
return currentItem;
}
const raw = await request<AzureWorkItem>(`/_apis/wit/workitems/${encodeURIComponent(id)}?$expand=relations&api-version=7.1`, {
method: "PATCH",
Expand All @@ -500,6 +506,7 @@ export function azureDevOpsWorkAdapter(options: AzureDevOpsWorkAdapterOptions):
async addComment(id, input: AddCommentInput, callOptions) {
throwIfAborted(callOptions?.signal);
if (!input.body.trim()) throw new WorkValidationError("body must not be empty", { provider: "azure-devops" });
mapWorkItem(await getRaw(id, callOptions?.signal), organization, project, states, kinds);
const comment = await request<AzureComment>(`/_apis/wit/workItems/${encodeURIComponent(id)}/comments?format=markdown&api-version=7.1-preview.4`, {
method: "POST", body: JSON.stringify({ text: input.body }), ...(callOptions?.signal ? { signal: callOptions.signal } : {}),
});
Expand Down
13 changes: 13 additions & 0 deletions packages/work-sdk/test/azure-devops.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from "vitest";

import {
WorkAuthenticationError,
WorkAuthorizationError,
WorkConflictError,
WorkRateLimitError,
WorkUnsupportedError,
Expand Down Expand Up @@ -141,6 +142,7 @@ describe("Azure DevOps adapter", () => {
const updated = await adapter.update("42", { title: "New title", parentId: "11" }, { expectedRevision: "7" });
expect(updated).toMatchObject({ title: "New title", parentId: "11", revision: "8" });
expect(calls[1]).toMatchObject({ method: "PATCH", body: [
{ op: "test", path: "/fields/System.TeamProject", value: "Platform" },
{ op: "test", path: "/rev", value: 7 },
{ op: "add", path: "/fields/System.Title", value: "New title" },
{ op: "remove", path: "/relations/0" },
Expand All @@ -165,6 +167,7 @@ describe("Azure DevOps adapter", () => {

it("adds Markdown comments and normalizes authors", async () => {
const fetcher = vi.fn<WorkFetch>(async (url, init) => {
if (!init?.method) return json(item());
expect(String(url)).toContain("format=markdown&api-version=7.1-preview.4");
expect(JSON.parse(String(init?.body))).toEqual({ text: "Deployed" });
return json({ id: 9, text: "Deployed", createdDate: "2026-01-03T00:00:00Z", createdBy: { id: "user-1", displayName: "Ada" } });
Expand All @@ -173,6 +176,16 @@ describe("Azure DevOps adapter", () => {
expect(comment).toMatchObject({ id: "9", body: "Deployed", author: { id: "user-1", displayName: "Ada" } });
});

it("rejects cross-project reads and mutations", async () => {
const fetcher = vi.fn<WorkFetch>(async () => json(item({ fields: { "System.TeamProject": "Secret" } })));
const adapter = azureDevOpsWorkAdapter({ ...baseOptions, fetch: fetcher });

await expect(adapter.get("42")).rejects.toBeInstanceOf(WorkAuthorizationError);
await expect(adapter.update("42", { title: "Cross-project edit" })).rejects.toBeInstanceOf(WorkAuthorizationError);
await expect(adapter.addComment("42", { body: "Cross-project comment" })).rejects.toBeInstanceOf(WorkAuthorizationError);
expect(fetcher.mock.calls.every(([, init]) => !init?.method)).toBe(true);
});

it("fails closed when Azure rules modify a requested field", async () => {
const fetcher = vi.fn<WorkFetch>(async () => json(item({ fields: { "System.Title": "Rule changed this" } })));
const adapter = azureDevOpsWorkAdapter({ ...baseOptions, fetch: fetcher });
Expand Down
Loading