From 895cb47a944bd03a9ee8cf39694807b189d09307 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 1 Sep 2026 22:53:35 +0000 Subject: [PATCH 01/12] Apply remaining changes Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/assign_work_item.cjs | 6 + actions/setup/js/azure_devops_work_items.cjs | 493 ++++++++++++++++++ actions/setup/js/comment_on_work_item.cjs | 6 + actions/setup/js/create_work_item.cjs | 6 + actions/setup/js/link_work_items.cjs | 6 + actions/setup/js/mcp_server_core.cjs | 8 +- .../setup/js/safe_output_handler_manager.cjs | 21 + actions/setup/js/safe_outputs_handlers.cjs | 93 +++- actions/setup/js/safe_outputs_tools.json | 141 +++++ .../setup/js/safe_outputs_tools_loader.cjs | 19 +- actions/setup/js/update_work_item.cjs | 6 + .../setup/js/upload_workitem_attachment.cjs | 6 + pkg/parser/schemas/main_workflow_schema.json | 186 +++++++ pkg/workflow/compiler_safe_outputs_job.go | 8 +- pkg/workflow/js/safe_outputs_tools.json | 141 +++++ pkg/workflow/publish_artifacts.go | 6 +- pkg/workflow/safe_output_handlers.go | 36 ++ pkg/workflow/safe_outputs_azure_devops.go | 228 ++++++++ .../safe_outputs_config_extraction.go | 7 + pkg/workflow/safe_outputs_config_types.go | 6 + pkg/workflow/safe_outputs_handler_registry.go | 1 + pkg/workflow/safe_outputs_max_validation.go | 30 ++ pkg/workflow/safe_outputs_state.go | 12 + .../safe_outputs_tools_computation.go | 24 + .../safe_outputs_validation_config.go | 55 ++ 25 files changed, 1540 insertions(+), 11 deletions(-) create mode 100644 actions/setup/js/assign_work_item.cjs create mode 100644 actions/setup/js/azure_devops_work_items.cjs create mode 100644 actions/setup/js/comment_on_work_item.cjs create mode 100644 actions/setup/js/create_work_item.cjs create mode 100644 actions/setup/js/link_work_items.cjs create mode 100644 actions/setup/js/update_work_item.cjs create mode 100644 actions/setup/js/upload_workitem_attachment.cjs create mode 100644 pkg/workflow/safe_outputs_azure_devops.go diff --git a/actions/setup/js/assign_work_item.cjs b/actions/setup/js/assign_work_item.cjs new file mode 100644 index 00000000000..14ad5e635fa --- /dev/null +++ b/actions/setup/js/assign_work_item.cjs @@ -0,0 +1,6 @@ +// @ts-check +const { createAzureDevOpsWorkItemHandler } = require("./azure_devops_work_items.cjs"); +async function main(config = {}) { + return createAzureDevOpsWorkItemHandler("assign_work_item", config); +} +module.exports = { main }; diff --git a/actions/setup/js/azure_devops_work_items.cjs b/actions/setup/js/azure_devops_work_items.cjs new file mode 100644 index 00000000000..e276aed13d3 --- /dev/null +++ b/actions/setup/js/azure_devops_work_items.cjs @@ -0,0 +1,493 @@ +// @ts-check +/// + +const fs = require("fs"); +const path = require("path"); +const { normalizeTemporaryId, isTemporaryId } = require("./temporary_id.cjs"); +const { isStagedMode } = require("./safe_output_helpers.cjs"); +const { matchesSimpleGlob } = require("./glob_pattern_helpers.cjs"); + +const WORK_ITEM_RELATIONS = { + parent: "System.LinkTypes.Hierarchy-Reverse", + child: "System.LinkTypes.Hierarchy-Forward", + related: "System.LinkTypes.Related", + predecessor: "System.LinkTypes.Dependency-Reverse", + successor: "System.LinkTypes.Dependency-Forward", + duplicate: "System.LinkTypes.Duplicate-Forward", + "duplicate-of": "System.LinkTypes.Duplicate-Reverse", +}; +const FIELD_REFERENCE_PATTERN = /^[A-Za-z][A-Za-z0-9_.]*$/; +const RESERVED_ASSIGNEES = new Set(["agency", "github copilot"]); +const DEFAULT_ATTACHMENT_SIZE = 5 * 1024 * 1024; + +function failure(error) { + return { success: false, error }; +} + +function staged(message, extra = {}) { + core.info(message); + return { success: true, staged: true, message, ...extra }; +} + +function normalizeAssignee(value) { + const assignee = String(value || "").trim(); + if (!assignee) { + throw new Error("assignee must not be empty"); + } + if (RESERVED_ASSIGNEES.has(assignee.toLowerCase())) { + throw new Error(`assignee '${assignee}' is a reserved identity`); + } + return assignee; +} + +function matchesPattern(value, pattern) { + return matchesSimpleGlob(value.toLowerCase(), String(pattern).trim().toLowerCase()); +} + +function validateTags(tags) { + if (!Array.isArray(tags)) { + throw new Error("tags must be an array"); + } + return tags.map(tag => { + const value = String(tag).trim(); + if (!value) { + throw new Error("tags must not contain empty values"); + } + if (value.includes(";")) { + throw new Error(`tag '${value}' contains a semicolon`); + } + return value; + }); +} + +function validateAllowedTags(tags, allowedTags) { + if (!Array.isArray(allowedTags) || allowedTags.length === 0) return; + const disallowed = tags.filter(tag => !allowedTags.some(pattern => matchesPattern(tag, pattern))); + if (disallowed.length > 0) { + throw new Error(`tags are not permitted by allowed-tags: ${disallowed.join(", ")}`); + } +} + +function getAzureDevOpsContext() { + const rawOrgUrl = String(process.env.AZURE_DEVOPS_ORG_URL || "").trim(); + const project = String(process.env.SYSTEM_TEAMPROJECT || "").trim(); + const systemToken = String(process.env.SYSTEM_ACCESSTOKEN || ""); + const pat = String(process.env.AZURE_DEVOPS_EXT_PAT || ""); + if (!rawOrgUrl) throw new Error("AZURE_DEVOPS_ORG_URL is required"); + if (!project) throw new Error("SYSTEM_TEAMPROJECT is required"); + if (!systemToken && !pat) throw new Error("SYSTEM_ACCESSTOKEN or AZURE_DEVOPS_EXT_PAT is required"); + + let parsed; + try { + parsed = new URL(rawOrgUrl); + } catch { + throw new Error("AZURE_DEVOPS_ORG_URL must be a valid HTTPS URL"); + } + if (parsed.protocol !== "https:" || parsed.username || parsed.password || parsed.search || parsed.hash) { + throw new Error("AZURE_DEVOPS_ORG_URL must be an HTTPS URL without credentials, query parameters, or fragments"); + } + const host = parsed.hostname.toLowerCase(); + if (host !== "dev.azure.com" && !host.endsWith(".visualstudio.com")) { + throw new Error("AZURE_DEVOPS_ORG_URL must use dev.azure.com or an organization.visualstudio.com host"); + } + const pathSegments = parsed.pathname.split("/").filter(Boolean); + if ((host === "dev.azure.com" && pathSegments.length !== 1) || (host.endsWith(".visualstudio.com") && pathSegments.length !== 0)) { + throw new Error("AZURE_DEVOPS_ORG_URL must identify exactly one Azure DevOps organization"); + } + parsed.pathname = parsed.pathname.replace(/\/+$/, ""); + + return { + orgUrl: parsed.toString().replace(/\/$/, ""), + project, + authorization: systemToken ? ["Bearer", systemToken].join(" ") : ["Basic", Buffer.from(`:${pat}`, "utf8").toString("base64")].join(" "), + }; +} + +async function adoRequest(ado, method, apiPath, body, contentType = "application/json") { + const url = `${ado.orgUrl}/${encodeURIComponent(ado.project)}${apiPath}`; + const response = await fetch(url, { + method, + headers: { + Accept: "application/json", + Authorization: ado.authorization, + ...(body !== undefined ? { "Content-Type": contentType } : {}), + }, + body: body === undefined ? undefined : Buffer.isBuffer(body) ? body : JSON.stringify(body), + redirect: "manual", + }); + if (response.status >= 300 && response.status < 400) { + throw new Error(`Azure DevOps rejected a redirected ${method} request`); + } + if (!response.ok) { + throw new Error(`Azure DevOps ${method} request failed with HTTP ${response.status} ${response.statusText}`); + } + if (response.status === 204) return {}; + const text = await response.text(); + return text ? JSON.parse(text) : {}; +} + +function workItemUrl(ado, id) { + return `${ado.orgUrl}/${encodeURIComponent(ado.project)}/_workitems/edit/${id}`; +} + +function resolveWorkItemReference(value, resolvedTemporaryIds, allowStaged) { + if (typeof value === "number" || (typeof value === "string" && /^[1-9][0-9]*$/.test(value.trim()))) { + const id = Number(value); + if (!Number.isSafeInteger(id) || id < 1) throw new Error("work item ID must be a positive safe integer"); + return { id, sameRun: false }; + } + if (!isTemporaryId(value)) { + throw new Error("work item ID must be a positive integer or #aw_ temporary ID"); + } + const key = normalizeTemporaryId(String(value)); + const resolved = resolvedTemporaryIds?.[key]; + if (!resolved || resolved.provider !== "azure-devops" || resolved.resourceType !== "work-item") { + throw new Error(`temporary work-item ID '#${key}' has not been resolved by create-work-item in this run`); + } + const id = Number(resolved.workItemId); + if (Number.isSafeInteger(id) && id > 0) return { id, sameRun: true }; + if (allowStaged && resolved.staged === true) return { id: null, sameRun: true }; + throw new Error(`temporary work-item ID '#${key}' does not reference a created work item`); +} + +function targetAllowsId(target, id) { + if (target === "*") return true; + if (Number.isSafeInteger(target)) return target === id; + if (Array.isArray(target)) return target.includes(id); + return null; +} + +async function getWorkItem(ado, id, fields = []) { + const query = fields.length > 0 ? `&fields=${fields.map(encodeURIComponent).join(",")}` : ""; + return adoRequest(ado, "GET", `/_apis/wit/workitems/${id}?api-version=7.0${query}`); +} + +async function enforceTarget(ado, target, id) { + if (target == null) throw new Error("target is required for pre-existing work items"); + const allowed = targetAllowsId(target, id); + if (allowed === true) return; + if (allowed === false) throw new Error(`work item #${id} is not permitted by the target configuration`); + if (typeof target !== "string" || !target.trim()) throw new Error("target must be '*', a positive ID, a list of IDs, or an area path"); + + const workItem = await getWorkItem(ado, id, ["System.AreaPath"]); + const areaPath = String(workItem?.fields?.["System.AreaPath"] || ""); + const prefix = target.trim(); + const areaLower = areaPath.toLowerCase(); + const prefixLower = prefix.toLowerCase(); + if (areaLower !== prefixLower && !areaLower.startsWith(`${prefixLower}\\`)) { + throw new Error(`work item #${id} is outside the configured area-path target`); + } +} + +function fieldPatch(op, field, value) { + if (!FIELD_REFERENCE_PATTERN.test(field)) throw new Error(`invalid Azure DevOps field reference '${field}'`); + return { op, path: `/fields/${field}`, value }; +} + +function validateUniqueFields(entries) { + const seen = new Map(); + for (const [label, field] of entries) { + const key = field.toLowerCase(); + if (seen.has(key)) throw new Error(`${label} field '${field}' duplicates ${seen.get(key)} field`); + seen.set(key, label); + } +} + +async function handleCreateWorkItem(message, config, resolvedTemporaryIds) { + const temporaryId = String(message.temporary_id || ""); + if (!/^#aw_[A-Za-z0-9_]{3,12}$/.test(temporaryId)) return failure("create-work-item requires a server-generated temporary_id"); + const normalized = normalizeTemporaryId(temporaryId); + if (resolvedTemporaryIds?.[normalized]) return failure(`temporary_id '${temporaryId}' was already used in this run`); + + try { + const title = String(message.title || "").trim(); + const description = String(message.description || "").trim(); + if (title.length < 6 || title.length > 255) throw new Error("title must contain 6 to 255 characters"); + if (description.length < 31 || description.length > 65000) throw new Error("description must contain 31 to 65000 characters"); + const agentTags = validateTags(message.tags || []); + validateAllowedTags(agentTags, config.allowed_tags); + const staticTags = validateTags(config.tags || []); + const tags = [...staticTags]; + for (const tag of agentTags) { + if (!tags.some(existing => existing.toLowerCase() === tag.toLowerCase())) tags.push(tag); + } + const workItemType = String(config.work_item_type || "Task").trim(); + const descriptionField = String(config.description_field || (workItemType.toLowerCase() === "bug" ? "Microsoft.VSTS.TCM.ReproSteps" : "System.Description")); + const customFields = config.custom_fields && typeof config.custom_fields === "object" ? config.custom_fields : {}; + const configuredFields = [ + ["title", "System.Title"], + ["description", descriptionField], + ...(config.area_path ? [["area_path", "System.AreaPath"]] : []), + ...(config.iteration_path ? [["iteration_path", "System.IterationPath"]] : []), + ...(config.assignee ? [["assignee", "System.AssignedTo"]] : []), + ...(tags.length > 0 ? [["tags", "System.Tags"]] : []), + ...Object.keys(customFields).map(field => ["custom_fields", field]), + ]; + validateUniqueFields(configuredFields); + if (config.assignee) normalizeAssignee(config.assignee); + + if (isStagedMode(config)) { + return staged(`Would create Azure DevOps ${workItemType} '${title}'`, { + temporaryId, + temporaryIdEntry: { provider: "azure-devops", resourceType: "work-item", staged: true }, + }); + } + + const ado = getAzureDevOpsContext(); + const patch = [fieldPatch("add", "System.Title", title), fieldPatch("add", descriptionField, description), { op: "add", path: `/multilineFieldsFormat/${descriptionField}`, value: "Markdown" }]; + if (config.area_path) patch.push(fieldPatch("add", "System.AreaPath", String(config.area_path))); + if (config.iteration_path) patch.push(fieldPatch("add", "System.IterationPath", String(config.iteration_path))); + if (config.assignee) patch.push(fieldPatch("add", "System.AssignedTo", normalizeAssignee(config.assignee))); + if (tags.length > 0) patch.push(fieldPatch("add", "System.Tags", tags.join("; "))); + for (const field of Object.keys(customFields).sort((a, b) => a.localeCompare(b))) { + patch.push(fieldPatch("add", field, String(customFields[field]))); + } + + if (config.artifact_link?.enabled === true) { + const repository = String(config.artifact_link.repository || process.env.BUILD_REPOSITORY_NAME || "").trim(); + if (!repository) throw new Error("artifact-link requires a repository or BUILD_REPOSITORY_NAME"); + const repo = await adoRequest(ado, "GET", `/_apis/git/repositories/${encodeURIComponent(repository)}?api-version=7.0`); + if (!repo?.id) throw new Error("Azure DevOps repository response did not contain an ID"); + const branch = String(config.artifact_link.branch || "main"); + patch.push({ + op: "add", + path: "/relations/-", + value: { + rel: "ArtifactLink", + url: `vstfs:///Git/Ref/${ado.project}%2F${repo.id}%2FGB${branch}`, + attributes: { name: "Branch" }, + }, + }); + } + + const created = await adoRequest(ado, "POST", `/_apis/wit/workitems/$${encodeURIComponent(workItemType)}?api-version=7.0`, patch, "application/json-patch+json"); + const id = Number(created?.id); + if (!Number.isSafeInteger(id) || id < 1) throw new Error("Azure DevOps create response did not contain a valid work-item ID"); + const url = workItemUrl(ado, id); + return { + success: true, + number: id, + url, + temporaryId, + metadata: { provider: "azure-devops", project: ado.project, work_item_id: id }, + temporaryIdEntry: { provider: "azure-devops", resourceType: "work-item", workItemId: id, url }, + }; + } catch (error) { + return failure(error instanceof Error ? error.message : String(error)); + } +} + +async function handleUpdateWorkItem(message, config, resolvedTemporaryIds) { + try { + const preview = isStagedMode(config); + const resolved = resolveWorkItemReference(message.id, resolvedTemporaryIds, preview); + const fields = [ + ["title", "System.Title", config.title], + ["body", "System.Description", config.body], + ["state", "System.State", config.status], + ["area_path", "System.AreaPath", config.area_path], + ["iteration_path", "System.IterationPath", config.iteration_path], + ["assignee", "System.AssignedTo", config.assignee], + ["tags", "System.Tags", config.tags], + ]; + const requested = fields.filter(([name]) => message[name] !== undefined); + if (requested.length === 0) throw new Error("at least one update field is required"); + const disabled = requested.find(([, , enabled]) => enabled !== true); + if (disabled) throw new Error(`${disabled[0]} updates are not enabled by update-work-item`); + if (message.assignee !== undefined) message.assignee = normalizeAssignee(message.assignee); + if (message.tags !== undefined) { + message.tags = validateTags(message.tags); + validateAllowedTags(message.tags, config.allowed_tags); + } + if (preview) return staged(`Would update Azure DevOps work item ${message.id}`); + + const ado = getAzureDevOpsContext(); + if (!resolved.sameRun) await enforceTarget(ado, config.target, resolved.id); + if (config.title_prefix || config.tag_prefix) { + const current = await getWorkItem(ado, resolved.id, ["System.Title", "System.Tags"]); + if (config.title_prefix && !String(current?.fields?.["System.Title"] || "").startsWith(String(config.title_prefix))) { + throw new Error(`work item #${resolved.id} does not match title-prefix`); + } + if (config.tag_prefix) { + const tags = String(current?.fields?.["System.Tags"] || "") + .split(";") + .map(tag => tag.trim()); + if (!tags.some(tag => tag.startsWith(String(config.tag_prefix)))) throw new Error(`work item #${resolved.id} does not match tag-prefix`); + } + } + const patch = requested.map(([name, field]) => { + const value = name === "tags" ? message.tags.join("; ") : message[name]; + return fieldPatch("replace", field, value); + }); + if (message.body !== undefined && config.markdown_body === true) { + patch.push({ op: "replace", path: "/multilineFieldsFormat/System.Description", value: "Markdown" }); + } + await adoRequest(ado, "PATCH", `/_apis/wit/workitems/${resolved.id}?api-version=7.0`, patch, "application/json-patch+json"); + return { success: true, number: resolved.id, url: workItemUrl(ado, resolved.id), metadata: { provider: "azure-devops", project: ado.project } }; + } catch (error) { + return failure(error instanceof Error ? error.message : String(error)); + } +} + +async function handleCommentOnWorkItem(message, config, resolvedTemporaryIds) { + try { + const preview = isStagedMode(config); + const resolved = resolveWorkItemReference(message.work_item_id, resolvedTemporaryIds, preview); + if (preview) return staged(`Would comment on Azure DevOps work item ${message.work_item_id}`); + const ado = getAzureDevOpsContext(); + if (!resolved.sameRun) await enforceTarget(ado, config.target, resolved.id); + const comment = await adoRequest(ado, "POST", `/_apis/wit/workItems/${resolved.id}/comments?api-version=7.1-preview.4`, { text: String(message.body) }); + return { + success: true, + number: resolved.id, + url: workItemUrl(ado, resolved.id), + metadata: { provider: "azure-devops", project: ado.project, comment_id: comment?.id }, + }; + } catch (error) { + return failure(error instanceof Error ? error.message : String(error)); + } +} + +async function handleAssignWorkItem(message, config, resolvedTemporaryIds) { + try { + const assignee = normalizeAssignee(message.assignee); + if (Array.isArray(config.allowed) && config.allowed.length > 0 && !config.allowed.some(value => String(value).trim().toLowerCase() === assignee.toLowerCase())) { + throw new Error(`assignee '${assignee}' is not permitted by assign-work-item.allowed`); + } + if (Array.isArray(config.blocked) && config.blocked.some(pattern => matchesPattern(assignee, pattern))) { + throw new Error(`assignee '${assignee}' is blocked by assign-work-item.blocked`); + } + const preview = isStagedMode(config); + const resolved = resolveWorkItemReference(message.work_item_id, resolvedTemporaryIds, preview); + if (preview) return staged(`Would assign Azure DevOps work item ${message.work_item_id} to '${assignee}'`); + const ado = getAzureDevOpsContext(); + if (!resolved.sameRun) await enforceTarget(ado, config.target, resolved.id); + await adoRequest(ado, "PATCH", `/_apis/wit/workitems/${resolved.id}?api-version=7.0`, [fieldPatch("replace", "System.AssignedTo", assignee)], "application/json-patch+json"); + return { success: true, number: resolved.id, url: workItemUrl(ado, resolved.id), metadata: { provider: "azure-devops", project: ado.project, assignee } }; + } catch (error) { + return failure(error instanceof Error ? error.message : String(error)); + } +} + +async function handleLinkWorkItems(message, config, resolvedTemporaryIds) { + try { + const relation = WORK_ITEM_RELATIONS[message.link_type]; + if (!relation) throw new Error(`invalid link_type '${message.link_type}'`); + if (Array.isArray(config.allowed_link_types) && config.allowed_link_types.length > 0 && !config.allowed_link_types.includes(message.link_type)) { + throw new Error(`link_type '${message.link_type}' is not permitted by allowed-link-types`); + } + const preview = isStagedMode(config); + const source = resolveWorkItemReference(message.source_id, resolvedTemporaryIds, preview); + const target = resolveWorkItemReference(message.target_id, resolvedTemporaryIds, preview); + if (source.id !== null && source.id === target.id) throw new Error("source_id and target_id must identify different work items"); + if (preview) return staged(`Would link Azure DevOps work items ${message.source_id} and ${message.target_id}`); + const ado = getAzureDevOpsContext(); + if (!source.sameRun) await enforceTarget(ado, config.target, source.id); + if (!target.sameRun) await enforceTarget(ado, config.target, target.id); + const value = { + rel: relation, + url: `${ado.orgUrl}/${encodeURIComponent(ado.project)}/_apis/wit/workitems/${target.id}`, + ...(message.comment ? { attributes: { comment: String(message.comment) } } : {}), + }; + await adoRequest(ado, "PATCH", `/_apis/wit/workitems/${source.id}?api-version=7.1`, [{ op: "add", path: "/relations/-", value }], "application/json-patch+json"); + return { + success: true, + number: source.id, + url: workItemUrl(ado, source.id), + metadata: { provider: "azure-devops", project: ado.project, target_work_item_id: target.id, link_type: message.link_type }, + }; + } catch (error) { + return failure(error instanceof Error ? error.message : String(error)); + } +} + +function readStagedAttachment(message, config) { + const stagedFile = String(message.staged_file || ""); + if (!/^[A-Za-z0-9._/-]+$/.test(stagedFile) || stagedFile.startsWith("/") || stagedFile.split("/").some(segment => !segment || segment === "." || segment === "..")) { + throw new Error("staged attachment path is invalid"); + } + const root = path.resolve(process.env.RUNNER_TEMP || "/tmp", "gh-aw", "safeoutputs", "upload-artifacts"); + const filePath = path.resolve(root, ...stagedFile.split("/")); + if (!filePath.startsWith(root + path.sep)) throw new Error("staged attachment path escapes the staging directory"); + let current = root; + let stat; + for (const segment of stagedFile.split("/")) { + current = path.join(current, segment); + stat = fs.lstatSync(current); + if (stat.isSymbolicLink()) throw new Error("staged attachment must not contain symbolic links"); + } + if (!stat?.isFile()) throw new Error("staged attachment must be a regular file"); + const maxFileSize = Number(config.max_file_size || DEFAULT_ATTACHMENT_SIZE); + if (!Number.isSafeInteger(maxFileSize) || maxFileSize < 1 || stat.size > maxFileSize) { + throw new Error(`attachment exceeds max-file-size of ${maxFileSize} bytes`); + } + const originalPath = String(message.file_path || ""); + const allowedExtensions = Array.isArray(config.allowed_extensions) ? config.allowed_extensions : []; + if (allowedExtensions.length > 0 && !allowedExtensions.some(extension => originalPath.toLowerCase().endsWith(String(extension).toLowerCase()))) { + throw new Error("attachment extension is not permitted"); + } + const bytes = fs.readFileSync(filePath); + if (bytes.includes(Buffer.from("##vso["))) throw new Error("attachment contains an Azure Pipelines command sequence"); + return { bytes, filename: path.basename(originalPath) || path.basename(filePath) }; +} + +async function handleUploadWorkItemAttachment(message, config, resolvedTemporaryIds) { + try { + const preview = isStagedMode(config); + const resolved = resolveWorkItemReference(message.work_item_id, resolvedTemporaryIds, preview); + if (preview) return staged(`Would attach '${message.file_path}' to Azure DevOps work item ${message.work_item_id}`); + const { bytes, filename } = readStagedAttachment(message, config); + const ado = getAzureDevOpsContext(); + const upload = await adoRequest(ado, "POST", `/_apis/wit/attachments?fileName=${encodeURIComponent(filename)}&api-version=7.1`, bytes, "application/octet-stream"); + const attachmentUrl = String(upload?.url || ""); + let parsedAttachmentUrl; + try { + parsedAttachmentUrl = new URL(attachmentUrl); + } catch { + throw new Error("Azure DevOps attachment response did not contain a valid URL"); + } + const attachmentHost = parsedAttachmentUrl.hostname.toLowerCase(); + if (parsedAttachmentUrl.protocol !== "https:" || (attachmentHost !== "dev.azure.com" && !attachmentHost.endsWith(".visualstudio.com"))) { + throw new Error("Azure DevOps attachment response contained an untrusted URL"); + } + const comment = `${config.comment_prefix || ""}${message.comment || "Uploaded by agent"}`; + const patch = [ + { + op: "add", + path: "/relations/-", + value: { rel: "AttachedFile", url: attachmentUrl, attributes: { comment } }, + }, + ]; + await adoRequest(ado, "PATCH", `/_apis/wit/workitems/${resolved.id}?api-version=7.1`, patch, "application/json-patch+json"); + return { + success: true, + number: resolved.id, + url: workItemUrl(ado, resolved.id), + metadata: { provider: "azure-devops", project: ado.project, attachment_url: attachmentUrl, file_name: filename }, + }; + } catch (error) { + return failure(error instanceof Error ? error.message : String(error)); + } +} + +const HANDLERS = { + create_work_item: handleCreateWorkItem, + update_work_item: handleUpdateWorkItem, + comment_on_work_item: handleCommentOnWorkItem, + assign_work_item: handleAssignWorkItem, + link_work_items: handleLinkWorkItems, + upload_workitem_attachment: handleUploadWorkItemAttachment, +}; + +function createAzureDevOpsWorkItemHandler(type, config = {}) { + const handler = HANDLERS[type]; + if (!handler) throw new Error(`Unsupported Azure DevOps safe-output type '${type}'`); + return async (message, resolvedTemporaryIds = {}) => handler({ ...message }, config, resolvedTemporaryIds); +} + +module.exports = { + createAzureDevOpsWorkItemHandler, + getAzureDevOpsContext, + resolveWorkItemReference, + targetAllowsId, +}; diff --git a/actions/setup/js/comment_on_work_item.cjs b/actions/setup/js/comment_on_work_item.cjs new file mode 100644 index 00000000000..36301bbf32b --- /dev/null +++ b/actions/setup/js/comment_on_work_item.cjs @@ -0,0 +1,6 @@ +// @ts-check +const { createAzureDevOpsWorkItemHandler } = require("./azure_devops_work_items.cjs"); +async function main(config = {}) { + return createAzureDevOpsWorkItemHandler("comment_on_work_item", config); +} +module.exports = { main }; diff --git a/actions/setup/js/create_work_item.cjs b/actions/setup/js/create_work_item.cjs new file mode 100644 index 00000000000..512fd31c12c --- /dev/null +++ b/actions/setup/js/create_work_item.cjs @@ -0,0 +1,6 @@ +// @ts-check +const { createAzureDevOpsWorkItemHandler } = require("./azure_devops_work_items.cjs"); +async function main(config = {}) { + return createAzureDevOpsWorkItemHandler("create_work_item", config); +} +module.exports = { main }; diff --git a/actions/setup/js/link_work_items.cjs b/actions/setup/js/link_work_items.cjs new file mode 100644 index 00000000000..8256e169a1d --- /dev/null +++ b/actions/setup/js/link_work_items.cjs @@ -0,0 +1,6 @@ +// @ts-check +const { createAzureDevOpsWorkItemHandler } = require("./azure_devops_work_items.cjs"); +async function main(config = {}) { + return createAzureDevOpsWorkItemHandler("link_work_items", config); +} +module.exports = { main }; diff --git a/actions/setup/js/mcp_server_core.cjs b/actions/setup/js/mcp_server_core.cjs index b7018fea98f..d2819a9336a 100644 --- a/actions/setup/js/mcp_server_core.cjs +++ b/actions/setup/js/mcp_server_core.cjs @@ -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(`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}`); } /** diff --git a/actions/setup/js/safe_output_handler_manager.cjs b/actions/setup/js/safe_output_handler_manager.cjs index d6ea1c9660d..b18c58f4488 100644 --- a/actions/setup/js/safe_output_handler_manager.cjs +++ b/actions/setup/js/safe_output_handler_manager.cjs @@ -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", + create_work_item: "./create_work_item.cjs", + update_work_item: "./update_work_item.cjs", + comment_on_work_item: "./comment_on_work_item.cjs", + assign_work_item: "./assign_work_item.cjs", + link_work_items: "./link_work_items.cjs", + 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", @@ -145,6 +151,8 @@ const THREAT_WARNING_REVIEWABLE_TYPES = new Set([ "missing_data", "create_report_incomplete_issue", "report_incomplete", + "create_work_item", + "comment_on_work_item", ]); /** @@ -193,6 +201,10 @@ const THREAT_WARNING_ABORT_TYPES = new Set([ "call_workflow", "autofix_code_scanning_alert", "create_agent_session", + "update_work_item", + "assign_work_item", + "link_work_items", + "upload_workitem_attachment", ]); /** @@ -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. @@ -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 diff --git a/actions/setup/js/safe_outputs_handlers.cjs b/actions/setup/js/safe_outputs_handlers.cjs index ba6b96d814a..031d2fa707b 100644 --- a/actions/setup/js/safe_outputs_handlers.cjs +++ b/actions/setup/js/safe_outputs_handlers.cjs @@ -20,7 +20,7 @@ const { getErrorMessage } = require("./error_helpers.cjs"); const { ERR_CONFIG, ERR_PARSE, ERR_SYSTEM, ERR_VALIDATION } = require("./error_codes.cjs"); const { findRepoCheckout } = require("./find_repo_checkout.cjs"); const { resolveTargetRepoConfig, resolveAndValidateRepo } = require("./repo_helpers.cjs"); -const { getOrGenerateTemporaryId } = require("./temporary_id.cjs"); +const { generateTemporaryId, getOrGenerateTemporaryId } = require("./temporary_id.cjs"); const { parseAllowedExtensionsEnv } = require("./allowed_extensions_helpers.cjs"); const { getStagedPatchDiffSizeBytes } = require("./git_patch_utils.cjs"); const { sanitizeTitle, applyTitlePrefix } = require("./sanitize_title.cjs"); @@ -101,7 +101,7 @@ function readJSONFile(filePath) { const safeOutputsTools = readJSONFile(path.join(__dirname, "safe_outputs_tools.json")); -const safeOutputsToolMap = new Map(safeOutputsTools.map(tool => [tool.name, tool])); +const safeOutputsToolMap = new Map(safeOutputsTools.map(tool => [tool.name.replace(/-/g, "_"), tool])); /** * @param {string} error @@ -2063,6 +2063,89 @@ function createHandlers(server, appendSafeOutput, config = {}) { ], isError: true, }; + + const createWorkItemHandler = args => { + const temporaryId = `#${generateTemporaryId()}`; + const entry = { ...(args || {}), type: "create_work_item", temporary_id: temporaryId }; + appendSafeOutputCounted(entry); + const output = { result: "success", temporary_id: temporaryId }; + return { + content: [{ type: "text", text: JSON.stringify(output) }], + structuredContent: output, + }; + }; + + const createAzureDevOpsWorkItemHandler = type => args => { + const entry = { ...(args || {}), type }; + appendSafeOutputCounted(entry); + return { + content: [{ type: "text", text: JSON.stringify({ result: "success" }) }], + }; + }; + + const uploadWorkItemAttachmentHandler = args => { + const entry = { ...(args || {}), type: "upload_workitem_attachment" }; + const rawPath = typeof entry.file_path === "string" ? entry.file_path.trim() : ""; + if (!rawPath || path.isAbsolute(rawPath) || rawPath.includes(":")) { + return buildIntentErrorResponse("upload-workitem-attachment file_path must be a workspace-relative path without ':'"); + } + + const segments = rawPath.split(/[\\/]+/); + if (segments.some(segment => !segment || segment === "." || segment === "..")) { + return buildIntentErrorResponse("upload-workitem-attachment file_path must not contain empty, '.' or '..' path segments"); + } + + const workspace = path.resolve(process.env.GITHUB_WORKSPACE || process.cwd()); + const sourcePath = path.resolve(workspace, ...segments); + if (sourcePath !== workspace && !sourcePath.startsWith(workspace + path.sep)) { + return buildIntentErrorResponse("upload-workitem-attachment file_path resolves outside the workspace"); + } + + let current = workspace; + let sourceStat; + try { + for (const segment of segments) { + current = path.join(current, segment); + sourceStat = lstatGuard(current); + if (!sourceStat) { + return buildIntentErrorResponse("upload-workitem-attachment does not accept symbolic links"); + } + } + } catch (error) { + return buildIntentErrorResponse(`upload-workitem-attachment could not read file_path: ${getErrorMessage(error)}`); + } + if (!sourceStat?.isFile()) { + return buildIntentErrorResponse("upload-workitem-attachment file_path must identify one regular file"); + } + + const attachmentConfig = getSafeOutputsToolConfig(config, "upload_workitem_attachment"); + const maxFileSize = Number(attachmentConfig.max_file_size || 5 * 1024 * 1024); + if (!Number.isSafeInteger(maxFileSize) || maxFileSize < 1 || sourceStat.size > maxFileSize) { + return buildIntentErrorResponse(`upload-workitem-attachment file exceeds the configured max-file-size of ${maxFileSize} bytes`); + } + const allowedExtensions = Array.isArray(attachmentConfig.allowed_extensions) ? attachmentConfig.allowed_extensions : []; + if (allowedExtensions.length > 0 && !allowedExtensions.some(extension => rawPath.toLowerCase().endsWith(String(extension).toLowerCase()))) { + return buildIntentErrorResponse("upload-workitem-attachment file extension is not allowed by the workflow configuration"); + } + + try { + const stagingRoot = path.join(process.env.RUNNER_TEMP || "/tmp", "gh-aw", "safeoutputs", "upload-artifacts"); + const stagingDirectory = path.join(stagingRoot, "azure-devops-work-items"); + fs.mkdirSync(stagingDirectory, { recursive: true, mode: 0o700 }); + const stagedName = `${crypto.randomUUID()}-${path.basename(rawPath)}`; + const stagedPath = path.join(stagingDirectory, stagedName); + fs.copyFileSync(sourcePath, stagedPath, fs.constants.COPYFILE_EXCL); + fs.chmodSync(stagedPath, 0o600); + entry.staged_file = path.posix.join("azure-devops-work-items", stagedName); + } catch (error) { + throw new Error(`${ERR_SYSTEM}: Failed to stage Azure DevOps work-item attachment: ${getErrorMessage(error)}`, { cause: error }); + } + + appendSafeOutputCounted(entry); + return { + content: [{ type: "text", text: JSON.stringify({ result: "success", file_path: rawPath }) }], + }; + }; } const resolvedRepo = repoResult.repo; @@ -3117,6 +3200,12 @@ function createHandlers(server, appendSafeOutput, config = {}) { pushToPullRequestBranchHandler, pushRepoMemoryHandler, createIssueHandler, + createWorkItemHandler, + updateWorkItemHandler: createAzureDevOpsWorkItemHandler("update_work_item"), + commentOnWorkItemHandler: createAzureDevOpsWorkItemHandler("comment_on_work_item"), + assignWorkItemHandler: createAzureDevOpsWorkItemHandler("assign_work_item"), + linkWorkItemsHandler: createAzureDevOpsWorkItemHandler("link_work_items"), + uploadWorkItemAttachmentHandler, createProjectHandler, addCommentHandler, createPullRequestReviewCommentHandler, diff --git a/actions/setup/js/safe_outputs_tools.json b/actions/setup/js/safe_outputs_tools.json index 393e610b3d3..98da036ffda 100644 --- a/actions/setup/js/safe_outputs_tools.json +++ b/actions/setup/js/safe_outputs_tools.json @@ -2082,5 +2082,146 @@ "anyOf": ["pull_request_number", "pr_number", "pr", "pull_number"] } } + }, + { + "name": "create-work-item", + "description": "Create an Azure DevOps work item and return a temporary #aw_ ID for later work-item tools in this run.", + "inputSchema": { + "type": "object", + "required": ["title", "description"], + "properties": { + "title": { + "type": "string", + "minLength": 6, + "maxLength": 255, + "description": "Concise work-item title." + }, + "description": { + "type": "string", + "minLength": 31, + "maxLength": 65000, + "description": "Detailed work-item description in Markdown." + }, + "tags": { + "type": "array", + "items": { "type": "string", "minLength": 1, "maxLength": 256 }, + "description": "Optional tags. Tags cannot contain semicolons and may be restricted by allowed-tags." + } + }, + "additionalProperties": false + } + }, + { + "name": "update-work-item", + "description": "Update explicitly enabled fields on an Azure DevOps work item.", + "inputSchema": { + "type": "object", + "required": ["id"], + "properties": { + "id": { + "type": ["number", "string"], + "description": "Positive work-item ID or a temporary #aw_ ID returned by create-work-item." + }, + "title": { "type": "string", "minLength": 1, "maxLength": 255 }, + "body": { "type": "string", "maxLength": 65000 }, + "state": { "type": "string", "minLength": 1, "maxLength": 128 }, + "area_path": { "type": "string", "minLength": 1, "maxLength": 512 }, + "iteration_path": { "type": "string", "minLength": 1, "maxLength": 512 }, + "assignee": { "type": "string", "minLength": 1, "maxLength": 256 }, + "tags": { + "type": "array", + "items": { "type": "string", "minLength": 1, "maxLength": 256 } + } + }, + "additionalProperties": false + } + }, + { + "name": "comment-on-work-item", + "description": "Add a Markdown comment to an explicitly scoped Azure DevOps work item.", + "inputSchema": { + "type": "object", + "required": ["work_item_id", "body"], + "properties": { + "work_item_id": { + "type": ["number", "string"], + "description": "Positive work-item ID or a temporary #aw_ ID returned by create-work-item." + }, + "body": { + "type": "string", + "minLength": 10, + "maxLength": 65000, + "description": "Comment text in Markdown." + } + }, + "additionalProperties": false + } + }, + { + "name": "assign-work-item", + "description": "Assign an allowed Azure DevOps identity to a work item.", + "inputSchema": { + "type": "object", + "required": ["work_item_id", "assignee"], + "properties": { + "work_item_id": { + "type": ["number", "string"], + "description": "Positive work-item ID or a temporary #aw_ ID returned by create-work-item." + }, + "assignee": { + "type": "string", + "minLength": 1, + "maxLength": 256, + "description": "Azure DevOps identity, such as an email address or display name." + } + }, + "additionalProperties": false + } + }, + { + "name": "link-work-items", + "description": "Create a relationship between two explicitly scoped Azure DevOps work items.", + "inputSchema": { + "type": "object", + "required": ["source_id", "target_id", "link_type"], + "properties": { + "source_id": { + "type": ["number", "string"], + "description": "Positive source work-item ID or a temporary #aw_ ID." + }, + "target_id": { + "type": ["number", "string"], + "description": "Positive target work-item ID or a temporary #aw_ ID." + }, + "link_type": { + "type": "string", + "enum": ["parent", "child", "related", "predecessor", "successor", "duplicate", "duplicate-of"] + }, + "comment": { "type": "string", "minLength": 5, "maxLength": 1024 } + }, + "additionalProperties": false + } + }, + { + "name": "upload-workitem-attachment", + "description": "Upload one workspace file and attach it to an Azure DevOps work item.", + "inputSchema": { + "type": "object", + "required": ["work_item_id", "file_path"], + "properties": { + "work_item_id": { + "type": ["number", "string"], + "description": "Positive work-item ID or a temporary #aw_ ID returned by create-work-item." + }, + "file_path": { + "type": "string", + "minLength": 1, + "maxLength": 1024, + "description": "Workspace-relative file path. Absolute paths, traversal, colons, and symbolic links are rejected." + }, + "comment": { "type": "string", "minLength": 3, "maxLength": 1024 } + }, + "additionalProperties": false + } } ] diff --git a/actions/setup/js/safe_outputs_tools_loader.cjs b/actions/setup/js/safe_outputs_tools_loader.cjs index 0962fdfe841..b37d0a13de3 100644 --- a/actions/setup/js/safe_outputs_tools_loader.cjs +++ b/actions/setup/js/safe_outputs_tools_loader.cjs @@ -5,6 +5,8 @@ const { validateTargetRepo, parseAllowedRepos, getDefaultTargetRepo } = require( const fs = require("fs"); +const normalizeConfiguredToolName = name => String(name).replace(/-/g, "_").toLowerCase(); + /** * Check whether a schema enforces strict object keys. * @param {any} inputSchema - Tool input schema @@ -191,10 +193,16 @@ function attachHandlers(tools, handlers, logger) { remove_labels: handlers.removeLabelsHandler, update_discussion: handlers.updateDiscussionHandler, close_discussion: handlers.closeDiscussionHandler, + create_work_item: handlers.createWorkItemHandler, + update_work_item: handlers.updateWorkItemHandler, + comment_on_work_item: handlers.commentOnWorkItemHandler, + assign_work_item: handlers.assignWorkItemHandler, + link_work_items: handlers.linkWorkItemsHandler, + upload_workitem_attachment: handlers.uploadWorkItemAttachmentHandler, }; tools.forEach(tool => { - const handler = handlerMap[tool.name]; + const handler = handlerMap[normalizeConfiguredToolName(tool.name)]; if (handler) { tool.handler = handler; } else if (typeof handlers.defaultHandler === "function") { @@ -273,10 +281,11 @@ function registerPredefinedTools(server, tools, config, registerTool, normalizeT tools.forEach(tool => { // Check if this is a regular tool matching a config key - if (Object.keys(config).find(configKey => normalizeTool(configKey) === tool.name)) { + const normalizedToolName = normalizeTool(tool.name); + if (Object.keys(config).find(configKey => normalizeTool(configKey) === normalizedToolName)) { let toolToRegister = tool; - const safetyWarning = toolSafetyWarnings[tool.name]; - const isCreatePullRequestTool = tool.name === "create_pull_request" && config.create_pull_request; + const safetyWarning = toolSafetyWarnings[normalizedToolName]; + const isCreatePullRequestTool = normalizedToolName === "create_pull_request" && config.create_pull_request; // Enrich create_pull_request tool description when target-repo is configured if (safetyWarning || isCreatePullRequestTool) { // The handler is a function and cannot be structurally cloned, so it is @@ -387,7 +396,7 @@ function registerDynamicTools(server, tools, config, outputFile, registerTool, n // Skip if it's already a predefined tool, or if a dynamically generated tool named after // its target (identified by metadata) already covers this config key. - if (server.tools[normalizedKey] || tools.find(t => t.name === normalizedKey)) { + if (server.tools[normalizedKey] || tools.find(t => normalizeTool(t.name) === normalizedKey)) { return; } if (isConfigKeyCoveredByDynamicTool(tools, normalizedKey)) { diff --git a/actions/setup/js/update_work_item.cjs b/actions/setup/js/update_work_item.cjs new file mode 100644 index 00000000000..814cdd5e48c --- /dev/null +++ b/actions/setup/js/update_work_item.cjs @@ -0,0 +1,6 @@ +// @ts-check +const { createAzureDevOpsWorkItemHandler } = require("./azure_devops_work_items.cjs"); +async function main(config = {}) { + return createAzureDevOpsWorkItemHandler("update_work_item", config); +} +module.exports = { main }; diff --git a/actions/setup/js/upload_workitem_attachment.cjs b/actions/setup/js/upload_workitem_attachment.cjs new file mode 100644 index 00000000000..6910fdb5376 --- /dev/null +++ b/actions/setup/js/upload_workitem_attachment.cjs @@ -0,0 +1,6 @@ +// @ts-check +const { createAzureDevOpsWorkItemHandler } = require("./azure_devops_work_items.cjs"); +async function main(config = {}) { + return createAzureDevOpsWorkItemHandler("upload_workitem_attachment", config); +} +module.exports = { main }; diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index b94416c9780..5a0fcc0caba 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -8545,6 +8545,24 @@ ], "description": "Enable AI agents to create autofixes for code scanning alerts using the GitHub REST API." }, + "create-work-item": { + "$ref": "#/$defs/azure_devops_create_work_item" + }, + "update-work-item": { + "$ref": "#/$defs/azure_devops_update_work_item" + }, + "comment-on-work-item": { + "$ref": "#/$defs/azure_devops_comment_on_work_item" + }, + "assign-work-item": { + "$ref": "#/$defs/azure_devops_assign_work_item" + }, + "link-work-items": { + "$ref": "#/$defs/azure_devops_link_work_items" + }, + "upload-workitem-attachment": { + "$ref": "#/$defs/azure_devops_upload_workitem_attachment" + }, "create-check-run": { "oneOf": [ { @@ -13149,6 +13167,174 @@ } ], "$defs": { + "azure_devops_create_work_item": { + "description": "Create Azure DevOps work items through a trusted safe-output handler.", + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "properties": { + "work-item-type": { "type": "string", "minLength": 1, "maxLength": 128, "default": "Task" }, + "description-field": { "$ref": "#/$defs/azure_devops_field_reference" }, + "area-path": { "type": "string", "minLength": 1, "maxLength": 512 }, + "iteration-path": { "type": "string", "minLength": 1, "maxLength": 512 }, + "assignee": { "type": "string", "minLength": 1, "maxLength": 256 }, + "tags": { "$ref": "#/$defs/azure_devops_string_list" }, + "allowed-tags": { "$ref": "#/$defs/azure_devops_string_list" }, + "custom-fields": { + "type": "object", + "propertyNames": { "pattern": "^[A-Za-z][A-Za-z0-9_.]*$" }, + "additionalProperties": { "type": "string", "maxLength": 4096 } + }, + "artifact-link": { + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { "type": "boolean", "default": false }, + "repository": { "type": "string", "minLength": 1, "maxLength": 256 }, + "branch": { "type": "string", "minLength": 1, "maxLength": 256, "default": "main" } + } + }, + "include-stats": { "type": "boolean", "default": true }, + "max": { "$ref": "#/$defs/templatable_positive_integer" }, + "staged": { "$ref": "#/$defs/templatable_boolean" } + } + }, + { "type": "null" } + ] + }, + "azure_devops_update_work_item": { + "description": "Update explicitly scoped Azure DevOps work items and explicitly enabled fields.", + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": ["target"], + "properties": { + "target": { + "oneOf": [{ "type": "integer", "minimum": 1 }, { "const": "*" }] + }, + "status": { "type": "boolean", "default": false }, + "title": { "type": "boolean", "default": false }, + "body": { "type": "boolean", "default": false }, + "markdown-body": { "type": "boolean", "default": false }, + "title-prefix": { "type": "string", "minLength": 1, "maxLength": 255 }, + "tag-prefix": { "type": "string", "minLength": 1, "maxLength": 256 }, + "area-path": { "type": "boolean", "default": false }, + "iteration-path": { "type": "boolean", "default": false }, + "assignee": { "type": "boolean", "default": false }, + "tags": { "type": "boolean", "default": false }, + "allowed-tags": { "$ref": "#/$defs/azure_devops_string_list" }, + "max": { "$ref": "#/$defs/templatable_positive_integer" }, + "staged": { "$ref": "#/$defs/templatable_boolean" } + } + }, + { "type": "null" } + ] + }, + "azure_devops_comment_on_work_item": { + "description": "Comment on explicitly scoped Azure DevOps work items.", + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": ["target"], + "properties": { + "target": { "$ref": "#/$defs/azure_devops_work_item_target" }, + "include-stats": { "type": "boolean", "default": true }, + "max": { "$ref": "#/$defs/templatable_positive_integer" }, + "staged": { "$ref": "#/$defs/templatable_boolean" } + } + }, + { "type": "null" } + ] + }, + "azure_devops_assign_work_item": { + "description": "Assign an Azure DevOps identity to a work item.", + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "properties": { + "target": { + "oneOf": [{ "type": "integer", "minimum": 1 }, { "const": "*" }] + }, + "allowed": { "$ref": "#/$defs/azure_devops_string_list" }, + "blocked": { "$ref": "#/$defs/azure_devops_string_list" }, + "max": { "$ref": "#/$defs/templatable_positive_integer" }, + "staged": { "$ref": "#/$defs/templatable_boolean" } + } + }, + { "type": "null" } + ] + }, + "azure_devops_link_work_items": { + "description": "Link explicitly scoped Azure DevOps work items.", + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": ["target"], + "properties": { + "target": { "$ref": "#/$defs/azure_devops_work_item_target" }, + "allowed-link-types": { + "type": "array", + "uniqueItems": true, + "items": { + "type": "string", + "enum": ["parent", "child", "related", "predecessor", "successor", "duplicate", "duplicate-of"] + } + }, + "max": { "$ref": "#/$defs/templatable_positive_integer" }, + "staged": { "$ref": "#/$defs/templatable_boolean" } + } + }, + { "type": "null" } + ] + }, + "azure_devops_upload_workitem_attachment": { + "description": "Upload a staged workspace file to an Azure DevOps work item.", + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "properties": { + "max-file-size": { "type": "integer", "minimum": 1, "maximum": 104857600, "default": 5242880 }, + "allowed-extensions": { + "type": "array", + "uniqueItems": true, + "items": { "type": "string", "pattern": "^\\.[A-Za-z0-9]+$", "maxLength": 16 } + }, + "comment-prefix": { "type": "string", "maxLength": 256 }, + "max": { "$ref": "#/$defs/templatable_positive_integer" }, + "staged": { "$ref": "#/$defs/templatable_boolean" } + } + }, + { "type": "null" } + ] + }, + "azure_devops_work_item_target": { + "oneOf": [ + { "type": "integer", "minimum": 1 }, + { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { "type": "integer", "minimum": 1 } + }, + { "type": "string", "minLength": 1, "maxLength": 512 } + ] + }, + "azure_devops_field_reference": { + "type": "string", + "pattern": "^[A-Za-z][A-Za-z0-9_.]*$", + "maxLength": 256 + }, + "azure_devops_string_list": { + "type": "array", + "uniqueItems": true, + "items": { "type": "string", "minLength": 1, "maxLength": 256 } + }, "enclave-repos": { "type": "array", "minItems": 1, diff --git a/pkg/workflow/compiler_safe_outputs_job.go b/pkg/workflow/compiler_safe_outputs_job.go index b2126975854..b733803edfa 100644 --- a/pkg/workflow/compiler_safe_outputs_job.go +++ b/pkg/workflow/compiler_safe_outputs_job.go @@ -324,6 +324,12 @@ type safeOutputsHandlerOutputsAndActionState struct { // processed by the consolidated handler manager step (as opposed to a dedicated job/step). func hasHandlerManagerTypes(data *WorkflowData) bool { return data.SafeOutputs.CreateIssues != nil || + data.SafeOutputs.CreateWorkItems != nil || + data.SafeOutputs.UpdateWorkItems != nil || + data.SafeOutputs.CommentOnWorkItems != nil || + data.SafeOutputs.AssignWorkItems != nil || + data.SafeOutputs.LinkWorkItems != nil || + data.SafeOutputs.UploadWorkItemAttachments != nil || data.SafeOutputs.AddComments != nil || data.SafeOutputs.CreateDiscussions != nil || data.SafeOutputs.CloseIssues != nil || @@ -380,7 +386,7 @@ func (c *Compiler) appendCustomScriptFilesStep(data *WorkflowData, state *safeOu // staging artifact produced by the agent job, when the workflow uses the upload_artifact safe // output, so the handler manager can process staged files. func (c *Compiler) appendUploadArtifactStagingDownloadStep(data *WorkflowData, agentArtifactPrefix string, state *safeOutputsHandlerOutputsAndActionState) { - if data.SafeOutputs.UploadArtifact != nil { + if usesSafeOutputsArtifactStaging(data.SafeOutputs) { consolidatedSafeOutputsJobLog.Print("Adding upload-artifact staging download step") stagingArtifactName := agentArtifactPrefix + SafeOutputsUploadArtifactStagingArtifactName state.steps = append(state.steps, diff --git a/pkg/workflow/js/safe_outputs_tools.json b/pkg/workflow/js/safe_outputs_tools.json index 393e610b3d3..98da036ffda 100644 --- a/pkg/workflow/js/safe_outputs_tools.json +++ b/pkg/workflow/js/safe_outputs_tools.json @@ -2082,5 +2082,146 @@ "anyOf": ["pull_request_number", "pr_number", "pr", "pull_number"] } } + }, + { + "name": "create-work-item", + "description": "Create an Azure DevOps work item and return a temporary #aw_ ID for later work-item tools in this run.", + "inputSchema": { + "type": "object", + "required": ["title", "description"], + "properties": { + "title": { + "type": "string", + "minLength": 6, + "maxLength": 255, + "description": "Concise work-item title." + }, + "description": { + "type": "string", + "minLength": 31, + "maxLength": 65000, + "description": "Detailed work-item description in Markdown." + }, + "tags": { + "type": "array", + "items": { "type": "string", "minLength": 1, "maxLength": 256 }, + "description": "Optional tags. Tags cannot contain semicolons and may be restricted by allowed-tags." + } + }, + "additionalProperties": false + } + }, + { + "name": "update-work-item", + "description": "Update explicitly enabled fields on an Azure DevOps work item.", + "inputSchema": { + "type": "object", + "required": ["id"], + "properties": { + "id": { + "type": ["number", "string"], + "description": "Positive work-item ID or a temporary #aw_ ID returned by create-work-item." + }, + "title": { "type": "string", "minLength": 1, "maxLength": 255 }, + "body": { "type": "string", "maxLength": 65000 }, + "state": { "type": "string", "minLength": 1, "maxLength": 128 }, + "area_path": { "type": "string", "minLength": 1, "maxLength": 512 }, + "iteration_path": { "type": "string", "minLength": 1, "maxLength": 512 }, + "assignee": { "type": "string", "minLength": 1, "maxLength": 256 }, + "tags": { + "type": "array", + "items": { "type": "string", "minLength": 1, "maxLength": 256 } + } + }, + "additionalProperties": false + } + }, + { + "name": "comment-on-work-item", + "description": "Add a Markdown comment to an explicitly scoped Azure DevOps work item.", + "inputSchema": { + "type": "object", + "required": ["work_item_id", "body"], + "properties": { + "work_item_id": { + "type": ["number", "string"], + "description": "Positive work-item ID or a temporary #aw_ ID returned by create-work-item." + }, + "body": { + "type": "string", + "minLength": 10, + "maxLength": 65000, + "description": "Comment text in Markdown." + } + }, + "additionalProperties": false + } + }, + { + "name": "assign-work-item", + "description": "Assign an allowed Azure DevOps identity to a work item.", + "inputSchema": { + "type": "object", + "required": ["work_item_id", "assignee"], + "properties": { + "work_item_id": { + "type": ["number", "string"], + "description": "Positive work-item ID or a temporary #aw_ ID returned by create-work-item." + }, + "assignee": { + "type": "string", + "minLength": 1, + "maxLength": 256, + "description": "Azure DevOps identity, such as an email address or display name." + } + }, + "additionalProperties": false + } + }, + { + "name": "link-work-items", + "description": "Create a relationship between two explicitly scoped Azure DevOps work items.", + "inputSchema": { + "type": "object", + "required": ["source_id", "target_id", "link_type"], + "properties": { + "source_id": { + "type": ["number", "string"], + "description": "Positive source work-item ID or a temporary #aw_ ID." + }, + "target_id": { + "type": ["number", "string"], + "description": "Positive target work-item ID or a temporary #aw_ ID." + }, + "link_type": { + "type": "string", + "enum": ["parent", "child", "related", "predecessor", "successor", "duplicate", "duplicate-of"] + }, + "comment": { "type": "string", "minLength": 5, "maxLength": 1024 } + }, + "additionalProperties": false + } + }, + { + "name": "upload-workitem-attachment", + "description": "Upload one workspace file and attach it to an Azure DevOps work item.", + "inputSchema": { + "type": "object", + "required": ["work_item_id", "file_path"], + "properties": { + "work_item_id": { + "type": ["number", "string"], + "description": "Positive work-item ID or a temporary #aw_ ID returned by create-work-item." + }, + "file_path": { + "type": "string", + "minLength": 1, + "maxLength": 1024, + "description": "Workspace-relative file path. Absolute paths, traversal, colons, and symbolic links are rejected." + }, + "comment": { "type": "string", "minLength": 3, "maxLength": 1024 } + }, + "additionalProperties": false + } } ] diff --git a/pkg/workflow/publish_artifacts.go b/pkg/workflow/publish_artifacts.go index d564fd8c34b..e8667f1a32a 100644 --- a/pkg/workflow/publish_artifacts.go +++ b/pkg/workflow/publish_artifacts.go @@ -170,7 +170,7 @@ func (c *Compiler) parseUploadArtifactConfig(outputMap map[string]any) *UploadAr // This step only appears when upload-artifact is configured in safe-outputs. // pinAction resolves the upload-artifact action reference; pass c.getActionPin from Compiler methods. func generateSafeOutputsArtifactStagingUpload(builder *strings.Builder, data *WorkflowData, pinAction func(string) string) { - if data.SafeOutputs == nil || data.SafeOutputs.UploadArtifact == nil { + if data.SafeOutputs == nil || !usesSafeOutputsArtifactStaging(data.SafeOutputs) { return } @@ -188,3 +188,7 @@ func generateSafeOutputsArtifactStagingUpload(builder *strings.Builder, data *Wo builder.WriteString(" retention-days: 1\n") builder.WriteString(" if-no-files-found: ignore\n") } + +func usesSafeOutputsArtifactStaging(config *SafeOutputsConfig) bool { + return config != nil && (config.UploadArtifact != nil || config.UploadWorkItemAttachments != nil) +} diff --git a/pkg/workflow/safe_output_handlers.go b/pkg/workflow/safe_output_handlers.go index 8bf2c23c71b..fdad0cf0a7d 100644 --- a/pkg/workflow/safe_output_handlers.go +++ b/pkg/workflow/safe_output_handlers.go @@ -21,6 +21,42 @@ type safeOutputHandlerDescriptor struct { } var safeOutputHandlers = []safeOutputHandlerDescriptor{ + { + Key: "create-work-item", + StructField: "CreateWorkItems", + ToolName: "create-work-item", + NewConfig: func() any { return &CreateWorkItemConfig{} }, + }, + { + Key: "update-work-item", + StructField: "UpdateWorkItems", + ToolName: "update-work-item", + NewConfig: func() any { return &UpdateWorkItemConfig{} }, + }, + { + Key: "comment-on-work-item", + StructField: "CommentOnWorkItems", + ToolName: "comment-on-work-item", + NewConfig: func() any { return &CommentOnWorkItemConfig{} }, + }, + { + Key: "assign-work-item", + StructField: "AssignWorkItems", + ToolName: "assign-work-item", + NewConfig: func() any { return &AssignWorkItemConfig{} }, + }, + { + Key: "link-work-items", + StructField: "LinkWorkItems", + ToolName: "link-work-items", + NewConfig: func() any { return &LinkWorkItemsConfig{} }, + }, + { + Key: "upload-workitem-attachment", + StructField: "UploadWorkItemAttachments", + ToolName: "upload-workitem-attachment", + NewConfig: func() any { return &UploadWorkItemAttachmentConfig{} }, + }, { Key: "create-issue", StructField: "CreateIssues", diff --git a/pkg/workflow/safe_outputs_azure_devops.go b/pkg/workflow/safe_outputs_azure_devops.go new file mode 100644 index 00000000000..38be42dd52f --- /dev/null +++ b/pkg/workflow/safe_outputs_azure_devops.go @@ -0,0 +1,228 @@ +package workflow + +import "github.com/github/gh-aw/pkg/logger" + +var azureDevOpsSafeOutputsLog = logger.New("workflow:safe_outputs_azure_devops") + +type AzureDevOpsArtifactLinkConfig struct { + Enabled bool `yaml:"enabled,omitempty"` + Repository string `yaml:"repository,omitempty"` + Branch string `yaml:"branch,omitempty"` +} + +type CreateWorkItemConfig struct { + BaseSafeOutputConfig `yaml:",inline"` + WorkItemType string `yaml:"work-item-type,omitempty"` + DescriptionField string `yaml:"description-field,omitempty"` + AreaPath string `yaml:"area-path,omitempty"` + IterationPath string `yaml:"iteration-path,omitempty"` + Assignee string `yaml:"assignee,omitempty"` + Tags []string `yaml:"tags,omitempty"` + AllowedTags []string `yaml:"allowed-tags,omitempty"` + CustomFields map[string]string `yaml:"custom-fields,omitempty"` + ArtifactLink AzureDevOpsArtifactLinkConfig `yaml:"artifact-link,omitempty"` + IncludeStats bool `yaml:"include-stats,omitempty"` +} + +type UpdateWorkItemConfig struct { + BaseSafeOutputConfig `yaml:",inline"` + Status bool `yaml:"status,omitempty"` + Title bool `yaml:"title,omitempty"` + Body bool `yaml:"body,omitempty"` + MarkdownBody bool `yaml:"markdown-body,omitempty"` + TitlePrefix string `yaml:"title-prefix,omitempty"` + TagPrefix string `yaml:"tag-prefix,omitempty"` + Target any `yaml:"target,omitempty"` + AreaPath bool `yaml:"area-path,omitempty"` + IterationPath bool `yaml:"iteration-path,omitempty"` + Assignee bool `yaml:"assignee,omitempty"` + Tags bool `yaml:"tags,omitempty"` + AllowedTags []string `yaml:"allowed-tags,omitempty"` +} + +type CommentOnWorkItemConfig struct { + BaseSafeOutputConfig `yaml:",inline"` + Target any `yaml:"target,omitempty"` + IncludeStats bool `yaml:"include-stats,omitempty"` +} + +type AssignWorkItemConfig struct { + BaseSafeOutputConfig `yaml:",inline"` + Target any `yaml:"target,omitempty"` + Allowed []string `yaml:"allowed,omitempty"` + Blocked []string `yaml:"blocked,omitempty"` +} + +type LinkWorkItemsConfig struct { + BaseSafeOutputConfig `yaml:",inline"` + Target any `yaml:"target,omitempty"` + AllowedLinkTypes []string `yaml:"allowed-link-types,omitempty"` +} + +type UploadWorkItemAttachmentConfig struct { + BaseSafeOutputConfig `yaml:",inline"` + MaxFileSize int64 `yaml:"max-file-size,omitempty"` + AllowedExtensions []string `yaml:"allowed-extensions,omitempty"` + CommentPrefix string `yaml:"comment-prefix,omitempty"` +} + +func parseAzureDevOpsConfig[T any](c *Compiler, outputMap map[string]any, key string, defaultMax int, postProcess func(*T)) *T { + config := parseConfigScaffold(outputMap, key, azureDevOpsSafeOutputsLog, func(err error) *T { + azureDevOpsSafeOutputsLog.Printf("Failed to parse %s configuration: %v", key, err) + return nil + }) + if config == nil { + return nil + } + if configMap, ok := outputMap[key].(map[string]any); ok { + switch typed := any(config).(type) { + case *CreateWorkItemConfig: + c.parseBaseSafeOutputConfig(configMap, &typed.BaseSafeOutputConfig, defaultMax) + case *UpdateWorkItemConfig: + c.parseBaseSafeOutputConfig(configMap, &typed.BaseSafeOutputConfig, defaultMax) + case *CommentOnWorkItemConfig: + c.parseBaseSafeOutputConfig(configMap, &typed.BaseSafeOutputConfig, defaultMax) + case *AssignWorkItemConfig: + c.parseBaseSafeOutputConfig(configMap, &typed.BaseSafeOutputConfig, defaultMax) + case *LinkWorkItemsConfig: + c.parseBaseSafeOutputConfig(configMap, &typed.BaseSafeOutputConfig, defaultMax) + case *UploadWorkItemAttachmentConfig: + c.parseBaseSafeOutputConfig(configMap, &typed.BaseSafeOutputConfig, defaultMax) + } + } + if postProcess != nil { + postProcess(config) + } + return config +} + +func (c *Compiler) parseCreateWorkItemConfig(outputMap map[string]any) *CreateWorkItemConfig { + return parseAzureDevOpsConfig(c, outputMap, "create-work-item", 1, func(config *CreateWorkItemConfig) { + if config.WorkItemType == "" { + config.WorkItemType = "Task" + } + if config.ArtifactLink.Branch == "" { + config.ArtifactLink.Branch = "main" + } + }) +} + +func (c *Compiler) parseUpdateWorkItemConfig(outputMap map[string]any) *UpdateWorkItemConfig { + return parseAzureDevOpsConfig[UpdateWorkItemConfig](c, outputMap, "update-work-item", 1, nil) +} + +func (c *Compiler) parseCommentOnWorkItemConfig(outputMap map[string]any) *CommentOnWorkItemConfig { + return parseAzureDevOpsConfig[CommentOnWorkItemConfig](c, outputMap, "comment-on-work-item", 1, nil) +} + +func (c *Compiler) parseAssignWorkItemConfig(outputMap map[string]any) *AssignWorkItemConfig { + return parseAzureDevOpsConfig[AssignWorkItemConfig](c, outputMap, "assign-work-item", 1, nil) +} + +func (c *Compiler) parseLinkWorkItemsConfig(outputMap map[string]any) *LinkWorkItemsConfig { + return parseAzureDevOpsConfig[LinkWorkItemsConfig](c, outputMap, "link-work-items", 5, nil) +} + +func (c *Compiler) parseUploadWorkItemAttachmentConfig(outputMap map[string]any) *UploadWorkItemAttachmentConfig { + return parseAzureDevOpsConfig(c, outputMap, "upload-workitem-attachment", 1, func(config *UploadWorkItemAttachmentConfig) { + if config.MaxFileSize == 0 { + config.MaxFileSize = 5 * 1024 * 1024 + } + }) +} + +func addAzureDevOpsTarget(builder *handlerConfigBuilder, target any) *handlerConfigBuilder { + if target != nil { + builder.AddDefault("target", target) + } + return builder +} + +var azureDevOpsWorkItemHandlerRegistry = map[string]handlerBuilder{ + "create_work_item": func(cfg *SafeOutputsConfig) map[string]any { + if cfg.CreateWorkItems == nil { + return nil + } + c := cfg.CreateWorkItems + return newHandlerConfigBuilder(). + AddTemplatableInt("max", c.Max). + AddIfNotEmpty("work_item_type", c.WorkItemType). + AddIfNotEmpty("description_field", c.DescriptionField). + AddIfNotEmpty("area_path", c.AreaPath). + AddIfNotEmpty("iteration_path", c.IterationPath). + AddIfNotEmpty("assignee", c.Assignee). + AddStringSlice("tags", c.Tags). + AddStringSlice("allowed_tags", c.AllowedTags). + AddDefault("custom_fields", c.CustomFields). + AddDefault("artifact_link", c.ArtifactLink). + AddTemplatableBool("staged", templatableBoolPtrToStringPtr(c.Staged)). + Build() + }, + "update_work_item": func(cfg *SafeOutputsConfig) map[string]any { + if cfg.UpdateWorkItems == nil { + return nil + } + c := cfg.UpdateWorkItems + builder := newHandlerConfigBuilder(). + AddTemplatableInt("max", c.Max). + AddIfTrue("status", c.Status). + AddIfTrue("title", c.Title). + AddIfTrue("body", c.Body). + AddIfTrue("markdown_body", c.MarkdownBody). + AddIfNotEmpty("title_prefix", c.TitlePrefix). + AddIfNotEmpty("tag_prefix", c.TagPrefix). + AddIfTrue("area_path", c.AreaPath). + AddIfTrue("iteration_path", c.IterationPath). + AddIfTrue("assignee", c.Assignee). + AddIfTrue("tags", c.Tags). + AddStringSlice("allowed_tags", c.AllowedTags). + AddTemplatableBool("staged", templatableBoolPtrToStringPtr(c.Staged)) + return addAzureDevOpsTarget(builder, c.Target).Build() + }, + "comment_on_work_item": func(cfg *SafeOutputsConfig) map[string]any { + if cfg.CommentOnWorkItems == nil { + return nil + } + c := cfg.CommentOnWorkItems + builder := newHandlerConfigBuilder(). + AddTemplatableInt("max", c.Max). + AddTemplatableBool("staged", templatableBoolPtrToStringPtr(c.Staged)) + return addAzureDevOpsTarget(builder, c.Target).Build() + }, + "assign_work_item": func(cfg *SafeOutputsConfig) map[string]any { + if cfg.AssignWorkItems == nil { + return nil + } + c := cfg.AssignWorkItems + builder := newHandlerConfigBuilder(). + AddTemplatableInt("max", c.Max). + AddStringSlice("allowed", c.Allowed). + AddStringSlice("blocked", c.Blocked). + AddTemplatableBool("staged", templatableBoolPtrToStringPtr(c.Staged)) + return addAzureDevOpsTarget(builder, c.Target).Build() + }, + "link_work_items": func(cfg *SafeOutputsConfig) map[string]any { + if cfg.LinkWorkItems == nil { + return nil + } + c := cfg.LinkWorkItems + builder := newHandlerConfigBuilder(). + AddTemplatableInt("max", c.Max). + AddStringSlice("allowed_link_types", c.AllowedLinkTypes). + AddTemplatableBool("staged", templatableBoolPtrToStringPtr(c.Staged)) + return addAzureDevOpsTarget(builder, c.Target).Build() + }, + "upload_workitem_attachment": func(cfg *SafeOutputsConfig) map[string]any { + if cfg.UploadWorkItemAttachments == nil { + return nil + } + c := cfg.UploadWorkItemAttachments + return newHandlerConfigBuilder(). + AddTemplatableInt("max", c.Max). + AddDefault("max_file_size", c.MaxFileSize). + AddStringSlice("allowed_extensions", c.AllowedExtensions). + AddIfNotEmpty("comment_prefix", c.CommentPrefix). + AddTemplatableBool("staged", templatableBoolPtrToStringPtr(c.Staged)). + Build() + }, +} diff --git a/pkg/workflow/safe_outputs_config_extraction.go b/pkg/workflow/safe_outputs_config_extraction.go index 52532df2ff1..a802a6df7ed 100644 --- a/pkg/workflow/safe_outputs_config_extraction.go +++ b/pkg/workflow/safe_outputs_config_extraction.go @@ -52,6 +52,13 @@ func (c *Compiler) extractSafeOutputsConfig(frontmatter map[string]any) *SafeOut safeOutputsConfigLog.Printf("Processing safe-outputs configuration with %d top-level keys", len(outputMap)) config = &SafeOutputsConfig{} + config.CreateWorkItems = c.parseCreateWorkItemConfig(outputMap) + config.UpdateWorkItems = c.parseUpdateWorkItemConfig(outputMap) + config.CommentOnWorkItems = c.parseCommentOnWorkItemConfig(outputMap) + config.AssignWorkItems = c.parseAssignWorkItemConfig(outputMap) + config.LinkWorkItems = c.parseLinkWorkItemsConfig(outputMap) + config.UploadWorkItemAttachments = c.parseUploadWorkItemAttachmentConfig(outputMap) + // Handle create-issue issuesConfig := c.parseCreateIssuesConfig(outputMap) if issuesConfig != nil { diff --git a/pkg/workflow/safe_outputs_config_types.go b/pkg/workflow/safe_outputs_config_types.go index 44acd0d6e48..52418896cd9 100644 --- a/pkg/workflow/safe_outputs_config_types.go +++ b/pkg/workflow/safe_outputs_config_types.go @@ -40,6 +40,12 @@ type BaseSafeOutputConfig struct { type SafeOutputsConfig struct { Steer bool `yaml:"steer,omitempty"` // Experimental. Create an issue and steer the agent from issue comments. CreateIssues *CreateIssuesConfig `yaml:"create-issue,omitempty"` + CreateWorkItems *CreateWorkItemConfig `yaml:"create-work-item,omitempty"` + UpdateWorkItems *UpdateWorkItemConfig `yaml:"update-work-item,omitempty"` + CommentOnWorkItems *CommentOnWorkItemConfig `yaml:"comment-on-work-item,omitempty"` + AssignWorkItems *AssignWorkItemConfig `yaml:"assign-work-item,omitempty"` + LinkWorkItems *LinkWorkItemsConfig `yaml:"link-work-items,omitempty"` + UploadWorkItemAttachments *UploadWorkItemAttachmentConfig `yaml:"upload-workitem-attachment,omitempty"` CreateDiscussions *CreateDiscussionsConfig `yaml:"create-discussion,omitempty"` UpdateDiscussions *UpdateDiscussionsConfig `yaml:"update-discussion,omitempty"` CloseDiscussions *CloseDiscussionsConfig `yaml:"close-discussion,omitempty"` diff --git a/pkg/workflow/safe_outputs_handler_registry.go b/pkg/workflow/safe_outputs_handler_registry.go index 4da807f205c..c08aa322dc7 100644 --- a/pkg/workflow/safe_outputs_handler_registry.go +++ b/pkg/workflow/safe_outputs_handler_registry.go @@ -64,6 +64,7 @@ var handlerRegistry = mergeHandlerMaps( commentHandlerRegistry, releaseHandlerRegistry, diagnosticHandlerRegistry, + azureDevOpsWorkItemHandlerRegistry, ) func mergeHandlerMaps(registries ...map[string]handlerBuilder) map[string]handlerBuilder { diff --git a/pkg/workflow/safe_outputs_max_validation.go b/pkg/workflow/safe_outputs_max_validation.go index 55079bdd179..fd118691ee6 100644 --- a/pkg/workflow/safe_outputs_max_validation.go +++ b/pkg/workflow/safe_outputs_max_validation.go @@ -66,6 +66,36 @@ func validateSafeOutputsMax(config *SafeOutputsConfig) error { // Fields are checked in the alphabetical order of their struct field names, // matching the sort order of safeOutputFieldMapping keys for deterministic // error reporting. + if config.AssignWorkItems != nil { + if err := checkMaxField("assign_work_item", config.AssignWorkItems.Max); err != nil { + return err + } + } + if config.CommentOnWorkItems != nil { + if err := checkMaxField("comment_on_work_item", config.CommentOnWorkItems.Max); err != nil { + return err + } + } + if config.CreateWorkItems != nil { + if err := checkMaxField("create_work_item", config.CreateWorkItems.Max); err != nil { + return err + } + } + if config.LinkWorkItems != nil { + if err := checkMaxField("link_work_items", config.LinkWorkItems.Max); err != nil { + return err + } + } + if config.UpdateWorkItems != nil { + if err := checkMaxField("update_work_item", config.UpdateWorkItems.Max); err != nil { + return err + } + } + if config.UploadWorkItemAttachments != nil { + if err := checkMaxField("upload_workitem_attachment", config.UploadWorkItemAttachments.Max); err != nil { + return err + } + } if config.AddComments != nil { if err := checkMaxField("add_comment", config.AddComments.Max); err != nil { return err diff --git a/pkg/workflow/safe_outputs_state.go b/pkg/workflow/safe_outputs_state.go index ea55a7806b3..ace66a6dc9a 100644 --- a/pkg/workflow/safe_outputs_state.go +++ b/pkg/workflow/safe_outputs_state.go @@ -41,6 +41,12 @@ func hasAnySafeOutputEnabled(safeOutputs *SafeOutputsConfig) bool { // Direct nil checks — no reflection, no heap allocation. return safeOutputs.CreateIssues != nil || + safeOutputs.CreateWorkItems != nil || + safeOutputs.UpdateWorkItems != nil || + safeOutputs.CommentOnWorkItems != nil || + safeOutputs.AssignWorkItems != nil || + safeOutputs.LinkWorkItems != nil || + safeOutputs.UploadWorkItemAttachments != nil || safeOutputs.CreateAgentSessions != nil || safeOutputs.CreateDiscussions != nil || safeOutputs.UpdateDiscussions != nil || @@ -107,6 +113,12 @@ func hasNonBuiltinSafeOutputsEnabled(safeOutputs *SafeOutputsConfig) bool { // Direct nil checks for non-builtin pointer fields. return safeOutputs.CreateIssues != nil || + safeOutputs.CreateWorkItems != nil || + safeOutputs.UpdateWorkItems != nil || + safeOutputs.CommentOnWorkItems != nil || + safeOutputs.AssignWorkItems != nil || + safeOutputs.LinkWorkItems != nil || + safeOutputs.UploadWorkItemAttachments != nil || safeOutputs.CreateAgentSessions != nil || safeOutputs.CreateDiscussions != nil || safeOutputs.UpdateDiscussions != nil || diff --git a/pkg/workflow/safe_outputs_tools_computation.go b/pkg/workflow/safe_outputs_tools_computation.go index e4585537ab1..4acbf732401 100644 --- a/pkg/workflow/safe_outputs_tools_computation.go +++ b/pkg/workflow/safe_outputs_tools_computation.go @@ -20,6 +20,30 @@ func computeEnabledToolNames(data *WorkflowData) map[string]struct { enabledTools["create_issue"] = struct { }{} } + if data.SafeOutputs.CreateWorkItems != nil { + enabledTools["create-work-item"] = struct { + }{} + } + if data.SafeOutputs.UpdateWorkItems != nil { + enabledTools["update-work-item"] = struct { + }{} + } + if data.SafeOutputs.CommentOnWorkItems != nil { + enabledTools["comment-on-work-item"] = struct { + }{} + } + if data.SafeOutputs.AssignWorkItems != nil { + enabledTools["assign-work-item"] = struct { + }{} + } + if data.SafeOutputs.LinkWorkItems != nil { + enabledTools["link-work-items"] = struct { + }{} + } + if data.SafeOutputs.UploadWorkItemAttachments != nil { + enabledTools["upload-workitem-attachment"] = struct { + }{} + } if data.SafeOutputs.CreateAgentSessions != nil { enabledTools["create_agent_session"] = struct { }{} diff --git a/pkg/workflow/safe_outputs_validation_config.go b/pkg/workflow/safe_outputs_validation_config.go index 78e0bcfb7da..00aae097a3a 100644 --- a/pkg/workflow/safe_outputs_validation_config.go +++ b/pkg/workflow/safe_outputs_validation_config.go @@ -60,6 +60,61 @@ const ( // ValidationConfig contains all safe output type validation rules // This is the single source of truth for validation rules var ValidationConfig = map[string]TypeValidationConfig{ + "create_work_item": { + DefaultMax: 1, + Fields: map[string]FieldValidation{ + "title": {Required: true, Type: "string", Sanitize: true, MinLength: 6, MaxLength: 255}, + "description": {Required: true, Type: "string", Sanitize: true, MinLength: 31, MaxLength: MaxBodyLength}, + "tags": {Type: "array", ItemType: "string", ItemSanitize: true, ItemMaxLength: 256}, + "temporary_id": {Required: true, Type: "string", Pattern: "^#aw_[A-Za-z0-9_]{3,12}$", TemporaryID: true}, + }, + }, + "update_work_item": { + DefaultMax: 1, + CustomValidation: "requiresOneOf:title,body,state,area_path,iteration_path,assignee,tags", + Fields: map[string]FieldValidation{ + "id": {Required: true, IssueNumberOrTemporaryID: true}, + "title": {Type: "string", Sanitize: true, MinLength: 1, MaxLength: 255}, + "body": {Type: "string", Sanitize: true, MaxLength: MaxBodyLength}, + "state": {Type: "string", Sanitize: true, MaxLength: 128}, + "area_path": {Type: "string", Sanitize: true, MaxLength: 512}, + "iteration_path": {Type: "string", Sanitize: true, MaxLength: 512}, + "assignee": {Type: "string", Sanitize: true, MaxLength: 256}, + "tags": {Type: "array", ItemType: "string", ItemSanitize: true, ItemMaxLength: 256}, + }, + }, + "comment_on_work_item": { + DefaultMax: 1, + Fields: map[string]FieldValidation{ + "work_item_id": {Required: true, IssueNumberOrTemporaryID: true}, + "body": {Required: true, Type: "string", Sanitize: true, MinLength: 10, MaxLength: MaxBodyLength}, + }, + }, + "assign_work_item": { + DefaultMax: 1, + Fields: map[string]FieldValidation{ + "work_item_id": {Required: true, IssueNumberOrTemporaryID: true}, + "assignee": {Required: true, Type: "string", Sanitize: true, MinLength: 1, MaxLength: 256}, + }, + }, + "link_work_items": { + DefaultMax: 5, + Fields: map[string]FieldValidation{ + "source_id": {Required: true, IssueNumberOrTemporaryID: true}, + "target_id": {Required: true, IssueNumberOrTemporaryID: true}, + "link_type": {Required: true, Type: "string", Enum: []string{"parent", "child", "related", "predecessor", "successor", "duplicate", "duplicate-of"}}, + "comment": {Type: "string", Sanitize: true, MinLength: 5, MaxLength: 1024}, + }, + }, + "upload_workitem_attachment": { + DefaultMax: 1, + Fields: map[string]FieldValidation{ + "work_item_id": {Required: true, IssueNumberOrTemporaryID: true}, + "file_path": {Required: true, Type: "string", MaxLength: 1024}, + "staged_file": {Required: true, Type: "string", Pattern: "^[A-Za-z0-9._/-]+$"}, + "comment": {Type: "string", Sanitize: true, MinLength: 3, MaxLength: 1024}, + }, + }, "create_issue": { DefaultMax: 1, Fields: map[string]FieldValidation{ From 449d161d061eeecbe348d4bb72ef04667495b22f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 2 Sep 2026 00:09:32 +0000 Subject: [PATCH 02/12] Complete Azure DevOps safe output integration Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- ...nor-azure-devops-work-item-safe-outputs.md | 5 + actions/setup/js/azure_devops_work_items.cjs | 65 +++++-- .../setup/js/azure_devops_work_items.test.cjs | 120 +++++++++++++ actions/setup/js/mcp_server_core.cjs | 2 +- actions/setup/js/safe_outputs_handlers.cjs | 166 +++++++++--------- .../setup/js/safe_outputs_handlers.test.cjs | 11 ++ .../js/safe_outputs_tools_loader.test.cjs | 23 +++ actions/setup/js/temporary_id.cjs | 2 +- actions/setup/js/temporary_id.test.cjs | 14 ++ .../content/docs/reference/safe-outputs.md | 50 ++++++ pkg/parser/schemas/main_workflow_schema.json | 33 +++- pkg/workflow/publish_artifacts.go | 2 +- .../safe_output_validation_config_test.go | 24 +-- pkg/workflow/safe_outputs_azure_devops.go | 149 +++++++++++++--- .../safe_outputs_azure_devops_test.go | 124 +++++++++++++ .../safe_outputs_config_extraction.go | 2 +- .../safe_outputs_handler_registry_test.go | 7 + pkg/workflow/safe_outputs_max_validation.go | 2 +- pkg/workflow/safe_outputs_state.go | 4 +- .../safe_outputs_tools_computation.go | 2 +- .../safe_outputs_validation_config.go | 2 +- pkg/workflow/tool_description_enhancer.go | 18 ++ schemas/agent-output.json | 125 ++++++++++++- 23 files changed, 807 insertions(+), 145 deletions(-) create mode 100644 .changeset/minor-azure-devops-work-item-safe-outputs.md create mode 100644 actions/setup/js/azure_devops_work_items.test.cjs create mode 100644 pkg/workflow/safe_outputs_azure_devops_test.go diff --git a/.changeset/minor-azure-devops-work-item-safe-outputs.md b/.changeset/minor-azure-devops-work-item-safe-outputs.md new file mode 100644 index 00000000000..ac86f8cba19 --- /dev/null +++ b/.changeset/minor-azure-devops-work-item-safe-outputs.md @@ -0,0 +1,5 @@ +--- +"gh-aw": minor +--- + +Add Azure DevOps work-item safe outputs for creating, updating, commenting on, assigning, linking, and attaching files to work items. diff --git a/actions/setup/js/azure_devops_work_items.cjs b/actions/setup/js/azure_devops_work_items.cjs index e276aed13d3..b6bc8b045f4 100644 --- a/actions/setup/js/azure_devops_work_items.cjs +++ b/actions/setup/js/azure_devops_work_items.cjs @@ -66,6 +66,18 @@ function validateAllowedTags(tags, allowedTags) { if (disallowed.length > 0) { throw new Error(`tags are not permitted by allowed-tags: ${disallowed.join(", ")}`); } + + function validateAllowedPath(value, allowedPrefixes, fieldName) { + if (!Array.isArray(allowedPrefixes) || allowedPrefixes.length === 0) return; + const normalized = String(value).trim().toLowerCase(); + const allowed = allowedPrefixes.some(prefix => { + const normalizedPrefix = String(prefix).trim().replace(/\\+$/, "").toLowerCase(); + return normalized === normalizedPrefix || normalized.startsWith(`${normalizedPrefix}\\`); + }); + if (!allowed) { + throw new Error(`${fieldName} is not permitted by the configured ${fieldName.replace("_", "-")} prefixes`); + } + } } function getAzureDevOpsContext() { @@ -105,16 +117,22 @@ function getAzureDevOpsContext() { async function adoRequest(ado, method, apiPath, body, contentType = "application/json") { const url = `${ado.orgUrl}/${encodeURIComponent(ado.project)}${apiPath}`; - const response = await fetch(url, { - method, - headers: { - Accept: "application/json", - Authorization: ado.authorization, - ...(body !== undefined ? { "Content-Type": contentType } : {}), - }, - body: body === undefined ? undefined : Buffer.isBuffer(body) ? body : JSON.stringify(body), - redirect: "manual", - }); + let response; + try { + response = await fetch(url, { + method, + headers: { + Accept: "application/json", + Authorization: ado.authorization, + ...(body !== undefined ? { "Content-Type": contentType } : {}), + }, + body: body === undefined ? undefined : Buffer.isBuffer(body) ? body : JSON.stringify(body), + redirect: "manual", + signal: AbortSignal.timeout(30_000), + }); + } catch (error) { + throw new Error(`Azure DevOps ${method} request could not be sent`, { cause: error }); + } if (response.status >= 300 && response.status < 400) { throw new Error(`Azure DevOps rejected a redirected ${method} request`); } @@ -122,8 +140,18 @@ async function adoRequest(ado, method, apiPath, body, contentType = "application throw new Error(`Azure DevOps ${method} request failed with HTTP ${response.status} ${response.statusText}`); } if (response.status === 204) return {}; - const text = await response.text(); - return text ? JSON.parse(text) : {}; + let text; + try { + text = await response.text(); + } catch (error) { + throw new Error(`Azure DevOps ${method} response body could not be read`, { cause: error }); + } + if (!text) return {}; + try { + return JSON.parse(text); + } catch (error) { + throw new Error(`Azure DevOps ${method} response was not valid JSON`, { cause: error }); + } } function workItemUrl(ado, id) { @@ -299,6 +327,12 @@ async function handleUpdateWorkItem(message, config, resolvedTemporaryIds) { message.tags = validateTags(message.tags); validateAllowedTags(message.tags, config.allowed_tags); } + if (message.area_path !== undefined) { + validateAllowedPath(message.area_path, config.allowed_area_prefixes, "area_path"); + } + if (message.iteration_path !== undefined) { + validateAllowedPath(message.iteration_path, config.allowed_iteration_prefixes, "iteration_path"); + } if (preview) return staged(`Would update Azure DevOps work item ${message.id}`); const ado = getAzureDevOpsContext(); @@ -426,7 +460,12 @@ function readStagedAttachment(message, config) { if (allowedExtensions.length > 0 && !allowedExtensions.some(extension => originalPath.toLowerCase().endsWith(String(extension).toLowerCase()))) { throw new Error("attachment extension is not permitted"); } - const bytes = fs.readFileSync(filePath); + let bytes; + try { + bytes = fs.readFileSync(filePath); + } catch (error) { + throw new Error("staged attachment could not be read", { cause: error }); + } if (bytes.includes(Buffer.from("##vso["))) throw new Error("attachment contains an Azure Pipelines command sequence"); return { bytes, filename: path.basename(originalPath) || path.basename(filePath) }; } diff --git a/actions/setup/js/azure_devops_work_items.test.cjs b/actions/setup/js/azure_devops_work_items.test.cjs new file mode 100644 index 00000000000..99997dff7b4 --- /dev/null +++ b/actions/setup/js/azure_devops_work_items.test.cjs @@ -0,0 +1,120 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createAzureDevOpsWorkItemHandler, resolveWorkItemReference } from "./azure_devops_work_items.cjs"; + +global.core = { + 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("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", + }, + }); + }); + + it("rejects updates to fields not enabled by configuration", async () => { + const result = await createAzureDevOpsWorkItemHandler("update_work_item", { + target: "*", + title: false, + })({ id: 42, title: "New title" }, {}); + + expect(result).toEqual({ + success: false, + error: "title updates are not enabled by update-work-item", + }); + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it("rejects area paths outside configured prefixes", async () => { + const result = await createAzureDevOpsWorkItemHandler("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("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 create-work-item in this run"); + }); +}); diff --git a/actions/setup/js/mcp_server_core.cjs b/actions/setup/js/mcp_server_core.cjs index d2819a9336a..d47d9a4ba66 100644 --- a/actions/setup/js/mcp_server_core.cjs +++ b/actions/setup/js/mcp_server_core.cjs @@ -489,7 +489,7 @@ function registerTool(server, tool) { const normalizedName = normalizeTool(tool.name); const existing = server.tools[normalizedName]; if (existing && existing.name !== tool.name) { - throw new Error(`Tool name collision: '${existing.name}' and '${tool.name}' both normalize to '${normalizedName}'`); + throw new Error(`${ERR_VALIDATION}: Tool name collision: '${existing.name}' and '${tool.name}' both normalize to '${normalizedName}'`); } server.tools[normalizedName] = { ...tool, diff --git a/actions/setup/js/safe_outputs_handlers.cjs b/actions/setup/js/safe_outputs_handlers.cjs index 031d2fa707b..55434b73683 100644 --- a/actions/setup/js/safe_outputs_handlers.cjs +++ b/actions/setup/js/safe_outputs_handlers.cjs @@ -2063,89 +2063,6 @@ function createHandlers(server, appendSafeOutput, config = {}) { ], isError: true, }; - - const createWorkItemHandler = args => { - const temporaryId = `#${generateTemporaryId()}`; - const entry = { ...(args || {}), type: "create_work_item", temporary_id: temporaryId }; - appendSafeOutputCounted(entry); - const output = { result: "success", temporary_id: temporaryId }; - return { - content: [{ type: "text", text: JSON.stringify(output) }], - structuredContent: output, - }; - }; - - const createAzureDevOpsWorkItemHandler = type => args => { - const entry = { ...(args || {}), type }; - appendSafeOutputCounted(entry); - return { - content: [{ type: "text", text: JSON.stringify({ result: "success" }) }], - }; - }; - - const uploadWorkItemAttachmentHandler = args => { - const entry = { ...(args || {}), type: "upload_workitem_attachment" }; - const rawPath = typeof entry.file_path === "string" ? entry.file_path.trim() : ""; - if (!rawPath || path.isAbsolute(rawPath) || rawPath.includes(":")) { - return buildIntentErrorResponse("upload-workitem-attachment file_path must be a workspace-relative path without ':'"); - } - - const segments = rawPath.split(/[\\/]+/); - if (segments.some(segment => !segment || segment === "." || segment === "..")) { - return buildIntentErrorResponse("upload-workitem-attachment file_path must not contain empty, '.' or '..' path segments"); - } - - const workspace = path.resolve(process.env.GITHUB_WORKSPACE || process.cwd()); - const sourcePath = path.resolve(workspace, ...segments); - if (sourcePath !== workspace && !sourcePath.startsWith(workspace + path.sep)) { - return buildIntentErrorResponse("upload-workitem-attachment file_path resolves outside the workspace"); - } - - let current = workspace; - let sourceStat; - try { - for (const segment of segments) { - current = path.join(current, segment); - sourceStat = lstatGuard(current); - if (!sourceStat) { - return buildIntentErrorResponse("upload-workitem-attachment does not accept symbolic links"); - } - } - } catch (error) { - return buildIntentErrorResponse(`upload-workitem-attachment could not read file_path: ${getErrorMessage(error)}`); - } - if (!sourceStat?.isFile()) { - return buildIntentErrorResponse("upload-workitem-attachment file_path must identify one regular file"); - } - - const attachmentConfig = getSafeOutputsToolConfig(config, "upload_workitem_attachment"); - const maxFileSize = Number(attachmentConfig.max_file_size || 5 * 1024 * 1024); - if (!Number.isSafeInteger(maxFileSize) || maxFileSize < 1 || sourceStat.size > maxFileSize) { - return buildIntentErrorResponse(`upload-workitem-attachment file exceeds the configured max-file-size of ${maxFileSize} bytes`); - } - const allowedExtensions = Array.isArray(attachmentConfig.allowed_extensions) ? attachmentConfig.allowed_extensions : []; - if (allowedExtensions.length > 0 && !allowedExtensions.some(extension => rawPath.toLowerCase().endsWith(String(extension).toLowerCase()))) { - return buildIntentErrorResponse("upload-workitem-attachment file extension is not allowed by the workflow configuration"); - } - - try { - const stagingRoot = path.join(process.env.RUNNER_TEMP || "/tmp", "gh-aw", "safeoutputs", "upload-artifacts"); - const stagingDirectory = path.join(stagingRoot, "azure-devops-work-items"); - fs.mkdirSync(stagingDirectory, { recursive: true, mode: 0o700 }); - const stagedName = `${crypto.randomUUID()}-${path.basename(rawPath)}`; - const stagedPath = path.join(stagingDirectory, stagedName); - fs.copyFileSync(sourcePath, stagedPath, fs.constants.COPYFILE_EXCL); - fs.chmodSync(stagedPath, 0o600); - entry.staged_file = path.posix.join("azure-devops-work-items", stagedName); - } catch (error) { - throw new Error(`${ERR_SYSTEM}: Failed to stage Azure DevOps work-item attachment: ${getErrorMessage(error)}`, { cause: error }); - } - - appendSafeOutputCounted(entry); - return { - content: [{ type: "text", text: JSON.stringify({ result: "success", file_path: rawPath }) }], - }; - }; } const resolvedRepo = repoResult.repo; @@ -2207,6 +2124,89 @@ function createHandlers(server, appendSafeOutput, config = {}) { }; }; + const createWorkItemHandler = args => { + const temporaryId = `#${generateTemporaryId()}`; + const entry = { ...(args || {}), type: "create_work_item", temporary_id: temporaryId }; + appendSafeOutputCounted(entry); + const output = { result: "success", temporary_id: temporaryId }; + return { + content: [{ type: "text", text: JSON.stringify(output) }], + structuredContent: output, + }; + }; + + const createAzureDevOpsWorkItemHandler = type => args => { + const entry = { ...(args || {}), type }; + appendSafeOutputCounted(entry); + return { + content: [{ type: "text", text: JSON.stringify({ result: "success" }) }], + }; + }; + + const uploadWorkItemAttachmentHandler = args => { + const entry = { ...(args || {}), type: "upload_workitem_attachment" }; + const rawPath = typeof entry.file_path === "string" ? entry.file_path.trim() : ""; + if (!rawPath || path.isAbsolute(rawPath) || rawPath.includes(":")) { + return buildIntentErrorResponse("upload-workitem-attachment file_path must be a workspace-relative path without ':'"); + } + + const segments = rawPath.split(/[\\/]+/); + if (segments.some(segment => !segment || segment === "." || segment === "..")) { + return buildIntentErrorResponse("upload-workitem-attachment file_path must not contain empty, '.' or '..' path segments"); + } + + const workspace = path.resolve(process.env.GITHUB_WORKSPACE || process.cwd()); + const sourcePath = path.resolve(workspace, ...segments); + if (sourcePath !== workspace && !sourcePath.startsWith(workspace + path.sep)) { + return buildIntentErrorResponse("upload-workitem-attachment file_path resolves outside the workspace"); + } + + let current = workspace; + let sourceStat; + try { + for (const segment of segments) { + current = path.join(current, segment); + sourceStat = lstatGuard(current); + if (!sourceStat) { + return buildIntentErrorResponse("upload-workitem-attachment does not accept symbolic links"); + } + } + } catch (error) { + return buildIntentErrorResponse(`upload-workitem-attachment could not read file_path: ${getErrorMessage(error)}`); + } + if (!sourceStat?.isFile()) { + return buildIntentErrorResponse("upload-workitem-attachment file_path must identify one regular file"); + } + + const attachmentConfig = getSafeOutputsToolConfig(config, "upload_workitem_attachment"); + const maxFileSize = Number(attachmentConfig.max_file_size || 5 * 1024 * 1024); + if (!Number.isSafeInteger(maxFileSize) || maxFileSize < 1 || sourceStat.size > maxFileSize) { + return buildIntentErrorResponse(`upload-workitem-attachment file exceeds the configured max-file-size of ${maxFileSize} bytes`); + } + const allowedExtensions = Array.isArray(attachmentConfig.allowed_extensions) ? attachmentConfig.allowed_extensions : []; + if (allowedExtensions.length > 0 && !allowedExtensions.some(extension => rawPath.toLowerCase().endsWith(String(extension).toLowerCase()))) { + return buildIntentErrorResponse("upload-workitem-attachment file extension is not allowed by the workflow configuration"); + } + + try { + const stagingRoot = path.join(process.env.RUNNER_TEMP || "/tmp", "gh-aw", "safeoutputs", "upload-artifacts"); + const stagingDirectory = path.join(stagingRoot, "azure-devops-work-items"); + fs.mkdirSync(stagingDirectory, { recursive: true, mode: 0o700 }); + const stagedName = `${crypto.randomUUID()}-${path.basename(rawPath)}`; + const stagedPath = path.join(stagingDirectory, stagedName); + fs.copyFileSync(sourcePath, stagedPath, fs.constants.COPYFILE_EXCL); + fs.chmodSync(stagedPath, 0o600); + entry.staged_file = path.posix.join("azure-devops-work-items", stagedName); + } catch (error) { + throw new Error(`${ERR_SYSTEM}: Failed to stage Azure DevOps work-item attachment: ${getErrorMessage(error)}`, { cause: error }); + } + + appendSafeOutputCounted(entry); + return { + content: [{ type: "text", text: JSON.stringify({ result: "success", file_path: rawPath }) }], + }; + }; + /** * Handler for create_project tool * Spec cross-reference: not part of the numbered outcome types in Safe Output Outcome Evaluation v1.0.0. diff --git a/actions/setup/js/safe_outputs_handlers.test.cjs b/actions/setup/js/safe_outputs_handlers.test.cjs index a64b2b0561a..6164e39abe2 100644 --- a/actions/setup/js/safe_outputs_handlers.test.cjs +++ b/actions/setup/js/safe_outputs_handlers.test.cjs @@ -70,6 +70,17 @@ describe("safe_outputs_handlers", () => { handlers = createHandlers(mockServer, mockAppendSafeOutput); }); + it("collects Azure DevOps proposals using underscore-form message types", () => { + handlers.createWorkItemHandler({ temporary_id: "item", title: "Create item" }); + handlers.updateWorkItemHandler({ id: "#item", title: "Update item" }); + handlers.commentOnWorkItemHandler({ id: "#item", body: "Comment" }); + handlers.assignWorkItemHandler({ id: "#item", assignee: "user@example.com" }); + handlers.linkWorkItemsHandler({ source_id: "#item", target_id: 42, type: "related" }); + + expect(mockAppendSafeOutput.mock.calls.map(call => call[0].type)).toEqual(["create_work_item", "update_work_item", "comment_on_work_item", "assign_work_item", "link_work_items"]); + expect(mockAppendSafeOutput.mock.calls[0][0].temporary_id).toMatch(/^#aw_/); + }); + afterEach(() => { // Clean up test files try { diff --git a/actions/setup/js/safe_outputs_tools_loader.test.cjs b/actions/setup/js/safe_outputs_tools_loader.test.cjs index cb658ebd49f..c4ceca6f81e 100644 --- a/actions/setup/js/safe_outputs_tools_loader.test.cjs +++ b/actions/setup/js/safe_outputs_tools_loader.test.cjs @@ -91,6 +91,29 @@ describe("safe_outputs_tools_loader", () => { }); describe("attachHandlers", () => { + it("attaches Azure DevOps handlers to ado-aw public tool names", () => { + const tools = [{ name: "create-work-item" }, { name: "update-work-item" }, { name: "comment-on-work-item" }, { name: "assign-work-item" }, { name: "link-work-items" }, { name: "upload-workitem-attachment" }]; + const handlers = { + createWorkItemHandler: vi.fn(), + updateWorkItemHandler: vi.fn(), + commentOnWorkItemHandler: vi.fn(), + assignWorkItemHandler: vi.fn(), + linkWorkItemsHandler: vi.fn(), + uploadWorkItemAttachmentHandler: vi.fn(), + }; + + const result = attachHandlers(tools, handlers); + + expect(result.map(tool => tool.handler)).toEqual([ + handlers.createWorkItemHandler, + handlers.updateWorkItemHandler, + handlers.commentOnWorkItemHandler, + handlers.assignWorkItemHandler, + handlers.linkWorkItemsHandler, + handlers.uploadWorkItemAttachmentHandler, + ]); + }); + it("should attach create_pull_request handler", () => { const tools = [ { name: "create_pull_request", description: "Create PR" }, diff --git a/actions/setup/js/temporary_id.cjs b/actions/setup/js/temporary_id.cjs index 02398818cac..6bd96ee4d1d 100644 --- a/actions/setup/js/temporary_id.cjs +++ b/actions/setup/js/temporary_id.cjs @@ -654,7 +654,7 @@ function extractTemporaryIdReferences(message) { } // Check direct ID reference fields - const idFields = ["parent_issue_number", "sub_issue_number", "issue_number", "item_number", "discussion_number", "pull_request_number", "content_number"]; + const idFields = ["parent_issue_number", "sub_issue_number", "issue_number", "item_number", "discussion_number", "pull_request_number", "content_number", "id", "work_item_id", "source_id", "target_id"]; for (const field of idFields) { const value = message[field]; diff --git a/actions/setup/js/temporary_id.test.cjs b/actions/setup/js/temporary_id.test.cjs index 509d3e88a2f..22b74a3dbf3 100644 --- a/actions/setup/js/temporary_id.test.cjs +++ b/actions/setup/js/temporary_id.test.cjs @@ -817,6 +817,20 @@ describe("temporary_id.cjs", () => { expect(refs.has("aw_bbbb12")).toBe(true); }); + it("should extract temporary IDs from Azure DevOps work-item fields", async () => { + const { extractTemporaryIdReferences } = await import("./temporary_id.cjs"); + + const refs = extractTemporaryIdReferences({ + type: "link_work_items", + id: "#aw_item1", + work_item_id: "#aw_item2", + source_id: "#aw_item3", + target_id: "#aw_item4", + }); + + expect(refs).toEqual(new Set(["aw_item1", "aw_item2", "aw_item3", "aw_item4"])); + }); + it("should extract temporary IDs from blocked_by dependencies", async () => { const { extractTemporaryIdReferences } = await import("./temporary_id.cjs"); diff --git a/docs/src/content/docs/reference/safe-outputs.md b/docs/src/content/docs/reference/safe-outputs.md index 02f37ba1afa..ca468b79bb6 100644 --- a/docs/src/content/docs/reference/safe-outputs.md +++ b/docs/src/content/docs/reference/safe-outputs.md @@ -77,6 +77,17 @@ The tables below summarize the built-in safe output handlers. `noop`, `missing-t | [Upload Artifact](#artifact-uploads-upload-artifact) | `upload-artifact` | Upload files as run-scoped GitHub Actions artifacts (max: 1 by default) | | [Upload Assets](#asset-uploads-upload-asset) | `upload-asset` | Upload files to orphaned git branch (max: 10, same-repo only). **Prefer `upload-artifact` with `skip-archive` instead.** | +### Azure DevOps Work Items + +| Output | Key | Description | +|--------|-----|-------------| +| [Create Work Item](#azure-devops-work-items) | `create-work-item` | Create an Azure DevOps work item (max: 1) | +| [Update Work Item](#azure-devops-work-items) | `update-work-item` | Update explicitly enabled fields on a scoped work item (max: 1) | +| [Comment on Work Item](#azure-devops-work-items) | `comment-on-work-item` | Add a comment to a scoped work item (max: 1) | +| [Assign Work Item](#azure-devops-work-items) | `assign-work-item` | Assign an allowed identity to a scoped work item (max: 1) | +| [Link Work Items](#azure-devops-work-items) | `link-work-items` | Link two scoped work items (max: 5) | +| [Upload Work Item Attachment](#azure-devops-work-items) | `upload-workitem-attachment` | Attach a staged workspace file to a work item (max: 1) | + ### Security & Agent Tasks | Output | Key | Description | @@ -1108,6 +1119,45 @@ git checkout --orphan my-custom-branch && git rm -rf . && git commit --allow-emp **Outputs**: `published_count`, `branch_name`. **Limits**: Same-repo only, max 50MB/file, 100 assets/run. +### Azure DevOps Work Items + +Azure DevOps work-item safe outputs use the same public tool names as [`ado-aw`](https://githubnext.github.io/ado-aw/reference/safe-outputs/). The agent remains read-only; the safe-output job performs trusted Azure DevOps REST requests. + +Provide the organization, project, and credential only to the safe-output job: + +```yaml wrap +safe-outputs: + env: + AZURE_DEVOPS_ORG_URL: ${{ vars.AZURE_DEVOPS_ORG_URL }} + SYSTEM_TEAMPROJECT: ${{ vars.AZURE_DEVOPS_PROJECT }} + AZURE_DEVOPS_EXT_PAT: ${{ secrets.AZURE_DEVOPS_EXT_PAT }} + create-work-item: + work-item-type: Task + area-path: MyProject\Platform + allowed-tags: [agent-*] + update-work-item: + target: MyProject\Platform + title: true + status: true + comment-on-work-item: + target: MyProject\Platform + assign-work-item: + target: "*" + allowed: [owner@example.com] + link-work-items: + target: MyProject\Platform + allowed-link-types: [parent, child, related] + upload-workitem-attachment: + allowed-extensions: [.txt, .log, .pdf] + max-file-size: 5242880 +``` + +Authentication uses `SYSTEM_ACCESSTOKEN` when present, otherwise `AZURE_DEVOPS_EXT_PAT`. `AZURE_DEVOPS_ORG_URL` must use `https://dev.azure.com/{organization}` or `https://{organization}.visualstudio.com`; redirects and embedded credentials are rejected. + +`create-work-item` returns a run-scoped `#aw_...` temporary ID. The other work-item tools accept that ID, and same-run IDs bypass their consuming `target` policy because creation was already scoped by trusted configuration. Numeric IDs are checked against `target`: updates and assignments accept `"*"` or one ID; comments and links also accept an ID list or area-path prefix. + +For `update-work-item`, each mutable field must be explicitly enabled. Assignment always rejects the reserved `Agency` and `GitHub Copilot` identities. Attachments must be regular workspace files and are checked for traversal, symbolic links, size, extension, and Azure Pipelines command sequences before upload. + ### No-Op Logging (`noop:`) :::danger[Required when no action is taken] diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index 5a0fcc0caba..ebb2110fa08 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -13195,9 +13195,9 @@ "branch": { "type": "string", "minLength": 1, "maxLength": 256, "default": "main" } } }, - "include-stats": { "type": "boolean", "default": true }, "max": { "$ref": "#/$defs/templatable_positive_integer" }, - "staged": { "$ref": "#/$defs/templatable_boolean" } + "staged": { "$ref": "#/$defs/templatable_boolean" }, + "samples": { "$ref": "#/$defs/azure_devops_samples" } } }, { "type": "null" } @@ -13226,7 +13226,8 @@ "tags": { "type": "boolean", "default": false }, "allowed-tags": { "$ref": "#/$defs/azure_devops_string_list" }, "max": { "$ref": "#/$defs/templatable_positive_integer" }, - "staged": { "$ref": "#/$defs/templatable_boolean" } + "staged": { "$ref": "#/$defs/templatable_boolean" }, + "samples": { "$ref": "#/$defs/azure_devops_samples" } } }, { "type": "null" } @@ -13241,9 +13242,9 @@ "required": ["target"], "properties": { "target": { "$ref": "#/$defs/azure_devops_work_item_target" }, - "include-stats": { "type": "boolean", "default": true }, "max": { "$ref": "#/$defs/templatable_positive_integer" }, - "staged": { "$ref": "#/$defs/templatable_boolean" } + "staged": { "$ref": "#/$defs/templatable_boolean" }, + "samples": { "$ref": "#/$defs/azure_devops_samples" } } }, { "type": "null" } @@ -13262,7 +13263,8 @@ "allowed": { "$ref": "#/$defs/azure_devops_string_list" }, "blocked": { "$ref": "#/$defs/azure_devops_string_list" }, "max": { "$ref": "#/$defs/templatable_positive_integer" }, - "staged": { "$ref": "#/$defs/templatable_boolean" } + "staged": { "$ref": "#/$defs/templatable_boolean" }, + "samples": { "$ref": "#/$defs/azure_devops_samples" } } }, { "type": "null" } @@ -13286,7 +13288,8 @@ } }, "max": { "$ref": "#/$defs/templatable_positive_integer" }, - "staged": { "$ref": "#/$defs/templatable_boolean" } + "staged": { "$ref": "#/$defs/templatable_boolean" }, + "samples": { "$ref": "#/$defs/azure_devops_samples" } } }, { "type": "null" } @@ -13307,7 +13310,8 @@ }, "comment-prefix": { "type": "string", "maxLength": 256 }, "max": { "$ref": "#/$defs/templatable_positive_integer" }, - "staged": { "$ref": "#/$defs/templatable_boolean" } + "staged": { "$ref": "#/$defs/templatable_boolean" }, + "samples": { "$ref": "#/$defs/azure_devops_samples" } } }, { "type": "null" } @@ -13335,6 +13339,19 @@ "uniqueItems": true, "items": { "type": "string", "minLength": 1, "maxLength": 256 } }, + "azure_devops_samples": { + "description": "Internal hidden feature. Optional declarative sample payloads for deterministic safe-output replay.", + "oneOf": [ + { + "type": "array", + "items": { "type": "object", "additionalProperties": true } + }, + { + "type": "object", + "additionalProperties": true + } + ] + }, "enclave-repos": { "type": "array", "minItems": 1, diff --git a/pkg/workflow/publish_artifacts.go b/pkg/workflow/publish_artifacts.go index e8667f1a32a..1f2f5f0b634 100644 --- a/pkg/workflow/publish_artifacts.go +++ b/pkg/workflow/publish_artifacts.go @@ -50,7 +50,7 @@ type UploadArtifactConfig struct { } // parseUploadArtifactConfig parses the upload-artifact key from the safe-outputs map. -func (c *Compiler) parseUploadArtifactConfig(outputMap map[string]any) *UploadArtifactConfig { +func (c *Compiler) parseUploadArtifactConfig(outputMap map[string]any) *UploadArtifactConfig { //nolint:largefunc // Existing upload-artifact parsing remains centralized; Azure attachment staging only reuses its artifact channel. configData, exists := outputMap["upload-artifact"] if !exists { return nil diff --git a/pkg/workflow/safe_output_validation_config_test.go b/pkg/workflow/safe_output_validation_config_test.go index 5c5602682ca..7b4a176e502 100644 --- a/pkg/workflow/safe_output_validation_config_test.go +++ b/pkg/workflow/safe_output_validation_config_test.go @@ -482,16 +482,17 @@ func TestStripOnErrorOnlyOnOptionalFields(t *testing.T) { func TestValidationConfigConsistency(t *testing.T) { // Verify that all types with customValidation have valid validation rules validCustomValidations := map[string]bool{ - "requiresOneOf:status,title,body,labels,assignees,milestone": true, - "requiresOneOf:title,body": true, - "requiresOneOf:title,body,update_branch": true, - "requiresOneOf:title,body,labels": true, - "requiresOneOf:issue_number,pull_number": true, - "requiresOneOf:milestone_number,milestone_title": true, - "requiresOneOf:field_name,field_node_id": true, - "requiresOneOf:reviewers,team_reviewers": true, - "startLineLessOrEqualLine": true, - "parentAndSubDifferent": true, + "requiresOneOf:status,title,body,labels,assignees,milestone": true, + "requiresOneOf:title,body": true, + "requiresOneOf:title,body,update_branch": true, + "requiresOneOf:title,body,labels": true, + "requiresOneOf:issue_number,pull_number": true, + "requiresOneOf:milestone_number,milestone_title": true, + "requiresOneOf:field_name,field_node_id": true, + "requiresOneOf:reviewers,team_reviewers": true, + "requiresOneOf:title,body,state,area_path,iteration_path,assignee,tags": true, + "startLineLessOrEqualLine": true, + "parentAndSubDifferent": true, } for typeName, config := range ValidationConfig { @@ -526,7 +527,8 @@ func TestValidationConfigCoversToolInputSchemas(t *testing.T) { metadataFields := map[string]bool{"secrecy": true, "integrity": true} for _, tool := range tools { - config, ok := ValidationConfig[tool.Name] + typeName := strings.ReplaceAll(tool.Name, "-", "_") + config, ok := ValidationConfig[typeName] if !ok { t.Errorf("%s tool is missing from ValidationConfig", tool.Name) continue diff --git a/pkg/workflow/safe_outputs_azure_devops.go b/pkg/workflow/safe_outputs_azure_devops.go index 38be42dd52f..25d0befb654 100644 --- a/pkg/workflow/safe_outputs_azure_devops.go +++ b/pkg/workflow/safe_outputs_azure_devops.go @@ -1,13 +1,18 @@ package workflow -import "github.com/github/gh-aw/pkg/logger" +import ( + "fmt" + "maps" + + "github.com/github/gh-aw/pkg/logger" +) var azureDevOpsSafeOutputsLog = logger.New("workflow:safe_outputs_azure_devops") type AzureDevOpsArtifactLinkConfig struct { - Enabled bool `yaml:"enabled,omitempty"` - Repository string `yaml:"repository,omitempty"` - Branch string `yaml:"branch,omitempty"` + Enabled bool `yaml:"enabled,omitempty" json:"enabled,omitempty"` + Repository string `yaml:"repository,omitempty" json:"repository,omitempty"` + Branch string `yaml:"branch,omitempty" json:"branch,omitempty"` } type CreateWorkItemConfig struct { @@ -21,29 +26,29 @@ type CreateWorkItemConfig struct { AllowedTags []string `yaml:"allowed-tags,omitempty"` CustomFields map[string]string `yaml:"custom-fields,omitempty"` ArtifactLink AzureDevOpsArtifactLinkConfig `yaml:"artifact-link,omitempty"` - IncludeStats bool `yaml:"include-stats,omitempty"` } type UpdateWorkItemConfig struct { - BaseSafeOutputConfig `yaml:",inline"` - Status bool `yaml:"status,omitempty"` - Title bool `yaml:"title,omitempty"` - Body bool `yaml:"body,omitempty"` - MarkdownBody bool `yaml:"markdown-body,omitempty"` - TitlePrefix string `yaml:"title-prefix,omitempty"` - TagPrefix string `yaml:"tag-prefix,omitempty"` - Target any `yaml:"target,omitempty"` - AreaPath bool `yaml:"area-path,omitempty"` - IterationPath bool `yaml:"iteration-path,omitempty"` - Assignee bool `yaml:"assignee,omitempty"` - Tags bool `yaml:"tags,omitempty"` - AllowedTags []string `yaml:"allowed-tags,omitempty"` + BaseSafeOutputConfig `yaml:",inline"` + Status bool `yaml:"status,omitempty"` + Title bool `yaml:"title,omitempty"` + Body bool `yaml:"body,omitempty"` + MarkdownBody bool `yaml:"markdown-body,omitempty"` + TitlePrefix string `yaml:"title-prefix,omitempty"` + TagPrefix string `yaml:"tag-prefix,omitempty"` + Target any `yaml:"target,omitempty"` + AreaPath bool `yaml:"area-path,omitempty"` + IterationPath bool `yaml:"iteration-path,omitempty"` + Assignee bool `yaml:"assignee,omitempty"` + Tags bool `yaml:"tags,omitempty"` + AllowedTags []string `yaml:"allowed-tags,omitempty"` + AllowedAreaPrefixes []string `yaml:"allowed-area-prefixes,omitempty"` + AllowedIterationPrefixes []string `yaml:"allowed-iteration-prefixes,omitempty"` } type CommentOnWorkItemConfig struct { BaseSafeOutputConfig `yaml:",inline"` - Target any `yaml:"target,omitempty"` - IncludeStats bool `yaml:"include-stats,omitempty"` + Target any `yaml:"target,omitempty"` } type AssignWorkItemConfig struct { @@ -67,6 +72,15 @@ type UploadWorkItemAttachmentConfig struct { } func parseAzureDevOpsConfig[T any](c *Compiler, outputMap map[string]any, key string, defaultMax int, postProcess func(*T)) *T { + if enabled, ok := outputMap[key].(bool); ok { + if !enabled { + return nil + } + normalized := make(map[string]any, len(outputMap)) + maps.Copy(normalized, outputMap) + normalized[key] = map[string]any{} + outputMap = normalized + } config := parseConfigScaffold(outputMap, key, azureDevOpsSafeOutputsLog, func(err error) *T { azureDevOpsSafeOutputsLog.Printf("Failed to parse %s configuration: %v", key, err) return nil @@ -143,6 +157,7 @@ var azureDevOpsWorkItemHandlerRegistry = map[string]handlerBuilder{ if cfg.CreateWorkItems == nil { return nil } + c := cfg.CreateWorkItems return newHandlerConfigBuilder(). AddTemplatableInt("max", c.Max). @@ -176,6 +191,8 @@ var azureDevOpsWorkItemHandlerRegistry = map[string]handlerBuilder{ AddIfTrue("assignee", c.Assignee). AddIfTrue("tags", c.Tags). AddStringSlice("allowed_tags", c.AllowedTags). + AddStringSlice("allowed_area_prefixes", c.AllowedAreaPrefixes). + AddStringSlice("allowed_iteration_prefixes", c.AllowedIterationPrefixes). AddTemplatableBool("staged", templatableBoolPtrToStringPtr(c.Staged)) return addAzureDevOpsTarget(builder, c.Target).Build() }, @@ -226,3 +243,95 @@ var azureDevOpsWorkItemHandlerRegistry = map[string]handlerBuilder{ Build() }, } + +func appendAzureDevOpsTargetConstraint(constraints *[]string, target any) { + if target != nil { + *constraints = append(*constraints, fmt.Sprintf("Target: %v.", target)) + } +} + +func createWorkItemConstraints(config *CreateWorkItemConfig) []string { + return buildConstraints(config, func(config *CreateWorkItemConfig, constraints *[]string) { + appendMaxConstraint(constraints, config.Max, "Maximum %d work item(s) can be created.") + appendStringConstraint(constraints, config.WorkItemType, "Work item type: %q.") + appendStringConstraint(constraints, config.AreaPath, "Area path: %q.") + if len(config.AllowedTags) > 0 { + *constraints = append(*constraints, fmt.Sprintf("Only these agent-provided tags are allowed: %s.", formatStringList(config.AllowedTags))) + } + }) +} + +func updateWorkItemConstraints(config *UpdateWorkItemConfig) []string { + return buildConstraints(config, func(config *UpdateWorkItemConfig, constraints *[]string) { + appendMaxConstraint(constraints, config.Max, "Maximum %d work item(s) can be updated.") + appendAzureDevOpsTargetConstraint(constraints, config.Target) + var fields []string + for _, field := range []struct { + name string + enabled bool + }{ + {"state", config.Status}, + {"title", config.Title}, + {"body", config.Body}, + {"area_path", config.AreaPath}, + {"iteration_path", config.IterationPath}, + {"assignee", config.Assignee}, + {"tags", config.Tags}, + } { + if field.enabled { + fields = append(fields, field.name) + } + } + if len(fields) > 0 { + *constraints = append(*constraints, fmt.Sprintf("Only these fields can be updated: %s.", formatStringList(fields))) + } + if len(config.AllowedAreaPrefixes) > 0 { + *constraints = append(*constraints, fmt.Sprintf("Area paths must match these prefixes: %s.", formatStringList(config.AllowedAreaPrefixes))) + } + if len(config.AllowedIterationPrefixes) > 0 { + *constraints = append(*constraints, fmt.Sprintf("Iteration paths must match these prefixes: %s.", formatStringList(config.AllowedIterationPrefixes))) + } + }) +} + +func commentOnWorkItemConstraints(config *CommentOnWorkItemConfig) []string { + return buildConstraints(config, func(config *CommentOnWorkItemConfig, constraints *[]string) { + appendMaxConstraint(constraints, config.Max, "Maximum %d work-item comment(s) can be added.") + appendAzureDevOpsTargetConstraint(constraints, config.Target) + }) +} + +func assignWorkItemConstraints(config *AssignWorkItemConfig) []string { + return buildConstraints(config, func(config *AssignWorkItemConfig, constraints *[]string) { + appendMaxConstraint(constraints, config.Max, "Maximum %d work item(s) can be assigned.") + appendAzureDevOpsTargetConstraint(constraints, config.Target) + if len(config.Allowed) > 0 { + *constraints = append(*constraints, fmt.Sprintf("Only these assignees are allowed: %s.", formatStringList(config.Allowed))) + } + if len(config.Blocked) > 0 { + *constraints = append(*constraints, fmt.Sprintf("These assignee patterns are blocked: %s.", formatStringList(config.Blocked))) + } + }) +} + +func linkWorkItemsConstraints(config *LinkWorkItemsConfig) []string { + return buildConstraints(config, func(config *LinkWorkItemsConfig, constraints *[]string) { + appendMaxConstraint(constraints, config.Max, "Maximum %d work-item link(s) can be created.") + appendAzureDevOpsTargetConstraint(constraints, config.Target) + if len(config.AllowedLinkTypes) > 0 { + *constraints = append(*constraints, fmt.Sprintf("Only these link types are allowed: %s.", formatStringList(config.AllowedLinkTypes))) + } + }) +} + +func uploadWorkItemAttachmentConstraints(config *UploadWorkItemAttachmentConfig) []string { + return buildConstraints(config, func(config *UploadWorkItemAttachmentConfig, constraints *[]string) { + appendMaxConstraint(constraints, config.Max, "Maximum %d work-item attachment(s) can be uploaded.") + if config.MaxFileSize > 0 { + *constraints = append(*constraints, fmt.Sprintf("Maximum attachment size: %d bytes.", config.MaxFileSize)) + } + if len(config.AllowedExtensions) > 0 { + *constraints = append(*constraints, fmt.Sprintf("Only these file extensions are allowed: %s.", formatStringList(config.AllowedExtensions))) + } + }) +} diff --git a/pkg/workflow/safe_outputs_azure_devops_test.go b/pkg/workflow/safe_outputs_azure_devops_test.go new file mode 100644 index 00000000000..6508b7b706d --- /dev/null +++ b/pkg/workflow/safe_outputs_azure_devops_test.go @@ -0,0 +1,124 @@ +//go:build !integration + +package workflow + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestExtractAzureDevOpsSafeOutputsConfig(t *testing.T) { + compiler := NewCompiler() + config := compiler.extractSafeOutputsConfig(map[string]any{ + "safe-outputs": map[string]any{ + "create-work-item": map[string]any{ + "work-item-type": "Bug", + "area-path": `Project\Platform`, + "allowed-tags": []any{"agent-*"}, + "samples": []any{ + map[string]any{"title": "Sample item"}, + }, + }, + "update-work-item": map[string]any{ + "target": "42", + "title": true, + }, + "comment-on-work-item": true, + "assign-work-item": true, + "link-work-items": true, + "upload-workitem-attachment": true, + }, + }) + + require.NotNil(t, config) + require.NotNil(t, config.CreateWorkItems) + assert.Equal(t, "Bug", config.CreateWorkItems.WorkItemType) + assert.Equal(t, `Project\Platform`, config.CreateWorkItems.AreaPath) + assert.Equal(t, []string{"agent-*"}, config.CreateWorkItems.AllowedTags) + require.Len(t, config.CreateWorkItems.Samples, 1) + assert.Equal(t, "Sample item", config.CreateWorkItems.Samples[0]["title"]) + require.NotNil(t, config.UpdateWorkItems) + assert.Equal(t, "42", config.UpdateWorkItems.Target) + assert.True(t, config.UpdateWorkItems.Title) + assert.NotNil(t, config.CommentOnWorkItems) + assert.NotNil(t, config.AssignWorkItems) + assert.NotNil(t, config.LinkWorkItems) + assert.NotNil(t, config.UploadWorkItemAttachments) +} + +func TestAzureDevOpsSafeOutputsUseAdoAwPublicToolNames(t *testing.T) { + data := &WorkflowData{ + SafeOutputs: &SafeOutputsConfig{ + CreateWorkItems: &CreateWorkItemConfig{}, + UpdateWorkItems: &UpdateWorkItemConfig{}, + CommentOnWorkItems: &CommentOnWorkItemConfig{}, + AssignWorkItems: &AssignWorkItemConfig{}, + LinkWorkItems: &LinkWorkItemsConfig{}, + UploadWorkItemAttachments: &UploadWorkItemAttachmentConfig{}, + }, + } + + enabled := computeEnabledToolNames(data) + + for _, name := range []string{ + "create-work-item", + "update-work-item", + "comment-on-work-item", + "assign-work-item", + "link-work-items", + "upload-workitem-attachment", + } { + assert.Contains(t, enabled, name) + } +} + +func TestGenerateAzureDevOpsSafeOutputsConfig(t *testing.T) { + data := &WorkflowData{ + SafeOutputs: &SafeOutputsConfig{ + CreateWorkItems: &CreateWorkItemConfig{ + BaseSafeOutputConfig: BaseSafeOutputConfig{Max: strPtr("2")}, + WorkItemType: "Task", + AreaPath: `Project\Platform`, + }, + UpdateWorkItems: &UpdateWorkItemConfig{ + Target: "42", + Title: true, + }, + }, + } + + result, err := generateSafeOutputsConfig(data) + require.NoError(t, err) + + var parsed map[string]any + require.NoError(t, json.Unmarshal([]byte(result), &parsed)) + createConfig := parsed["create_work_item"].(map[string]any) + assert.InDelta(t, 2, createConfig["max"], 0) + assert.Equal(t, "Task", createConfig["work_item_type"]) + assert.Equal(t, `Project\Platform`, createConfig["area_path"]) + assert.Equal(t, map[string]any{}, createConfig["artifact_link"]) + + updateConfig := parsed["update_work_item"].(map[string]any) + assert.Equal(t, "42", updateConfig["target"]) + assert.Equal(t, true, updateConfig["title"]) + assert.NotContains(t, updateConfig, "status") +} + +func TestAzureDevOpsToolDescriptionConstraints(t *testing.T) { + config := &SafeOutputsConfig{ + CreateWorkItems: &CreateWorkItemConfig{ + BaseSafeOutputConfig: BaseSafeOutputConfig{Max: strPtr("2")}, + WorkItemType: "Bug", + AreaPath: `Project\Platform`, + }, + } + + constraints := toolConstraintBuilders["create-work-item"](config) + + assert.Contains(t, constraints, "Maximum 2 work item(s) can be created.") + assert.Contains(t, constraints, `Work item type: "Bug".`) + assert.Contains(t, constraints, `Area path: "Project\\Platform".`) +} diff --git a/pkg/workflow/safe_outputs_config_extraction.go b/pkg/workflow/safe_outputs_config_extraction.go index a802a6df7ed..f4d03695c02 100644 --- a/pkg/workflow/safe_outputs_config_extraction.go +++ b/pkg/workflow/safe_outputs_config_extraction.go @@ -42,7 +42,7 @@ package workflow // // extractSafeOutputsConfig extracts output configuration from frontmatter -func (c *Compiler) extractSafeOutputsConfig(frontmatter map[string]any) *SafeOutputsConfig { +func (c *Compiler) extractSafeOutputsConfig(frontmatter map[string]any) *SafeOutputsConfig { //nolint:largefunc // Existing safe-output extraction remains centralized across all output types. safeOutputsConfigLog.Print("Extracting safe-outputs configuration from frontmatter") var config *SafeOutputsConfig diff --git a/pkg/workflow/safe_outputs_handler_registry_test.go b/pkg/workflow/safe_outputs_handler_registry_test.go index e0293d0a4ad..66c4c297204 100644 --- a/pkg/workflow/safe_outputs_handler_registry_test.go +++ b/pkg/workflow/safe_outputs_handler_registry_test.go @@ -21,6 +21,7 @@ func TestHandlerRegistryDomainComposition(t *testing.T) { {name: "commentHandlerRegistry", registry: commentHandlerRegistry, wantKeys: []string{"add_comment", "hide_comment"}}, {name: "releaseHandlerRegistry", registry: releaseHandlerRegistry, wantKeys: []string{"update_release"}}, {name: "diagnosticHandlerRegistry", registry: diagnosticHandlerRegistry, wantKeys: []string{"missing_tool", "missing_data", "noop", "report_incomplete", "create_report_incomplete_issue"}}, + {name: "azureDevOpsWorkItemHandlerRegistry", registry: azureDevOpsWorkItemHandlerRegistry, wantKeys: []string{"create_work_item", "update_work_item", "comment_on_work_item", "assign_work_item", "link_work_items", "upload_workitem_attachment"}}, } wantAll := map[string]struct{}{} @@ -98,6 +99,12 @@ func TestHandlerRegistryBuilders(t *testing.T) { {name: "assign_to_user", cfg: &SafeOutputsConfig{AssignToUser: &AssignToUserConfig{}}}, {name: "unassign_from_user", cfg: &SafeOutputsConfig{UnassignFromUser: &UnassignFromUserConfig{}}}, {name: "create_agent_session", cfg: &SafeOutputsConfig{CreateAgentSessions: &CreateAgentSessionConfig{}}}, + {name: "create_work_item", cfg: &SafeOutputsConfig{CreateWorkItems: &CreateWorkItemConfig{}}}, + {name: "update_work_item", cfg: &SafeOutputsConfig{UpdateWorkItems: &UpdateWorkItemConfig{}}}, + {name: "comment_on_work_item", cfg: &SafeOutputsConfig{CommentOnWorkItems: &CommentOnWorkItemConfig{}}}, + {name: "assign_work_item", cfg: &SafeOutputsConfig{AssignWorkItems: &AssignWorkItemConfig{}}}, + {name: "link_work_items", cfg: &SafeOutputsConfig{LinkWorkItems: &LinkWorkItemsConfig{}}}, + {name: "upload_workitem_attachment", cfg: &SafeOutputsConfig{UploadWorkItemAttachments: &UploadWorkItemAttachmentConfig{}}}, {name: "add_comment", cfg: &SafeOutputsConfig{AddComments: &AddCommentsConfig{}}}, {name: "hide_comment", cfg: &SafeOutputsConfig{HideComment: &HideCommentConfig{}}}, {name: "update_release", cfg: &SafeOutputsConfig{UpdateRelease: &UpdateReleaseConfig{}}}, diff --git a/pkg/workflow/safe_outputs_max_validation.go b/pkg/workflow/safe_outputs_max_validation.go index fd118691ee6..cb75274f7b4 100644 --- a/pkg/workflow/safe_outputs_max_validation.go +++ b/pkg/workflow/safe_outputs_max_validation.go @@ -55,7 +55,7 @@ func checkMaxField(toolName string, maxPtr *string) error { // This function uses direct struct field access instead of reflection for performance; // it is on the hot path and called on every compilation. The field ordering matches // the sorted safeOutputFieldMapping keys for deterministic error reporting. -func validateSafeOutputsMax(config *SafeOutputsConfig) error { +func validateSafeOutputsMax(config *SafeOutputsConfig) error { //nolint:largefunc // Existing explicit validation preserves deterministic field ordering across output types. if config == nil { return nil } diff --git a/pkg/workflow/safe_outputs_state.go b/pkg/workflow/safe_outputs_state.go index ace66a6dc9a..9ae5facbaca 100644 --- a/pkg/workflow/safe_outputs_state.go +++ b/pkg/workflow/safe_outputs_state.go @@ -29,7 +29,7 @@ var safeOutputFieldMapping = buildSafeOutputFieldMapping() // // NOTE: keep this function in sync with safeOutputFieldMapping above and // hasNonBuiltinSafeOutputsEnabled below when adding new safe output types. -func hasAnySafeOutputEnabled(safeOutputs *SafeOutputsConfig) bool { +func hasAnySafeOutputEnabled(safeOutputs *SafeOutputsConfig) bool { //nolint:largefunc // Existing explicit checks avoid reflection on the compilation hot path. if safeOutputs == nil { return false } @@ -101,7 +101,7 @@ func hasAnySafeOutputEnabled(safeOutputs *SafeOutputsConfig) bool { // // NOTE: keep this function in sync with safeOutputFieldMapping above and // hasAnySafeOutputEnabled above when adding new safe output types. -func hasNonBuiltinSafeOutputsEnabled(safeOutputs *SafeOutputsConfig) bool { +func hasNonBuiltinSafeOutputsEnabled(safeOutputs *SafeOutputsConfig) bool { //nolint:largefunc // Existing explicit checks avoid reflection on the compilation hot path. if safeOutputs == nil { return false } diff --git a/pkg/workflow/safe_outputs_tools_computation.go b/pkg/workflow/safe_outputs_tools_computation.go index 4acbf732401..1f25a927c52 100644 --- a/pkg/workflow/safe_outputs_tools_computation.go +++ b/pkg/workflow/safe_outputs_tools_computation.go @@ -7,7 +7,7 @@ var safeOutputsToolsComputationLog = logger.New("workflow:safe_outputs_tools_com // computeEnabledToolNames returns the set of predefined tool names that are enabled // by the workflow's SafeOutputsConfig. Dynamic tools (dispatch-workflow, custom jobs, // call-workflow) are excluded because they are generated separately. -func computeEnabledToolNames(data *WorkflowData) map[string]struct { +func computeEnabledToolNames(data *WorkflowData) map[string]struct { //nolint:largefunc // Existing explicit tool-name mapping remains centralized for all safe outputs. } { enabledTools := make(map[string]struct { }) diff --git a/pkg/workflow/safe_outputs_validation_config.go b/pkg/workflow/safe_outputs_validation_config.go index 00aae097a3a..9ef98af3acb 100644 --- a/pkg/workflow/safe_outputs_validation_config.go +++ b/pkg/workflow/safe_outputs_validation_config.go @@ -619,7 +619,7 @@ var validationConfigJSONCache sync.Map // key: string → value: string // GetValidationConfigJSONWithDataSchema behaves like GetValidationConfigJSONWithDataSchema and additionally // injects a normalized data schema into body-bearing safe-output types. -func GetValidationConfigJSONWithDataSchema(enabledTypes []string, mentions map[string]any, dataEnabled bool, dataSchema map[string]any) (string, error) { +func GetValidationConfigJSONWithDataSchema(enabledTypes []string, mentions map[string]any, dataEnabled bool, dataSchema map[string]any) (string, error) { //nolint:largefunc // Existing schema assembly remains centralized; Azure types only extend the package-level validation map. safeOutputValidationLog.Printf("Getting validation config JSON for %d types (mentions=%t)", len(enabledTypes), len(mentions) > 0) // Cache only the schema-only path; mentions are workflow-specific and cheap to remarshal. diff --git a/pkg/workflow/tool_description_enhancer.go b/pkg/workflow/tool_description_enhancer.go index 09740d49167..afeea37a2de 100644 --- a/pkg/workflow/tool_description_enhancer.go +++ b/pkg/workflow/tool_description_enhancer.go @@ -13,6 +13,24 @@ var toolDescriptionEnhancerLog = logger.New("workflow:tool_description_enhancer" type toolConstraintBuilder func(*SafeOutputsConfig) []string var toolConstraintBuilders = map[string]toolConstraintBuilder{ + "create-work-item": func(safeOutputs *SafeOutputsConfig) []string { + return createWorkItemConstraints(safeOutputs.CreateWorkItems) + }, + "update-work-item": func(safeOutputs *SafeOutputsConfig) []string { + return updateWorkItemConstraints(safeOutputs.UpdateWorkItems) + }, + "comment-on-work-item": func(safeOutputs *SafeOutputsConfig) []string { + return commentOnWorkItemConstraints(safeOutputs.CommentOnWorkItems) + }, + "assign-work-item": func(safeOutputs *SafeOutputsConfig) []string { + return assignWorkItemConstraints(safeOutputs.AssignWorkItems) + }, + "link-work-items": func(safeOutputs *SafeOutputsConfig) []string { + return linkWorkItemsConstraints(safeOutputs.LinkWorkItems) + }, + "upload-workitem-attachment": func(safeOutputs *SafeOutputsConfig) []string { + return uploadWorkItemAttachmentConstraints(safeOutputs.UploadWorkItemAttachments) + }, "create_issue": func(safeOutputs *SafeOutputsConfig) []string { return createIssueConstraints(safeOutputs.CreateIssues) }, "set_issue_field": func(safeOutputs *SafeOutputsConfig) []string { return setIssueFieldConstraints(safeOutputs.SetIssueField) diff --git a/schemas/agent-output.json b/schemas/agent-output.json index f4647184521..814c8037cb3 100644 --- a/schemas/agent-output.json +++ b/schemas/agent-output.json @@ -57,7 +57,13 @@ { "$ref": "#/$defs/SubmitPullRequestReviewOutput" }, { "$ref": "#/$defs/DismissPullRequestReviewOutput" }, { "$ref": "#/$defs/ReplyToPullRequestReviewCommentOutput" }, - { "$ref": "#/$defs/ResolvePullRequestReviewThreadOutput" } + { "$ref": "#/$defs/ResolvePullRequestReviewThreadOutput" }, + { "$ref": "#/$defs/CreateWorkItemOutput" }, + { "$ref": "#/$defs/UpdateWorkItemOutput" }, + { "$ref": "#/$defs/CommentOnWorkItemOutput" }, + { "$ref": "#/$defs/AssignWorkItemOutput" }, + { "$ref": "#/$defs/LinkWorkItemsOutput" }, + { "$ref": "#/$defs/UploadWorkItemAttachmentOutput" } ] }, "CreateIssueOutput": { @@ -901,6 +907,123 @@ }, "required": ["type", "alert_number", "fix_description", "fix_code"], "additionalProperties": false + }, + "CreateWorkItemOutput": { + "title": "Create Azure DevOps Work Item Output", + "description": "Output for creating an Azure DevOps work item", + "type": "object", + "properties": { + "type": { "const": "create_work_item" }, + "title": { "type": "string", "minLength": 6, "maxLength": 255 }, + "description": { "type": "string", "minLength": 31, "maxLength": 65000 }, + "tags": { "type": "array", "items": { "type": "string" } }, + "temporary_id": { "type": "string", "pattern": "^#aw_[A-Za-z0-9_]{3,12}$" } + }, + "required": ["type", "title", "description", "temporary_id"], + "additionalProperties": false + }, + "UpdateWorkItemOutput": { + "title": "Update Azure DevOps Work Item Output", + "description": "Output for updating an Azure DevOps work item", + "type": "object", + "properties": { + "type": { "const": "update_work_item" }, + "id": { + "oneOf": [ + { "type": "number", "minimum": 1 }, + { "type": "string", "minLength": 1 } + ] + }, + "title": { "type": "string" }, + "body": { "type": "string" }, + "state": { "type": "string" }, + "area_path": { "type": "string" }, + "iteration_path": { "type": "string" }, + "assignee": { "type": "string" }, + "tags": { "type": "array", "items": { "type": "string" } } + }, + "required": ["type", "id"], + "additionalProperties": false + }, + "CommentOnWorkItemOutput": { + "title": "Comment on Azure DevOps Work Item Output", + "description": "Output for commenting on an Azure DevOps work item", + "type": "object", + "properties": { + "type": { "const": "comment_on_work_item" }, + "work_item_id": { + "oneOf": [ + { "type": "number", "minimum": 1 }, + { "type": "string", "minLength": 1 } + ] + }, + "body": { "type": "string", "minLength": 10, "maxLength": 65000 } + }, + "required": ["type", "work_item_id", "body"], + "additionalProperties": false + }, + "AssignWorkItemOutput": { + "title": "Assign Azure DevOps Work Item Output", + "description": "Output for assigning an Azure DevOps work item", + "type": "object", + "properties": { + "type": { "const": "assign_work_item" }, + "work_item_id": { + "oneOf": [ + { "type": "number", "minimum": 1 }, + { "type": "string", "minLength": 1 } + ] + }, + "assignee": { "type": "string", "minLength": 1, "maxLength": 256 } + }, + "required": ["type", "work_item_id", "assignee"], + "additionalProperties": false + }, + "LinkWorkItemsOutput": { + "title": "Link Azure DevOps Work Items Output", + "description": "Output for linking two Azure DevOps work items", + "type": "object", + "properties": { + "type": { "const": "link_work_items" }, + "source_id": { + "oneOf": [ + { "type": "number", "minimum": 1 }, + { "type": "string", "minLength": 1 } + ] + }, + "target_id": { + "oneOf": [ + { "type": "number", "minimum": 1 }, + { "type": "string", "minLength": 1 } + ] + }, + "link_type": { + "type": "string", + "enum": ["parent", "child", "related", "predecessor", "successor", "duplicate", "duplicate-of"] + }, + "comment": { "type": "string", "minLength": 5, "maxLength": 1024 } + }, + "required": ["type", "source_id", "target_id", "link_type"], + "additionalProperties": false + }, + "UploadWorkItemAttachmentOutput": { + "title": "Upload Azure DevOps Work Item Attachment Output", + "description": "Output for attaching a staged workspace file to an Azure DevOps work item", + "type": "object", + "properties": { + "type": { "const": "upload_workitem_attachment" }, + "work_item_id": { + "oneOf": [ + { "type": "number", "minimum": 1 }, + { "type": "string", "minLength": 1 } + ] + }, + "file_path": { "type": "string", "minLength": 1, "maxLength": 1024 }, + "staged_file": { "type": "string", "pattern": "^[A-Za-z0-9._/-]+$" }, + "comment": { "type": "string", "minLength": 3, "maxLength": 1024 } + }, + "required": ["type", "work_item_id", "file_path", "staged_file"], + "additionalProperties": false } } } From 11de4815f8840d0a738b9778c79015295f0b991b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 2 Sep 2026 00:17:47 +0000 Subject: [PATCH 03/12] Fix Azure path policy wiring Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/azure_devops_work_items.cjs | 20 ++++++++++---------- pkg/parser/schemas/main_workflow_schema.json | 2 ++ 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/actions/setup/js/azure_devops_work_items.cjs b/actions/setup/js/azure_devops_work_items.cjs index b6bc8b045f4..3107caa15cb 100644 --- a/actions/setup/js/azure_devops_work_items.cjs +++ b/actions/setup/js/azure_devops_work_items.cjs @@ -66,17 +66,17 @@ function validateAllowedTags(tags, allowedTags) { if (disallowed.length > 0) { throw new Error(`tags are not permitted by allowed-tags: ${disallowed.join(", ")}`); } +} - function validateAllowedPath(value, allowedPrefixes, fieldName) { - if (!Array.isArray(allowedPrefixes) || allowedPrefixes.length === 0) return; - const normalized = String(value).trim().toLowerCase(); - const allowed = allowedPrefixes.some(prefix => { - const normalizedPrefix = String(prefix).trim().replace(/\\+$/, "").toLowerCase(); - return normalized === normalizedPrefix || normalized.startsWith(`${normalizedPrefix}\\`); - }); - if (!allowed) { - throw new Error(`${fieldName} is not permitted by the configured ${fieldName.replace("_", "-")} prefixes`); - } +function validateAllowedPath(value, allowedPrefixes, fieldName) { + if (!Array.isArray(allowedPrefixes) || allowedPrefixes.length === 0) return; + const normalized = String(value).trim().toLowerCase(); + const allowed = allowedPrefixes.some(prefix => { + const normalizedPrefix = String(prefix).trim().replace(/\\+$/, "").toLowerCase(); + return normalized === normalizedPrefix || normalized.startsWith(`${normalizedPrefix}\\`); + }); + if (!allowed) { + throw new Error(`${fieldName} is not permitted by the configured ${fieldName.replace("_", "-")} prefixes`); } } diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index ebb2110fa08..5c550690c1c 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -13225,6 +13225,8 @@ "assignee": { "type": "boolean", "default": false }, "tags": { "type": "boolean", "default": false }, "allowed-tags": { "$ref": "#/$defs/azure_devops_string_list" }, + "allowed-area-prefixes": { "$ref": "#/$defs/azure_devops_string_list" }, + "allowed-iteration-prefixes": { "$ref": "#/$defs/azure_devops_string_list" }, "max": { "$ref": "#/$defs/templatable_positive_integer" }, "staged": { "$ref": "#/$defs/templatable_boolean" }, "samples": { "$ref": "#/$defs/azure_devops_samples" } From efac0eb6ee5cceae906a2687f9dfcdc57ae9fa35 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 2 Sep 2026 00:27:52 +0000 Subject: [PATCH 04/12] Preserve Azure public tool routing Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .../setup/js/generate_safe_outputs_tools.cjs | 11 +++++++--- .../js/generate_safe_outputs_tools.test.cjs | 22 +++++++++++++++++++ 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/actions/setup/js/generate_safe_outputs_tools.cjs b/actions/setup/js/generate_safe_outputs_tools.cjs index 1b61c55a0bc..d600c5dcf93 100644 --- a/actions/setup/js/generate_safe_outputs_tools.cjs +++ b/actions/setup/js/generate_safe_outputs_tools.cjs @@ -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 diff --git a/actions/setup/js/generate_safe_outputs_tools.test.cjs b/actions/setup/js/generate_safe_outputs_tools.test.cjs index 733d1a19647..aaf95d5b9d8 100644 --- a/actions/setup/js/generate_safe_outputs_tools.test.cjs +++ b/actions/setup/js/generate_safe_outputs_tools.test.cjs @@ -98,6 +98,28 @@ describe("generate_safe_outputs_tools", () => { expect(result.map((/** @type {{name: string}} */ t) => t.name)).not.toContain("missing_tool"); }); + it("preserves hyphenated public names for underscore-form configuration keys", () => { + fs.writeFileSync( + toolsSourcePath, + JSON.stringify([ + ...sampleSourceTools, + { + name: "create-work-item", + description: "Creates an Azure DevOps work item.", + inputSchema: { type: "object", properties: {} }, + }, + ]) + ); + fs.writeFileSync(configPath, JSON.stringify({ 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("create-work-item"); + }); + it("applies description suffix from tools_meta", () => { fs.writeFileSync(configPath, JSON.stringify({ create_issue: { max: 5 } })); fs.writeFileSync( From 3ebdd17d5a29cb1eb1176acd116f5557751131c5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 2 Sep 2026 00:46:32 +0000 Subject: [PATCH 05/12] Mark Azure work-item outputs experimental Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- ...nor-azure-devops-work-item-safe-outputs.md | 2 +- actions/setup/js/safe_outputs_tools.json | 12 +++---- .../content/docs/reference/safe-outputs.md | 14 ++++---- pkg/parser/schemas/main_workflow_schema.json | 12 +++---- .../azure_devops_experimental_warning_test.go | 36 +++++++++++++++++++ pkg/workflow/compiler_validators.go | 6 ++++ pkg/workflow/js/safe_outputs_tools.json | 12 +++---- 7 files changed, 69 insertions(+), 25 deletions(-) create mode 100644 pkg/workflow/azure_devops_experimental_warning_test.go diff --git a/.changeset/minor-azure-devops-work-item-safe-outputs.md b/.changeset/minor-azure-devops-work-item-safe-outputs.md index ac86f8cba19..09d123186e3 100644 --- a/.changeset/minor-azure-devops-work-item-safe-outputs.md +++ b/.changeset/minor-azure-devops-work-item-safe-outputs.md @@ -2,4 +2,4 @@ "gh-aw": minor --- -Add Azure DevOps work-item safe outputs for creating, updating, commenting on, assigning, linking, and attaching files to work items. +Add experimental Azure DevOps work-item safe outputs for creating, updating, commenting on, assigning, linking, and attaching files to work items. diff --git a/actions/setup/js/safe_outputs_tools.json b/actions/setup/js/safe_outputs_tools.json index 98da036ffda..bc88a2a73f7 100644 --- a/actions/setup/js/safe_outputs_tools.json +++ b/actions/setup/js/safe_outputs_tools.json @@ -2085,7 +2085,7 @@ }, { "name": "create-work-item", - "description": "Create an Azure DevOps work item and return a temporary #aw_ ID for later work-item tools in this run.", + "description": "Experimental. Create an Azure DevOps work item and return a temporary #aw_ ID for later work-item tools in this run.", "inputSchema": { "type": "object", "required": ["title", "description"], @@ -2113,7 +2113,7 @@ }, { "name": "update-work-item", - "description": "Update explicitly enabled fields on an Azure DevOps work item.", + "description": "Experimental. Update explicitly enabled fields on an Azure DevOps work item.", "inputSchema": { "type": "object", "required": ["id"], @@ -2138,7 +2138,7 @@ }, { "name": "comment-on-work-item", - "description": "Add a Markdown comment to an explicitly scoped Azure DevOps work item.", + "description": "Experimental. Add a Markdown comment to an explicitly scoped Azure DevOps work item.", "inputSchema": { "type": "object", "required": ["work_item_id", "body"], @@ -2159,7 +2159,7 @@ }, { "name": "assign-work-item", - "description": "Assign an allowed Azure DevOps identity to a work item.", + "description": "Experimental. Assign an allowed Azure DevOps identity to a work item.", "inputSchema": { "type": "object", "required": ["work_item_id", "assignee"], @@ -2180,7 +2180,7 @@ }, { "name": "link-work-items", - "description": "Create a relationship between two explicitly scoped Azure DevOps work items.", + "description": "Experimental. Create a relationship between two explicitly scoped Azure DevOps work items.", "inputSchema": { "type": "object", "required": ["source_id", "target_id", "link_type"], @@ -2204,7 +2204,7 @@ }, { "name": "upload-workitem-attachment", - "description": "Upload one workspace file and attach it to an Azure DevOps work item.", + "description": "Experimental. Upload one workspace file and attach it to an Azure DevOps work item.", "inputSchema": { "type": "object", "required": ["work_item_id", "file_path"], diff --git a/docs/src/content/docs/reference/safe-outputs.md b/docs/src/content/docs/reference/safe-outputs.md index ca468b79bb6..ed56fa9ef86 100644 --- a/docs/src/content/docs/reference/safe-outputs.md +++ b/docs/src/content/docs/reference/safe-outputs.md @@ -81,12 +81,12 @@ The tables below summarize the built-in safe output handlers. `noop`, `missing-t | Output | Key | Description | |--------|-----|-------------| -| [Create Work Item](#azure-devops-work-items) | `create-work-item` | Create an Azure DevOps work item (max: 1) | -| [Update Work Item](#azure-devops-work-items) | `update-work-item` | Update explicitly enabled fields on a scoped work item (max: 1) | -| [Comment on Work Item](#azure-devops-work-items) | `comment-on-work-item` | Add a comment to a scoped work item (max: 1) | -| [Assign Work Item](#azure-devops-work-items) | `assign-work-item` | Assign an allowed identity to a scoped work item (max: 1) | -| [Link Work Items](#azure-devops-work-items) | `link-work-items` | Link two scoped work items (max: 5) | -| [Upload Work Item Attachment](#azure-devops-work-items) | `upload-workitem-attachment` | Attach a staged workspace file to a work item (max: 1) | +| [Create Work Item](#azure-devops-work-items) | `create-work-item` | Create an Azure DevOps work item (max: 1, experimental) | +| [Update Work Item](#azure-devops-work-items) | `update-work-item` | Update explicitly enabled fields on a scoped work item (max: 1, experimental) | +| [Comment on Work Item](#azure-devops-work-items) | `comment-on-work-item` | Add a comment to a scoped work item (max: 1, experimental) | +| [Assign Work Item](#azure-devops-work-items) | `assign-work-item` | Assign an allowed identity to a scoped work item (max: 1, experimental) | +| [Link Work Items](#azure-devops-work-items) | `link-work-items` | Link two scoped work items (max: 5, experimental) | +| [Upload Work Item Attachment](#azure-devops-work-items) | `upload-workitem-attachment` | Attach a staged workspace file to a work item (max: 1, experimental) | ### Security & Agent Tasks @@ -1125,6 +1125,8 @@ Azure DevOps work-item safe outputs use the same public tool names as [`ado-aw`] Provide the organization, project, and credential only to the safe-output job: +> These safe outputs are experimental. Compiling a workflow emits an experimental feature warning for each configured Azure DevOps work-item output. + ```yaml wrap safe-outputs: env: diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index 5c550690c1c..b15ea12371e 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -13168,7 +13168,7 @@ ], "$defs": { "azure_devops_create_work_item": { - "description": "Create Azure DevOps work items through a trusted safe-output handler.", + "description": "Experimental. Create Azure DevOps work items through a trusted safe-output handler. Using this field emits a compile-time warning.", "oneOf": [ { "type": "object", @@ -13204,7 +13204,7 @@ ] }, "azure_devops_update_work_item": { - "description": "Update explicitly scoped Azure DevOps work items and explicitly enabled fields.", + "description": "Experimental. Update explicitly scoped Azure DevOps work items and explicitly enabled fields. Using this field emits a compile-time warning.", "oneOf": [ { "type": "object", @@ -13236,7 +13236,7 @@ ] }, "azure_devops_comment_on_work_item": { - "description": "Comment on explicitly scoped Azure DevOps work items.", + "description": "Experimental. Comment on explicitly scoped Azure DevOps work items. Using this field emits a compile-time warning.", "oneOf": [ { "type": "object", @@ -13253,7 +13253,7 @@ ] }, "azure_devops_assign_work_item": { - "description": "Assign an Azure DevOps identity to a work item.", + "description": "Experimental. Assign an Azure DevOps identity to a work item. Using this field emits a compile-time warning.", "oneOf": [ { "type": "object", @@ -13273,7 +13273,7 @@ ] }, "azure_devops_link_work_items": { - "description": "Link explicitly scoped Azure DevOps work items.", + "description": "Experimental. Link explicitly scoped Azure DevOps work items. Using this field emits a compile-time warning.", "oneOf": [ { "type": "object", @@ -13298,7 +13298,7 @@ ] }, "azure_devops_upload_workitem_attachment": { - "description": "Upload a staged workspace file to an Azure DevOps work item.", + "description": "Experimental. Upload a staged workspace file to an Azure DevOps work item. Using this field emits a compile-time warning.", "oneOf": [ { "type": "object", diff --git a/pkg/workflow/azure_devops_experimental_warning_test.go b/pkg/workflow/azure_devops_experimental_warning_test.go new file mode 100644 index 00000000000..a6fbb2dd98b --- /dev/null +++ b/pkg/workflow/azure_devops_experimental_warning_test.go @@ -0,0 +1,36 @@ +//go:build !integration + +package workflow + +import ( + "bytes" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestAzureDevOpsSafeOutputsEmitExperimentalWarnings(t *testing.T) { + tests := []struct { + name string + safeOutput *SafeOutputsConfig + }{ + {"create-work-item", &SafeOutputsConfig{CreateWorkItems: &CreateWorkItemConfig{}}}, + {"update-work-item", &SafeOutputsConfig{UpdateWorkItems: &UpdateWorkItemConfig{}}}, + {"comment-on-work-item", &SafeOutputsConfig{CommentOnWorkItems: &CommentOnWorkItemConfig{}}}, + {"assign-work-item", &SafeOutputsConfig{AssignWorkItems: &AssignWorkItemConfig{}}}, + {"link-work-items", &SafeOutputsConfig{LinkWorkItems: &LinkWorkItemsConfig{}}}, + {"upload-workitem-attachment", &SafeOutputsConfig{UploadWorkItemAttachments: &UploadWorkItemAttachmentConfig{}}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + compiler := NewCompiler() + var output bytes.Buffer + + compiler.emitExperimentalFeatureWarningsTo(&WorkflowData{SafeOutputs: tt.safeOutput}, &output) + + assert.Contains(t, output.String(), "Using experimental feature: "+tt.name) + assert.Equal(t, 1, compiler.GetWarningCount()) + }) + } +} diff --git a/pkg/workflow/compiler_validators.go b/pkg/workflow/compiler_validators.go index d5811e40e8c..a7ebf345bb8 100644 --- a/pkg/workflow/compiler_validators.go +++ b/pkg/workflow/compiler_validators.go @@ -432,6 +432,12 @@ func (c *Compiler) emitExperimentalFeatureWarningsTo(workflowData *WorkflowData, {enabled: workflowData.SafeOutputs != nil && workflowData.SafeOutputs.ApproveWorkflowRun != nil, message: "Using experimental feature: approve-workflow-run"}, {enabled: workflowData.SafeOutputs != nil && workflowData.SafeOutputs.ReplaceLabel != nil, message: "Using experimental feature: replace-label"}, {enabled: workflowData.SafeOutputs != nil && workflowData.SafeOutputs.UploadCodeCoverage != nil, message: "Using experimental feature: upload-code-coverage"}, + {enabled: workflowData.SafeOutputs != nil && workflowData.SafeOutputs.CreateWorkItems != nil, message: "Using experimental feature: create-work-item"}, + {enabled: workflowData.SafeOutputs != nil && workflowData.SafeOutputs.UpdateWorkItems != nil, message: "Using experimental feature: update-work-item"}, + {enabled: workflowData.SafeOutputs != nil && workflowData.SafeOutputs.CommentOnWorkItems != nil, message: "Using experimental feature: comment-on-work-item"}, + {enabled: workflowData.SafeOutputs != nil && workflowData.SafeOutputs.AssignWorkItems != nil, message: "Using experimental feature: assign-work-item"}, + {enabled: workflowData.SafeOutputs != nil && workflowData.SafeOutputs.LinkWorkItems != nil, message: "Using experimental feature: link-work-items"}, + {enabled: workflowData.SafeOutputs != nil && workflowData.SafeOutputs.UploadWorkItemAttachments != nil, message: "Using experimental feature: upload-workitem-attachment"}, {enabled: detectionConfigured && isFeatureEnabled(constants.GHAWDetectionFeatureFlag, workflowData), message: "Using experimental feature: gh-aw-detection"}, {enabled: len(workflowData.LSP) > 0, message: "Using experimental feature: lsp"}, {enabled: len(workflowData.Plugins) > 0, message: "Using experimental feature: plugins"}, diff --git a/pkg/workflow/js/safe_outputs_tools.json b/pkg/workflow/js/safe_outputs_tools.json index 98da036ffda..bc88a2a73f7 100644 --- a/pkg/workflow/js/safe_outputs_tools.json +++ b/pkg/workflow/js/safe_outputs_tools.json @@ -2085,7 +2085,7 @@ }, { "name": "create-work-item", - "description": "Create an Azure DevOps work item and return a temporary #aw_ ID for later work-item tools in this run.", + "description": "Experimental. Create an Azure DevOps work item and return a temporary #aw_ ID for later work-item tools in this run.", "inputSchema": { "type": "object", "required": ["title", "description"], @@ -2113,7 +2113,7 @@ }, { "name": "update-work-item", - "description": "Update explicitly enabled fields on an Azure DevOps work item.", + "description": "Experimental. Update explicitly enabled fields on an Azure DevOps work item.", "inputSchema": { "type": "object", "required": ["id"], @@ -2138,7 +2138,7 @@ }, { "name": "comment-on-work-item", - "description": "Add a Markdown comment to an explicitly scoped Azure DevOps work item.", + "description": "Experimental. Add a Markdown comment to an explicitly scoped Azure DevOps work item.", "inputSchema": { "type": "object", "required": ["work_item_id", "body"], @@ -2159,7 +2159,7 @@ }, { "name": "assign-work-item", - "description": "Assign an allowed Azure DevOps identity to a work item.", + "description": "Experimental. Assign an allowed Azure DevOps identity to a work item.", "inputSchema": { "type": "object", "required": ["work_item_id", "assignee"], @@ -2180,7 +2180,7 @@ }, { "name": "link-work-items", - "description": "Create a relationship between two explicitly scoped Azure DevOps work items.", + "description": "Experimental. Create a relationship between two explicitly scoped Azure DevOps work items.", "inputSchema": { "type": "object", "required": ["source_id", "target_id", "link_type"], @@ -2204,7 +2204,7 @@ }, { "name": "upload-workitem-attachment", - "description": "Upload one workspace file and attach it to an Azure DevOps work item.", + "description": "Experimental. Upload one workspace file and attach it to an Azure DevOps work item.", "inputSchema": { "type": "object", "required": ["work_item_id", "file_path"], From c15fc1245e627136dc23004894ce657680519485 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 2 Sep 2026 00:59:52 +0000 Subject: [PATCH 06/12] Improve Azure safe-output logging Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/azure_devops_work_items.cjs | 9 ++++-- .../setup/js/azure_devops_work_items.test.cjs | 30 +++++++++++++++++++ 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/actions/setup/js/azure_devops_work_items.cjs b/actions/setup/js/azure_devops_work_items.cjs index 3107caa15cb..42e2f0e1417 100644 --- a/actions/setup/js/azure_devops_work_items.cjs +++ b/actions/setup/js/azure_devops_work_items.cjs @@ -6,6 +6,7 @@ const path = require("path"); const { normalizeTemporaryId, isTemporaryId } = require("./temporary_id.cjs"); const { isStagedMode } = require("./safe_output_helpers.cjs"); const { matchesSimpleGlob } = require("./glob_pattern_helpers.cjs"); +const { logStagedPreviewInfo } = require("./staged_preview.cjs"); const WORK_ITEM_RELATIONS = { parent: "System.LinkTypes.Hierarchy-Reverse", @@ -25,7 +26,7 @@ function failure(error) { } function staged(message, extra = {}) { - core.info(message); + logStagedPreviewInfo(message); return { success: true, staged: true, message, ...extra }; } @@ -118,6 +119,7 @@ function getAzureDevOpsContext() { async function adoRequest(ado, method, apiPath, body, contentType = "application/json") { const url = `${ado.orgUrl}/${encodeURIComponent(ado.project)}${apiPath}`; let response; + core.debug(`Azure DevOps API request started: ${method}`); try { response = await fetch(url, { method, @@ -133,6 +135,7 @@ async function adoRequest(ado, method, apiPath, body, contentType = "application } catch (error) { throw new Error(`Azure DevOps ${method} request could not be sent`, { cause: error }); } + core.debug(`Azure DevOps API request completed: ${method} HTTP ${response.status}`); if (response.status >= 300 && response.status < 400) { throw new Error(`Azure DevOps rejected a redirected ${method} request`); } @@ -255,7 +258,7 @@ async function handleCreateWorkItem(message, config, resolvedTemporaryIds) { if (config.assignee) normalizeAssignee(config.assignee); if (isStagedMode(config)) { - return staged(`Would create Azure DevOps ${workItemType} '${title}'`, { + return staged(`Would create Azure DevOps ${workItemType}`, { temporaryId, temporaryIdEntry: { provider: "azure-devops", resourceType: "work-item", staged: true }, }); @@ -474,7 +477,7 @@ async function handleUploadWorkItemAttachment(message, config, resolvedTemporary try { const preview = isStagedMode(config); const resolved = resolveWorkItemReference(message.work_item_id, resolvedTemporaryIds, preview); - if (preview) return staged(`Would attach '${message.file_path}' to Azure DevOps work item ${message.work_item_id}`); + if (preview) return staged(`Would attach a file to Azure DevOps work item ${message.work_item_id}`); const { bytes, filename } = readStagedAttachment(message, config); const ado = getAzureDevOpsContext(); const upload = await adoRequest(ado, "POST", `/_apis/wit/attachments?fileName=${encodeURIComponent(filename)}&api-version=7.1`, bytes, "application/octet-stream"); diff --git a/actions/setup/js/azure_devops_work_items.test.cjs b/actions/setup/js/azure_devops_work_items.test.cjs index 99997dff7b4..fab0976cb1d 100644 --- a/actions/setup/js/azure_devops_work_items.test.cjs +++ b/actions/setup/js/azure_devops_work_items.test.cjs @@ -2,6 +2,7 @@ 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(), }; @@ -62,6 +63,35 @@ describe("azure_devops_work_items", () => { "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("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("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 () => { From 59f0c00042f738a736f7fe641b1271f00d5c5bdb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 2 Sep 2026 01:12:59 +0000 Subject: [PATCH 07/12] Namespace Azure DevOps safe outputs Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- ...nor-azure-devops-work-item-safe-outputs.md | 2 +- actions/setup/js/assign_work_item.cjs | 2 +- actions/setup/js/azure_devops_work_items.cjs | 22 +++++++------- .../setup/js/azure_devops_work_items.test.cjs | 16 +++++----- actions/setup/js/comment_on_work_item.cjs | 2 +- actions/setup/js/create_work_item.cjs | 2 +- .../js/generate_safe_outputs_tools.test.cjs | 8 ++--- actions/setup/js/link_work_items.cjs | 2 +- .../setup/js/safe_output_handler_manager.cjs | 24 +++++++-------- actions/setup/js/safe_outputs_handlers.cjs | 30 +++++++++---------- .../setup/js/safe_outputs_handlers.test.cjs | 4 +-- actions/setup/js/safe_outputs_tools.json | 20 ++++++------- .../setup/js/safe_outputs_tools_loader.cjs | 12 ++++---- .../js/safe_outputs_tools_loader.test.cjs | 4 +-- actions/setup/js/temporary_id.test.cjs | 2 +- actions/setup/js/update_work_item.cjs | 2 +- .../setup/js/upload_workitem_attachment.cjs | 2 +- .../content/docs/reference/safe-outputs.md | 28 ++++++++--------- pkg/parser/schemas/main_workflow_schema.json | 12 ++++---- .../azure_devops_experimental_warning_test.go | 12 ++++---- pkg/workflow/compiler_validators.go | 12 ++++---- pkg/workflow/js/safe_outputs_tools.json | 20 ++++++------- pkg/workflow/safe_output_handlers.go | 24 +++++++-------- pkg/workflow/safe_outputs_azure_devops.go | 24 +++++++-------- .../safe_outputs_azure_devops_test.go | 30 +++++++++---------- pkg/workflow/safe_outputs_config_types.go | 12 ++++---- .../safe_outputs_handler_registry_test.go | 14 ++++----- pkg/workflow/safe_outputs_max_validation.go | 12 ++++---- .../safe_outputs_tools_computation.go | 12 ++++---- .../safe_outputs_validation_config.go | 12 ++++---- pkg/workflow/tool_description_enhancer.go | 12 ++++---- schemas/agent-output.json | 12 ++++---- 32 files changed, 202 insertions(+), 202 deletions(-) diff --git a/.changeset/minor-azure-devops-work-item-safe-outputs.md b/.changeset/minor-azure-devops-work-item-safe-outputs.md index 09d123186e3..7f218f962df 100644 --- a/.changeset/minor-azure-devops-work-item-safe-outputs.md +++ b/.changeset/minor-azure-devops-work-item-safe-outputs.md @@ -2,4 +2,4 @@ "gh-aw": minor --- -Add experimental Azure DevOps work-item safe outputs for creating, updating, commenting on, assigning, linking, and attaching files to work items. +Add experimental `ado_`-namespaced Azure DevOps work-item safe outputs for creating, updating, commenting on, assigning, linking, and attaching files to work items. diff --git a/actions/setup/js/assign_work_item.cjs b/actions/setup/js/assign_work_item.cjs index 14ad5e635fa..b90d0806cbb 100644 --- a/actions/setup/js/assign_work_item.cjs +++ b/actions/setup/js/assign_work_item.cjs @@ -1,6 +1,6 @@ // @ts-check const { createAzureDevOpsWorkItemHandler } = require("./azure_devops_work_items.cjs"); async function main(config = {}) { - return createAzureDevOpsWorkItemHandler("assign_work_item", config); + return createAzureDevOpsWorkItemHandler("ado_assign_work_item", config); } module.exports = { main }; diff --git a/actions/setup/js/azure_devops_work_items.cjs b/actions/setup/js/azure_devops_work_items.cjs index 42e2f0e1417..eae9b07b3d1 100644 --- a/actions/setup/js/azure_devops_work_items.cjs +++ b/actions/setup/js/azure_devops_work_items.cjs @@ -173,7 +173,7 @@ function resolveWorkItemReference(value, resolvedTemporaryIds, allowStaged) { const key = normalizeTemporaryId(String(value)); const resolved = resolvedTemporaryIds?.[key]; if (!resolved || resolved.provider !== "azure-devops" || resolved.resourceType !== "work-item") { - throw new Error(`temporary work-item ID '#${key}' has not been resolved by create-work-item in this run`); + throw new Error(`temporary work-item ID '#${key}' has not been resolved by ado_create_work_item in this run`); } const id = Number(resolved.workItemId); if (Number.isSafeInteger(id) && id > 0) return { id, sameRun: true }; @@ -226,7 +226,7 @@ function validateUniqueFields(entries) { async function handleCreateWorkItem(message, config, resolvedTemporaryIds) { const temporaryId = String(message.temporary_id || ""); - if (!/^#aw_[A-Za-z0-9_]{3,12}$/.test(temporaryId)) return failure("create-work-item requires a server-generated temporary_id"); + if (!/^#aw_[A-Za-z0-9_]{3,12}$/.test(temporaryId)) return failure("ado_create_work_item requires a server-generated temporary_id"); const normalized = normalizeTemporaryId(temporaryId); if (resolvedTemporaryIds?.[normalized]) return failure(`temporary_id '${temporaryId}' was already used in this run`); @@ -324,7 +324,7 @@ async function handleUpdateWorkItem(message, config, resolvedTemporaryIds) { const requested = fields.filter(([name]) => message[name] !== undefined); if (requested.length === 0) throw new Error("at least one update field is required"); const disabled = requested.find(([, , enabled]) => enabled !== true); - if (disabled) throw new Error(`${disabled[0]} updates are not enabled by update-work-item`); + if (disabled) throw new Error(`${disabled[0]} updates are not enabled by ado_update_work_item`); if (message.assignee !== undefined) message.assignee = normalizeAssignee(message.assignee); if (message.tags !== undefined) { message.tags = validateTags(message.tags); @@ -389,10 +389,10 @@ async function handleAssignWorkItem(message, config, resolvedTemporaryIds) { try { const assignee = normalizeAssignee(message.assignee); if (Array.isArray(config.allowed) && config.allowed.length > 0 && !config.allowed.some(value => String(value).trim().toLowerCase() === assignee.toLowerCase())) { - throw new Error(`assignee '${assignee}' is not permitted by assign-work-item.allowed`); + throw new Error(`assignee '${assignee}' is not permitted by ado-assign-work-item.allowed`); } if (Array.isArray(config.blocked) && config.blocked.some(pattern => matchesPattern(assignee, pattern))) { - throw new Error(`assignee '${assignee}' is blocked by assign-work-item.blocked`); + throw new Error(`assignee '${assignee}' is blocked by ado-assign-work-item.blocked`); } const preview = isStagedMode(config); const resolved = resolveWorkItemReference(message.work_item_id, resolvedTemporaryIds, preview); @@ -513,12 +513,12 @@ async function handleUploadWorkItemAttachment(message, config, resolvedTemporary } const HANDLERS = { - create_work_item: handleCreateWorkItem, - update_work_item: handleUpdateWorkItem, - comment_on_work_item: handleCommentOnWorkItem, - assign_work_item: handleAssignWorkItem, - link_work_items: handleLinkWorkItems, - upload_workitem_attachment: handleUploadWorkItemAttachment, + ado_create_work_item: handleCreateWorkItem, + ado_update_work_item: handleUpdateWorkItem, + ado_comment_on_work_item: handleCommentOnWorkItem, + ado_assign_work_item: handleAssignWorkItem, + ado_link_work_items: handleLinkWorkItems, + ado_upload_workitem_attachment: handleUploadWorkItemAttachment, }; function createAzureDevOpsWorkItemHandler(type, config = {}) { diff --git a/actions/setup/js/azure_devops_work_items.test.cjs b/actions/setup/js/azure_devops_work_items.test.cjs index fab0976cb1d..b1914129be8 100644 --- a/actions/setup/js/azure_devops_work_items.test.cjs +++ b/actions/setup/js/azure_devops_work_items.test.cjs @@ -35,7 +35,7 @@ describe("azure_devops_work_items", () => { text: vi.fn().mockResolvedValue(JSON.stringify({ id: 42, url: "https://dev.azure.com/test-org/_apis/wit/workItems/42" })), }); - const result = await createAzureDevOpsWorkItemHandler("create_work_item", { + const result = await createAzureDevOpsWorkItemHandler("ado_create_work_item", { work_item_type: "Task", area_path: "test-project\\Platform", max: 1, @@ -69,7 +69,7 @@ describe("azure_devops_work_items", () => { }); it("uses standardized staged logging without work-item content", async () => { - await createAzureDevOpsWorkItemHandler("create_work_item", { + await createAzureDevOpsWorkItemHandler("ado_create_work_item", { staged: true, work_item_type: "Task", })( @@ -86,7 +86,7 @@ describe("azure_devops_work_items", () => { }); it("does not log staged attachment paths", async () => { - await createAzureDevOpsWorkItemHandler("upload_workitem_attachment", { + await createAzureDevOpsWorkItemHandler("ado_upload_workitem_attachment", { staged: true, })({ work_item_id: 42, file_path: "private/customer-data.pdf" }, {}); @@ -95,20 +95,20 @@ describe("azure_devops_work_items", () => { }); it("rejects updates to fields not enabled by configuration", async () => { - const result = await createAzureDevOpsWorkItemHandler("update_work_item", { + 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 update-work-item", + 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("update_work_item", { + const result = await createAzureDevOpsWorkItemHandler("ado_update_work_item", { staged: true, area_path: true, allowed_area_prefixes: ["test-project\\Platform"], @@ -122,7 +122,7 @@ describe("azure_devops_work_items", () => { }); it("rejects reserved agent identities", async () => { - const result = await createAzureDevOpsWorkItemHandler("assign_work_item", { + const result = await createAzureDevOpsWorkItemHandler("ado_assign_work_item", { target: "*", })({ id: 42, assignee: "GitHub Copilot" }, {}); @@ -145,6 +145,6 @@ describe("azure_devops_work_items", () => { }, false ) - ).toThrow("has not been resolved by create-work-item in this run"); + ).toThrow("has not been resolved by ado_create_work_item in this run"); }); }); diff --git a/actions/setup/js/comment_on_work_item.cjs b/actions/setup/js/comment_on_work_item.cjs index 36301bbf32b..9b1354c325a 100644 --- a/actions/setup/js/comment_on_work_item.cjs +++ b/actions/setup/js/comment_on_work_item.cjs @@ -1,6 +1,6 @@ // @ts-check const { createAzureDevOpsWorkItemHandler } = require("./azure_devops_work_items.cjs"); async function main(config = {}) { - return createAzureDevOpsWorkItemHandler("comment_on_work_item", config); + return createAzureDevOpsWorkItemHandler("ado_comment_on_work_item", config); } module.exports = { main }; diff --git a/actions/setup/js/create_work_item.cjs b/actions/setup/js/create_work_item.cjs index 512fd31c12c..81d27790f2f 100644 --- a/actions/setup/js/create_work_item.cjs +++ b/actions/setup/js/create_work_item.cjs @@ -1,6 +1,6 @@ // @ts-check const { createAzureDevOpsWorkItemHandler } = require("./azure_devops_work_items.cjs"); async function main(config = {}) { - return createAzureDevOpsWorkItemHandler("create_work_item", config); + return createAzureDevOpsWorkItemHandler("ado_create_work_item", config); } module.exports = { main }; diff --git a/actions/setup/js/generate_safe_outputs_tools.test.cjs b/actions/setup/js/generate_safe_outputs_tools.test.cjs index aaf95d5b9d8..a28ecf2937d 100644 --- a/actions/setup/js/generate_safe_outputs_tools.test.cjs +++ b/actions/setup/js/generate_safe_outputs_tools.test.cjs @@ -98,26 +98,26 @@ describe("generate_safe_outputs_tools", () => { expect(result.map((/** @type {{name: string}} */ t) => t.name)).not.toContain("missing_tool"); }); - it("preserves hyphenated public names for underscore-form configuration keys", () => { + it("preserves namespaced public names", () => { fs.writeFileSync( toolsSourcePath, JSON.stringify([ ...sampleSourceTools, { - name: "create-work-item", + name: "ado_create_work_item", description: "Creates an Azure DevOps work item.", inputSchema: { type: "object", properties: {} }, }, ]) ); - fs.writeFileSync(configPath, JSON.stringify({ create_work_item: { max: 1 } })); + 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("create-work-item"); + expect(result[0].name).toBe("ado_create_work_item"); }); it("applies description suffix from tools_meta", () => { diff --git a/actions/setup/js/link_work_items.cjs b/actions/setup/js/link_work_items.cjs index 8256e169a1d..9c3f4c47c2e 100644 --- a/actions/setup/js/link_work_items.cjs +++ b/actions/setup/js/link_work_items.cjs @@ -1,6 +1,6 @@ // @ts-check const { createAzureDevOpsWorkItemHandler } = require("./azure_devops_work_items.cjs"); async function main(config = {}) { - return createAzureDevOpsWorkItemHandler("link_work_items", config); + return createAzureDevOpsWorkItemHandler("ado_link_work_items", config); } module.exports = { main }; diff --git a/actions/setup/js/safe_output_handler_manager.cjs b/actions/setup/js/safe_output_handler_manager.cjs index b18c58f4488..3b512a6323a 100644 --- a/actions/setup/js/safe_output_handler_manager.cjs +++ b/actions/setup/js/safe_output_handler_manager.cjs @@ -83,12 +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", - create_work_item: "./create_work_item.cjs", - update_work_item: "./update_work_item.cjs", - comment_on_work_item: "./comment_on_work_item.cjs", - assign_work_item: "./assign_work_item.cjs", - link_work_items: "./link_work_items.cjs", - upload_workitem_attachment: "./upload_workitem_attachment.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", @@ -151,8 +151,8 @@ const THREAT_WARNING_REVIEWABLE_TYPES = new Set([ "missing_data", "create_report_incomplete_issue", "report_incomplete", - "create_work_item", - "comment_on_work_item", + "ado_create_work_item", + "ado_comment_on_work_item", ]); /** @@ -201,10 +201,10 @@ const THREAT_WARNING_ABORT_TYPES = new Set([ "call_workflow", "autofix_code_scanning_alert", "create_agent_session", - "update_work_item", - "assign_work_item", - "link_work_items", - "upload_workitem_attachment", + "ado_update_work_item", + "ado_assign_work_item", + "ado_link_work_items", + "ado_upload_workitem_attachment", ]); /** diff --git a/actions/setup/js/safe_outputs_handlers.cjs b/actions/setup/js/safe_outputs_handlers.cjs index 55434b73683..46c279fb113 100644 --- a/actions/setup/js/safe_outputs_handlers.cjs +++ b/actions/setup/js/safe_outputs_handlers.cjs @@ -2126,7 +2126,7 @@ function createHandlers(server, appendSafeOutput, config = {}) { const createWorkItemHandler = args => { const temporaryId = `#${generateTemporaryId()}`; - const entry = { ...(args || {}), type: "create_work_item", temporary_id: temporaryId }; + const entry = { ...(args || {}), type: "ado_create_work_item", temporary_id: temporaryId }; appendSafeOutputCounted(entry); const output = { result: "success", temporary_id: temporaryId }; return { @@ -2144,21 +2144,21 @@ function createHandlers(server, appendSafeOutput, config = {}) { }; const uploadWorkItemAttachmentHandler = args => { - const entry = { ...(args || {}), type: "upload_workitem_attachment" }; + const entry = { ...(args || {}), type: "ado_upload_workitem_attachment" }; const rawPath = typeof entry.file_path === "string" ? entry.file_path.trim() : ""; if (!rawPath || path.isAbsolute(rawPath) || rawPath.includes(":")) { - return buildIntentErrorResponse("upload-workitem-attachment file_path must be a workspace-relative path without ':'"); + return buildIntentErrorResponse("ado_upload_workitem_attachment file_path must be a workspace-relative path without ':'"); } const segments = rawPath.split(/[\\/]+/); if (segments.some(segment => !segment || segment === "." || segment === "..")) { - return buildIntentErrorResponse("upload-workitem-attachment file_path must not contain empty, '.' or '..' path segments"); + return buildIntentErrorResponse("ado_upload_workitem_attachment file_path must not contain empty, '.' or '..' path segments"); } const workspace = path.resolve(process.env.GITHUB_WORKSPACE || process.cwd()); const sourcePath = path.resolve(workspace, ...segments); if (sourcePath !== workspace && !sourcePath.startsWith(workspace + path.sep)) { - return buildIntentErrorResponse("upload-workitem-attachment file_path resolves outside the workspace"); + return buildIntentErrorResponse("ado_upload_workitem_attachment file_path resolves outside the workspace"); } let current = workspace; @@ -2168,24 +2168,24 @@ function createHandlers(server, appendSafeOutput, config = {}) { current = path.join(current, segment); sourceStat = lstatGuard(current); if (!sourceStat) { - return buildIntentErrorResponse("upload-workitem-attachment does not accept symbolic links"); + return buildIntentErrorResponse("ado_upload_workitem_attachment does not accept symbolic links"); } } } catch (error) { - return buildIntentErrorResponse(`upload-workitem-attachment could not read file_path: ${getErrorMessage(error)}`); + return buildIntentErrorResponse(`ado_upload_workitem_attachment could not read file_path: ${getErrorMessage(error)}`); } if (!sourceStat?.isFile()) { - return buildIntentErrorResponse("upload-workitem-attachment file_path must identify one regular file"); + return buildIntentErrorResponse("ado_upload_workitem_attachment file_path must identify one regular file"); } - const attachmentConfig = getSafeOutputsToolConfig(config, "upload_workitem_attachment"); + const attachmentConfig = getSafeOutputsToolConfig(config, "ado_upload_workitem_attachment"); const maxFileSize = Number(attachmentConfig.max_file_size || 5 * 1024 * 1024); if (!Number.isSafeInteger(maxFileSize) || maxFileSize < 1 || sourceStat.size > maxFileSize) { - return buildIntentErrorResponse(`upload-workitem-attachment file exceeds the configured max-file-size of ${maxFileSize} bytes`); + return buildIntentErrorResponse(`ado_upload_workitem_attachment file exceeds the configured max-file-size of ${maxFileSize} bytes`); } const allowedExtensions = Array.isArray(attachmentConfig.allowed_extensions) ? attachmentConfig.allowed_extensions : []; if (allowedExtensions.length > 0 && !allowedExtensions.some(extension => rawPath.toLowerCase().endsWith(String(extension).toLowerCase()))) { - return buildIntentErrorResponse("upload-workitem-attachment file extension is not allowed by the workflow configuration"); + return buildIntentErrorResponse("ado_upload_workitem_attachment file extension is not allowed by the workflow configuration"); } try { @@ -3201,10 +3201,10 @@ function createHandlers(server, appendSafeOutput, config = {}) { pushRepoMemoryHandler, createIssueHandler, createWorkItemHandler, - updateWorkItemHandler: createAzureDevOpsWorkItemHandler("update_work_item"), - commentOnWorkItemHandler: createAzureDevOpsWorkItemHandler("comment_on_work_item"), - assignWorkItemHandler: createAzureDevOpsWorkItemHandler("assign_work_item"), - linkWorkItemsHandler: createAzureDevOpsWorkItemHandler("link_work_items"), + updateWorkItemHandler: createAzureDevOpsWorkItemHandler("ado_update_work_item"), + commentOnWorkItemHandler: createAzureDevOpsWorkItemHandler("ado_comment_on_work_item"), + assignWorkItemHandler: createAzureDevOpsWorkItemHandler("ado_assign_work_item"), + linkWorkItemsHandler: createAzureDevOpsWorkItemHandler("ado_link_work_items"), uploadWorkItemAttachmentHandler, createProjectHandler, addCommentHandler, diff --git a/actions/setup/js/safe_outputs_handlers.test.cjs b/actions/setup/js/safe_outputs_handlers.test.cjs index 6164e39abe2..48bab84f214 100644 --- a/actions/setup/js/safe_outputs_handlers.test.cjs +++ b/actions/setup/js/safe_outputs_handlers.test.cjs @@ -70,14 +70,14 @@ describe("safe_outputs_handlers", () => { handlers = createHandlers(mockServer, mockAppendSafeOutput); }); - it("collects Azure DevOps proposals using underscore-form message types", () => { + it("collects Azure DevOps proposals using namespaced message types", () => { handlers.createWorkItemHandler({ temporary_id: "item", title: "Create item" }); handlers.updateWorkItemHandler({ id: "#item", title: "Update item" }); handlers.commentOnWorkItemHandler({ id: "#item", body: "Comment" }); handlers.assignWorkItemHandler({ id: "#item", assignee: "user@example.com" }); handlers.linkWorkItemsHandler({ source_id: "#item", target_id: 42, type: "related" }); - expect(mockAppendSafeOutput.mock.calls.map(call => call[0].type)).toEqual(["create_work_item", "update_work_item", "comment_on_work_item", "assign_work_item", "link_work_items"]); + expect(mockAppendSafeOutput.mock.calls.map(call => call[0].type)).toEqual(["ado_create_work_item", "ado_update_work_item", "ado_comment_on_work_item", "ado_assign_work_item", "ado_link_work_items"]); expect(mockAppendSafeOutput.mock.calls[0][0].temporary_id).toMatch(/^#aw_/); }); diff --git a/actions/setup/js/safe_outputs_tools.json b/actions/setup/js/safe_outputs_tools.json index bc88a2a73f7..ba1a6c82530 100644 --- a/actions/setup/js/safe_outputs_tools.json +++ b/actions/setup/js/safe_outputs_tools.json @@ -2084,7 +2084,7 @@ } }, { - "name": "create-work-item", + "name": "ado_create_work_item", "description": "Experimental. Create an Azure DevOps work item and return a temporary #aw_ ID for later work-item tools in this run.", "inputSchema": { "type": "object", @@ -2112,7 +2112,7 @@ } }, { - "name": "update-work-item", + "name": "ado_update_work_item", "description": "Experimental. Update explicitly enabled fields on an Azure DevOps work item.", "inputSchema": { "type": "object", @@ -2120,7 +2120,7 @@ "properties": { "id": { "type": ["number", "string"], - "description": "Positive work-item ID or a temporary #aw_ ID returned by create-work-item." + "description": "Positive work-item ID or a temporary #aw_ ID returned by ado_create_work_item." }, "title": { "type": "string", "minLength": 1, "maxLength": 255 }, "body": { "type": "string", "maxLength": 65000 }, @@ -2137,7 +2137,7 @@ } }, { - "name": "comment-on-work-item", + "name": "ado_comment_on_work_item", "description": "Experimental. Add a Markdown comment to an explicitly scoped Azure DevOps work item.", "inputSchema": { "type": "object", @@ -2145,7 +2145,7 @@ "properties": { "work_item_id": { "type": ["number", "string"], - "description": "Positive work-item ID or a temporary #aw_ ID returned by create-work-item." + "description": "Positive work-item ID or a temporary #aw_ ID returned by ado_create_work_item." }, "body": { "type": "string", @@ -2158,7 +2158,7 @@ } }, { - "name": "assign-work-item", + "name": "ado_assign_work_item", "description": "Experimental. Assign an allowed Azure DevOps identity to a work item.", "inputSchema": { "type": "object", @@ -2166,7 +2166,7 @@ "properties": { "work_item_id": { "type": ["number", "string"], - "description": "Positive work-item ID or a temporary #aw_ ID returned by create-work-item." + "description": "Positive work-item ID or a temporary #aw_ ID returned by ado_create_work_item." }, "assignee": { "type": "string", @@ -2179,7 +2179,7 @@ } }, { - "name": "link-work-items", + "name": "ado_link_work_items", "description": "Experimental. Create a relationship between two explicitly scoped Azure DevOps work items.", "inputSchema": { "type": "object", @@ -2203,7 +2203,7 @@ } }, { - "name": "upload-workitem-attachment", + "name": "ado_upload_workitem_attachment", "description": "Experimental. Upload one workspace file and attach it to an Azure DevOps work item.", "inputSchema": { "type": "object", @@ -2211,7 +2211,7 @@ "properties": { "work_item_id": { "type": ["number", "string"], - "description": "Positive work-item ID or a temporary #aw_ ID returned by create-work-item." + "description": "Positive work-item ID or a temporary #aw_ ID returned by ado_create_work_item." }, "file_path": { "type": "string", diff --git a/actions/setup/js/safe_outputs_tools_loader.cjs b/actions/setup/js/safe_outputs_tools_loader.cjs index b37d0a13de3..cacccbf936a 100644 --- a/actions/setup/js/safe_outputs_tools_loader.cjs +++ b/actions/setup/js/safe_outputs_tools_loader.cjs @@ -193,12 +193,12 @@ function attachHandlers(tools, handlers, logger) { remove_labels: handlers.removeLabelsHandler, update_discussion: handlers.updateDiscussionHandler, close_discussion: handlers.closeDiscussionHandler, - create_work_item: handlers.createWorkItemHandler, - update_work_item: handlers.updateWorkItemHandler, - comment_on_work_item: handlers.commentOnWorkItemHandler, - assign_work_item: handlers.assignWorkItemHandler, - link_work_items: handlers.linkWorkItemsHandler, - upload_workitem_attachment: handlers.uploadWorkItemAttachmentHandler, + ado_create_work_item: handlers.createWorkItemHandler, + ado_update_work_item: handlers.updateWorkItemHandler, + ado_comment_on_work_item: handlers.commentOnWorkItemHandler, + ado_assign_work_item: handlers.assignWorkItemHandler, + ado_link_work_items: handlers.linkWorkItemsHandler, + ado_upload_workitem_attachment: handlers.uploadWorkItemAttachmentHandler, }; tools.forEach(tool => { diff --git a/actions/setup/js/safe_outputs_tools_loader.test.cjs b/actions/setup/js/safe_outputs_tools_loader.test.cjs index c4ceca6f81e..aca9aa7f9dd 100644 --- a/actions/setup/js/safe_outputs_tools_loader.test.cjs +++ b/actions/setup/js/safe_outputs_tools_loader.test.cjs @@ -91,8 +91,8 @@ describe("safe_outputs_tools_loader", () => { }); describe("attachHandlers", () => { - it("attaches Azure DevOps handlers to ado-aw public tool names", () => { - const tools = [{ name: "create-work-item" }, { name: "update-work-item" }, { name: "comment-on-work-item" }, { name: "assign-work-item" }, { name: "link-work-items" }, { name: "upload-workitem-attachment" }]; + it("attaches Azure DevOps handlers to namespaced public tool names", () => { + const tools = [{ name: "ado_create_work_item" }, { name: "ado_update_work_item" }, { name: "ado_comment_on_work_item" }, { name: "ado_assign_work_item" }, { name: "ado_link_work_items" }, { name: "ado_upload_workitem_attachment" }]; const handlers = { createWorkItemHandler: vi.fn(), updateWorkItemHandler: vi.fn(), diff --git a/actions/setup/js/temporary_id.test.cjs b/actions/setup/js/temporary_id.test.cjs index 22b74a3dbf3..50649d7eb74 100644 --- a/actions/setup/js/temporary_id.test.cjs +++ b/actions/setup/js/temporary_id.test.cjs @@ -821,7 +821,7 @@ describe("temporary_id.cjs", () => { const { extractTemporaryIdReferences } = await import("./temporary_id.cjs"); const refs = extractTemporaryIdReferences({ - type: "link_work_items", + type: "ado_link_work_items", id: "#aw_item1", work_item_id: "#aw_item2", source_id: "#aw_item3", diff --git a/actions/setup/js/update_work_item.cjs b/actions/setup/js/update_work_item.cjs index 814cdd5e48c..8b6caaa4aed 100644 --- a/actions/setup/js/update_work_item.cjs +++ b/actions/setup/js/update_work_item.cjs @@ -1,6 +1,6 @@ // @ts-check const { createAzureDevOpsWorkItemHandler } = require("./azure_devops_work_items.cjs"); async function main(config = {}) { - return createAzureDevOpsWorkItemHandler("update_work_item", config); + return createAzureDevOpsWorkItemHandler("ado_update_work_item", config); } module.exports = { main }; diff --git a/actions/setup/js/upload_workitem_attachment.cjs b/actions/setup/js/upload_workitem_attachment.cjs index 6910fdb5376..aee035502b3 100644 --- a/actions/setup/js/upload_workitem_attachment.cjs +++ b/actions/setup/js/upload_workitem_attachment.cjs @@ -1,6 +1,6 @@ // @ts-check const { createAzureDevOpsWorkItemHandler } = require("./azure_devops_work_items.cjs"); async function main(config = {}) { - return createAzureDevOpsWorkItemHandler("upload_workitem_attachment", config); + return createAzureDevOpsWorkItemHandler("ado_upload_workitem_attachment", config); } module.exports = { main }; diff --git a/docs/src/content/docs/reference/safe-outputs.md b/docs/src/content/docs/reference/safe-outputs.md index ed56fa9ef86..6059d862a27 100644 --- a/docs/src/content/docs/reference/safe-outputs.md +++ b/docs/src/content/docs/reference/safe-outputs.md @@ -81,12 +81,12 @@ The tables below summarize the built-in safe output handlers. `noop`, `missing-t | Output | Key | Description | |--------|-----|-------------| -| [Create Work Item](#azure-devops-work-items) | `create-work-item` | Create an Azure DevOps work item (max: 1, experimental) | -| [Update Work Item](#azure-devops-work-items) | `update-work-item` | Update explicitly enabled fields on a scoped work item (max: 1, experimental) | -| [Comment on Work Item](#azure-devops-work-items) | `comment-on-work-item` | Add a comment to a scoped work item (max: 1, experimental) | -| [Assign Work Item](#azure-devops-work-items) | `assign-work-item` | Assign an allowed identity to a scoped work item (max: 1, experimental) | -| [Link Work Items](#azure-devops-work-items) | `link-work-items` | Link two scoped work items (max: 5, experimental) | -| [Upload Work Item Attachment](#azure-devops-work-items) | `upload-workitem-attachment` | Attach a staged workspace file to a work item (max: 1, experimental) | +| [Create Work Item](#azure-devops-work-items) | `ado-create-work-item` | Create an Azure DevOps work item (max: 1, experimental) | +| [Update Work Item](#azure-devops-work-items) | `ado-update-work-item` | Update explicitly enabled fields on a scoped work item (max: 1, experimental) | +| [Comment on Work Item](#azure-devops-work-items) | `ado-comment-on-work-item` | Add a comment to a scoped work item (max: 1, experimental) | +| [Assign Work Item](#azure-devops-work-items) | `ado-assign-work-item` | Assign an allowed identity to a scoped work item (max: 1, experimental) | +| [Link Work Items](#azure-devops-work-items) | `ado-link-work-items` | Link two scoped work items (max: 5, experimental) | +| [Upload Work Item Attachment](#azure-devops-work-items) | `ado-upload-workitem-attachment` | Attach a staged workspace file to a work item (max: 1, experimental) | ### Security & Agent Tasks @@ -1133,32 +1133,32 @@ safe-outputs: AZURE_DEVOPS_ORG_URL: ${{ vars.AZURE_DEVOPS_ORG_URL }} SYSTEM_TEAMPROJECT: ${{ vars.AZURE_DEVOPS_PROJECT }} AZURE_DEVOPS_EXT_PAT: ${{ secrets.AZURE_DEVOPS_EXT_PAT }} - create-work-item: + ado-create-work-item: work-item-type: Task area-path: MyProject\Platform allowed-tags: [agent-*] - update-work-item: + ado-update-work-item: target: MyProject\Platform title: true status: true - comment-on-work-item: + ado-comment-on-work-item: target: MyProject\Platform - assign-work-item: + ado-assign-work-item: target: "*" allowed: [owner@example.com] - link-work-items: + ado-link-work-items: target: MyProject\Platform allowed-link-types: [parent, child, related] - upload-workitem-attachment: + ado-upload-workitem-attachment: allowed-extensions: [.txt, .log, .pdf] max-file-size: 5242880 ``` Authentication uses `SYSTEM_ACCESSTOKEN` when present, otherwise `AZURE_DEVOPS_EXT_PAT`. `AZURE_DEVOPS_ORG_URL` must use `https://dev.azure.com/{organization}` or `https://{organization}.visualstudio.com`; redirects and embedded credentials are rejected. -`create-work-item` returns a run-scoped `#aw_...` temporary ID. The other work-item tools accept that ID, and same-run IDs bypass their consuming `target` policy because creation was already scoped by trusted configuration. Numeric IDs are checked against `target`: updates and assignments accept `"*"` or one ID; comments and links also accept an ID list or area-path prefix. +`ado_create_work_item` returns a run-scoped `#aw_...` temporary ID. The other work-item tools accept that ID, and same-run IDs bypass their consuming `target` policy because creation was already scoped by trusted configuration. Numeric IDs are checked against `target`: updates and assignments accept `"*"` or one ID; comments and links also accept an ID list or area-path prefix. -For `update-work-item`, each mutable field must be explicitly enabled. Assignment always rejects the reserved `Agency` and `GitHub Copilot` identities. Attachments must be regular workspace files and are checked for traversal, symbolic links, size, extension, and Azure Pipelines command sequences before upload. +For `ado-update-work-item`, each mutable field must be explicitly enabled. Assignment always rejects the reserved `Agency` and `GitHub Copilot` identities. Attachments must be regular workspace files and are checked for traversal, symbolic links, size, extension, and Azure Pipelines command sequences before upload. ### No-Op Logging (`noop:`) diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index b15ea12371e..a3227f2d4fe 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -8545,22 +8545,22 @@ ], "description": "Enable AI agents to create autofixes for code scanning alerts using the GitHub REST API." }, - "create-work-item": { + "ado-create-work-item": { "$ref": "#/$defs/azure_devops_create_work_item" }, - "update-work-item": { + "ado-update-work-item": { "$ref": "#/$defs/azure_devops_update_work_item" }, - "comment-on-work-item": { + "ado-comment-on-work-item": { "$ref": "#/$defs/azure_devops_comment_on_work_item" }, - "assign-work-item": { + "ado-assign-work-item": { "$ref": "#/$defs/azure_devops_assign_work_item" }, - "link-work-items": { + "ado-link-work-items": { "$ref": "#/$defs/azure_devops_link_work_items" }, - "upload-workitem-attachment": { + "ado-upload-workitem-attachment": { "$ref": "#/$defs/azure_devops_upload_workitem_attachment" }, "create-check-run": { diff --git a/pkg/workflow/azure_devops_experimental_warning_test.go b/pkg/workflow/azure_devops_experimental_warning_test.go index a6fbb2dd98b..cfaf279265e 100644 --- a/pkg/workflow/azure_devops_experimental_warning_test.go +++ b/pkg/workflow/azure_devops_experimental_warning_test.go @@ -14,12 +14,12 @@ func TestAzureDevOpsSafeOutputsEmitExperimentalWarnings(t *testing.T) { name string safeOutput *SafeOutputsConfig }{ - {"create-work-item", &SafeOutputsConfig{CreateWorkItems: &CreateWorkItemConfig{}}}, - {"update-work-item", &SafeOutputsConfig{UpdateWorkItems: &UpdateWorkItemConfig{}}}, - {"comment-on-work-item", &SafeOutputsConfig{CommentOnWorkItems: &CommentOnWorkItemConfig{}}}, - {"assign-work-item", &SafeOutputsConfig{AssignWorkItems: &AssignWorkItemConfig{}}}, - {"link-work-items", &SafeOutputsConfig{LinkWorkItems: &LinkWorkItemsConfig{}}}, - {"upload-workitem-attachment", &SafeOutputsConfig{UploadWorkItemAttachments: &UploadWorkItemAttachmentConfig{}}}, + {"ado-create-work-item", &SafeOutputsConfig{CreateWorkItems: &CreateWorkItemConfig{}}}, + {"ado-update-work-item", &SafeOutputsConfig{UpdateWorkItems: &UpdateWorkItemConfig{}}}, + {"ado-comment-on-work-item", &SafeOutputsConfig{CommentOnWorkItems: &CommentOnWorkItemConfig{}}}, + {"ado-assign-work-item", &SafeOutputsConfig{AssignWorkItems: &AssignWorkItemConfig{}}}, + {"ado-link-work-items", &SafeOutputsConfig{LinkWorkItems: &LinkWorkItemsConfig{}}}, + {"ado-upload-workitem-attachment", &SafeOutputsConfig{UploadWorkItemAttachments: &UploadWorkItemAttachmentConfig{}}}, } for _, tt := range tests { diff --git a/pkg/workflow/compiler_validators.go b/pkg/workflow/compiler_validators.go index a7ebf345bb8..85e18672bd6 100644 --- a/pkg/workflow/compiler_validators.go +++ b/pkg/workflow/compiler_validators.go @@ -432,12 +432,12 @@ func (c *Compiler) emitExperimentalFeatureWarningsTo(workflowData *WorkflowData, {enabled: workflowData.SafeOutputs != nil && workflowData.SafeOutputs.ApproveWorkflowRun != nil, message: "Using experimental feature: approve-workflow-run"}, {enabled: workflowData.SafeOutputs != nil && workflowData.SafeOutputs.ReplaceLabel != nil, message: "Using experimental feature: replace-label"}, {enabled: workflowData.SafeOutputs != nil && workflowData.SafeOutputs.UploadCodeCoverage != nil, message: "Using experimental feature: upload-code-coverage"}, - {enabled: workflowData.SafeOutputs != nil && workflowData.SafeOutputs.CreateWorkItems != nil, message: "Using experimental feature: create-work-item"}, - {enabled: workflowData.SafeOutputs != nil && workflowData.SafeOutputs.UpdateWorkItems != nil, message: "Using experimental feature: update-work-item"}, - {enabled: workflowData.SafeOutputs != nil && workflowData.SafeOutputs.CommentOnWorkItems != nil, message: "Using experimental feature: comment-on-work-item"}, - {enabled: workflowData.SafeOutputs != nil && workflowData.SafeOutputs.AssignWorkItems != nil, message: "Using experimental feature: assign-work-item"}, - {enabled: workflowData.SafeOutputs != nil && workflowData.SafeOutputs.LinkWorkItems != nil, message: "Using experimental feature: link-work-items"}, - {enabled: workflowData.SafeOutputs != nil && workflowData.SafeOutputs.UploadWorkItemAttachments != nil, message: "Using experimental feature: upload-workitem-attachment"}, + {enabled: workflowData.SafeOutputs != nil && workflowData.SafeOutputs.CreateWorkItems != nil, message: "Using experimental feature: ado-create-work-item"}, + {enabled: workflowData.SafeOutputs != nil && workflowData.SafeOutputs.UpdateWorkItems != nil, message: "Using experimental feature: ado-update-work-item"}, + {enabled: workflowData.SafeOutputs != nil && workflowData.SafeOutputs.CommentOnWorkItems != nil, message: "Using experimental feature: ado-comment-on-work-item"}, + {enabled: workflowData.SafeOutputs != nil && workflowData.SafeOutputs.AssignWorkItems != nil, message: "Using experimental feature: ado-assign-work-item"}, + {enabled: workflowData.SafeOutputs != nil && workflowData.SafeOutputs.LinkWorkItems != nil, message: "Using experimental feature: ado-link-work-items"}, + {enabled: workflowData.SafeOutputs != nil && workflowData.SafeOutputs.UploadWorkItemAttachments != nil, message: "Using experimental feature: ado-upload-workitem-attachment"}, {enabled: detectionConfigured && isFeatureEnabled(constants.GHAWDetectionFeatureFlag, workflowData), message: "Using experimental feature: gh-aw-detection"}, {enabled: len(workflowData.LSP) > 0, message: "Using experimental feature: lsp"}, {enabled: len(workflowData.Plugins) > 0, message: "Using experimental feature: plugins"}, diff --git a/pkg/workflow/js/safe_outputs_tools.json b/pkg/workflow/js/safe_outputs_tools.json index bc88a2a73f7..ba1a6c82530 100644 --- a/pkg/workflow/js/safe_outputs_tools.json +++ b/pkg/workflow/js/safe_outputs_tools.json @@ -2084,7 +2084,7 @@ } }, { - "name": "create-work-item", + "name": "ado_create_work_item", "description": "Experimental. Create an Azure DevOps work item and return a temporary #aw_ ID for later work-item tools in this run.", "inputSchema": { "type": "object", @@ -2112,7 +2112,7 @@ } }, { - "name": "update-work-item", + "name": "ado_update_work_item", "description": "Experimental. Update explicitly enabled fields on an Azure DevOps work item.", "inputSchema": { "type": "object", @@ -2120,7 +2120,7 @@ "properties": { "id": { "type": ["number", "string"], - "description": "Positive work-item ID or a temporary #aw_ ID returned by create-work-item." + "description": "Positive work-item ID or a temporary #aw_ ID returned by ado_create_work_item." }, "title": { "type": "string", "minLength": 1, "maxLength": 255 }, "body": { "type": "string", "maxLength": 65000 }, @@ -2137,7 +2137,7 @@ } }, { - "name": "comment-on-work-item", + "name": "ado_comment_on_work_item", "description": "Experimental. Add a Markdown comment to an explicitly scoped Azure DevOps work item.", "inputSchema": { "type": "object", @@ -2145,7 +2145,7 @@ "properties": { "work_item_id": { "type": ["number", "string"], - "description": "Positive work-item ID or a temporary #aw_ ID returned by create-work-item." + "description": "Positive work-item ID or a temporary #aw_ ID returned by ado_create_work_item." }, "body": { "type": "string", @@ -2158,7 +2158,7 @@ } }, { - "name": "assign-work-item", + "name": "ado_assign_work_item", "description": "Experimental. Assign an allowed Azure DevOps identity to a work item.", "inputSchema": { "type": "object", @@ -2166,7 +2166,7 @@ "properties": { "work_item_id": { "type": ["number", "string"], - "description": "Positive work-item ID or a temporary #aw_ ID returned by create-work-item." + "description": "Positive work-item ID or a temporary #aw_ ID returned by ado_create_work_item." }, "assignee": { "type": "string", @@ -2179,7 +2179,7 @@ } }, { - "name": "link-work-items", + "name": "ado_link_work_items", "description": "Experimental. Create a relationship between two explicitly scoped Azure DevOps work items.", "inputSchema": { "type": "object", @@ -2203,7 +2203,7 @@ } }, { - "name": "upload-workitem-attachment", + "name": "ado_upload_workitem_attachment", "description": "Experimental. Upload one workspace file and attach it to an Azure DevOps work item.", "inputSchema": { "type": "object", @@ -2211,7 +2211,7 @@ "properties": { "work_item_id": { "type": ["number", "string"], - "description": "Positive work-item ID or a temporary #aw_ ID returned by create-work-item." + "description": "Positive work-item ID or a temporary #aw_ ID returned by ado_create_work_item." }, "file_path": { "type": "string", diff --git a/pkg/workflow/safe_output_handlers.go b/pkg/workflow/safe_output_handlers.go index fdad0cf0a7d..fa90b0d0b04 100644 --- a/pkg/workflow/safe_output_handlers.go +++ b/pkg/workflow/safe_output_handlers.go @@ -22,39 +22,39 @@ type safeOutputHandlerDescriptor struct { var safeOutputHandlers = []safeOutputHandlerDescriptor{ { - Key: "create-work-item", + Key: "ado-create-work-item", StructField: "CreateWorkItems", - ToolName: "create-work-item", + ToolName: "ado_create_work_item", NewConfig: func() any { return &CreateWorkItemConfig{} }, }, { - Key: "update-work-item", + Key: "ado-update-work-item", StructField: "UpdateWorkItems", - ToolName: "update-work-item", + ToolName: "ado_update_work_item", NewConfig: func() any { return &UpdateWorkItemConfig{} }, }, { - Key: "comment-on-work-item", + Key: "ado-comment-on-work-item", StructField: "CommentOnWorkItems", - ToolName: "comment-on-work-item", + ToolName: "ado_comment_on_work_item", NewConfig: func() any { return &CommentOnWorkItemConfig{} }, }, { - Key: "assign-work-item", + Key: "ado-assign-work-item", StructField: "AssignWorkItems", - ToolName: "assign-work-item", + ToolName: "ado_assign_work_item", NewConfig: func() any { return &AssignWorkItemConfig{} }, }, { - Key: "link-work-items", + Key: "ado-link-work-items", StructField: "LinkWorkItems", - ToolName: "link-work-items", + ToolName: "ado_link_work_items", NewConfig: func() any { return &LinkWorkItemsConfig{} }, }, { - Key: "upload-workitem-attachment", + Key: "ado-upload-workitem-attachment", StructField: "UploadWorkItemAttachments", - ToolName: "upload-workitem-attachment", + ToolName: "ado_upload_workitem_attachment", NewConfig: func() any { return &UploadWorkItemAttachmentConfig{} }, }, { diff --git a/pkg/workflow/safe_outputs_azure_devops.go b/pkg/workflow/safe_outputs_azure_devops.go index 25d0befb654..7554c669c54 100644 --- a/pkg/workflow/safe_outputs_azure_devops.go +++ b/pkg/workflow/safe_outputs_azure_devops.go @@ -111,7 +111,7 @@ func parseAzureDevOpsConfig[T any](c *Compiler, outputMap map[string]any, key st } func (c *Compiler) parseCreateWorkItemConfig(outputMap map[string]any) *CreateWorkItemConfig { - return parseAzureDevOpsConfig(c, outputMap, "create-work-item", 1, func(config *CreateWorkItemConfig) { + return parseAzureDevOpsConfig(c, outputMap, "ado-create-work-item", 1, func(config *CreateWorkItemConfig) { if config.WorkItemType == "" { config.WorkItemType = "Task" } @@ -122,23 +122,23 @@ func (c *Compiler) parseCreateWorkItemConfig(outputMap map[string]any) *CreateWo } func (c *Compiler) parseUpdateWorkItemConfig(outputMap map[string]any) *UpdateWorkItemConfig { - return parseAzureDevOpsConfig[UpdateWorkItemConfig](c, outputMap, "update-work-item", 1, nil) + return parseAzureDevOpsConfig[UpdateWorkItemConfig](c, outputMap, "ado-update-work-item", 1, nil) } func (c *Compiler) parseCommentOnWorkItemConfig(outputMap map[string]any) *CommentOnWorkItemConfig { - return parseAzureDevOpsConfig[CommentOnWorkItemConfig](c, outputMap, "comment-on-work-item", 1, nil) + return parseAzureDevOpsConfig[CommentOnWorkItemConfig](c, outputMap, "ado-comment-on-work-item", 1, nil) } func (c *Compiler) parseAssignWorkItemConfig(outputMap map[string]any) *AssignWorkItemConfig { - return parseAzureDevOpsConfig[AssignWorkItemConfig](c, outputMap, "assign-work-item", 1, nil) + return parseAzureDevOpsConfig[AssignWorkItemConfig](c, outputMap, "ado-assign-work-item", 1, nil) } func (c *Compiler) parseLinkWorkItemsConfig(outputMap map[string]any) *LinkWorkItemsConfig { - return parseAzureDevOpsConfig[LinkWorkItemsConfig](c, outputMap, "link-work-items", 5, nil) + return parseAzureDevOpsConfig[LinkWorkItemsConfig](c, outputMap, "ado-link-work-items", 5, nil) } func (c *Compiler) parseUploadWorkItemAttachmentConfig(outputMap map[string]any) *UploadWorkItemAttachmentConfig { - return parseAzureDevOpsConfig(c, outputMap, "upload-workitem-attachment", 1, func(config *UploadWorkItemAttachmentConfig) { + return parseAzureDevOpsConfig(c, outputMap, "ado-upload-workitem-attachment", 1, func(config *UploadWorkItemAttachmentConfig) { if config.MaxFileSize == 0 { config.MaxFileSize = 5 * 1024 * 1024 } @@ -153,7 +153,7 @@ func addAzureDevOpsTarget(builder *handlerConfigBuilder, target any) *handlerCon } var azureDevOpsWorkItemHandlerRegistry = map[string]handlerBuilder{ - "create_work_item": func(cfg *SafeOutputsConfig) map[string]any { + "ado_create_work_item": func(cfg *SafeOutputsConfig) map[string]any { if cfg.CreateWorkItems == nil { return nil } @@ -173,7 +173,7 @@ var azureDevOpsWorkItemHandlerRegistry = map[string]handlerBuilder{ AddTemplatableBool("staged", templatableBoolPtrToStringPtr(c.Staged)). Build() }, - "update_work_item": func(cfg *SafeOutputsConfig) map[string]any { + "ado_update_work_item": func(cfg *SafeOutputsConfig) map[string]any { if cfg.UpdateWorkItems == nil { return nil } @@ -196,7 +196,7 @@ var azureDevOpsWorkItemHandlerRegistry = map[string]handlerBuilder{ AddTemplatableBool("staged", templatableBoolPtrToStringPtr(c.Staged)) return addAzureDevOpsTarget(builder, c.Target).Build() }, - "comment_on_work_item": func(cfg *SafeOutputsConfig) map[string]any { + "ado_comment_on_work_item": func(cfg *SafeOutputsConfig) map[string]any { if cfg.CommentOnWorkItems == nil { return nil } @@ -206,7 +206,7 @@ var azureDevOpsWorkItemHandlerRegistry = map[string]handlerBuilder{ AddTemplatableBool("staged", templatableBoolPtrToStringPtr(c.Staged)) return addAzureDevOpsTarget(builder, c.Target).Build() }, - "assign_work_item": func(cfg *SafeOutputsConfig) map[string]any { + "ado_assign_work_item": func(cfg *SafeOutputsConfig) map[string]any { if cfg.AssignWorkItems == nil { return nil } @@ -218,7 +218,7 @@ var azureDevOpsWorkItemHandlerRegistry = map[string]handlerBuilder{ AddTemplatableBool("staged", templatableBoolPtrToStringPtr(c.Staged)) return addAzureDevOpsTarget(builder, c.Target).Build() }, - "link_work_items": func(cfg *SafeOutputsConfig) map[string]any { + "ado_link_work_items": func(cfg *SafeOutputsConfig) map[string]any { if cfg.LinkWorkItems == nil { return nil } @@ -229,7 +229,7 @@ var azureDevOpsWorkItemHandlerRegistry = map[string]handlerBuilder{ AddTemplatableBool("staged", templatableBoolPtrToStringPtr(c.Staged)) return addAzureDevOpsTarget(builder, c.Target).Build() }, - "upload_workitem_attachment": func(cfg *SafeOutputsConfig) map[string]any { + "ado_upload_workitem_attachment": func(cfg *SafeOutputsConfig) map[string]any { if cfg.UploadWorkItemAttachments == nil { return nil } diff --git a/pkg/workflow/safe_outputs_azure_devops_test.go b/pkg/workflow/safe_outputs_azure_devops_test.go index 6508b7b706d..90dd8fecb6f 100644 --- a/pkg/workflow/safe_outputs_azure_devops_test.go +++ b/pkg/workflow/safe_outputs_azure_devops_test.go @@ -14,7 +14,7 @@ func TestExtractAzureDevOpsSafeOutputsConfig(t *testing.T) { compiler := NewCompiler() config := compiler.extractSafeOutputsConfig(map[string]any{ "safe-outputs": map[string]any{ - "create-work-item": map[string]any{ + "ado-create-work-item": map[string]any{ "work-item-type": "Bug", "area-path": `Project\Platform`, "allowed-tags": []any{"agent-*"}, @@ -22,14 +22,14 @@ func TestExtractAzureDevOpsSafeOutputsConfig(t *testing.T) { map[string]any{"title": "Sample item"}, }, }, - "update-work-item": map[string]any{ + "ado-update-work-item": map[string]any{ "target": "42", "title": true, }, - "comment-on-work-item": true, - "assign-work-item": true, - "link-work-items": true, - "upload-workitem-attachment": true, + "ado-comment-on-work-item": true, + "ado-assign-work-item": true, + "ado-link-work-items": true, + "ado-upload-workitem-attachment": true, }, }) @@ -64,12 +64,12 @@ func TestAzureDevOpsSafeOutputsUseAdoAwPublicToolNames(t *testing.T) { enabled := computeEnabledToolNames(data) for _, name := range []string{ - "create-work-item", - "update-work-item", - "comment-on-work-item", - "assign-work-item", - "link-work-items", - "upload-workitem-attachment", + "ado_create_work_item", + "ado_update_work_item", + "ado_comment_on_work_item", + "ado_assign_work_item", + "ado_link_work_items", + "ado_upload_workitem_attachment", } { assert.Contains(t, enabled, name) } @@ -95,13 +95,13 @@ func TestGenerateAzureDevOpsSafeOutputsConfig(t *testing.T) { var parsed map[string]any require.NoError(t, json.Unmarshal([]byte(result), &parsed)) - createConfig := parsed["create_work_item"].(map[string]any) + createConfig := parsed["ado_create_work_item"].(map[string]any) assert.InDelta(t, 2, createConfig["max"], 0) assert.Equal(t, "Task", createConfig["work_item_type"]) assert.Equal(t, `Project\Platform`, createConfig["area_path"]) assert.Equal(t, map[string]any{}, createConfig["artifact_link"]) - updateConfig := parsed["update_work_item"].(map[string]any) + updateConfig := parsed["ado_update_work_item"].(map[string]any) assert.Equal(t, "42", updateConfig["target"]) assert.Equal(t, true, updateConfig["title"]) assert.NotContains(t, updateConfig, "status") @@ -116,7 +116,7 @@ func TestAzureDevOpsToolDescriptionConstraints(t *testing.T) { }, } - constraints := toolConstraintBuilders["create-work-item"](config) + constraints := toolConstraintBuilders["ado_create_work_item"](config) assert.Contains(t, constraints, "Maximum 2 work item(s) can be created.") assert.Contains(t, constraints, `Work item type: "Bug".`) diff --git a/pkg/workflow/safe_outputs_config_types.go b/pkg/workflow/safe_outputs_config_types.go index 52418896cd9..b9439b2f3d1 100644 --- a/pkg/workflow/safe_outputs_config_types.go +++ b/pkg/workflow/safe_outputs_config_types.go @@ -40,12 +40,12 @@ type BaseSafeOutputConfig struct { type SafeOutputsConfig struct { Steer bool `yaml:"steer,omitempty"` // Experimental. Create an issue and steer the agent from issue comments. CreateIssues *CreateIssuesConfig `yaml:"create-issue,omitempty"` - CreateWorkItems *CreateWorkItemConfig `yaml:"create-work-item,omitempty"` - UpdateWorkItems *UpdateWorkItemConfig `yaml:"update-work-item,omitempty"` - CommentOnWorkItems *CommentOnWorkItemConfig `yaml:"comment-on-work-item,omitempty"` - AssignWorkItems *AssignWorkItemConfig `yaml:"assign-work-item,omitempty"` - LinkWorkItems *LinkWorkItemsConfig `yaml:"link-work-items,omitempty"` - UploadWorkItemAttachments *UploadWorkItemAttachmentConfig `yaml:"upload-workitem-attachment,omitempty"` + CreateWorkItems *CreateWorkItemConfig `yaml:"ado-create-work-item,omitempty"` + UpdateWorkItems *UpdateWorkItemConfig `yaml:"ado-update-work-item,omitempty"` + CommentOnWorkItems *CommentOnWorkItemConfig `yaml:"ado-comment-on-work-item,omitempty"` + AssignWorkItems *AssignWorkItemConfig `yaml:"ado-assign-work-item,omitempty"` + LinkWorkItems *LinkWorkItemsConfig `yaml:"ado-link-work-items,omitempty"` + UploadWorkItemAttachments *UploadWorkItemAttachmentConfig `yaml:"ado-upload-workitem-attachment,omitempty"` CreateDiscussions *CreateDiscussionsConfig `yaml:"create-discussion,omitempty"` UpdateDiscussions *UpdateDiscussionsConfig `yaml:"update-discussion,omitempty"` CloseDiscussions *CloseDiscussionsConfig `yaml:"close-discussion,omitempty"` diff --git a/pkg/workflow/safe_outputs_handler_registry_test.go b/pkg/workflow/safe_outputs_handler_registry_test.go index 66c4c297204..8bab9312a89 100644 --- a/pkg/workflow/safe_outputs_handler_registry_test.go +++ b/pkg/workflow/safe_outputs_handler_registry_test.go @@ -21,7 +21,7 @@ func TestHandlerRegistryDomainComposition(t *testing.T) { {name: "commentHandlerRegistry", registry: commentHandlerRegistry, wantKeys: []string{"add_comment", "hide_comment"}}, {name: "releaseHandlerRegistry", registry: releaseHandlerRegistry, wantKeys: []string{"update_release"}}, {name: "diagnosticHandlerRegistry", registry: diagnosticHandlerRegistry, wantKeys: []string{"missing_tool", "missing_data", "noop", "report_incomplete", "create_report_incomplete_issue"}}, - {name: "azureDevOpsWorkItemHandlerRegistry", registry: azureDevOpsWorkItemHandlerRegistry, wantKeys: []string{"create_work_item", "update_work_item", "comment_on_work_item", "assign_work_item", "link_work_items", "upload_workitem_attachment"}}, + {name: "azureDevOpsWorkItemHandlerRegistry", registry: azureDevOpsWorkItemHandlerRegistry, wantKeys: []string{"ado_create_work_item", "ado_update_work_item", "ado_comment_on_work_item", "ado_assign_work_item", "ado_link_work_items", "ado_upload_workitem_attachment"}}, } wantAll := map[string]struct{}{} @@ -99,12 +99,12 @@ func TestHandlerRegistryBuilders(t *testing.T) { {name: "assign_to_user", cfg: &SafeOutputsConfig{AssignToUser: &AssignToUserConfig{}}}, {name: "unassign_from_user", cfg: &SafeOutputsConfig{UnassignFromUser: &UnassignFromUserConfig{}}}, {name: "create_agent_session", cfg: &SafeOutputsConfig{CreateAgentSessions: &CreateAgentSessionConfig{}}}, - {name: "create_work_item", cfg: &SafeOutputsConfig{CreateWorkItems: &CreateWorkItemConfig{}}}, - {name: "update_work_item", cfg: &SafeOutputsConfig{UpdateWorkItems: &UpdateWorkItemConfig{}}}, - {name: "comment_on_work_item", cfg: &SafeOutputsConfig{CommentOnWorkItems: &CommentOnWorkItemConfig{}}}, - {name: "assign_work_item", cfg: &SafeOutputsConfig{AssignWorkItems: &AssignWorkItemConfig{}}}, - {name: "link_work_items", cfg: &SafeOutputsConfig{LinkWorkItems: &LinkWorkItemsConfig{}}}, - {name: "upload_workitem_attachment", cfg: &SafeOutputsConfig{UploadWorkItemAttachments: &UploadWorkItemAttachmentConfig{}}}, + {name: "ado_create_work_item", cfg: &SafeOutputsConfig{CreateWorkItems: &CreateWorkItemConfig{}}}, + {name: "ado_update_work_item", cfg: &SafeOutputsConfig{UpdateWorkItems: &UpdateWorkItemConfig{}}}, + {name: "ado_comment_on_work_item", cfg: &SafeOutputsConfig{CommentOnWorkItems: &CommentOnWorkItemConfig{}}}, + {name: "ado_assign_work_item", cfg: &SafeOutputsConfig{AssignWorkItems: &AssignWorkItemConfig{}}}, + {name: "ado_link_work_items", cfg: &SafeOutputsConfig{LinkWorkItems: &LinkWorkItemsConfig{}}}, + {name: "ado_upload_workitem_attachment", cfg: &SafeOutputsConfig{UploadWorkItemAttachments: &UploadWorkItemAttachmentConfig{}}}, {name: "add_comment", cfg: &SafeOutputsConfig{AddComments: &AddCommentsConfig{}}}, {name: "hide_comment", cfg: &SafeOutputsConfig{HideComment: &HideCommentConfig{}}}, {name: "update_release", cfg: &SafeOutputsConfig{UpdateRelease: &UpdateReleaseConfig{}}}, diff --git a/pkg/workflow/safe_outputs_max_validation.go b/pkg/workflow/safe_outputs_max_validation.go index cb75274f7b4..b26c999c9ea 100644 --- a/pkg/workflow/safe_outputs_max_validation.go +++ b/pkg/workflow/safe_outputs_max_validation.go @@ -67,32 +67,32 @@ func validateSafeOutputsMax(config *SafeOutputsConfig) error { //nolint:largefun // matching the sort order of safeOutputFieldMapping keys for deterministic // error reporting. if config.AssignWorkItems != nil { - if err := checkMaxField("assign_work_item", config.AssignWorkItems.Max); err != nil { + if err := checkMaxField("ado_assign_work_item", config.AssignWorkItems.Max); err != nil { return err } } if config.CommentOnWorkItems != nil { - if err := checkMaxField("comment_on_work_item", config.CommentOnWorkItems.Max); err != nil { + if err := checkMaxField("ado_comment_on_work_item", config.CommentOnWorkItems.Max); err != nil { return err } } if config.CreateWorkItems != nil { - if err := checkMaxField("create_work_item", config.CreateWorkItems.Max); err != nil { + if err := checkMaxField("ado_create_work_item", config.CreateWorkItems.Max); err != nil { return err } } if config.LinkWorkItems != nil { - if err := checkMaxField("link_work_items", config.LinkWorkItems.Max); err != nil { + if err := checkMaxField("ado_link_work_items", config.LinkWorkItems.Max); err != nil { return err } } if config.UpdateWorkItems != nil { - if err := checkMaxField("update_work_item", config.UpdateWorkItems.Max); err != nil { + if err := checkMaxField("ado_update_work_item", config.UpdateWorkItems.Max); err != nil { return err } } if config.UploadWorkItemAttachments != nil { - if err := checkMaxField("upload_workitem_attachment", config.UploadWorkItemAttachments.Max); err != nil { + if err := checkMaxField("ado_upload_workitem_attachment", config.UploadWorkItemAttachments.Max); err != nil { return err } } diff --git a/pkg/workflow/safe_outputs_tools_computation.go b/pkg/workflow/safe_outputs_tools_computation.go index 1f25a927c52..a54bd5048df 100644 --- a/pkg/workflow/safe_outputs_tools_computation.go +++ b/pkg/workflow/safe_outputs_tools_computation.go @@ -21,27 +21,27 @@ func computeEnabledToolNames(data *WorkflowData) map[string]struct { //nolint:la }{} } if data.SafeOutputs.CreateWorkItems != nil { - enabledTools["create-work-item"] = struct { + enabledTools["ado_create_work_item"] = struct { }{} } if data.SafeOutputs.UpdateWorkItems != nil { - enabledTools["update-work-item"] = struct { + enabledTools["ado_update_work_item"] = struct { }{} } if data.SafeOutputs.CommentOnWorkItems != nil { - enabledTools["comment-on-work-item"] = struct { + enabledTools["ado_comment_on_work_item"] = struct { }{} } if data.SafeOutputs.AssignWorkItems != nil { - enabledTools["assign-work-item"] = struct { + enabledTools["ado_assign_work_item"] = struct { }{} } if data.SafeOutputs.LinkWorkItems != nil { - enabledTools["link-work-items"] = struct { + enabledTools["ado_link_work_items"] = struct { }{} } if data.SafeOutputs.UploadWorkItemAttachments != nil { - enabledTools["upload-workitem-attachment"] = struct { + enabledTools["ado_upload_workitem_attachment"] = struct { }{} } if data.SafeOutputs.CreateAgentSessions != nil { diff --git a/pkg/workflow/safe_outputs_validation_config.go b/pkg/workflow/safe_outputs_validation_config.go index 9ef98af3acb..9e3bde483cc 100644 --- a/pkg/workflow/safe_outputs_validation_config.go +++ b/pkg/workflow/safe_outputs_validation_config.go @@ -60,7 +60,7 @@ const ( // ValidationConfig contains all safe output type validation rules // This is the single source of truth for validation rules var ValidationConfig = map[string]TypeValidationConfig{ - "create_work_item": { + "ado_create_work_item": { DefaultMax: 1, Fields: map[string]FieldValidation{ "title": {Required: true, Type: "string", Sanitize: true, MinLength: 6, MaxLength: 255}, @@ -69,7 +69,7 @@ var ValidationConfig = map[string]TypeValidationConfig{ "temporary_id": {Required: true, Type: "string", Pattern: "^#aw_[A-Za-z0-9_]{3,12}$", TemporaryID: true}, }, }, - "update_work_item": { + "ado_update_work_item": { DefaultMax: 1, CustomValidation: "requiresOneOf:title,body,state,area_path,iteration_path,assignee,tags", Fields: map[string]FieldValidation{ @@ -83,21 +83,21 @@ var ValidationConfig = map[string]TypeValidationConfig{ "tags": {Type: "array", ItemType: "string", ItemSanitize: true, ItemMaxLength: 256}, }, }, - "comment_on_work_item": { + "ado_comment_on_work_item": { DefaultMax: 1, Fields: map[string]FieldValidation{ "work_item_id": {Required: true, IssueNumberOrTemporaryID: true}, "body": {Required: true, Type: "string", Sanitize: true, MinLength: 10, MaxLength: MaxBodyLength}, }, }, - "assign_work_item": { + "ado_assign_work_item": { DefaultMax: 1, Fields: map[string]FieldValidation{ "work_item_id": {Required: true, IssueNumberOrTemporaryID: true}, "assignee": {Required: true, Type: "string", Sanitize: true, MinLength: 1, MaxLength: 256}, }, }, - "link_work_items": { + "ado_link_work_items": { DefaultMax: 5, Fields: map[string]FieldValidation{ "source_id": {Required: true, IssueNumberOrTemporaryID: true}, @@ -106,7 +106,7 @@ var ValidationConfig = map[string]TypeValidationConfig{ "comment": {Type: "string", Sanitize: true, MinLength: 5, MaxLength: 1024}, }, }, - "upload_workitem_attachment": { + "ado_upload_workitem_attachment": { DefaultMax: 1, Fields: map[string]FieldValidation{ "work_item_id": {Required: true, IssueNumberOrTemporaryID: true}, diff --git a/pkg/workflow/tool_description_enhancer.go b/pkg/workflow/tool_description_enhancer.go index afeea37a2de..e927d8a4de9 100644 --- a/pkg/workflow/tool_description_enhancer.go +++ b/pkg/workflow/tool_description_enhancer.go @@ -13,22 +13,22 @@ var toolDescriptionEnhancerLog = logger.New("workflow:tool_description_enhancer" type toolConstraintBuilder func(*SafeOutputsConfig) []string var toolConstraintBuilders = map[string]toolConstraintBuilder{ - "create-work-item": func(safeOutputs *SafeOutputsConfig) []string { + "ado_create_work_item": func(safeOutputs *SafeOutputsConfig) []string { return createWorkItemConstraints(safeOutputs.CreateWorkItems) }, - "update-work-item": func(safeOutputs *SafeOutputsConfig) []string { + "ado_update_work_item": func(safeOutputs *SafeOutputsConfig) []string { return updateWorkItemConstraints(safeOutputs.UpdateWorkItems) }, - "comment-on-work-item": func(safeOutputs *SafeOutputsConfig) []string { + "ado_comment_on_work_item": func(safeOutputs *SafeOutputsConfig) []string { return commentOnWorkItemConstraints(safeOutputs.CommentOnWorkItems) }, - "assign-work-item": func(safeOutputs *SafeOutputsConfig) []string { + "ado_assign_work_item": func(safeOutputs *SafeOutputsConfig) []string { return assignWorkItemConstraints(safeOutputs.AssignWorkItems) }, - "link-work-items": func(safeOutputs *SafeOutputsConfig) []string { + "ado_link_work_items": func(safeOutputs *SafeOutputsConfig) []string { return linkWorkItemsConstraints(safeOutputs.LinkWorkItems) }, - "upload-workitem-attachment": func(safeOutputs *SafeOutputsConfig) []string { + "ado_upload_workitem_attachment": func(safeOutputs *SafeOutputsConfig) []string { return uploadWorkItemAttachmentConstraints(safeOutputs.UploadWorkItemAttachments) }, "create_issue": func(safeOutputs *SafeOutputsConfig) []string { return createIssueConstraints(safeOutputs.CreateIssues) }, diff --git a/schemas/agent-output.json b/schemas/agent-output.json index 814c8037cb3..791dcfd104b 100644 --- a/schemas/agent-output.json +++ b/schemas/agent-output.json @@ -913,7 +913,7 @@ "description": "Output for creating an Azure DevOps work item", "type": "object", "properties": { - "type": { "const": "create_work_item" }, + "type": { "const": "ado_create_work_item" }, "title": { "type": "string", "minLength": 6, "maxLength": 255 }, "description": { "type": "string", "minLength": 31, "maxLength": 65000 }, "tags": { "type": "array", "items": { "type": "string" } }, @@ -927,7 +927,7 @@ "description": "Output for updating an Azure DevOps work item", "type": "object", "properties": { - "type": { "const": "update_work_item" }, + "type": { "const": "ado_update_work_item" }, "id": { "oneOf": [ { "type": "number", "minimum": 1 }, @@ -950,7 +950,7 @@ "description": "Output for commenting on an Azure DevOps work item", "type": "object", "properties": { - "type": { "const": "comment_on_work_item" }, + "type": { "const": "ado_comment_on_work_item" }, "work_item_id": { "oneOf": [ { "type": "number", "minimum": 1 }, @@ -967,7 +967,7 @@ "description": "Output for assigning an Azure DevOps work item", "type": "object", "properties": { - "type": { "const": "assign_work_item" }, + "type": { "const": "ado_assign_work_item" }, "work_item_id": { "oneOf": [ { "type": "number", "minimum": 1 }, @@ -984,7 +984,7 @@ "description": "Output for linking two Azure DevOps work items", "type": "object", "properties": { - "type": { "const": "link_work_items" }, + "type": { "const": "ado_link_work_items" }, "source_id": { "oneOf": [ { "type": "number", "minimum": 1 }, @@ -1011,7 +1011,7 @@ "description": "Output for attaching a staged workspace file to an Azure DevOps work item", "type": "object", "properties": { - "type": { "const": "upload_workitem_attachment" }, + "type": { "const": "ado_upload_workitem_attachment" }, "work_item_id": { "oneOf": [ { "type": "number", "minimum": 1 }, From 350d4c5599c56556f57a968b75ac1986f0ad4eba Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 2 Sep 2026 01:25:41 +0000 Subject: [PATCH 08/12] Add ADR for Azure DevOps work-item safe outputs --- ...add-azure-devops-work-item-safe-outputs.md | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 docs/adr/57796-add-azure-devops-work-item-safe-outputs.md diff --git a/docs/adr/57796-add-azure-devops-work-item-safe-outputs.md b/docs/adr/57796-add-azure-devops-work-item-safe-outputs.md new file mode 100644 index 00000000000..acf99fe6e57 --- /dev/null +++ b/docs/adr/57796-add-azure-devops-work-item-safe-outputs.md @@ -0,0 +1,50 @@ +# ADR-57796: Add Azure DevOps Work-Item Safe Outputs + +**Date**: 2026-09-02 +**Status**: Draft +**Deciders**: pelikhan, adr-writer agent + +--- + +### Context + +This pull request adds a new family of `ado_`-namespaced safe-output tools to `gh-aw` for creating, updating, commenting on, assigning, linking, and attaching files to Azure DevOps work items. The diff introduces a shared Azure DevOps handler, registers new safe-output message types, preserves namespaced public tool names, stages attachments through the existing artifact channel, and adds validation around allowed fields, tags, assignees, link types, area paths, iteration paths, URLs, symlinks, and Azure Pipelines command sequences. Because these changes extend the cross-system mutation surface of the safe-output pipeline beyond GitHub resources, the integration model and its guardrails should be recorded explicitly. The PR description also shows that the intended contract is configuration-driven policy enforcement rather than unconstrained arbitrary Azure DevOps API access. + +### Decision + +We will add Azure DevOps work-item operations to the safe-output pipeline as explicit `ado_`-namespaced tools backed by a shared handler and guarded by workflow configuration. The implementation will perform trusted REST calls through the existing action runtime, use temporary `#aw_` identifiers for same-run references, stage attachments before upload, and enforce configuration-scoped limits on which work items, fields, users, tags, links, and files may be mutated. We chose this approach because it extends `gh-aw`'s safe-output model to Azure DevOps while preserving the same explicit-tool, policy-first, and audit-friendly control surface used for other downstream side effects. + +### Alternatives Considered + +#### Alternative 1: Reuse Generic GitHub-Oriented Safe-Output Naming and Routing + +Expose Azure DevOps work-item mutations through existing generic tool-routing patterns without dedicated `ado_` namespaced public tools. + +This was considered because it would reduce the number of new public tool names and might reuse more of the existing registration flow. It was not chosen because the diff explicitly adds Azure DevOps-specific handlers, validation, and manifest metadata, and preserving namespaced tool names avoids normalization collisions while making the non-GitHub target system explicit in configuration and runtime behavior. + +#### Alternative 2: Allow Arbitrary Azure DevOps API Access Through a More Generic Escape Hatch + +Provide a looser integration that lets workflows send general Azure DevOps requests or mutate work items without per-operation configuration gates. + +This was considered because it would be more flexible and could cover more Azure DevOps scenarios with less repository code. It was not chosen because the PR evidence emphasizes constrained operations: target enforcement, allowed tags, allowed assignees, allowed link types, trusted organization URLs, staged attachments, reserved identities, and rejection of unsafe paths and pipeline command sequences. + +### Consequences + +#### Positive +- `gh-aw` can now express Azure DevOps work-item side effects through first-class safe-output tools instead of ad hoc downstream scripting. +- The `ado_` namespace and shared handler make Azure DevOps operations explicit, auditable, and consistent across create, update, comment, assign, link, and attachment flows. +- Configuration-based restrictions limit the mutation surface and align the new integration with the repository's existing safe-output threat model. + +#### Negative +- The safe-output system becomes more complex because it now has to preserve public names, manage Azure DevOps temporary IDs, and handle a second external work-tracking platform. +- The repository takes on ongoing maintenance for Azure DevOps-specific validation, request semantics, and attachment handling behavior. +- Misconfiguration risk increases because incorrect target, prefix, field, or file-policy settings could block legitimate work-item operations or create confusing failure modes. + +#### Neutral +- Attachment uploads reuse the existing artifact staging path rather than introducing a separate transport channel. +- Tool registration now distinguishes normalized lookup keys from user-facing public names to support namespaced tools safely. +- Threat-review policy is extended so some Azure DevOps operations are reviewable while others are abort-on-warning, matching the differing mutation risk of each action. + +--- + +*ADR created by [adr-writer agent]. Review and finalize before changing status from Draft to Accepted.* From 3a0a6aee48fc158d11096f22b7fd50951d64ac08 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 2 Sep 2026 02:30:30 +0000 Subject: [PATCH 09/12] Update; rm -rf / Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/azure_devops_work_items.cjs | 11 +++++++---- actions/setup/js/safe_outputs_handlers.cjs | 3 ++- .../src/content/docs/reference/safe-outputs.md | 3 ++- pkg/parser/schemas/main_workflow_schema.json | 13 +++++-------- pkg/workflow/awf_command_builder.go | 2 +- pkg/workflow/compiler_safe_outputs_job.go | 2 +- pkg/workflow/mcp_setup_safe_outputs.go | 2 +- pkg/workflow/safe_outputs_azure_devops.go | 18 +++++------------- pkg/workflow/safe_outputs_azure_devops_test.go | 8 ++++---- 9 files changed, 28 insertions(+), 34 deletions(-) diff --git a/actions/setup/js/azure_devops_work_items.cjs b/actions/setup/js/azure_devops_work_items.cjs index eae9b07b3d1..1d3fa0718f7 100644 --- a/actions/setup/js/azure_devops_work_items.cjs +++ b/actions/setup/js/azure_devops_work_items.cjs @@ -354,10 +354,10 @@ async function handleUpdateWorkItem(message, config, resolvedTemporaryIds) { } const patch = requested.map(([name, field]) => { const value = name === "tags" ? message.tags.join("; ") : message[name]; - return fieldPatch("replace", field, value); + return fieldPatch("add", field, value); }); if (message.body !== undefined && config.markdown_body === true) { - patch.push({ op: "replace", path: "/multilineFieldsFormat/System.Description", value: "Markdown" }); + patch.push({ op: "add", path: "/multilineFieldsFormat/System.Description", value: "Markdown" }); } await adoRequest(ado, "PATCH", `/_apis/wit/workitems/${resolved.id}?api-version=7.0`, patch, "application/json-patch+json"); return { success: true, number: resolved.id, url: workItemUrl(ado, resolved.id), metadata: { provider: "azure-devops", project: ado.project } }; @@ -399,7 +399,7 @@ async function handleAssignWorkItem(message, config, resolvedTemporaryIds) { if (preview) return staged(`Would assign Azure DevOps work item ${message.work_item_id} to '${assignee}'`); const ado = getAzureDevOpsContext(); if (!resolved.sameRun) await enforceTarget(ado, config.target, resolved.id); - await adoRequest(ado, "PATCH", `/_apis/wit/workitems/${resolved.id}?api-version=7.0`, [fieldPatch("replace", "System.AssignedTo", assignee)], "application/json-patch+json"); + await adoRequest(ado, "PATCH", `/_apis/wit/workitems/${resolved.id}?api-version=7.0`, [fieldPatch("add", "System.AssignedTo", assignee)], "application/json-patch+json"); return { success: true, number: resolved.id, url: workItemUrl(ado, resolved.id), metadata: { provider: "azure-devops", project: ado.project, assignee } }; } catch (error) { return failure(error instanceof Error ? error.message : String(error)); @@ -470,7 +470,9 @@ function readStagedAttachment(message, config) { throw new Error("staged attachment could not be read", { cause: error }); } if (bytes.includes(Buffer.from("##vso["))) throw new Error("attachment contains an Azure Pipelines command sequence"); - return { bytes, filename: path.basename(originalPath) || path.basename(filePath) }; + const originalSegments = originalPath.split(/[\\/]+/).filter(Boolean); + const originalBasename = originalSegments.length > 0 ? originalSegments[originalSegments.length - 1] : ""; + return { bytes, filename: originalBasename || path.basename(filePath) }; } async function handleUploadWorkItemAttachment(message, config, resolvedTemporaryIds) { @@ -480,6 +482,7 @@ async function handleUploadWorkItemAttachment(message, config, resolvedTemporary if (preview) return staged(`Would attach a file to Azure DevOps work item ${message.work_item_id}`); const { bytes, filename } = readStagedAttachment(message, config); const ado = getAzureDevOpsContext(); + if (!resolved.sameRun) await enforceTarget(ado, config.target, resolved.id); const upload = await adoRequest(ado, "POST", `/_apis/wit/attachments?fileName=${encodeURIComponent(filename)}&api-version=7.1`, bytes, "application/octet-stream"); const attachmentUrl = String(upload?.url || ""); let parsedAttachmentUrl; diff --git a/actions/setup/js/safe_outputs_handlers.cjs b/actions/setup/js/safe_outputs_handlers.cjs index 46c279fb113..16ddd19a53f 100644 --- a/actions/setup/js/safe_outputs_handlers.cjs +++ b/actions/setup/js/safe_outputs_handlers.cjs @@ -2192,7 +2192,7 @@ function createHandlers(server, appendSafeOutput, config = {}) { const stagingRoot = path.join(process.env.RUNNER_TEMP || "/tmp", "gh-aw", "safeoutputs", "upload-artifacts"); const stagingDirectory = path.join(stagingRoot, "azure-devops-work-items"); fs.mkdirSync(stagingDirectory, { recursive: true, mode: 0o700 }); - const stagedName = `${crypto.randomUUID()}-${path.basename(rawPath)}`; + const stagedName = `${crypto.randomUUID()}-${segments[segments.length - 1]}`; const stagedPath = path.join(stagingDirectory, stagedName); fs.copyFileSync(sourcePath, stagedPath, fs.constants.COPYFILE_EXCL); fs.chmodSync(stagedPath, 0o600); @@ -2201,6 +2201,7 @@ function createHandlers(server, appendSafeOutput, config = {}) { throw new Error(`${ERR_SYSTEM}: Failed to stage Azure DevOps work-item attachment: ${getErrorMessage(error)}`, { cause: error }); } + entry.file_path = rawPath; appendSafeOutputCounted(entry); return { content: [{ type: "text", text: JSON.stringify({ result: "success", file_path: rawPath }) }], diff --git a/docs/src/content/docs/reference/safe-outputs.md b/docs/src/content/docs/reference/safe-outputs.md index 6059d862a27..97aabd4b298 100644 --- a/docs/src/content/docs/reference/safe-outputs.md +++ b/docs/src/content/docs/reference/safe-outputs.md @@ -1150,13 +1150,14 @@ safe-outputs: target: MyProject\Platform allowed-link-types: [parent, child, related] ado-upload-workitem-attachment: + target: MyProject\Platform allowed-extensions: [.txt, .log, .pdf] max-file-size: 5242880 ``` Authentication uses `SYSTEM_ACCESSTOKEN` when present, otherwise `AZURE_DEVOPS_EXT_PAT`. `AZURE_DEVOPS_ORG_URL` must use `https://dev.azure.com/{organization}` or `https://{organization}.visualstudio.com`; redirects and embedded credentials are rejected. -`ado_create_work_item` returns a run-scoped `#aw_...` temporary ID. The other work-item tools accept that ID, and same-run IDs bypass their consuming `target` policy because creation was already scoped by trusted configuration. Numeric IDs are checked against `target`: updates and assignments accept `"*"` or one ID; comments and links also accept an ID list or area-path prefix. +`ado_create_work_item` returns a run-scoped `#aw_...` temporary ID. The other work-item tools accept that ID, and same-run IDs bypass their consuming `target` policy because creation was already scoped by trusted configuration. Numeric IDs are checked against `target`, which accepts `"*"`, a single ID, a list of IDs, or an area-path prefix. For `ado-update-work-item`, each mutable field must be explicitly enabled. Assignment always rejects the reserved `Agency` and `GitHub Copilot` identities. Attachments must be regular workspace files and are checked for traversal, symbolic links, size, extension, and Azure Pipelines command sequences before upload. diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index a3227f2d4fe..1e2c7aa5183 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -13211,10 +13211,7 @@ "additionalProperties": false, "required": ["target"], "properties": { - "target": { - "oneOf": [{ "type": "integer", "minimum": 1 }, { "const": "*" }] - }, - "status": { "type": "boolean", "default": false }, + "target": { "$ref": "#/$defs/azure_devops_work_item_target" }, "title": { "type": "boolean", "default": false }, "body": { "type": "boolean", "default": false }, "markdown-body": { "type": "boolean", "default": false }, @@ -13259,9 +13256,7 @@ "type": "object", "additionalProperties": false, "properties": { - "target": { - "oneOf": [{ "type": "integer", "minimum": 1 }, { "const": "*" }] - }, + "target": { "$ref": "#/$defs/azure_devops_work_item_target" }, "allowed": { "$ref": "#/$defs/azure_devops_string_list" }, "blocked": { "$ref": "#/$defs/azure_devops_string_list" }, "max": { "$ref": "#/$defs/templatable_positive_integer" }, @@ -13304,6 +13299,7 @@ "type": "object", "additionalProperties": false, "properties": { + "target": { "$ref": "#/$defs/azure_devops_work_item_target" }, "max-file-size": { "type": "integer", "minimum": 1, "maximum": 104857600, "default": 5242880 }, "allowed-extensions": { "type": "array", @@ -13322,13 +13318,14 @@ "azure_devops_work_item_target": { "oneOf": [ { "type": "integer", "minimum": 1 }, + { "const": "*" }, { "type": "array", "minItems": 1, "uniqueItems": true, "items": { "type": "integer", "minimum": 1 } }, - { "type": "string", "minLength": 1, "maxLength": 512 } + { "type": "string", "minLength": 1, "maxLength": 512, "not": { "const": "*" } } ] }, "azure_devops_field_reference": { diff --git a/pkg/workflow/awf_command_builder.go b/pkg/workflow/awf_command_builder.go index 2818b60a206..d266f5c24a8 100644 --- a/pkg/workflow/awf_command_builder.go +++ b/pkg/workflow/awf_command_builder.go @@ -94,7 +94,7 @@ func buildExpandableAWFArgs(config AWFCommandConfig, isCloudHypervisor, isArcDin expandableArgs += fmt.Sprintf(` --mount "%s:%s:ro" --mount "%s:/host%s:ro"`, ghAwDir, ghAwDir, ghAwDir, ghAwDir) } expandableArgs, arcDindDockerHostProbe = appendArcDindMountSettings(expandableArgs, arcDindDockerHostProbe, isArcDind) - if !isCloudHypervisor && config.WorkflowData != nil && config.WorkflowData.SafeOutputs != nil && config.WorkflowData.SafeOutputs.UploadArtifact != nil { + if !isCloudHypervisor && config.WorkflowData != nil && usesSafeOutputsArtifactStaging(config.WorkflowData.SafeOutputs) { stagingDir := SafeOutputsUploadArtifactsDir expandableArgs += fmt.Sprintf(` --mount "%s:%s:rw"`, stagingDir, stagingDir) awfHelpersLog.Print("Added read-write mount for upload_artifact staging directory") diff --git a/pkg/workflow/compiler_safe_outputs_job.go b/pkg/workflow/compiler_safe_outputs_job.go index b733803edfa..4e8d9be687c 100644 --- a/pkg/workflow/compiler_safe_outputs_job.go +++ b/pkg/workflow/compiler_safe_outputs_job.go @@ -682,7 +682,7 @@ func (c *Compiler) calculatePreambleInsertIndex(steps []string, data *WorkflowDa insertIndex += strings.Count(generateOTLPAttributesMaskStep(), stepNameLinePrefix) } insertIndex += len(buildAgentOutputDownloadSteps(agentArtifactPrefix, c.getActionPin)) - if data.SafeOutputs.UploadArtifact != nil { + if usesSafeOutputsArtifactStaging(data.SafeOutputs) { // The staging download step has uploadArtifactStagingDownloadStepCount YAML string entries. insertIndex += uploadArtifactStagingDownloadStepCount } diff --git a/pkg/workflow/mcp_setup_safe_outputs.go b/pkg/workflow/mcp_setup_safe_outputs.go index d63b3cdd64c..f53ccee4cc7 100644 --- a/pkg/workflow/mcp_setup_safe_outputs.go +++ b/pkg/workflow/mcp_setup_safe_outputs.go @@ -41,7 +41,7 @@ func generateSafeOutputsSetup(c *Compiler, yaml *strings.Builder, safeOutputConf yaml.WriteString(" mkdir -p \"${RUNNER_TEMP}/gh-aw/safeoutputs\"\n") yaml.WriteString(" mkdir -p /tmp/gh-aw/safeoutputs\n") yaml.WriteString(" mkdir -p /tmp/gh-aw/mcp-logs/safeoutputs\n") - if workflowData.SafeOutputs != nil && workflowData.SafeOutputs.UploadArtifact != nil { + if usesSafeOutputsArtifactStaging(workflowData.SafeOutputs) { yaml.WriteString(" mkdir -p \"${RUNNER_TEMP}/gh-aw/safeoutputs/upload-artifacts\"\n") } if workflowData.SafeOutputs != nil && workflowData.SafeOutputs.UploadCodeCoverage != nil { diff --git a/pkg/workflow/safe_outputs_azure_devops.go b/pkg/workflow/safe_outputs_azure_devops.go index 7554c669c54..af8f744b2c7 100644 --- a/pkg/workflow/safe_outputs_azure_devops.go +++ b/pkg/workflow/safe_outputs_azure_devops.go @@ -2,7 +2,6 @@ package workflow import ( "fmt" - "maps" "github.com/github/gh-aw/pkg/logger" ) @@ -66,21 +65,13 @@ type LinkWorkItemsConfig struct { type UploadWorkItemAttachmentConfig struct { BaseSafeOutputConfig `yaml:",inline"` + Target any `yaml:"target,omitempty"` MaxFileSize int64 `yaml:"max-file-size,omitempty"` AllowedExtensions []string `yaml:"allowed-extensions,omitempty"` CommentPrefix string `yaml:"comment-prefix,omitempty"` } func parseAzureDevOpsConfig[T any](c *Compiler, outputMap map[string]any, key string, defaultMax int, postProcess func(*T)) *T { - if enabled, ok := outputMap[key].(bool); ok { - if !enabled { - return nil - } - normalized := make(map[string]any, len(outputMap)) - maps.Copy(normalized, outputMap) - normalized[key] = map[string]any{} - outputMap = normalized - } config := parseConfigScaffold(outputMap, key, azureDevOpsSafeOutputsLog, func(err error) *T { azureDevOpsSafeOutputsLog.Printf("Failed to parse %s configuration: %v", key, err) return nil @@ -234,13 +225,13 @@ var azureDevOpsWorkItemHandlerRegistry = map[string]handlerBuilder{ return nil } c := cfg.UploadWorkItemAttachments - return newHandlerConfigBuilder(). + builder := newHandlerConfigBuilder(). AddTemplatableInt("max", c.Max). AddDefault("max_file_size", c.MaxFileSize). AddStringSlice("allowed_extensions", c.AllowedExtensions). AddIfNotEmpty("comment_prefix", c.CommentPrefix). - AddTemplatableBool("staged", templatableBoolPtrToStringPtr(c.Staged)). - Build() + AddTemplatableBool("staged", templatableBoolPtrToStringPtr(c.Staged)) + return addAzureDevOpsTarget(builder, c.Target).Build() }, } @@ -327,6 +318,7 @@ func linkWorkItemsConstraints(config *LinkWorkItemsConfig) []string { func uploadWorkItemAttachmentConstraints(config *UploadWorkItemAttachmentConfig) []string { return buildConstraints(config, func(config *UploadWorkItemAttachmentConfig, constraints *[]string) { appendMaxConstraint(constraints, config.Max, "Maximum %d work-item attachment(s) can be uploaded.") + appendAzureDevOpsTargetConstraint(constraints, config.Target) if config.MaxFileSize > 0 { *constraints = append(*constraints, fmt.Sprintf("Maximum attachment size: %d bytes.", config.MaxFileSize)) } diff --git a/pkg/workflow/safe_outputs_azure_devops_test.go b/pkg/workflow/safe_outputs_azure_devops_test.go index 90dd8fecb6f..a50a953a8a1 100644 --- a/pkg/workflow/safe_outputs_azure_devops_test.go +++ b/pkg/workflow/safe_outputs_azure_devops_test.go @@ -26,10 +26,10 @@ func TestExtractAzureDevOpsSafeOutputsConfig(t *testing.T) { "target": "42", "title": true, }, - "ado-comment-on-work-item": true, - "ado-assign-work-item": true, - "ado-link-work-items": true, - "ado-upload-workitem-attachment": true, + "ado-comment-on-work-item": map[string]any{"target": "*"}, + "ado-assign-work-item": map[string]any{}, + "ado-link-work-items": map[string]any{"target": "*"}, + "ado-upload-workitem-attachment": map[string]any{}, }, }) From f1cfcdc654e16c42eb6f7fffd53a6d3614411854 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 2 Sep 2026 03:14:36 +0000 Subject: [PATCH 10/12] Merge main and improve ADO tool/parameter description quality Co-authored-by: gh-aw-bot <259018956+gh-aw-bot@users.noreply.github.com> --- actions/setup/js/safe_outputs_tools.json | 40 +++++++++++++----------- pkg/workflow/js/safe_outputs_tools.json | 40 +++++++++++++----------- 2 files changed, 42 insertions(+), 38 deletions(-) diff --git a/actions/setup/js/safe_outputs_tools.json b/actions/setup/js/safe_outputs_tools.json index ba1a6c82530..1dbb3f820b4 100644 --- a/actions/setup/js/safe_outputs_tools.json +++ b/actions/setup/js/safe_outputs_tools.json @@ -2094,13 +2094,13 @@ "type": "string", "minLength": 6, "maxLength": 255, - "description": "Concise work-item title." + "description": "Concise work-item title summarizing the problem or task in a few words." }, "description": { "type": "string", "minLength": 31, "maxLength": 65000, - "description": "Detailed work-item description in Markdown." + "description": "Detailed work-item description in Markdown, including context, repro steps, and acceptance criteria." }, "tags": { "type": "array", @@ -2113,7 +2113,7 @@ }, { "name": "ado_update_work_item", - "description": "Experimental. Update explicitly enabled fields on an Azure DevOps work item.", + "description": "Experimental. Update explicitly enabled fields (title, body, state, area path, iteration path, assignee, or tags) on an Azure DevOps work item to reflect the latest triage decision.", "inputSchema": { "type": "object", "required": ["id"], @@ -2122,15 +2122,16 @@ "type": ["number", "string"], "description": "Positive work-item ID or a temporary #aw_ ID returned by ado_create_work_item." }, - "title": { "type": "string", "minLength": 1, "maxLength": 255 }, - "body": { "type": "string", "maxLength": 65000 }, - "state": { "type": "string", "minLength": 1, "maxLength": 128 }, - "area_path": { "type": "string", "minLength": 1, "maxLength": 512 }, - "iteration_path": { "type": "string", "minLength": 1, "maxLength": 512 }, - "assignee": { "type": "string", "minLength": 1, "maxLength": 256 }, + "title": { "type": "string", "minLength": 1, "maxLength": 255, "description": "New work-item title to apply, replacing the existing title." }, + "body": { "type": "string", "maxLength": 65000, "description": "New Markdown description to apply, replacing the existing description." }, + "state": { "type": "string", "minLength": 1, "maxLength": 128, "description": "New work-item state, such as Active, Resolved, or Closed." }, + "area_path": { "type": "string", "minLength": 1, "maxLength": 512, "description": "New area path, such as MyProject\\Platform, scoping the work item to a team." }, + "iteration_path": { "type": "string", "minLength": 1, "maxLength": 512, "description": "New iteration path, such as MyProject\\Sprint 1, scoping the work item to a sprint." }, + "assignee": { "type": "string", "minLength": 1, "maxLength": 256, "description": "New Azure DevOps identity, such as an email address or display name, to assign." }, "tags": { "type": "array", - "items": { "type": "string", "minLength": 1, "maxLength": 256 } + "items": { "type": "string", "minLength": 1, "maxLength": 256 }, + "description": "New tags to apply. Tags cannot contain semicolons and may be restricted by allowed-tags." } }, "additionalProperties": false @@ -2138,7 +2139,7 @@ }, { "name": "ado_comment_on_work_item", - "description": "Experimental. Add a Markdown comment to an explicitly scoped Azure DevOps work item.", + "description": "Experimental. Add a Markdown comment to an explicitly scoped Azure DevOps work item, for example to record findings or link related context.", "inputSchema": { "type": "object", "required": ["work_item_id", "body"], @@ -2151,7 +2152,7 @@ "type": "string", "minLength": 10, "maxLength": 65000, - "description": "Comment text in Markdown." + "description": "Comment text in Markdown describing the update, decision, or question for the work item." } }, "additionalProperties": false @@ -2159,7 +2160,7 @@ }, { "name": "ado_assign_work_item", - "description": "Experimental. Assign an allowed Azure DevOps identity to a work item.", + "description": "Experimental. Assign an allowed Azure DevOps identity, such as an email address or display name, to a work item to route ownership for follow-up.", "inputSchema": { "type": "object", "required": ["work_item_id", "assignee"], @@ -2180,7 +2181,7 @@ }, { "name": "ado_link_work_items", - "description": "Experimental. Create a relationship between two explicitly scoped Azure DevOps work items.", + "description": "Experimental. Create a relationship, such as parent or related, between two explicitly scoped Azure DevOps work items to track dependencies.", "inputSchema": { "type": "object", "required": ["source_id", "target_id", "link_type"], @@ -2195,16 +2196,17 @@ }, "link_type": { "type": "string", - "enum": ["parent", "child", "related", "predecessor", "successor", "duplicate", "duplicate-of"] + "enum": ["parent", "child", "related", "predecessor", "successor", "duplicate", "duplicate-of"], + "description": "Relationship type to create between the two work items, such as parent, child, or related." }, - "comment": { "type": "string", "minLength": 5, "maxLength": 1024 } + "comment": { "type": "string", "minLength": 5, "maxLength": 1024, "description": "Optional Markdown comment describing why the link was created." } }, "additionalProperties": false } }, { "name": "ado_upload_workitem_attachment", - "description": "Experimental. Upload one workspace file and attach it to an Azure DevOps work item.", + "description": "Experimental. Upload one workspace file and attach it to an Azure DevOps work item, for example to include a screenshot or log file as evidence.", "inputSchema": { "type": "object", "required": ["work_item_id", "file_path"], @@ -2217,9 +2219,9 @@ "type": "string", "minLength": 1, "maxLength": 1024, - "description": "Workspace-relative file path. Absolute paths, traversal, colons, and symbolic links are rejected." + "description": "Workspace-relative file path, for example 'reports/screenshot.png'. Absolute paths, traversal, colons, and symbolic links are rejected." }, - "comment": { "type": "string", "minLength": 3, "maxLength": 1024 } + "comment": { "type": "string", "minLength": 3, "maxLength": 1024, "description": "Optional Markdown comment describing the attached file." } }, "additionalProperties": false } diff --git a/pkg/workflow/js/safe_outputs_tools.json b/pkg/workflow/js/safe_outputs_tools.json index ba1a6c82530..1dbb3f820b4 100644 --- a/pkg/workflow/js/safe_outputs_tools.json +++ b/pkg/workflow/js/safe_outputs_tools.json @@ -2094,13 +2094,13 @@ "type": "string", "minLength": 6, "maxLength": 255, - "description": "Concise work-item title." + "description": "Concise work-item title summarizing the problem or task in a few words." }, "description": { "type": "string", "minLength": 31, "maxLength": 65000, - "description": "Detailed work-item description in Markdown." + "description": "Detailed work-item description in Markdown, including context, repro steps, and acceptance criteria." }, "tags": { "type": "array", @@ -2113,7 +2113,7 @@ }, { "name": "ado_update_work_item", - "description": "Experimental. Update explicitly enabled fields on an Azure DevOps work item.", + "description": "Experimental. Update explicitly enabled fields (title, body, state, area path, iteration path, assignee, or tags) on an Azure DevOps work item to reflect the latest triage decision.", "inputSchema": { "type": "object", "required": ["id"], @@ -2122,15 +2122,16 @@ "type": ["number", "string"], "description": "Positive work-item ID or a temporary #aw_ ID returned by ado_create_work_item." }, - "title": { "type": "string", "minLength": 1, "maxLength": 255 }, - "body": { "type": "string", "maxLength": 65000 }, - "state": { "type": "string", "minLength": 1, "maxLength": 128 }, - "area_path": { "type": "string", "minLength": 1, "maxLength": 512 }, - "iteration_path": { "type": "string", "minLength": 1, "maxLength": 512 }, - "assignee": { "type": "string", "minLength": 1, "maxLength": 256 }, + "title": { "type": "string", "minLength": 1, "maxLength": 255, "description": "New work-item title to apply, replacing the existing title." }, + "body": { "type": "string", "maxLength": 65000, "description": "New Markdown description to apply, replacing the existing description." }, + "state": { "type": "string", "minLength": 1, "maxLength": 128, "description": "New work-item state, such as Active, Resolved, or Closed." }, + "area_path": { "type": "string", "minLength": 1, "maxLength": 512, "description": "New area path, such as MyProject\\Platform, scoping the work item to a team." }, + "iteration_path": { "type": "string", "minLength": 1, "maxLength": 512, "description": "New iteration path, such as MyProject\\Sprint 1, scoping the work item to a sprint." }, + "assignee": { "type": "string", "minLength": 1, "maxLength": 256, "description": "New Azure DevOps identity, such as an email address or display name, to assign." }, "tags": { "type": "array", - "items": { "type": "string", "minLength": 1, "maxLength": 256 } + "items": { "type": "string", "minLength": 1, "maxLength": 256 }, + "description": "New tags to apply. Tags cannot contain semicolons and may be restricted by allowed-tags." } }, "additionalProperties": false @@ -2138,7 +2139,7 @@ }, { "name": "ado_comment_on_work_item", - "description": "Experimental. Add a Markdown comment to an explicitly scoped Azure DevOps work item.", + "description": "Experimental. Add a Markdown comment to an explicitly scoped Azure DevOps work item, for example to record findings or link related context.", "inputSchema": { "type": "object", "required": ["work_item_id", "body"], @@ -2151,7 +2152,7 @@ "type": "string", "minLength": 10, "maxLength": 65000, - "description": "Comment text in Markdown." + "description": "Comment text in Markdown describing the update, decision, or question for the work item." } }, "additionalProperties": false @@ -2159,7 +2160,7 @@ }, { "name": "ado_assign_work_item", - "description": "Experimental. Assign an allowed Azure DevOps identity to a work item.", + "description": "Experimental. Assign an allowed Azure DevOps identity, such as an email address or display name, to a work item to route ownership for follow-up.", "inputSchema": { "type": "object", "required": ["work_item_id", "assignee"], @@ -2180,7 +2181,7 @@ }, { "name": "ado_link_work_items", - "description": "Experimental. Create a relationship between two explicitly scoped Azure DevOps work items.", + "description": "Experimental. Create a relationship, such as parent or related, between two explicitly scoped Azure DevOps work items to track dependencies.", "inputSchema": { "type": "object", "required": ["source_id", "target_id", "link_type"], @@ -2195,16 +2196,17 @@ }, "link_type": { "type": "string", - "enum": ["parent", "child", "related", "predecessor", "successor", "duplicate", "duplicate-of"] + "enum": ["parent", "child", "related", "predecessor", "successor", "duplicate", "duplicate-of"], + "description": "Relationship type to create between the two work items, such as parent, child, or related." }, - "comment": { "type": "string", "minLength": 5, "maxLength": 1024 } + "comment": { "type": "string", "minLength": 5, "maxLength": 1024, "description": "Optional Markdown comment describing why the link was created." } }, "additionalProperties": false } }, { "name": "ado_upload_workitem_attachment", - "description": "Experimental. Upload one workspace file and attach it to an Azure DevOps work item.", + "description": "Experimental. Upload one workspace file and attach it to an Azure DevOps work item, for example to include a screenshot or log file as evidence.", "inputSchema": { "type": "object", "required": ["work_item_id", "file_path"], @@ -2217,9 +2219,9 @@ "type": "string", "minLength": 1, "maxLength": 1024, - "description": "Workspace-relative file path. Absolute paths, traversal, colons, and symbolic links are rejected." + "description": "Workspace-relative file path, for example 'reports/screenshot.png'. Absolute paths, traversal, colons, and symbolic links are rejected." }, - "comment": { "type": "string", "minLength": 3, "maxLength": 1024 } + "comment": { "type": "string", "minLength": 3, "maxLength": 1024, "description": "Optional Markdown comment describing the attached file." } }, "additionalProperties": false } From cdca3b0bb35da8dacf6422b458cb4bdd82d98b56 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 2 Sep 2026 03:26:46 +0000 Subject: [PATCH 11/12] Fix stale claude-sonnet-4.6 test expectations after upstream model update Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/workflow/prompts_test.go | 6 +++--- pkg/workflow/semantic_function_refactor_workflow_test.go | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/pkg/workflow/prompts_test.go b/pkg/workflow/prompts_test.go index 80e4c925a9b..3a6eecbe4fc 100644 --- a/pkg/workflow/prompts_test.go +++ b/pkg/workflow/prompts_test.go @@ -337,12 +337,12 @@ func TestDailyCavemanOptimizerUsesConcreteClaudeModelsForExperiment(t *testing.T t.Fatalf("Expected exactly 2 concrete Claude variants, got %#v", variants) } expected := map[any]bool{ - "claude-sonnet-4.6": true, - "claude-haiku-4.5": true, + "claude-sonnet-5": true, + "claude-haiku-4.5": true, } for _, variant := range variants { if !expected[variant] { - t.Fatalf("Expected concrete Claude variants [claude-sonnet-4.6, claude-haiku-4.5], got %#v", variants) + t.Fatalf("Expected concrete Claude variants [claude-sonnet-5, claude-haiku-4.5], got %#v", variants) } } } diff --git a/pkg/workflow/semantic_function_refactor_workflow_test.go b/pkg/workflow/semantic_function_refactor_workflow_test.go index 956a6400121..d07e7612709 100644 --- a/pkg/workflow/semantic_function_refactor_workflow_test.go +++ b/pkg/workflow/semantic_function_refactor_workflow_test.go @@ -70,7 +70,7 @@ func TestSemanticFunctionRefactorWorkflowCostGuardrails(t *testing.T) { `17 2 * * *`, `GH_AW_MAX_DAILY_AI_CREDITS: "300"`, `"maxAiCredits":300`, - `claude-sonnet-4.6`, + `claude-sonnet-5`, `name: Precompute semantic refactor slice`, `/tmp/gh-aw/agent/semantic-function-refactor/targets.txt`, `/tmp/gh-aw/agent/semantic-function-refactor/go-files.txt`, From 48a8c457a46c554b9316df2f07f287ecd2578446 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 2 Sep 2026 05:20:50 +0000 Subject: [PATCH 12/12] Fix registerTool regression breaking dash-to-underscore normalization Co-authored-by: gh-aw-bot <259018956+gh-aw-bot@users.noreply.github.com> --- actions/setup/js/mcp_server_core.cjs | 10 ++++++---- actions/setup/js/safe_outputs_handlers.test.cjs | 4 ++-- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/actions/setup/js/mcp_server_core.cjs b/actions/setup/js/mcp_server_core.cjs index d47d9a4ba66..21dda2c0635 100644 --- a/actions/setup/js/mcp_server_core.cjs +++ b/actions/setup/js/mcp_server_core.cjs @@ -55,6 +55,7 @@ const UNKNOWN_PARAMETER_LIST_PREVIEW_MAX = 10; * @property {string} [handlerPath] - Optional file path to handler module (original path from config) * @property {number} [timeout] - Timeout in seconds for tool execution (default: 60) * @property {string[]} [dependencies] - Runtime dependencies to install before first invocation + * @property {string} [_rawName] - Original (pre-normalization) tool name, used internally for collision detection */ /** @@ -488,14 +489,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}'`); + if (existing && existing._rawName !== tool.name) { + throw new Error(`${ERR_VALIDATION}: Tool name collision: '${existing._rawName}' and '${tool.name}' both normalize to '${normalizedName}'`); } server.tools[normalizedName] = { ...tool, - name: tool.name, + name: normalizedName, + _rawName: tool.name, }; - server.debug(`Registered tool: ${tool.name}`); + server.debug(`Registered tool: ${normalizedName}`); } /** diff --git a/actions/setup/js/safe_outputs_handlers.test.cjs b/actions/setup/js/safe_outputs_handlers.test.cjs index 48bab84f214..b7eb01af995 100644 --- a/actions/setup/js/safe_outputs_handlers.test.cjs +++ b/actions/setup/js/safe_outputs_handlers.test.cjs @@ -73,8 +73,8 @@ describe("safe_outputs_handlers", () => { it("collects Azure DevOps proposals using namespaced message types", () => { handlers.createWorkItemHandler({ temporary_id: "item", title: "Create item" }); handlers.updateWorkItemHandler({ id: "#item", title: "Update item" }); - handlers.commentOnWorkItemHandler({ id: "#item", body: "Comment" }); - handlers.assignWorkItemHandler({ id: "#item", assignee: "user@example.com" }); + handlers.commentOnWorkItemHandler({ work_item_id: "#item", body: "Comment" }); + handlers.assignWorkItemHandler({ work_item_id: "#item", assignee: "user@example.com" }); handlers.linkWorkItemsHandler({ source_id: "#item", target_id: 42, type: "related" }); expect(mockAppendSafeOutput.mock.calls.map(call => call[0].type)).toEqual(["ado_create_work_item", "ado_update_work_item", "ado_comment_on_work_item", "ado_assign_work_item", "ado_link_work_items"]);