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..7f218f962df --- /dev/null +++ b/.changeset/minor-azure-devops-work-item-safe-outputs.md @@ -0,0 +1,5 @@ +--- +"gh-aw": minor +--- + +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 new file mode 100644 index 00000000000..b90d0806cbb --- /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("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 new file mode 100644 index 00000000000..1d3fa0718f7 --- /dev/null +++ b/actions/setup/js/azure_devops_work_items.cjs @@ -0,0 +1,538 @@ +// @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 { logStagedPreviewInfo } = require("./staged_preview.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 = {}) { + logStagedPreviewInfo(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 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() { + 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}`; + let response; + core.debug(`Azure DevOps API request started: ${method}`); + 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 }); + } + 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`); + } + if (!response.ok) { + throw new Error(`Azure DevOps ${method} request failed with HTTP ${response.status} ${response.statusText}`); + } + if (response.status === 204) return {}; + 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) { + 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 ado_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("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`); + + 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}`, { + 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 ado_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 (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(); + 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("add", field, value); + }); + if (message.body !== undefined && config.markdown_body === true) { + 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 } }; + } 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 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 ado-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("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)); + } +} + +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"); + } + 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"); + 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) { + try { + const preview = isStagedMode(config); + const resolved = resolveWorkItemReference(message.work_item_id, resolvedTemporaryIds, preview); + 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; + 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 = { + 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 = {}) { + 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/azure_devops_work_items.test.cjs b/actions/setup/js/azure_devops_work_items.test.cjs new file mode 100644 index 00000000000..b1914129be8 --- /dev/null +++ b/actions/setup/js/azure_devops_work_items.test.cjs @@ -0,0 +1,150 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createAzureDevOpsWorkItemHandler, resolveWorkItemReference } from "./azure_devops_work_items.cjs"; + +global.core = { + debug: vi.fn(), + info: vi.fn(), + warning: vi.fn(), +}; + +describe("azure_devops_work_items", () => { + beforeEach(() => { + vi.clearAllMocks(); + process.env.SYSTEM_ACCESSTOKEN = "test-token"; + process.env.AZURE_DEVOPS_ORG_URL = "https://dev.azure.com/test-org"; + process.env.SYSTEM_TEAMPROJECT = "test-project"; + process.env.GITHUB_RUN_ID = "123"; + process.env.GITHUB_RUN_ATTEMPT = "1"; + global.fetch = vi.fn(); + }); + + afterEach(() => { + delete process.env.SYSTEM_ACCESSTOKEN; + delete process.env.AZURE_DEVOPS_ORG_URL; + delete process.env.SYSTEM_TEAMPROJECT; + delete process.env.GITHUB_RUN_ID; + delete process.env.GITHUB_RUN_ATTEMPT; + delete global.fetch; + }); + + it("creates a work item through the configured organization and project", async () => { + global.fetch.mockResolvedValue({ + ok: true, + status: 200, + statusText: "OK", + text: vi.fn().mockResolvedValue(JSON.stringify({ id: 42, url: "https://dev.azure.com/test-org/_apis/wit/workItems/42" })), + }); + + const result = await createAzureDevOpsWorkItemHandler("ado_create_work_item", { + work_item_type: "Task", + area_path: "test-project\\Platform", + max: 1, + })( + { + temporary_id: "#aw_item", + title: "Fix the build", + description: "Detailed description of the build failure.", + }, + {} + ); + + expect(result).toMatchObject({ + success: true, + temporaryId: "#aw_item", + number: 42, + }); + expect(global.fetch).toHaveBeenCalledOnce(); + expect(global.fetch.mock.calls[0][0]).toBe("https://dev.azure.com/test-org/test-project/_apis/wit/workitems/$Task?api-version=7.0"); + expect(global.fetch.mock.calls[0][1]).toMatchObject({ + method: "POST", + redirect: "manual", + headers: { + Accept: "application/json", + "Content-Type": "application/json-patch+json", + }, + }); + expect(core.debug).toHaveBeenNthCalledWith(1, "Azure DevOps API request started: POST"); + expect(core.debug).toHaveBeenNthCalledWith(2, "Azure DevOps API request completed: POST HTTP 200"); + expect(core.debug.mock.calls.flat().join(" ")).not.toContain("test-token"); + }); + + it("uses standardized staged logging without work-item content", async () => { + await createAzureDevOpsWorkItemHandler("ado_create_work_item", { + staged: true, + work_item_type: "Task", + })( + { + temporary_id: "#aw_item", + title: "Sensitive customer incident", + description: "Detailed sensitive customer incident description.", + }, + {} + ); + + expect(core.info).toHaveBeenCalledWith("🎭 Staged Mode Preview — Would create Azure DevOps Task"); + expect(core.info.mock.calls.flat().join(" ")).not.toContain("Sensitive customer incident"); + }); + + it("does not log staged attachment paths", async () => { + await createAzureDevOpsWorkItemHandler("ado_upload_workitem_attachment", { + staged: true, + })({ work_item_id: 42, file_path: "private/customer-data.pdf" }, {}); + + expect(core.info).toHaveBeenCalledWith("🎭 Staged Mode Preview — Would attach a file to Azure DevOps work item 42"); + expect(core.info.mock.calls.flat().join(" ")).not.toContain("private/customer-data.pdf"); + }); + + it("rejects updates to fields not enabled by configuration", async () => { + const result = await createAzureDevOpsWorkItemHandler("ado_update_work_item", { + target: "*", + title: false, + })({ id: 42, title: "New title" }, {}); + + expect(result).toEqual({ + success: false, + error: "title updates are not enabled by ado_update_work_item", + }); + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it("rejects area paths outside configured prefixes", async () => { + const result = await createAzureDevOpsWorkItemHandler("ado_update_work_item", { + staged: true, + area_path: true, + allowed_area_prefixes: ["test-project\\Platform"], + })({ id: 42, area_path: "test-project\\Other" }, {}); + + expect(result).toEqual({ + success: false, + error: "area_path is not permitted by the configured area-path prefixes", + }); + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it("rejects reserved agent identities", async () => { + const result = await createAzureDevOpsWorkItemHandler("ado_assign_work_item", { + target: "*", + })({ id: 42, assignee: "GitHub Copilot" }, {}); + + expect(result).toEqual({ + success: false, + error: "assignee 'GitHub Copilot' is a reserved identity", + }); + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it("rejects temporary IDs from a different provider", () => { + expect(() => + resolveWorkItemReference( + "#aw_issue", + { + aw_issue: { + repo: "owner/repo", + number: 42, + }, + }, + false + ) + ).toThrow("has not been resolved by ado_create_work_item in this run"); + }); +}); 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..9b1354c325a --- /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("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 new file mode 100644 index 00000000000..81d27790f2f --- /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("ado_create_work_item", config); +} +module.exports = { main }; diff --git a/actions/setup/js/generate_safe_outputs_tools.cjs b/actions/setup/js/generate_safe_outputs_tools.cjs index 71cebaa7c72..ae09ab0a9d6 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..a28ecf2937d 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 namespaced public names", () => { + fs.writeFileSync( + toolsSourcePath, + JSON.stringify([ + ...sampleSourceTools, + { + name: "ado_create_work_item", + description: "Creates an Azure DevOps work item.", + inputSchema: { type: "object", properties: {} }, + }, + ]) + ); + fs.writeFileSync(configPath, JSON.stringify({ ado_create_work_item: { max: 1 } })); + fs.writeFileSync(toolsMetaPath, JSON.stringify({ description_suffixes: {}, repo_params: {}, dynamic_tools: [] })); + + runScript(); + + const result = JSON.parse(fs.readFileSync(outputPath, "utf8")); + expect(result).toHaveLength(1); + expect(result[0].name).toBe("ado_create_work_item"); + }); + it("applies description suffix from tools_meta", () => { fs.writeFileSync(configPath, JSON.stringify({ create_issue: { max: 5 } })); fs.writeFileSync( diff --git a/actions/setup/js/link_work_items.cjs b/actions/setup/js/link_work_items.cjs new file mode 100644 index 00000000000..9c3f4c47c2e --- /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("ado_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..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 */ /** @@ -487,9 +488,14 @@ function loadToolHandlers(server, tools, basePath) { */ function registerTool(server, tool) { const normalizedName = normalizeTool(tool.name); + const existing = server.tools[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: normalizedName, + _rawName: tool.name, }; server.debug(`Registered tool: ${normalizedName}`); } diff --git a/actions/setup/js/safe_output_handler_manager.cjs b/actions/setup/js/safe_output_handler_manager.cjs index cc8e632ce0c..87e50ab6646 100644 --- a/actions/setup/js/safe_output_handler_manager.cjs +++ b/actions/setup/js/safe_output_handler_manager.cjs @@ -91,6 +91,12 @@ const HANDLER_MAP = { report_incomplete: "./report_incomplete_handler.cjs", create_report_incomplete_issue: "./create_report_incomplete_issue.cjs", create_project: "./create_project.cjs", + ado_create_work_item: "./create_work_item.cjs", + ado_update_work_item: "./update_work_item.cjs", + ado_comment_on_work_item: "./comment_on_work_item.cjs", + ado_assign_work_item: "./assign_work_item.cjs", + ado_link_work_items: "./link_work_items.cjs", + ado_upload_workitem_attachment: "./upload_workitem_attachment.cjs", create_project_status_update: "./create_project_status_update.cjs", update_project: "./update_project.cjs", upload_artifact: "./upload_artifact.cjs", @@ -158,6 +164,8 @@ const THREAT_WARNING_REVIEWABLE_TYPES = new Set([ "missing_data", "create_report_incomplete_issue", "report_incomplete", + "ado_create_work_item", + "ado_comment_on_work_item", ]); /** @@ -208,6 +216,10 @@ const THREAT_WARNING_ABORT_TYPES = new Set([ "call_workflow", "autofix_code_scanning_alert", "create_agent_session", + "ado_update_work_item", + "ado_assign_work_item", + "ado_link_work_items", + "ado_upload_workitem_attachment", ]); /** @@ -1110,6 +1122,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. @@ -1300,6 +1317,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 098a521ff5b..81b1bb45308 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 @@ -2124,6 +2124,90 @@ function createHandlers(server, appendSafeOutput, config = {}) { }; }; + const createWorkItemHandler = args => { + const temporaryId = `#${generateTemporaryId()}`; + const entry = { ...(args || {}), type: "ado_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: "ado_upload_workitem_attachment" }; + const rawPath = typeof entry.file_path === "string" ? entry.file_path.trim() : ""; + if (!rawPath || path.isAbsolute(rawPath) || rawPath.includes(":")) { + 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("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("ado_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("ado_upload_workitem_attachment does not accept symbolic links"); + } + } + } catch (error) { + return buildIntentErrorResponse(`ado_upload_workitem_attachment could not read file_path: ${getErrorMessage(error)}`); + } + if (!sourceStat?.isFile()) { + return buildIntentErrorResponse("ado_upload_workitem_attachment file_path must identify one regular file"); + } + + 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(`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("ado_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()}-${segments[segments.length - 1]}`; + 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 }); + } + + entry.file_path = rawPath; + 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. @@ -3129,6 +3213,12 @@ function createHandlers(server, appendSafeOutput, config = {}) { pushToPullRequestBranchHandler, pushRepoMemoryHandler, createIssueHandler, + createWorkItemHandler, + 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, jiraCreateIssueHandler, jiraUpdateIssueHandler, jiraAddCommentHandler, diff --git a/actions/setup/js/safe_outputs_handlers.test.cjs b/actions/setup/js/safe_outputs_handlers.test.cjs index 42fc3adcc16..86be9ca5fcd 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 namespaced message types", () => { + handlers.createWorkItemHandler({ temporary_id: "item", title: "Create item" }); + handlers.updateWorkItemHandler({ id: "#item", title: "Update item" }); + 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"]); + 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.json b/actions/setup/js/safe_outputs_tools.json index ae38dee2ed4..ed023c7e1e9 100644 --- a/actions/setup/js/safe_outputs_tools.json +++ b/actions/setup/js/safe_outputs_tools.json @@ -2269,5 +2269,148 @@ "anyOf": ["pull_request_number", "pr_number", "pr", "pull_number"] } } + }, + { + "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", + "required": ["title", "description"], + "properties": { + "title": { + "type": "string", + "minLength": 6, + "maxLength": 255, + "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, including context, repro steps, and acceptance criteria." + }, + "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": "ado_update_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"], + "properties": { + "id": { + "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, "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 }, + "description": "New tags to apply. Tags cannot contain semicolons and may be restricted by allowed-tags." + } + }, + "additionalProperties": false + } + }, + { + "name": "ado_comment_on_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"], + "properties": { + "work_item_id": { + "type": ["number", "string"], + "description": "Positive work-item ID or a temporary #aw_ ID returned by ado_create_work_item." + }, + "body": { + "type": "string", + "minLength": 10, + "maxLength": 65000, + "description": "Comment text in Markdown describing the update, decision, or question for the work item." + } + }, + "additionalProperties": false + } + }, + { + "name": "ado_assign_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"], + "properties": { + "work_item_id": { + "type": ["number", "string"], + "description": "Positive work-item ID or a temporary #aw_ ID returned by ado_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": "ado_link_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"], + "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"], + "description": "Relationship type to create between the two work items, such as parent, child, or related." + }, + "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, for example to include a screenshot or log file as evidence.", + "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 ado_create_work_item." + }, + "file_path": { + "type": "string", + "minLength": 1, + "maxLength": 1024, + "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, "description": "Optional Markdown comment describing the attached file." } + }, + "additionalProperties": false + } } ] diff --git a/actions/setup/js/safe_outputs_tools_loader.cjs b/actions/setup/js/safe_outputs_tools_loader.cjs index 828e04d4893..dce6458a8f3 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 @@ -195,10 +197,16 @@ function attachHandlers(tools, handlers, logger) { remove_labels: handlers.removeLabelsHandler, update_discussion: handlers.updateDiscussionHandler, close_discussion: handlers.closeDiscussionHandler, + 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 => { - const handler = handlerMap[tool.name]; + const handler = handlerMap[normalizeConfiguredToolName(tool.name)]; if (handler) { tool.handler = handler; } else if (typeof handlers.defaultHandler === "function") { @@ -277,10 +285,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 @@ -391,7 +400,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/safe_outputs_tools_loader.test.cjs b/actions/setup/js/safe_outputs_tools_loader.test.cjs index cb658ebd49f..aca9aa7f9dd 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 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(), + 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..50649d7eb74 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: "ado_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/actions/setup/js/update_work_item.cjs b/actions/setup/js/update_work_item.cjs new file mode 100644 index 00000000000..8b6caaa4aed --- /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("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 new file mode 100644 index 00000000000..aee035502b3 --- /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("ado_upload_workitem_attachment", config); +} +module.exports = { main }; 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.* diff --git a/docs/src/content/docs/reference/safe-outputs.md b/docs/src/content/docs/reference/safe-outputs.md index 9c6553f8537..6fd96a9d63a 100644 --- a/docs/src/content/docs/reference/safe-outputs.md +++ b/docs/src/content/docs/reference/safe-outputs.md @@ -86,6 +86,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) | `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 | Output | Key | Description | @@ -1195,6 +1206,48 @@ 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: + +> 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: + AZURE_DEVOPS_ORG_URL: ${{ vars.AZURE_DEVOPS_ORG_URL }} + SYSTEM_TEAMPROJECT: ${{ vars.AZURE_DEVOPS_PROJECT }} + AZURE_DEVOPS_EXT_PAT: ${{ secrets.AZURE_DEVOPS_EXT_PAT }} + ado-create-work-item: + work-item-type: Task + area-path: MyProject\Platform + allowed-tags: [agent-*] + ado-update-work-item: + target: MyProject\Platform + title: true + status: true + ado-comment-on-work-item: + target: MyProject\Platform + ado-assign-work-item: + target: "*" + allowed: [owner@example.com] + ado-link-work-items: + 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`, 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. + ### 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 aca11c5002f..3e1168606a2 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -8797,6 +8797,24 @@ ], "description": "Enable AI agents to create autofixes for code scanning alerts using the GitHub REST API." }, + "ado-create-work-item": { + "$ref": "#/$defs/azure_devops_create_work_item" + }, + "ado-update-work-item": { + "$ref": "#/$defs/azure_devops_update_work_item" + }, + "ado-comment-on-work-item": { + "$ref": "#/$defs/azure_devops_comment_on_work_item" + }, + "ado-assign-work-item": { + "$ref": "#/$defs/azure_devops_assign_work_item" + }, + "ado-link-work-items": { + "$ref": "#/$defs/azure_devops_link_work_items" + }, + "ado-upload-workitem-attachment": { + "$ref": "#/$defs/azure_devops_upload_workitem_attachment" + }, "create-check-run": { "oneOf": [ { @@ -13416,6 +13434,190 @@ } ], "$defs": { + "azure_devops_create_work_item": { + "description": "Experimental. Create Azure DevOps work items through a trusted safe-output handler. Using this field emits a compile-time warning.", + "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" } + } + }, + "max": { "$ref": "#/$defs/templatable_positive_integer" }, + "staged": { "$ref": "#/$defs/templatable_boolean" }, + "samples": { "$ref": "#/$defs/azure_devops_samples" } + } + }, + { "type": "null" } + ] + }, + "azure_devops_update_work_item": { + "description": "Experimental. Update explicitly scoped Azure DevOps work items and explicitly enabled fields. Using this field emits a compile-time warning.", + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": ["target"], + "properties": { + "target": { "$ref": "#/$defs/azure_devops_work_item_target" }, + "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" }, + "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" } + } + }, + { "type": "null" } + ] + }, + "azure_devops_comment_on_work_item": { + "description": "Experimental. Comment on explicitly scoped Azure DevOps work items. Using this field emits a compile-time warning.", + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": ["target"], + "properties": { + "target": { "$ref": "#/$defs/azure_devops_work_item_target" }, + "max": { "$ref": "#/$defs/templatable_positive_integer" }, + "staged": { "$ref": "#/$defs/templatable_boolean" }, + "samples": { "$ref": "#/$defs/azure_devops_samples" } + } + }, + { "type": "null" } + ] + }, + "azure_devops_assign_work_item": { + "description": "Experimental. Assign an Azure DevOps identity to a work item. Using this field emits a compile-time warning.", + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "properties": { + "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" }, + "staged": { "$ref": "#/$defs/templatable_boolean" }, + "samples": { "$ref": "#/$defs/azure_devops_samples" } + } + }, + { "type": "null" } + ] + }, + "azure_devops_link_work_items": { + "description": "Experimental. Link explicitly scoped Azure DevOps work items. Using this field emits a compile-time warning.", + "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" }, + "samples": { "$ref": "#/$defs/azure_devops_samples" } + } + }, + { "type": "null" } + ] + }, + "azure_devops_upload_workitem_attachment": { + "description": "Experimental. Upload a staged workspace file to an Azure DevOps work item. Using this field emits a compile-time warning.", + "oneOf": [ + { + "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", + "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" }, + "samples": { "$ref": "#/$defs/azure_devops_samples" } + } + }, + { "type": "null" } + ] + }, + "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, "not": { "const": "*" } } + ] + }, + "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 } + }, + "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/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/azure_devops_experimental_warning_test.go b/pkg/workflow/azure_devops_experimental_warning_test.go new file mode 100644 index 00000000000..cfaf279265e --- /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 + }{ + {"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 { + 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_safe_outputs_job.go b/pkg/workflow/compiler_safe_outputs_job.go index 501f2f4990d..85de5aa982e 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.LinearCreateIssue != nil || data.SafeOutputs.LinearAddComment != nil || data.SafeOutputs.LinearUpdateIssue != nil || @@ -383,7 +389,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, @@ -683,7 +689,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/compiler_validators.go b/pkg/workflow/compiler_validators.go index 42e7df1de4c..6f4a4d810f9 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: 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: hasLinearSafeOutputs(workflowData.SafeOutputs), message: "Using experimental feature: Linear safe outputs"}, {enabled: detectionConfigured && isFeatureEnabled(constants.GHAWDetectionFeatureFlag, workflowData), message: "Using experimental feature: gh-aw-detection"}, {enabled: len(workflowData.LSP) > 0, message: "Using experimental feature: lsp"}, diff --git a/pkg/workflow/js/safe_outputs_tools.json b/pkg/workflow/js/safe_outputs_tools.json index ae38dee2ed4..ed023c7e1e9 100644 --- a/pkg/workflow/js/safe_outputs_tools.json +++ b/pkg/workflow/js/safe_outputs_tools.json @@ -2269,5 +2269,148 @@ "anyOf": ["pull_request_number", "pr_number", "pr", "pull_number"] } } + }, + { + "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", + "required": ["title", "description"], + "properties": { + "title": { + "type": "string", + "minLength": 6, + "maxLength": 255, + "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, including context, repro steps, and acceptance criteria." + }, + "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": "ado_update_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"], + "properties": { + "id": { + "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, "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 }, + "description": "New tags to apply. Tags cannot contain semicolons and may be restricted by allowed-tags." + } + }, + "additionalProperties": false + } + }, + { + "name": "ado_comment_on_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"], + "properties": { + "work_item_id": { + "type": ["number", "string"], + "description": "Positive work-item ID or a temporary #aw_ ID returned by ado_create_work_item." + }, + "body": { + "type": "string", + "minLength": 10, + "maxLength": 65000, + "description": "Comment text in Markdown describing the update, decision, or question for the work item." + } + }, + "additionalProperties": false + } + }, + { + "name": "ado_assign_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"], + "properties": { + "work_item_id": { + "type": ["number", "string"], + "description": "Positive work-item ID or a temporary #aw_ ID returned by ado_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": "ado_link_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"], + "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"], + "description": "Relationship type to create between the two work items, such as parent, child, or related." + }, + "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, for example to include a screenshot or log file as evidence.", + "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 ado_create_work_item." + }, + "file_path": { + "type": "string", + "minLength": 1, + "maxLength": 1024, + "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, "description": "Optional Markdown comment describing the attached file." } + }, + "additionalProperties": false + } } ] 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/publish_artifacts.go b/pkg/workflow/publish_artifacts.go index d564fd8c34b..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 @@ -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 900b122e853..ea373b86708 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: "ado-create-work-item", + StructField: "CreateWorkItems", + ToolName: "ado_create_work_item", + NewConfig: func() any { return &CreateWorkItemConfig{} }, + }, + { + Key: "ado-update-work-item", + StructField: "UpdateWorkItems", + ToolName: "ado_update_work_item", + NewConfig: func() any { return &UpdateWorkItemConfig{} }, + }, + { + Key: "ado-comment-on-work-item", + StructField: "CommentOnWorkItems", + ToolName: "ado_comment_on_work_item", + NewConfig: func() any { return &CommentOnWorkItemConfig{} }, + }, + { + Key: "ado-assign-work-item", + StructField: "AssignWorkItems", + ToolName: "ado_assign_work_item", + NewConfig: func() any { return &AssignWorkItemConfig{} }, + }, + { + Key: "ado-link-work-items", + StructField: "LinkWorkItems", + ToolName: "ado_link_work_items", + NewConfig: func() any { return &LinkWorkItemsConfig{} }, + }, + { + Key: "ado-upload-workitem-attachment", + StructField: "UploadWorkItemAttachments", + ToolName: "ado_upload_workitem_attachment", + NewConfig: func() any { return &UploadWorkItemAttachmentConfig{} }, + }, { Key: "linear-create-issue", StructField: "LinearCreateIssue", diff --git a/pkg/workflow/safe_output_validation_config_test.go b/pkg/workflow/safe_output_validation_config_test.go index 01baba99532..3d835c65a16 100644 --- a/pkg/workflow/safe_output_validation_config_test.go +++ b/pkg/workflow/safe_output_validation_config_test.go @@ -482,17 +482,18 @@ 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:summary,description": 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:summary,description": 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 { @@ -527,7 +528,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 new file mode 100644 index 00000000000..af8f744b2c7 --- /dev/null +++ b/pkg/workflow/safe_outputs_azure_devops.go @@ -0,0 +1,329 @@ +package workflow + +import ( + "fmt" + + "github.com/github/gh-aw/pkg/logger" +) + +var azureDevOpsSafeOutputsLog = logger.New("workflow:safe_outputs_azure_devops") + +type AzureDevOpsArtifactLinkConfig struct { + 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 { + 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"` +} + +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"` + 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"` +} + +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"` + 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 { + 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, "ado-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, "ado-update-work-item", 1, nil) +} + +func (c *Compiler) parseCommentOnWorkItemConfig(outputMap map[string]any) *CommentOnWorkItemConfig { + 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, "ado-assign-work-item", 1, nil) +} + +func (c *Compiler) parseLinkWorkItemsConfig(outputMap map[string]any) *LinkWorkItemsConfig { + return parseAzureDevOpsConfig[LinkWorkItemsConfig](c, outputMap, "ado-link-work-items", 5, nil) +} + +func (c *Compiler) parseUploadWorkItemAttachmentConfig(outputMap map[string]any) *UploadWorkItemAttachmentConfig { + return parseAzureDevOpsConfig(c, outputMap, "ado-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{ + "ado_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() + }, + "ado_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). + AddStringSlice("allowed_area_prefixes", c.AllowedAreaPrefixes). + AddStringSlice("allowed_iteration_prefixes", c.AllowedIterationPrefixes). + AddTemplatableBool("staged", templatableBoolPtrToStringPtr(c.Staged)) + return addAzureDevOpsTarget(builder, c.Target).Build() + }, + "ado_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() + }, + "ado_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() + }, + "ado_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() + }, + "ado_upload_workitem_attachment": func(cfg *SafeOutputsConfig) map[string]any { + if cfg.UploadWorkItemAttachments == nil { + return nil + } + c := cfg.UploadWorkItemAttachments + 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)) + return addAzureDevOpsTarget(builder, c.Target).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.") + appendAzureDevOpsTargetConstraint(constraints, config.Target) + 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..a50a953a8a1 --- /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{ + "ado-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"}, + }, + }, + "ado-update-work-item": map[string]any{ + "target": "42", + "title": 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{}, + }, + }) + + 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{ + "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) + } +} + +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["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["ado_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["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".`) + 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 483894501fe..3f86fdd2242 100644 --- a/pkg/workflow/safe_outputs_config_extraction.go +++ b/pkg/workflow/safe_outputs_config_extraction.go @@ -54,6 +54,12 @@ 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) config.LinearCreateIssue = c.parseLinearCreateIssueConfig(outputMap) config.LinearAddComment = c.parseLinearAddCommentConfig(outputMap) config.LinearUpdateIssue = c.parseLinearUpdateIssueConfig(outputMap) diff --git a/pkg/workflow/safe_outputs_config_types.go b/pkg/workflow/safe_outputs_config_types.go index 7c87cf25a04..5bd8aed0474 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:"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"` LinearCreateIssue *LinearCreateIssueConfig `yaml:"linear-create-issue,omitempty"` LinearAddComment *LinearTargetConfig `yaml:"linear-add-comment,omitempty"` LinearUpdateIssue *LinearUpdateIssueConfig `yaml:"linear-update-issue,omitempty"` diff --git a/pkg/workflow/safe_outputs_handler_registry.go b/pkg/workflow/safe_outputs_handler_registry.go index bd592a9276e..3a2048b1b15 100644 --- a/pkg/workflow/safe_outputs_handler_registry.go +++ b/pkg/workflow/safe_outputs_handler_registry.go @@ -66,6 +66,7 @@ var handlerRegistry = mergeHandlerMaps( linearHandlerRegistry, releaseHandlerRegistry, diagnosticHandlerRegistry, + azureDevOpsWorkItemHandlerRegistry, ) func mergeHandlerMaps(registries ...map[string]handlerBuilder) map[string]handlerBuilder { diff --git a/pkg/workflow/safe_outputs_handler_registry_test.go b/pkg/workflow/safe_outputs_handler_registry_test.go index 5766e361050..7b5454293d5 100644 --- a/pkg/workflow/safe_outputs_handler_registry_test.go +++ b/pkg/workflow/safe_outputs_handler_registry_test.go @@ -22,6 +22,7 @@ func TestHandlerRegistryDomainComposition(t *testing.T) { {name: "jiraHandlerRegistry", registry: jiraHandlerRegistry, wantKeys: []string{"jira_create_issue", "jira_update_issue", "jira_add_comment", "jira_add_label"}}, {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{"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"}}, {name: "linearHandlerRegistry", registry: linearHandlerRegistry, wantKeys: []string{"linear_create_issue", "linear_add_comment", "linear_update_issue"}}, } @@ -100,6 +101,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: "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: "jira_create_issue", cfg: &SafeOutputsConfig{JiraCreateIssue: &JiraSafeOutputConfig{}}}, {name: "jira_update_issue", cfg: &SafeOutputsConfig{JiraUpdateIssue: &JiraSafeOutputConfig{}}}, diff --git a/pkg/workflow/safe_outputs_max_validation.go b/pkg/workflow/safe_outputs_max_validation.go index 8f6cbb72313..915cf9c817a 100644 --- a/pkg/workflow/safe_outputs_max_validation.go +++ b/pkg/workflow/safe_outputs_max_validation.go @@ -68,6 +68,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("ado_assign_work_item", config.AssignWorkItems.Max); err != nil { + return err + } + } + if config.CommentOnWorkItems != nil { + if err := checkMaxField("ado_comment_on_work_item", config.CommentOnWorkItems.Max); err != nil { + return err + } + } + if config.CreateWorkItems != nil { + if err := checkMaxField("ado_create_work_item", config.CreateWorkItems.Max); err != nil { + return err + } + } + if config.LinkWorkItems != nil { + if err := checkMaxField("ado_link_work_items", config.LinkWorkItems.Max); err != nil { + return err + } + } + if config.UpdateWorkItems != nil { + if err := checkMaxField("ado_update_work_item", config.UpdateWorkItems.Max); err != nil { + return err + } + } + if config.UploadWorkItemAttachments != nil { + if err := checkMaxField("ado_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 340622d50f9..30912dac76b 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 } @@ -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 || hasAnyJiraSafeOutputEnabled(safeOutputs) || safeOutputs.CreateAgentSessions != nil || safeOutputs.CreateDiscussions != nil || @@ -97,7 +103,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 } @@ -109,6 +115,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 || hasAnyJiraSafeOutputEnabled(safeOutputs) || safeOutputs.CreateAgentSessions != nil || safeOutputs.CreateDiscussions != nil || diff --git a/pkg/workflow/safe_outputs_tools_computation.go b/pkg/workflow/safe_outputs_tools_computation.go index e0389cde88b..5febc63e58a 100644 --- a/pkg/workflow/safe_outputs_tools_computation.go +++ b/pkg/workflow/safe_outputs_tools_computation.go @@ -22,6 +22,30 @@ func computeEnabledToolNames(data *WorkflowData) map[string]struct { enabledTools["create_issue"] = struct { }{} } + if data.SafeOutputs.CreateWorkItems != nil { + enabledTools["ado_create_work_item"] = struct { + }{} + } + if data.SafeOutputs.UpdateWorkItems != nil { + enabledTools["ado_update_work_item"] = struct { + }{} + } + if data.SafeOutputs.CommentOnWorkItems != nil { + enabledTools["ado_comment_on_work_item"] = struct { + }{} + } + if data.SafeOutputs.AssignWorkItems != nil { + enabledTools["ado_assign_work_item"] = struct { + }{} + } + if data.SafeOutputs.LinkWorkItems != nil { + enabledTools["ado_link_work_items"] = struct { + }{} + } + if data.SafeOutputs.UploadWorkItemAttachments != nil { + enabledTools["ado_upload_workitem_attachment"] = struct { + }{} + } if data.SafeOutputs.LinearCreateIssue != nil { enabledTools["linear_create_issue"] = struct{}{} } diff --git a/pkg/workflow/safe_outputs_validation_config.go b/pkg/workflow/safe_outputs_validation_config.go index 65076ce9285..461ba325db9 100644 --- a/pkg/workflow/safe_outputs_validation_config.go +++ b/pkg/workflow/safe_outputs_validation_config.go @@ -65,6 +65,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{ + "ado_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}, + }, + }, + "ado_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}, + }, + }, + "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}, + }, + }, + "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}, + }, + }, + "ado_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}, + }, + }, + "ado_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}, + }, + }, "linear_create_issue": { DefaultMax: 1, Fields: map[string]FieldValidation{ diff --git a/pkg/workflow/tool_description_enhancer.go b/pkg/workflow/tool_description_enhancer.go index 09740d49167..e927d8a4de9 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{ + "ado_create_work_item": func(safeOutputs *SafeOutputsConfig) []string { + return createWorkItemConstraints(safeOutputs.CreateWorkItems) + }, + "ado_update_work_item": func(safeOutputs *SafeOutputsConfig) []string { + return updateWorkItemConstraints(safeOutputs.UpdateWorkItems) + }, + "ado_comment_on_work_item": func(safeOutputs *SafeOutputsConfig) []string { + return commentOnWorkItemConstraints(safeOutputs.CommentOnWorkItems) + }, + "ado_assign_work_item": func(safeOutputs *SafeOutputsConfig) []string { + return assignWorkItemConstraints(safeOutputs.AssignWorkItems) + }, + "ado_link_work_items": func(safeOutputs *SafeOutputsConfig) []string { + return linkWorkItemsConstraints(safeOutputs.LinkWorkItems) + }, + "ado_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..791dcfd104b 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": "ado_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": "ado_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": "ado_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": "ado_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": "ado_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": "ado_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 } } }