Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
Show all changes
18 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
5 changes: 5 additions & 0 deletions .changeset/minor-azure-devops-work-item-safe-outputs.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions actions/setup/js/assign_work_item.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
// @ts-check
const { createAzureDevOpsWorkItemHandler } = require("./azure_devops_work_items.cjs");
async function main(config = {}) {
return createAzureDevOpsWorkItemHandler("ado_assign_work_item", config);
}
module.exports = { main };
535 changes: 535 additions & 0 deletions actions/setup/js/azure_devops_work_items.cjs

Large diffs are not rendered by default.

150 changes: 150 additions & 0 deletions actions/setup/js/azure_devops_work_items.test.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createAzureDevOpsWorkItemHandler, resolveWorkItemReference } from "./azure_devops_work_items.cjs";

global.core = {
debug: vi.fn(),
info: vi.fn(),
warning: vi.fn(),
};

describe("azure_devops_work_items", () => {
beforeEach(() => {
vi.clearAllMocks();
process.env.SYSTEM_ACCESSTOKEN = "test-token";
process.env.AZURE_DEVOPS_ORG_URL = "https://dev.azure.com/test-org";
process.env.SYSTEM_TEAMPROJECT = "test-project";
process.env.GITHUB_RUN_ID = "123";
process.env.GITHUB_RUN_ATTEMPT = "1";
global.fetch = vi.fn();
});

afterEach(() => {
delete process.env.SYSTEM_ACCESSTOKEN;
delete process.env.AZURE_DEVOPS_ORG_URL;
delete process.env.SYSTEM_TEAMPROJECT;
delete process.env.GITHUB_RUN_ID;
delete process.env.GITHUB_RUN_ATTEMPT;
delete global.fetch;
});

it("creates a work item through the configured organization and project", async () => {
global.fetch.mockResolvedValue({
ok: true,
status: 200,
statusText: "OK",
text: vi.fn().mockResolvedValue(JSON.stringify({ id: 42, url: "https://dev.azure.com/test-org/_apis/wit/workItems/42" })),
});

const result = await createAzureDevOpsWorkItemHandler("ado_create_work_item", {
work_item_type: "Task",
area_path: "test-project\\Platform",
max: 1,
})(
{
temporary_id: "#aw_item",
title: "Fix the build",
description: "Detailed description of the build failure.",
},
{}
);

expect(result).toMatchObject({
success: true,
temporaryId: "#aw_item",
number: 42,
});
expect(global.fetch).toHaveBeenCalledOnce();
expect(global.fetch.mock.calls[0][0]).toBe("https://dev.azure.com/test-org/test-project/_apis/wit/workitems/$Task?api-version=7.0");
expect(global.fetch.mock.calls[0][1]).toMatchObject({
method: "POST",
redirect: "manual",
headers: {
Accept: "application/json",
"Content-Type": "application/json-patch+json",
},
});
expect(core.debug).toHaveBeenNthCalledWith(1, "Azure DevOps API request started: POST");
expect(core.debug).toHaveBeenNthCalledWith(2, "Azure DevOps API request completed: POST HTTP 200");
expect(core.debug.mock.calls.flat().join(" ")).not.toContain("test-token");
});

it("uses standardized staged logging without work-item content", async () => {
await createAzureDevOpsWorkItemHandler("ado_create_work_item", {
staged: true,
work_item_type: "Task",
})(
{
temporary_id: "#aw_item",
title: "Sensitive customer incident",
description: "Detailed sensitive customer incident description.",
},
{}
);

expect(core.info).toHaveBeenCalledWith("🎭 Staged Mode Preview — Would create Azure DevOps Task");
expect(core.info.mock.calls.flat().join(" ")).not.toContain("Sensitive customer incident");
});

it("does not log staged attachment paths", async () => {
await createAzureDevOpsWorkItemHandler("ado_upload_workitem_attachment", {
staged: true,
})({ work_item_id: 42, file_path: "private/customer-data.pdf" }, {});

expect(core.info).toHaveBeenCalledWith("🎭 Staged Mode Preview — Would attach a file to Azure DevOps work item 42");
expect(core.info.mock.calls.flat().join(" ")).not.toContain("private/customer-data.pdf");
});

it("rejects updates to fields not enabled by configuration", async () => {
const result = await createAzureDevOpsWorkItemHandler("ado_update_work_item", {
target: "*",
title: false,
})({ id: 42, title: "New title" }, {});

expect(result).toEqual({
success: false,
error: "title updates are not enabled by ado_update_work_item",
});
expect(global.fetch).not.toHaveBeenCalled();
});

it("rejects area paths outside configured prefixes", async () => {
const result = await createAzureDevOpsWorkItemHandler("ado_update_work_item", {
staged: true,
area_path: true,
allowed_area_prefixes: ["test-project\\Platform"],
})({ id: 42, area_path: "test-project\\Other" }, {});

expect(result).toEqual({
success: false,
error: "area_path is not permitted by the configured area-path prefixes",
});
expect(global.fetch).not.toHaveBeenCalled();
});

it("rejects reserved agent identities", async () => {
const result = await createAzureDevOpsWorkItemHandler("ado_assign_work_item", {
target: "*",
})({ id: 42, assignee: "GitHub Copilot" }, {});

expect(result).toEqual({
success: false,
error: "assignee 'GitHub Copilot' is a reserved identity",
});
expect(global.fetch).not.toHaveBeenCalled();
});

it("rejects temporary IDs from a different provider", () => {
expect(() =>
resolveWorkItemReference(
"#aw_issue",
{
aw_issue: {
repo: "owner/repo",
number: 42,
},
},
false
)
).toThrow("has not been resolved by ado_create_work_item in this run");
});
});
6 changes: 6 additions & 0 deletions actions/setup/js/comment_on_work_item.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
// @ts-check
const { createAzureDevOpsWorkItemHandler } = require("./azure_devops_work_items.cjs");
async function main(config = {}) {
return createAzureDevOpsWorkItemHandler("ado_comment_on_work_item", config);
}
module.exports = { main };
6 changes: 6 additions & 0 deletions actions/setup/js/create_work_item.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
// @ts-check
const { createAzureDevOpsWorkItemHandler } = require("./azure_devops_work_items.cjs");
async function main(config = {}) {
return createAzureDevOpsWorkItemHandler("ado_create_work_item", config);
}
module.exports = { main };
11 changes: 8 additions & 3 deletions actions/setup/js/generate_safe_outputs_tools.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -304,15 +304,20 @@ async function main() {
}

// Build set of source tool names (predefined/static tools only)
const sourceToolNames = new Set(allTools.map(t => t.name));
const normalizeToolName = name => String(name).replace(/-/g, "_").toLowerCase();
const sourceToolNames = new Set(allTools.map(t => normalizeToolName(t.name)));

// Determine enabled tools: config keys that match source tool names
// This filters out non-tool config entries like dispatch_workflow, call_workflow,
// mentions, max_bot_mentions, etc.
const enabledToolNames = new Set(Object.keys(config).filter(k => sourceToolNames.has(k)));
const enabledToolNames = new Set(
Object.keys(config)
.map(normalizeToolName)
.filter(name => sourceToolNames.has(name))
);
// Filter predefined tools to those enabled in config and apply enhancements
const filteredTools = allTools
.filter(tool => enabledToolNames.has(tool.name))
.filter(tool => enabledToolNames.has(normalizeToolName(tool.name)))
.map(tool => {
// Deep copy to avoid modifying the original. `tool` here is parsed straight from the
// JSON tools-source file (see toolsSourcePath above), so it can never carry a function-valued
Expand Down
22 changes: 22 additions & 0 deletions actions/setup/js/generate_safe_outputs_tools.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,28 @@ describe("generate_safe_outputs_tools", () => {
expect(result.map((/** @type {{name: string}} */ t) => t.name)).not.toContain("missing_tool");
});

it("preserves namespaced public names", () => {
fs.writeFileSync(
toolsSourcePath,
JSON.stringify([
...sampleSourceTools,
{
name: "ado_create_work_item",
description: "Creates an Azure DevOps work item.",
inputSchema: { type: "object", properties: {} },
},
])
);
fs.writeFileSync(configPath, JSON.stringify({ ado_create_work_item: { max: 1 } }));
fs.writeFileSync(toolsMetaPath, JSON.stringify({ description_suffixes: {}, repo_params: {}, dynamic_tools: [] }));

runScript();

const result = JSON.parse(fs.readFileSync(outputPath, "utf8"));
expect(result).toHaveLength(1);
expect(result[0].name).toBe("ado_create_work_item");
});

it("applies description suffix from tools_meta", () => {
fs.writeFileSync(configPath, JSON.stringify({ create_issue: { max: 5 } }));
fs.writeFileSync(
Expand Down
6 changes: 6 additions & 0 deletions actions/setup/js/link_work_items.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
// @ts-check
const { createAzureDevOpsWorkItemHandler } = require("./azure_devops_work_items.cjs");
async function main(config = {}) {
return createAzureDevOpsWorkItemHandler("ado_link_work_items", config);
}
module.exports = { main };
8 changes: 6 additions & 2 deletions actions/setup/js/mcp_server_core.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -487,11 +487,15 @@ function loadToolHandlers(server, tools, basePath) {
*/
function registerTool(server, tool) {
const normalizedName = normalizeTool(tool.name);
const existing = server.tools[normalizedName];
if (existing && existing.name !== tool.name) {
throw new Error(`${ERR_VALIDATION}: Tool name collision: '${existing.name}' and '${tool.name}' both normalize to '${normalizedName}'`);
}
server.tools[normalizedName] = {
...tool,
name: normalizedName,
name: tool.name,
};
server.debug(`Registered tool: ${normalizedName}`);
server.debug(`Registered tool: ${tool.name}`);
}

/**
Expand Down
21 changes: 21 additions & 0 deletions actions/setup/js/safe_output_handler_manager.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,12 @@ const HANDLER_MAP = {
report_incomplete: "./report_incomplete_handler.cjs",
create_report_incomplete_issue: "./create_report_incomplete_issue.cjs",
create_project: "./create_project.cjs",
ado_create_work_item: "./create_work_item.cjs",
ado_update_work_item: "./update_work_item.cjs",
ado_comment_on_work_item: "./comment_on_work_item.cjs",
ado_assign_work_item: "./assign_work_item.cjs",
ado_link_work_items: "./link_work_items.cjs",
ado_upload_workitem_attachment: "./upload_workitem_attachment.cjs",
create_project_status_update: "./create_project_status_update.cjs",
update_project: "./update_project.cjs",
upload_artifact: "./upload_artifact.cjs",
Expand Down Expand Up @@ -145,6 +151,8 @@ const THREAT_WARNING_REVIEWABLE_TYPES = new Set([
"missing_data",
"create_report_incomplete_issue",
"report_incomplete",
"ado_create_work_item",
"ado_comment_on_work_item",
]);

/**
Expand Down Expand Up @@ -193,6 +201,10 @@ const THREAT_WARNING_ABORT_TYPES = new Set([
"call_workflow",
"autofix_code_scanning_alert",
"create_agent_session",
"ado_update_work_item",
"ado_assign_work_item",
"ado_link_work_items",
"ado_upload_workitem_attachment",
]);

/**
Expand Down Expand Up @@ -1095,6 +1107,11 @@ async function processMessages(messageHandlers, messages, onItemCreated = null)
});
core.info(`Registered temporary ID: ${result.temporaryId} -> ${result.repo}#${result.number}`);
}
if (result && result.temporaryId && result.temporaryIdEntry) {
const normalizedTempId = normalizeTemporaryId(result.temporaryId);
temporaryIdMap.set(normalizedTempId, result.temporaryIdEntry);
core.info(`Registered Azure DevOps temporary ID: ${result.temporaryId}`);
}

// If this was a successful upload_artifact, register the artifact URL so that
// subsequent messages can have '#aw_ID' references replaced with the real URL.
Expand Down Expand Up @@ -1285,6 +1302,10 @@ async function processMessages(messageHandlers, messages, onItemCreated = null)
originalTempIdMapSize: tempIdMapSizeBefore,
});
}
if (result && result.temporaryId && result.temporaryIdEntry) {
const normalizedTempId = normalizeTemporaryId(result.temporaryId);
temporaryIdMap.set(normalizedTempId, result.temporaryIdEntry);
}
}

// Update the result to success
Expand Down
Loading
Loading