From 13e0f2e435705a290fffd3724491bdd9711b2482 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 2 Sep 2026 01:43:15 +0000 Subject: [PATCH 1/8] Add Jira safe output implementation Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/jira_add_comment.cjs | 5 + actions/setup/js/jira_add_label.cjs | 5 + actions/setup/js/jira_client.cjs | 133 ++++++++++++++ actions/setup/js/jira_client.test.cjs | 89 ++++++++++ actions/setup/js/jira_create_issue.cjs | 5 + actions/setup/js/jira_handlers.cjs | 137 +++++++++++++++ actions/setup/js/jira_handlers.test.cjs | 159 +++++++++++++++++ actions/setup/js/jira_update_issue.cjs | 5 + .../setup/js/safe_output_handler_manager.cjs | 8 + actions/setup/js/safe_outputs_handlers.cjs | 16 ++ actions/setup/js/safe_outputs_tools.json | 110 ++++++++++++ .../setup/js/safe_outputs_tools_loader.cjs | 4 + pkg/parser/jira_safe_outputs_schema_test.go | 31 ++++ pkg/parser/schemas/main_workflow_schema.json | 164 ++++++++++++++++++ pkg/workflow/jira.go | 37 ++++ pkg/workflow/jira_test.go | 63 +++++++ pkg/workflow/js/safe_outputs_tools.json | 110 ++++++++++++ pkg/workflow/safe_output_handlers.go | 24 +++ .../safe_outputs_config_extraction.go | 4 + pkg/workflow/safe_outputs_config_types.go | 4 + pkg/workflow/safe_outputs_handler_registry.go | 1 + .../safe_outputs_handler_registry_jira.go | 27 +++ .../safe_outputs_handler_registry_test.go | 5 + pkg/workflow/safe_outputs_state.go | 2 + 24 files changed, 1148 insertions(+) create mode 100644 actions/setup/js/jira_add_comment.cjs create mode 100644 actions/setup/js/jira_add_label.cjs create mode 100644 actions/setup/js/jira_client.cjs create mode 100644 actions/setup/js/jira_client.test.cjs create mode 100644 actions/setup/js/jira_create_issue.cjs create mode 100644 actions/setup/js/jira_handlers.cjs create mode 100644 actions/setup/js/jira_handlers.test.cjs create mode 100644 actions/setup/js/jira_update_issue.cjs create mode 100644 pkg/parser/jira_safe_outputs_schema_test.go create mode 100644 pkg/workflow/jira.go create mode 100644 pkg/workflow/jira_test.go create mode 100644 pkg/workflow/safe_outputs_handler_registry_jira.go diff --git a/actions/setup/js/jira_add_comment.cjs b/actions/setup/js/jira_add_comment.cjs new file mode 100644 index 00000000000..7bdd5de591d --- /dev/null +++ b/actions/setup/js/jira_add_comment.cjs @@ -0,0 +1,5 @@ +// @ts-check + +const { addComment } = require("./jira_handlers.cjs"); + +module.exports = { main: addComment }; diff --git a/actions/setup/js/jira_add_label.cjs b/actions/setup/js/jira_add_label.cjs new file mode 100644 index 00000000000..bce6445b49c --- /dev/null +++ b/actions/setup/js/jira_add_label.cjs @@ -0,0 +1,5 @@ +// @ts-check + +const { addLabel } = require("./jira_handlers.cjs"); + +module.exports = { main: addLabel }; diff --git a/actions/setup/js/jira_client.cjs b/actions/setup/js/jira_client.cjs new file mode 100644 index 00000000000..4d67edad194 --- /dev/null +++ b/actions/setup/js/jira_client.cjs @@ -0,0 +1,133 @@ +// @ts-check + +const { sanitizeContent } = require("./sanitize_content.cjs"); + +const JIRA_API_PATH = "/rest/api/3"; + +function normalizeJiraBaseUrl(value) { + const raw = typeof value === "string" ? value.trim() : ""; + if (!raw) { + throw new Error("Jira configuration is missing JIRA_BASE_URL"); + } + + let url; + try { + url = new URL(raw); + } catch { + throw new Error("JIRA_BASE_URL must be a valid URL"); + } + + const isLocal = url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "::1"; + if (url.protocol !== "https:" && !(isLocal && url.protocol === "http:")) { + throw new Error("JIRA_BASE_URL must use HTTPS"); + } + if (url.search || url.hash) { + throw new Error("JIRA_BASE_URL must not include a query string or fragment"); + } + + url.pathname = url.pathname.replace(/\/+$/, "").replace(/\/rest\/api\/3$/i, ""); + return url.toString().replace(/\/+$/, ""); +} + +function textToADF(value) { + const text = String(value).replace(/\r\n?/g, "\n"); + return { + type: "doc", + version: 1, + content: text.split("\n").map(line => ({ + type: "paragraph", + content: line ? [{ type: "text", text: line }] : [], + })), + }; +} + +function redactJiraSecrets(value, secrets) { + let result = String(value); + for (const secret of secrets) { + if (secret) { + result = result.split(secret).join("***"); + } + } + return result; +} + +function formatJiraError(status, statusText, responseBody, secrets) { + const details = []; + if (responseBody && typeof responseBody === "object") { + if (Array.isArray(responseBody.errorMessages)) { + details.push(...responseBody.errorMessages.filter(message => typeof message === "string")); + } + if (responseBody.errors && typeof responseBody.errors === "object" && !Array.isArray(responseBody.errors)) { + for (const [field, message] of Object.entries(responseBody.errors)) { + if (typeof message === "string") { + details.push(`${field}: ${message}`); + } + } + } + } + + const detail = details.length > 0 ? `: ${details.join("; ")}` : ""; + const safe = sanitizeContent(redactJiraSecrets(`Jira API request failed (${status} ${statusText || "Error"})${detail}`, secrets), 2000); + return safe || `Jira API request failed (${status})`; +} + +function createJiraClient(env = process.env, fetchImpl = global.fetch) { + const baseUrl = normalizeJiraBaseUrl(env.JIRA_BASE_URL); + const email = typeof env.JIRA_USER_EMAIL === "string" ? env.JIRA_USER_EMAIL.trim() : ""; + const token = typeof env.JIRA_API_TOKEN === "string" ? env.JIRA_API_TOKEN : ""; + if (!email || !token) { + throw new Error("Jira configuration requires JIRA_USER_EMAIL and JIRA_API_TOKEN"); + } + if (typeof fetchImpl !== "function") { + throw new Error("Jira requests require the fetch API"); + } + + const authorization = `Basic ${Buffer.from(`${email}:${token}`, "utf8").toString("base64")}`; + const secrets = [token, email, authorization]; + + return { + async request(path, options = {}) { + const normalizedPath = path.startsWith("/") ? path : `/${path}`; + const url = `${baseUrl}${JIRA_API_PATH}${normalizedPath}`; + let response; + try { + response = await fetchImpl(url, { + method: options.method || "GET", + headers: { + Accept: "application/json", + Authorization: authorization, + "Content-Type": "application/json", + }, + ...(options.body === undefined ? {} : { body: JSON.stringify(options.body) }), + }); + } catch { + throw new Error("Jira API request failed due to a network error"); + } + + const responseText = await response.text(); + let responseBody = null; + if (responseText) { + try { + responseBody = JSON.parse(responseText); + } catch { + if (response.ok) { + throw new Error(`Jira API returned an invalid JSON response (${response.status})`); + } + } + } + + if (!response.ok) { + throw new Error(formatJiraError(response.status, response.statusText, responseBody, secrets)); + } + return responseBody; + }, + }; +} + +module.exports = { + JIRA_API_PATH, + createJiraClient, + formatJiraError, + normalizeJiraBaseUrl, + textToADF, +}; diff --git a/actions/setup/js/jira_client.test.cjs b/actions/setup/js/jira_client.test.cjs new file mode 100644 index 00000000000..58a56640935 --- /dev/null +++ b/actions/setup/js/jira_client.test.cjs @@ -0,0 +1,89 @@ +// @ts-check +import { afterEach, describe, expect, it, vi } from "vitest"; + +const { createJiraClient, formatJiraError, normalizeJiraBaseUrl, textToADF } = require("./jira_client.cjs"); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("jira client", () => { + it("normalizes site and Atlassian gateway base URLs", () => { + expect(normalizeJiraBaseUrl("https://example.atlassian.net/")).toBe("https://example.atlassian.net"); + expect(normalizeJiraBaseUrl("https://example.atlassian.net/rest/api/3")).toBe("https://example.atlassian.net"); + expect(normalizeJiraBaseUrl("https://api.atlassian.com/ex/jira/cloud-id/")).toBe("https://api.atlassian.com/ex/jira/cloud-id"); + }); + + it("rejects unsafe base URLs", () => { + expect(() => normalizeJiraBaseUrl("http://example.atlassian.net")).toThrow("must use HTTPS"); + expect(() => normalizeJiraBaseUrl("https://example.atlassian.net?token=value")).toThrow("query string"); + }); + + it("converts plain text and newlines to ADF version 1", () => { + expect(textToADF("first\n\nsecond")).toEqual({ + type: "doc", + version: 1, + content: [ + { type: "paragraph", content: [{ type: "text", text: "first" }] }, + { type: "paragraph", content: [] }, + { type: "paragraph", content: [{ type: "text", text: "second" }] }, + ], + }); + }); + + it("sends credentials only in the HTTP Authorization header and accepts 204", async () => { + const fetchMock = vi.fn(async () => ({ + ok: true, + status: 204, + statusText: "No Content", + text: async () => "", + })); + const client = createJiraClient( + { + JIRA_BASE_URL: "https://example.atlassian.net", + JIRA_USER_EMAIL: "jira@example.com", + JIRA_API_TOKEN: "secret-token", + }, + fetchMock + ); + + await expect(client.request("/issue/ENG-1", { method: "PUT", body: { fields: { summary: "Updated" } } })).resolves.toBeNull(); + const [url, options] = fetchMock.mock.calls[0]; + expect(url).toBe("https://example.atlassian.net/rest/api/3/issue/ENG-1"); + expect(options.headers.Authorization).toBe(`Basic ${Buffer.from("jira@example.com:secret-token").toString("base64")}`); + expect(options.body).not.toContain("secret-token"); + }); + + it("surfaces structured Jira errors without leaking credentials", async () => { + const fetchMock = vi.fn(async () => ({ + ok: false, + status: 400, + statusText: "Bad Request", + text: async () => + JSON.stringify({ + errorMessages: ["Cannot create issue for jira@example.com"], + errors: { summary: "secret-token is invalid" }, + }), + })); + const client = createJiraClient( + { + JIRA_BASE_URL: "https://example.atlassian.net", + JIRA_USER_EMAIL: "jira@example.com", + JIRA_API_TOKEN: "secret-token", + }, + fetchMock + ); + + await expect(client.request("/issue", { method: "POST", body: {} })).rejects.toThrow("summary: *** is invalid"); + await expect(client.request("/issue", { method: "POST", body: {} })).rejects.not.toThrow(/secret-token|jira@example\.com/); + }); + + it("reports missing configuration without values", () => { + expect(() => createJiraClient({})).toThrow("JIRA_BASE_URL"); + expect(() => createJiraClient({ JIRA_BASE_URL: "https://example.atlassian.net" })).toThrow("JIRA_USER_EMAIL and JIRA_API_TOKEN"); + }); + + it("formats field and global errors", () => { + expect(formatJiraError(400, "Bad Request", { errorMessages: ["Invalid request"], errors: { project: "Unknown project" } }, [])).toContain("Invalid request; project: Unknown project"); + }); +}); diff --git a/actions/setup/js/jira_create_issue.cjs b/actions/setup/js/jira_create_issue.cjs new file mode 100644 index 00000000000..da515db92bd --- /dev/null +++ b/actions/setup/js/jira_create_issue.cjs @@ -0,0 +1,5 @@ +// @ts-check + +const { createIssue } = require("./jira_handlers.cjs"); + +module.exports = { main: createIssue }; diff --git a/actions/setup/js/jira_handlers.cjs b/actions/setup/js/jira_handlers.cjs new file mode 100644 index 00000000000..0e6669e39b4 --- /dev/null +++ b/actions/setup/js/jira_handlers.cjs @@ -0,0 +1,137 @@ +// @ts-check + +const { createCountGatedHandler } = require("./handler_scaffold.cjs"); +const { sanitizeContent } = require("./sanitize_content.cjs"); +const { logStagedPreviewInfo } = require("./staged_preview.cjs"); +const { createJiraClient, textToADF } = require("./jira_client.cjs"); + +function requiredString(value, field, maxLength = 255) { + if (typeof value !== "string" || value.trim() === "") { + throw new Error(`${field} must be a non-empty string`); + } + const sanitized = sanitizeContent(value.trim(), maxLength); + if (!sanitized) { + throw new Error(`${field} must contain valid text`); + } + return sanitized; +} + +function optionalString(value, field, maxLength = 32767) { + if (value === undefined) { + return undefined; + } + return requiredString(value, field, maxLength); +} + +function jiraHandler(handlerType, handle) { + return createCountGatedHandler({ + handlerType, + setup: async (_config, _maxCount, isStaged) => { + const client = isStaged ? null : createJiraClient(); + return async message => { + try { + return await handle(message || {}, client, isStaged); + } catch (error) { + const message = error instanceof Error ? error.message : "Jira operation failed"; + core.error(message); + return { success: false, error: message }; + } + }; + }, + }); +} + +const createIssue = jiraHandler("jira_create_issue", async (message, client, isStaged) => { + const projectKey = requiredString(message.project_key, "project_key"); + const issueType = requiredString(message.issue_type, "issue_type"); + const summary = requiredString(message.summary, "summary"); + const description = optionalString(message.description, "description"); + + if (isStaged) { + logStagedPreviewInfo(`Jira create issue — Project: ${projectKey}; Type: ${issueType}; Summary: ${summary}${description ? `; Description: ${description}` : ""}`); + return { success: true, staged: true, project_key: projectKey, issue_type: issueType, summary }; + } + + const fields = { + project: { key: projectKey }, + issuetype: { name: issueType }, + summary, + ...(description === undefined ? {} : { description: textToADF(description) }), + }; + const result = await client.request("/issue", { method: "POST", body: { fields } }); + if (!result || typeof result.key !== "string") { + throw new Error("Jira create issue returned an incomplete response"); + } + return { + success: true, + issue_key: result.key, + ...(result.id ? { issue_id: String(result.id) } : {}), + ...(result.self ? { url: String(result.self) } : {}), + metadata: { issue_key: result.key, ...(result.id ? { issue_id: String(result.id) } : {}) }, + }; +}); + +const updateIssue = jiraHandler("jira_update_issue", async (message, client, isStaged) => { + const issueKey = requiredString(message.issue_key, "issue_key"); + const summary = optionalString(message.summary, "summary", 255); + const description = optionalString(message.description, "description"); + if (summary === undefined && description === undefined) { + throw new Error("jira_update_issue requires summary or description"); + } + + if (isStaged) { + logStagedPreviewInfo(`Jira update issue — Issue: ${issueKey}${summary ? `; Summary: ${summary}` : ""}${description ? `; Description: ${description}` : ""}`); + return { success: true, staged: true, issue_key: issueKey }; + } + + await client.request(`/issue/${encodeURIComponent(issueKey)}`, { + method: "PUT", + body: { + fields: { + ...(summary === undefined ? {} : { summary }), + ...(description === undefined ? {} : { description: textToADF(description) }), + }, + }, + }); + return { success: true, issue_key: issueKey, metadata: { issue_key: issueKey } }; +}); + +const addComment = jiraHandler("jira_add_comment", async (message, client, isStaged) => { + const issueKey = requiredString(message.issue_key, "issue_key"); + const body = requiredString(message.body, "body", 32767); + + if (isStaged) { + logStagedPreviewInfo(`Jira add comment — Issue: ${issueKey}; Body: ${body}`); + return { success: true, staged: true, issue_key: issueKey }; + } + + const result = await client.request(`/issue/${encodeURIComponent(issueKey)}/comment`, { + method: "POST", + body: { body: textToADF(body) }, + }); + return { + success: true, + issue_key: issueKey, + ...(result?.id ? { comment_id: String(result.id) } : {}), + ...(result?.self ? { url: String(result.self) } : {}), + metadata: { issue_key: issueKey, ...(result?.id ? { comment_id: String(result.id) } : {}) }, + }; +}); + +const addLabel = jiraHandler("jira_add_label", async (message, client, isStaged) => { + const issueKey = requiredString(message.issue_key, "issue_key"); + const label = requiredString(message.label, "label"); + + if (isStaged) { + logStagedPreviewInfo(`Jira add label — Issue: ${issueKey}; Label: ${label}`); + return { success: true, staged: true, issue_key: issueKey, label }; + } + + await client.request(`/issue/${encodeURIComponent(issueKey)}`, { + method: "PUT", + body: { update: { labels: [{ add: label }] } }, + }); + return { success: true, issue_key: issueKey, label, metadata: { issue_key: issueKey, label } }; +}); + +module.exports = { addComment, addLabel, createIssue, updateIssue }; diff --git a/actions/setup/js/jira_handlers.test.cjs b/actions/setup/js/jira_handlers.test.cjs new file mode 100644 index 00000000000..470504b8cfa --- /dev/null +++ b/actions/setup/js/jira_handlers.test.cjs @@ -0,0 +1,159 @@ +// @ts-check +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const { main: createIssueMain } = require("./jira_create_issue.cjs"); +const { main: updateIssueMain } = require("./jira_update_issue.cjs"); +const { main: addCommentMain } = require("./jira_add_comment.cjs"); +const { main: addLabelMain } = require("./jira_add_label.cjs"); + +describe("Jira safe-output handlers", () => { + let requests; + + beforeEach(() => { + requests = []; + global.core = { + info: vi.fn(), + error: vi.fn(), + warning: vi.fn(), + debug: vi.fn(), + }; + process.env.JIRA_BASE_URL = "https://example.atlassian.net"; + process.env.JIRA_USER_EMAIL = "jira@example.com"; + process.env.JIRA_API_TOKEN = "secret-token"; + global.fetch = vi.fn(async (url, options) => { + requests.push({ url, options, body: options.body ? JSON.parse(options.body) : undefined }); + if (url.endsWith("/comment")) { + return response(201, { id: "20001", self: `${url}/20001` }); + } + if (options.method === "POST") { + return response(201, { id: "10042", key: "ENG-123", self: `${url}/10042` }); + } + return response(204); + }); + }); + + afterEach(() => { + delete process.env.JIRA_BASE_URL; + delete process.env.JIRA_USER_EMAIL; + delete process.env.JIRA_API_TOKEN; + delete process.env.GH_AW_SAFE_OUTPUTS_STAGED; + vi.unstubAllGlobals(); + }); + + it("creates Jira issues with issue type name and ADF description", async () => { + const handler = await createIssueMain({ max: 1 }); + const result = await handler({ + project_key: "ENG", + issue_type: "Task", + summary: "Investigate parser", + description: "First paragraph\nSecond paragraph", + }); + + expect(result).toMatchObject({ success: true, issue_key: "ENG-123", issue_id: "10042" }); + expect(requests[0]).toMatchObject({ + url: "https://example.atlassian.net/rest/api/3/issue", + body: { + fields: { + project: { key: "ENG" }, + issuetype: { name: "Task" }, + summary: "Investigate parser", + description: { + type: "doc", + version: 1, + }, + }, + }, + }); + expect(requests[0].body.fields.description.content).toHaveLength(2); + }); + + it.each([ + [{ summary: "Updated" }, { summary: "Updated" }], + [{ description: "Updated description" }, { description: { type: "doc", version: 1 } }], + [ + { summary: "Updated", description: "Both" }, + { summary: "Updated", description: { type: "doc", version: 1 } }, + ], + ])("updates only requested Jira fields", async (updates, expectedFields) => { + const handler = await updateIssueMain({ max: 1 }); + const result = await handler({ issue_key: "ENG-123", ...updates }); + + expect(result).toMatchObject({ success: true, issue_key: "ENG-123" }); + expect(requests[0].url).toBe("https://example.atlassian.net/rest/api/3/issue/ENG-123"); + expect(requests[0].body.fields).toMatchObject(expectedFields); + expect(Object.keys(requests[0].body.fields)).toEqual(Object.keys(updates)); + }); + + it("rejects a Jira update with no changed fields", async () => { + const handler = await updateIssueMain({ max: 1 }); + await expect(handler({ issue_key: "ENG-123" })).resolves.toMatchObject({ + success: false, + error: "jira_update_issue requires summary or description", + }); + expect(requests).toHaveLength(0); + }); + + it("adds a Jira comment as ADF", async () => { + const handler = await addCommentMain({ max: 1 }); + const result = await handler({ issue_key: "ENG-123", body: "Investigation complete." }); + + expect(result).toMatchObject({ success: true, issue_key: "ENG-123", comment_id: "20001" }); + expect(requests[0].body).toEqual({ + body: { + type: "doc", + version: 1, + content: [{ type: "paragraph", content: [{ type: "text", text: "Investigation complete." }] }], + }, + }); + }); + + it("adds one Jira label with additive update semantics", async () => { + const handler = await addLabelMain({ max: 1 }); + const result = await handler({ issue_key: "ENG-123", label: "needs-investigation" }); + + expect(result).toMatchObject({ success: true, issue_key: "ENG-123", label: "needs-investigation" }); + expect(requests[0].body).toEqual({ update: { labels: [{ add: "needs-investigation" }] } }); + expect(requests[0].body.fields).toBeUndefined(); + }); + + it.each([ + [createIssueMain, { project_key: "ENG", issue_type: "Task", summary: "Preview" }, "Jira create issue"], + [updateIssueMain, { issue_key: "ENG-123", summary: "Preview" }, "Jira update issue"], + [addCommentMain, { issue_key: "ENG-123", body: "Preview" }, "Jira add comment"], + [addLabelMain, { issue_key: "ENG-123", label: "preview" }, "Jira add label"], + ])("stages every Jira operation without credentials or HTTP requests", async (factory, message, previewText) => { + delete process.env.JIRA_BASE_URL; + delete process.env.JIRA_USER_EMAIL; + delete process.env.JIRA_API_TOKEN; + const handler = await factory({ max: 1, staged: true }); + const result = await handler(message); + + expect(result).toMatchObject({ success: true, staged: true }); + expect(global.fetch).not.toHaveBeenCalled(); + expect(global.core.info).toHaveBeenCalledWith(expect.stringContaining(previewText)); + }); + + it("returns a safe Jira API error", async () => { + global.fetch = vi.fn(async () => + response(400, { + errorMessages: ["Invalid project"], + errors: { summary: "Invalid summary" }, + }) + ); + const handler = await createIssueMain({ max: 1 }); + const result = await handler({ project_key: "BAD", issue_type: "Task", summary: "Bad issue" }); + + expect(result).toMatchObject({ success: false }); + expect(result.error).toContain("Invalid project"); + expect(result.error).not.toContain("secret-token"); + }); +}); + +function response(status, body) { + return { + ok: status >= 200 && status < 300, + status, + statusText: status === 204 ? "No Content" : status === 201 ? "Created" : "Bad Request", + text: async () => (body === undefined ? "" : JSON.stringify(body)), + }; +} diff --git a/actions/setup/js/jira_update_issue.cjs b/actions/setup/js/jira_update_issue.cjs new file mode 100644 index 00000000000..ba1bda0fbad --- /dev/null +++ b/actions/setup/js/jira_update_issue.cjs @@ -0,0 +1,5 @@ +// @ts-check + +const { updateIssue } = require("./jira_handlers.cjs"); + +module.exports = { main: updateIssue }; diff --git a/actions/setup/js/safe_output_handler_manager.cjs b/actions/setup/js/safe_output_handler_manager.cjs index d6ea1c9660d..0b1cef21e30 100644 --- a/actions/setup/js/safe_output_handler_manager.cjs +++ b/actions/setup/js/safe_output_handler_manager.cjs @@ -37,6 +37,10 @@ const GITHUB_TOKEN_CONFIG_KEY = "github-token"; */ const HANDLER_MAP = { create_issue: "./create_issue.cjs", + jira_create_issue: "./jira_create_issue.cjs", + jira_update_issue: "./jira_update_issue.cjs", + jira_add_comment: "./jira_add_comment.cjs", + jira_add_label: "./jira_add_label.cjs", add_comment: "./add_comment.cjs", comment_memory: "./comment_memory.cjs", create_discussion: "./create_discussion.cjs", @@ -125,6 +129,10 @@ const WTD3_REQUIREMENT_ID = "WTD3"; */ const THREAT_WARNING_REVIEWABLE_TYPES = new Set([ "create_issue", + "jira_create_issue", + "jira_update_issue", + "jira_add_comment", + "jira_add_label", "add_comment", "create_pull_request", "comment_memory", diff --git a/actions/setup/js/safe_outputs_handlers.cjs b/actions/setup/js/safe_outputs_handlers.cjs index ba6b96d814a..098a521ff5b 100644 --- a/actions/setup/js/safe_outputs_handlers.cjs +++ b/actions/setup/js/safe_outputs_handlers.cjs @@ -2813,6 +2813,18 @@ function createHandlers(server, appendSafeOutput, config = {}) { return defaultHandler("update_issue")(args || {}); }; + const jiraCreateIssueHandler = defaultHandler("jira_create_issue"); + const jiraAddCommentHandler = defaultHandler("jira_add_comment"); + const jiraAddLabelHandler = defaultHandler("jira_add_label"); + const jiraUpdateIssueHandler = args => { + const summary = typeof args?.summary === "string" ? args.summary.trim() : ""; + const description = typeof args?.description === "string" ? args.description.trim() : ""; + if (!summary && !description) { + return buildIntentErrorResponse("jira_update_issue requires at least one non-empty field: summary or description"); + } + return defaultHandler("jira_update_issue")(args || {}); + }; + /** * Handler for update_pull_request tool * Spec cross-reference: Safe Output Outcome Evaluation §update_pull_request. @@ -3117,6 +3129,10 @@ function createHandlers(server, appendSafeOutput, config = {}) { pushToPullRequestBranchHandler, pushRepoMemoryHandler, createIssueHandler, + jiraCreateIssueHandler, + jiraUpdateIssueHandler, + jiraAddCommentHandler, + jiraAddLabelHandler, createProjectHandler, addCommentHandler, createPullRequestReviewCommentHandler, diff --git a/actions/setup/js/safe_outputs_tools.json b/actions/setup/js/safe_outputs_tools.json index 393e610b3d3..af82e0028e0 100644 --- a/actions/setup/js/safe_outputs_tools.json +++ b/actions/setup/js/safe_outputs_tools.json @@ -79,6 +79,116 @@ "additionalProperties": false } }, + { + "name": "jira_create_issue", + "description": "Jira create issue: Create a new issue in Jira. This writes to Jira, not GitHub. Provide ordinary text; description is converted to Atlassian Document Format internally.", + "inputSchema": { + "type": "object", + "required": ["project_key", "issue_type", "summary"], + "properties": { + "project_key": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Jira project key, for example ENG." + }, + "issue_type": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Human-readable Jira issue type name, for example Task or Bug." + }, + "summary": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Summary for the new Jira issue." + }, + "description": { + "type": "string", + "minLength": 1, + "maxLength": 32767, + "description": "Optional plain-text Jira issue description. Converted to Atlassian Document Format internally." + } + }, + "additionalProperties": false + } + }, + { + "name": "jira_update_issue", + "description": "Jira update issue: Update the summary or description of an existing Jira issue. This writes to Jira, not GitHub, and does not transition the issue or change its labels. Provide at least one field to update.", + "inputSchema": { + "type": "object", + "required": ["issue_key"], + "properties": { + "issue_key": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Key of the existing Jira issue, for example ENG-123." + }, + "summary": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Optional replacement Jira issue summary." + }, + "description": { + "type": "string", + "minLength": 1, + "maxLength": 32767, + "description": "Optional replacement plain-text Jira issue description. Converted to Atlassian Document Format internally." + } + }, + "additionalProperties": false + } + }, + { + "name": "jira_add_comment", + "description": "Jira add comment: Add a comment to an existing Jira issue. This writes to Jira, not GitHub. Provide ordinary text; the body is converted to Atlassian Document Format internally.", + "inputSchema": { + "type": "object", + "required": ["issue_key", "body"], + "properties": { + "issue_key": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Key of the existing Jira issue, for example ENG-123." + }, + "body": { + "type": "string", + "minLength": 1, + "maxLength": 32767, + "description": "Plain-text comment body. Converted to Atlassian Document Format internally." + } + }, + "additionalProperties": false + } + }, + { + "name": "jira_add_label", + "description": "Jira add label: Add one label to an existing Jira issue without replacing or removing its current labels. This writes to Jira, not GitHub.", + "inputSchema": { + "type": "object", + "required": ["issue_key", "label"], + "properties": { + "issue_key": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Key of the existing Jira issue, for example ENG-123." + }, + "label": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "One Jira label to add. Existing labels are preserved." + } + }, + "additionalProperties": false + } + }, { "name": "create_agent_session", "description": "Create a GitHub Copilot coding agent session to delegate coding work. Use this when you need another Copilot coding agent to implement code changes, fix bugs, or complete development tasks. The task becomes a new issue that triggers the Copilot coding agent. For non-coding tasks or manual work items, use create_issue instead.", diff --git a/actions/setup/js/safe_outputs_tools_loader.cjs b/actions/setup/js/safe_outputs_tools_loader.cjs index 0962fdfe841..828e04d4893 100644 --- a/actions/setup/js/safe_outputs_tools_loader.cjs +++ b/actions/setup/js/safe_outputs_tools_loader.cjs @@ -168,6 +168,10 @@ function loadTools(server) { function attachHandlers(tools, handlers, logger) { const handlerMap = { create_issue: handlers.createIssueHandler, + jira_create_issue: handlers.jiraCreateIssueHandler, + jira_update_issue: handlers.jiraUpdateIssueHandler, + jira_add_comment: handlers.jiraAddCommentHandler, + jira_add_label: handlers.jiraAddLabelHandler, create_pull_request: handlers.createPullRequestHandler, push_to_pull_request_branch: handlers.pushToPullRequestBranchHandler, push_repo_memory: handlers.pushRepoMemoryHandler, diff --git a/pkg/parser/jira_safe_outputs_schema_test.go b/pkg/parser/jira_safe_outputs_schema_test.go new file mode 100644 index 00000000000..072d0058265 --- /dev/null +++ b/pkg/parser/jira_safe_outputs_schema_test.go @@ -0,0 +1,31 @@ +package parser + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestJiraSafeOutputsSchema(t *testing.T) { + valid := map[string]any{ + "on": map[string]any{"workflow_dispatch": nil}, + "safe-outputs": map[string]any{ + "jira-create-issue": map[string]any{"max": 1}, + "jira-update-issue": map[string]any{"staged": true}, + "jira-add-comment": nil, + "jira-add-label": map[string]any{"max": "${{ inputs.max }}"}, + }, + } + require.NoError(t, ValidateMainWorkflowFrontmatterWithSchemaAndLocation(valid, "/tmp/jira-valid.md")) + + invalid := map[string]any{ + "on": map[string]any{"workflow_dispatch": nil}, + "safe-outputs": map[string]any{ + "jira-create-issue": map[string]any{"target-repo": "owner/repo"}, + }, + } + err := ValidateMainWorkflowFrontmatterWithSchemaAndLocation(invalid, "/tmp/jira-invalid.md") + require.Error(t, err) + assert.Contains(t, err.Error(), "target-repo") +} diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index b94416c9780..9f1b505e0e2 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -7315,6 +7315,170 @@ ], "description": "Enable AI agents to approve pending workflow runs in the action required state." }, + "jira-create-issue": { + "oneOf": [ + { + "type": "object", + "description": "Configuration for creating Jira Cloud issues through the privileged safe-output execution path.", + "properties": { + "max": { + "description": "Maximum number of Jira issues to create (default: 1).", + "oneOf": [ + { + "type": "integer", + "minimum": 1, + "maximum": 100 + }, + { + "type": "string", + "pattern": "^\\$\\{\\{.*\\}\\}$" + } + ] + }, + "staged": { + "description": "Preview Jira issue creation without sending an HTTP mutation.", + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "pattern": "^\\$\\{\\{.*\\}\\}$" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null", + "description": "Enable Jira issue creation with default configuration." + } + ] + }, + "jira-update-issue": { + "oneOf": [ + { + "type": "object", + "description": "Configuration for updating Jira Cloud issue summaries or descriptions through the privileged safe-output execution path.", + "properties": { + "max": { + "description": "Maximum number of Jira issues to update (default: 1).", + "oneOf": [ + { + "type": "integer", + "minimum": 1, + "maximum": 100 + }, + { + "type": "string", + "pattern": "^\\$\\{\\{.*\\}\\}$" + } + ] + }, + "staged": { + "description": "Preview Jira issue updates without sending an HTTP mutation.", + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "pattern": "^\\$\\{\\{.*\\}\\}$" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null", + "description": "Enable Jira issue updates with default configuration." + } + ] + }, + "jira-add-comment": { + "oneOf": [ + { + "type": "object", + "description": "Configuration for commenting on Jira Cloud issues through the privileged safe-output execution path.", + "properties": { + "max": { + "description": "Maximum number of Jira comments to add (default: 1).", + "oneOf": [ + { + "type": "integer", + "minimum": 1, + "maximum": 100 + }, + { + "type": "string", + "pattern": "^\\$\\{\\{.*\\}\\}$" + } + ] + }, + "staged": { + "description": "Preview Jira comments without sending an HTTP mutation.", + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "pattern": "^\\$\\{\\{.*\\}\\}$" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null", + "description": "Enable Jira comments with default configuration." + } + ] + }, + "jira-add-label": { + "oneOf": [ + { + "type": "object", + "description": "Configuration for adding labels to Jira Cloud issues through the privileged safe-output execution path.", + "properties": { + "max": { + "description": "Maximum number of Jira labels to add (default: 1).", + "oneOf": [ + { + "type": "integer", + "minimum": 1, + "maximum": 100 + }, + { + "type": "string", + "pattern": "^\\$\\{\\{.*\\}\\}$" + } + ] + }, + "staged": { + "description": "Preview Jira label additions without sending an HTTP mutation.", + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "pattern": "^\\$\\{\\{.*\\}\\}$" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null", + "description": "Enable Jira label additions with default configuration." + } + ] + }, "add-comment": { "oneOf": [ { diff --git a/pkg/workflow/jira.go b/pkg/workflow/jira.go new file mode 100644 index 00000000000..80ced182977 --- /dev/null +++ b/pkg/workflow/jira.go @@ -0,0 +1,37 @@ +package workflow + +import "github.com/github/gh-aw/pkg/logger" + +var jiraSafeOutputsLog = logger.New("workflow:jira_safe_outputs") + +// JiraSafeOutputConfig holds the common configuration for Jira safe outputs. +type JiraSafeOutputConfig struct { + BaseSafeOutputConfig `yaml:",inline"` +} + +func (c *Compiler) parseJiraSafeOutputConfig(outputMap map[string]any, key string) *JiraSafeOutputConfig { + return parseConfigScaffoldWithPostProcess(outputMap, key, jiraSafeOutputsLog, + func(err error) *JiraSafeOutputConfig { + jiraSafeOutputsLog.Printf("Failed to unmarshal %s config: %v", key, err) + return &JiraSafeOutputConfig{} + }, + func(config *JiraSafeOutputConfig) { + if config.Max == nil { + config.Max = defaultIntStr(1) + } + }) +} + +func (c *Compiler) extractJiraSafeOutputConfigs(outputMap map[string]any, config *SafeOutputsConfig) { + config.JiraCreateIssue = c.parseJiraSafeOutputConfig(outputMap, "jira-create-issue") + config.JiraUpdateIssue = c.parseJiraSafeOutputConfig(outputMap, "jira-update-issue") + config.JiraAddComment = c.parseJiraSafeOutputConfig(outputMap, "jira-add-comment") + config.JiraAddLabel = c.parseJiraSafeOutputConfig(outputMap, "jira-add-label") +} + +func hasAnyJiraSafeOutputEnabled(config *SafeOutputsConfig) bool { + return config.JiraCreateIssue != nil || + config.JiraUpdateIssue != nil || + config.JiraAddComment != nil || + config.JiraAddLabel != nil +} diff --git a/pkg/workflow/jira_test.go b/pkg/workflow/jira_test.go new file mode 100644 index 00000000000..13552beeaf9 --- /dev/null +++ b/pkg/workflow/jira_test.go @@ -0,0 +1,63 @@ +package workflow + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestJiraSafeOutputConfigParsing(t *testing.T) { + compiler := &Compiler{} + frontmatter := map[string]any{ + "safe-outputs": map[string]any{ + "jira-create-issue": map[string]any{"max": 2, "staged": true}, + "jira-update-issue": nil, + "jira-add-comment": map[string]any{"max": "${{ inputs.max }}"}, + "jira-add-label": map[string]any{}, + }, + } + + config := compiler.extractSafeOutputsConfig(frontmatter) + require.NotNil(t, config) + require.NotNil(t, config.JiraCreateIssue) + require.NotNil(t, config.JiraUpdateIssue) + require.NotNil(t, config.JiraAddComment) + require.NotNil(t, config.JiraAddLabel) + assert.Equal(t, "2", *config.JiraCreateIssue.Max) + assert.True(t, templatableBoolIsTrue(config.JiraCreateIssue.Staged)) + assert.Equal(t, "1", *config.JiraUpdateIssue.Max) + assert.Equal(t, "${{ inputs.max }}", *config.JiraAddComment.Max) + assert.Equal(t, "1", *config.JiraAddLabel.Max) +} + +func TestJiraHandlerConfigContainsOnlyCommonControls(t *testing.T) { + staged := TemplatableBool("true") + config := &JiraSafeOutputConfig{ + BaseSafeOutputConfig: BaseSafeOutputConfig{ + Max: defaultIntStr(3), + Staged: &staged, + }, + } + + assert.Equal(t, map[string]any{"max": 3, "staged": true}, buildJiraHandlerConfig(config)) + assert.Nil(t, buildJiraHandlerConfig(nil)) +} + +func TestJiraSafeOutputsRequireNoGitHubWritePermissions(t *testing.T) { + config := &SafeOutputsConfig{ + JiraCreateIssue: &JiraSafeOutputConfig{}, + JiraUpdateIssue: &JiraSafeOutputConfig{}, + JiraAddComment: &JiraSafeOutputConfig{}, + JiraAddLabel: &JiraSafeOutputConfig{}, + } + + permissions := ComputePermissionsForSafeOutputs(config) + require.NotNil(t, permissions) + assert.Equal(t, "permissions: {}", permissions.RenderToYAML()) +} + +func TestJiraSafeOutputsCountAsNonBuiltin(t *testing.T) { + assert.True(t, hasAnySafeOutputEnabled(&SafeOutputsConfig{JiraAddComment: &JiraSafeOutputConfig{}})) + assert.True(t, hasNonBuiltinSafeOutputsEnabled(&SafeOutputsConfig{JiraAddComment: &JiraSafeOutputConfig{}})) +} diff --git a/pkg/workflow/js/safe_outputs_tools.json b/pkg/workflow/js/safe_outputs_tools.json index 393e610b3d3..af82e0028e0 100644 --- a/pkg/workflow/js/safe_outputs_tools.json +++ b/pkg/workflow/js/safe_outputs_tools.json @@ -79,6 +79,116 @@ "additionalProperties": false } }, + { + "name": "jira_create_issue", + "description": "Jira create issue: Create a new issue in Jira. This writes to Jira, not GitHub. Provide ordinary text; description is converted to Atlassian Document Format internally.", + "inputSchema": { + "type": "object", + "required": ["project_key", "issue_type", "summary"], + "properties": { + "project_key": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Jira project key, for example ENG." + }, + "issue_type": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Human-readable Jira issue type name, for example Task or Bug." + }, + "summary": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Summary for the new Jira issue." + }, + "description": { + "type": "string", + "minLength": 1, + "maxLength": 32767, + "description": "Optional plain-text Jira issue description. Converted to Atlassian Document Format internally." + } + }, + "additionalProperties": false + } + }, + { + "name": "jira_update_issue", + "description": "Jira update issue: Update the summary or description of an existing Jira issue. This writes to Jira, not GitHub, and does not transition the issue or change its labels. Provide at least one field to update.", + "inputSchema": { + "type": "object", + "required": ["issue_key"], + "properties": { + "issue_key": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Key of the existing Jira issue, for example ENG-123." + }, + "summary": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Optional replacement Jira issue summary." + }, + "description": { + "type": "string", + "minLength": 1, + "maxLength": 32767, + "description": "Optional replacement plain-text Jira issue description. Converted to Atlassian Document Format internally." + } + }, + "additionalProperties": false + } + }, + { + "name": "jira_add_comment", + "description": "Jira add comment: Add a comment to an existing Jira issue. This writes to Jira, not GitHub. Provide ordinary text; the body is converted to Atlassian Document Format internally.", + "inputSchema": { + "type": "object", + "required": ["issue_key", "body"], + "properties": { + "issue_key": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Key of the existing Jira issue, for example ENG-123." + }, + "body": { + "type": "string", + "minLength": 1, + "maxLength": 32767, + "description": "Plain-text comment body. Converted to Atlassian Document Format internally." + } + }, + "additionalProperties": false + } + }, + { + "name": "jira_add_label", + "description": "Jira add label: Add one label to an existing Jira issue without replacing or removing its current labels. This writes to Jira, not GitHub.", + "inputSchema": { + "type": "object", + "required": ["issue_key", "label"], + "properties": { + "issue_key": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Key of the existing Jira issue, for example ENG-123." + }, + "label": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "One Jira label to add. Existing labels are preserved." + } + }, + "additionalProperties": false + } + }, { "name": "create_agent_session", "description": "Create a GitHub Copilot coding agent session to delegate coding work. Use this when you need another Copilot coding agent to implement code changes, fix bugs, or complete development tasks. The task becomes a new issue that triggers the Copilot coding agent. For non-coding tasks or manual work items, use create_issue instead.", diff --git a/pkg/workflow/safe_output_handlers.go b/pkg/workflow/safe_output_handlers.go index 8bf2c23c71b..ecfc111d784 100644 --- a/pkg/workflow/safe_output_handlers.go +++ b/pkg/workflow/safe_output_handlers.go @@ -33,6 +33,30 @@ var safeOutputHandlers = []safeOutputHandlerDescriptor{ return NewPermissionsIssuesWrite() }, }, + { + Key: "jira-create-issue", + StructField: "JiraCreateIssue", + ToolName: "jira_create_issue", + NewConfig: func() any { return &JiraSafeOutputConfig{} }, + }, + { + Key: "jira-update-issue", + StructField: "JiraUpdateIssue", + ToolName: "jira_update_issue", + NewConfig: func() any { return &JiraSafeOutputConfig{} }, + }, + { + Key: "jira-add-comment", + StructField: "JiraAddComment", + ToolName: "jira_add_comment", + NewConfig: func() any { return &JiraSafeOutputConfig{} }, + }, + { + Key: "jira-add-label", + StructField: "JiraAddLabel", + ToolName: "jira_add_label", + NewConfig: func() any { return &JiraSafeOutputConfig{} }, + }, { Key: "create-agent-session", Aliases: []string{"create-agent-task"}, diff --git a/pkg/workflow/safe_outputs_config_extraction.go b/pkg/workflow/safe_outputs_config_extraction.go index 52532df2ff1..251fde55cd2 100644 --- a/pkg/workflow/safe_outputs_config_extraction.go +++ b/pkg/workflow/safe_outputs_config_extraction.go @@ -42,6 +42,8 @@ package workflow // // extractSafeOutputsConfig extracts output configuration from frontmatter +// +//nolint:largefunc // Existing centralized safe-output extraction remains intentionally sequential. func (c *Compiler) extractSafeOutputsConfig(frontmatter map[string]any) *SafeOutputsConfig { safeOutputsConfigLog.Print("Extracting safe-outputs configuration from frontmatter") @@ -59,6 +61,8 @@ func (c *Compiler) extractSafeOutputsConfig(frontmatter map[string]any) *SafeOut config.CreateIssues = issuesConfig } + c.extractJiraSafeOutputConfigs(outputMap, config) + // Handle create-agent-session agentSessionConfig := c.parseAgentSessionConfig(outputMap) if agentSessionConfig != nil { diff --git a/pkg/workflow/safe_outputs_config_types.go b/pkg/workflow/safe_outputs_config_types.go index 44acd0d6e48..990b5322879 100644 --- a/pkg/workflow/safe_outputs_config_types.go +++ b/pkg/workflow/safe_outputs_config_types.go @@ -57,6 +57,10 @@ type SafeOutputsConfig struct { CreateCodeScanningAlerts *CreateCodeScanningAlertsConfig `yaml:"create-code-scanning-alert,omitempty"` AutofixCodeScanningAlert *AutofixCodeScanningAlertConfig `yaml:"autofix-code-scanning-alert,omitempty"` CreateCheckRun *CreateCheckRunConfig `yaml:"create-check-run,omitempty"` // Create GitHub Check Runs to report agent analysis results + JiraCreateIssue *JiraSafeOutputConfig `yaml:"jira-create-issue,omitempty"` + JiraUpdateIssue *JiraSafeOutputConfig `yaml:"jira-update-issue,omitempty"` + JiraAddComment *JiraSafeOutputConfig `yaml:"jira-add-comment,omitempty"` + JiraAddLabel *JiraSafeOutputConfig `yaml:"jira-add-label,omitempty"` AddLabels *AddLabelsConfig `yaml:"add-labels,omitempty"` RemoveLabels *RemoveLabelsConfig `yaml:"remove-labels,omitempty"` ReplaceLabel *ReplaceLabelConfig `yaml:"replace-label,omitempty"` // Replace one label with another in a single atomic operation diff --git a/pkg/workflow/safe_outputs_handler_registry.go b/pkg/workflow/safe_outputs_handler_registry.go index 4da807f205c..963da1bd9df 100644 --- a/pkg/workflow/safe_outputs_handler_registry.go +++ b/pkg/workflow/safe_outputs_handler_registry.go @@ -62,6 +62,7 @@ var handlerRegistry = mergeHandlerMaps( projectHandlerRegistry, assignmentHandlerRegistry, commentHandlerRegistry, + jiraHandlerRegistry, releaseHandlerRegistry, diagnosticHandlerRegistry, ) diff --git a/pkg/workflow/safe_outputs_handler_registry_jira.go b/pkg/workflow/safe_outputs_handler_registry_jira.go new file mode 100644 index 00000000000..a2922d057a2 --- /dev/null +++ b/pkg/workflow/safe_outputs_handler_registry_jira.go @@ -0,0 +1,27 @@ +package workflow + +// jiraHandlerRegistry contains Jira Cloud handler builders. +var jiraHandlerRegistry = map[string]handlerBuilder{ + "jira_create_issue": func(cfg *SafeOutputsConfig) map[string]any { + return buildJiraHandlerConfig(cfg.JiraCreateIssue) + }, + "jira_update_issue": func(cfg *SafeOutputsConfig) map[string]any { + return buildJiraHandlerConfig(cfg.JiraUpdateIssue) + }, + "jira_add_comment": func(cfg *SafeOutputsConfig) map[string]any { + return buildJiraHandlerConfig(cfg.JiraAddComment) + }, + "jira_add_label": func(cfg *SafeOutputsConfig) map[string]any { + return buildJiraHandlerConfig(cfg.JiraAddLabel) + }, +} + +func buildJiraHandlerConfig(config *JiraSafeOutputConfig) map[string]any { + if config == nil { + return nil + } + return newHandlerConfigBuilder(). + AddTemplatableInt("max", config.Max). + AddTemplatableBool("staged", templatableBoolPtrToStringPtr(config.Staged)). + Build() +} diff --git a/pkg/workflow/safe_outputs_handler_registry_test.go b/pkg/workflow/safe_outputs_handler_registry_test.go index e0293d0a4ad..17bf75a2841 100644 --- a/pkg/workflow/safe_outputs_handler_registry_test.go +++ b/pkg/workflow/safe_outputs_handler_registry_test.go @@ -19,6 +19,7 @@ func TestHandlerRegistryDomainComposition(t *testing.T) { {name: "projectHandlerRegistry", registry: projectHandlerRegistry, wantKeys: []string{"create_project", "update_project", "create_project_status_update"}}, {name: "assignmentHandlerRegistry", registry: assignmentHandlerRegistry, wantKeys: []string{"assign_to_agent", "assign_to_user", "unassign_from_user", "create_agent_session"}}, {name: "commentHandlerRegistry", registry: commentHandlerRegistry, wantKeys: []string{"add_comment", "hide_comment"}}, + {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"}}, } @@ -99,6 +100,10 @@ func TestHandlerRegistryBuilders(t *testing.T) { {name: "unassign_from_user", cfg: &SafeOutputsConfig{UnassignFromUser: &UnassignFromUserConfig{}}}, {name: "create_agent_session", cfg: &SafeOutputsConfig{CreateAgentSessions: &CreateAgentSessionConfig{}}}, {name: "add_comment", cfg: &SafeOutputsConfig{AddComments: &AddCommentsConfig{}}}, + {name: "jira_create_issue", cfg: &SafeOutputsConfig{JiraCreateIssue: &JiraSafeOutputConfig{}}}, + {name: "jira_update_issue", cfg: &SafeOutputsConfig{JiraUpdateIssue: &JiraSafeOutputConfig{}}}, + {name: "jira_add_comment", cfg: &SafeOutputsConfig{JiraAddComment: &JiraSafeOutputConfig{}}}, + {name: "jira_add_label", cfg: &SafeOutputsConfig{JiraAddLabel: &JiraSafeOutputConfig{}}}, {name: "hide_comment", cfg: &SafeOutputsConfig{HideComment: &HideCommentConfig{}}}, {name: "update_release", cfg: &SafeOutputsConfig{UpdateRelease: &UpdateReleaseConfig{}}}, {name: "missing_tool", cfg: &SafeOutputsConfig{MissingTool: &MissingToolConfig{}}}, diff --git a/pkg/workflow/safe_outputs_state.go b/pkg/workflow/safe_outputs_state.go index ea55a7806b3..fccc247e3f5 100644 --- a/pkg/workflow/safe_outputs_state.go +++ b/pkg/workflow/safe_outputs_state.go @@ -41,6 +41,7 @@ func hasAnySafeOutputEnabled(safeOutputs *SafeOutputsConfig) bool { // Direct nil checks — no reflection, no heap allocation. return safeOutputs.CreateIssues != nil || + hasAnyJiraSafeOutputEnabled(safeOutputs) || safeOutputs.CreateAgentSessions != nil || safeOutputs.CreateDiscussions != nil || safeOutputs.UpdateDiscussions != nil || @@ -107,6 +108,7 @@ func hasNonBuiltinSafeOutputsEnabled(safeOutputs *SafeOutputsConfig) bool { // Direct nil checks for non-builtin pointer fields. return safeOutputs.CreateIssues != nil || + hasAnyJiraSafeOutputEnabled(safeOutputs) || safeOutputs.CreateAgentSessions != nil || safeOutputs.CreateDiscussions != nil || safeOutputs.UpdateDiscussions != nil || From 3433791b784a423be8a13256473859cf0db96eb3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 2 Sep 2026 01:45:31 +0000 Subject: [PATCH 2/8] Document Jira safe outputs Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .github/aw/designer-mappings.md | 4 + .github/aw/safe-outputs-content.md | 29 ++++ .../docs/reference/frontmatter-full.md | 129 ++++++++++++++++++ .../content/docs/reference/safe-outputs.md | 55 ++++++++ 4 files changed, 217 insertions(+) diff --git a/.github/aw/designer-mappings.md b/.github/aw/designer-mappings.md index be56bd117ee..6fe930d6890 100644 --- a/.github/aw/designer-mappings.md +++ b/.github/aw/designer-mappings.md @@ -31,6 +31,10 @@ Quick-reference mapping tables for `.github/aw/designer.md`. Load this file duri | "post a comment" | `add-comment` | | "create an issue" | `create-issue` | | "update issue title/body" | `update-issue` | +| "create a Jira issue" | `jira-create-issue` | +| "update Jira issue ENG-123" | `jira-update-issue` | +| "comment on Jira issue ENG-123" | `jira-add-comment` | +| "add a Jira label" | `jira-add-label` | | "close the issue" | `close-issue` | | "assign someone", "remove assignment" | `assign-to-user`, `unassign-from-user` | | "set issue type/field/milestone" | `set-issue-type`, `set-issue-field`, `assign-milestone` | diff --git a/.github/aw/safe-outputs-content.md b/.github/aw/safe-outputs-content.md index 4482eb5bcfa..c68d4bf533e 100644 --- a/.github/aw/safe-outputs-content.md +++ b/.github/aw/safe-outputs-content.md @@ -4,6 +4,35 @@ description: Safe-output reference for issue, discussion, comment, and pull requ # Safe Outputs: GitHub Content +- Jira operations are explicitly namespaced and run through Jira Cloud REST API v3: + + ```yaml + safe-outputs: + env: + JIRA_BASE_URL: ${{ secrets.JIRA_BASE_URL }} + JIRA_USER_EMAIL: ${{ secrets.JIRA_USER_EMAIL }} + JIRA_API_TOKEN: ${{ secrets.JIRA_API_TOKEN }} + jira-create-issue: + max: 1 + jira-update-issue: + max: 1 + jira-add-comment: + max: 1 + jira-add-label: + max: 3 + ``` + + | Frontmatter | Tool | Agent inputs | + |---|---|---| + | `jira-create-issue` | `jira_create_issue` | `project_key`, `issue_type`, `summary`, optional `description` | + | `jira-update-issue` | `jira_update_issue` | `issue_key` and at least one of `summary`, `description` | + | `jira-add-comment` | `jira_add_comment` | `issue_key`, `body` | + | `jira-add-label` | `jira_add_label` | `issue_key`, `label` | + + Use the Jira-prefixed tool whenever the target is Jira. Unprefixed issue, comment, and label tools target GitHub. Description and comment strings are converted to ADF internally. Label addition is additive and preserves existing labels. Each Jira output supports `max` and `staged`; staged mode sends no HTTP request and does not require credentials. + + Jira update, comment, and label operations require a known issue key. Same-run references to an issue created by `jira_create_issue` are not supported. The initial integration does not provide transitions, assignments, custom fields, label removal, JQL, bulk operations, or arbitrary REST calls. + - `create-issue:` - Safe GitHub issue creation (bugs, features) ```yaml diff --git a/docs/src/content/docs/reference/frontmatter-full.md b/docs/src/content/docs/reference/frontmatter-full.md index e89c0619bcb..14be4883a5a 100644 --- a/docs/src/content/docs/reference/frontmatter-full.md +++ b/docs/src/content/docs/reference/frontmatter-full.md @@ -4109,6 +4109,13 @@ tools: # (optional) version: null + # Removed: legacy MCP-mode argument list. Accepted here only so the compiler can + # reject 'mode: mcp' configurations with actionable migration guidance instead of + # an unhelpful schema error. + # (optional) + args: [] + # Array of strings + # Integration mode. Only 'cli' is supported. The compiler rejects the removed # 'mcp' value with migration guidance. Must be a literal value; GitHub Actions # expressions are rejected. @@ -4122,6 +4129,12 @@ tools: # GitHub Actions expression. mode: "example-value" + # Browsers to provision before the agent starts. Defaults to Chromium. Chrome is + # accepted as an alias for Chromium. + # (optional) + browsers: [] + # Array of strings + # GitHub Agentic Workflows MCP server for workflow introspection and analysis. # Provides tools for checking status, compiling workflows, downloading logs, and # auditing runs. @@ -8504,6 +8517,122 @@ safe-outputs: # Format 2: Enable workflow run approval with default configuration approve-workflow-run: null + # (optional) + # Accepted formats: + + # Format 1: Configuration for creating Jira Cloud issues through the privileged + # safe-output execution path. + jira-create-issue: + # Maximum number of Jira issues to create (default: 1). + # (optional) + # Accepted formats: + + # Format 1: integer + max: 1 + + # Format 2: string + max: "example-value" + + # Preview Jira issue creation without sending an HTTP mutation. + # (optional) + # Accepted formats: + + # Format 1: boolean + staged: true + + # Format 2: string + staged: "example-value" + + # Format 2: Enable Jira issue creation with default configuration. + jira-create-issue: null + + # (optional) + # Accepted formats: + + # Format 1: Configuration for updating Jira Cloud issue summaries or descriptions + # through the privileged safe-output execution path. + jira-update-issue: + # Maximum number of Jira issues to update (default: 1). + # (optional) + # Accepted formats: + + # Format 1: integer + max: 1 + + # Format 2: string + max: "example-value" + + # Preview Jira issue updates without sending an HTTP mutation. + # (optional) + # Accepted formats: + + # Format 1: boolean + staged: true + + # Format 2: string + staged: "example-value" + + # Format 2: Enable Jira issue updates with default configuration. + jira-update-issue: null + + # (optional) + # Accepted formats: + + # Format 1: Configuration for commenting on Jira Cloud issues through the + # privileged safe-output execution path. + jira-add-comment: + # Maximum number of Jira comments to add (default: 1). + # (optional) + # Accepted formats: + + # Format 1: integer + max: 1 + + # Format 2: string + max: "example-value" + + # Preview Jira comments without sending an HTTP mutation. + # (optional) + # Accepted formats: + + # Format 1: boolean + staged: true + + # Format 2: string + staged: "example-value" + + # Format 2: Enable Jira comments with default configuration. + jira-add-comment: null + + # (optional) + # Accepted formats: + + # Format 1: Configuration for adding labels to Jira Cloud issues through the + # privileged safe-output execution path. + jira-add-label: + # Maximum number of Jira labels to add (default: 1). + # (optional) + # Accepted formats: + + # Format 1: integer + max: 1 + + # Format 2: string + max: "example-value" + + # Preview Jira label additions without sending an HTTP mutation. + # (optional) + # Accepted formats: + + # Format 1: boolean + staged: true + + # Format 2: string + staged: "example-value" + + # Format 2: Enable Jira label additions with default configuration. + jira-add-label: null + # Enable AI agents to add comments to GitHub issues, pull requests, or # discussions. Supports templating, cross-repository commenting, and automatic # mentions. diff --git a/docs/src/content/docs/reference/safe-outputs.md b/docs/src/content/docs/reference/safe-outputs.md index 02f37ba1afa..acea3649e90 100644 --- a/docs/src/content/docs/reference/safe-outputs.md +++ b/docs/src/content/docs/reference/safe-outputs.md @@ -66,6 +66,15 @@ The tables below summarize the built-in safe output handlers. `noop`, `missing-t | [Set Issue Type](#set-issue-type-set-issue-type) | `set-issue-type` | Set or clear the type of GitHub issues (max: 5) | | [Set Issue Field](#set-issue-field-set-issue-field) | `set-issue-field` | Set one issue field value by name/value (max: 5) | +### External Integrations + +| Output | Key | Description | +|--------|-----|-------------| +| [Jira Create Issue](#jira-safe-outputs) | `jira-create-issue` | Create a Jira issue (max: 1) | +| [Jira Update Issue](#jira-safe-outputs) | `jira-update-issue` | Update a Jira issue summary or description (max: 1) | +| [Jira Add Comment](#jira-safe-outputs) | `jira-add-comment` | Add a comment to a Jira issue (max: 1) | +| [Jira Add Label](#jira-safe-outputs) | `jira-add-label` | Add one label to a Jira issue (max: 1) | + ### Projects, Releases & Assets | Output | Key | Description | @@ -106,6 +115,52 @@ Create custom post-processing jobs registered as Model Context Protocol (MCP) to Mount any public GitHub Action as a once-callable MCP tool. The compiler pins the action reference to a SHA at compile time and derives the tool's input schema from the action's `action.yml`. See [GitHub Action Wrappers](/gh-aw/reference/custom-safe-outputs/#github-action-wrappers-safe-outputsactions). +## Jira Safe Outputs + +Jira safe outputs call Jira Cloud REST API v3 from the privileged safe-output job. Jira credentials are not exposed to the agent or included in `agent_output`. + +```aw wrap +--- +on: + workflow_dispatch: +safe-outputs: + env: + JIRA_BASE_URL: ${{ secrets.JIRA_BASE_URL }} + JIRA_USER_EMAIL: ${{ secrets.JIRA_USER_EMAIL }} + JIRA_API_TOKEN: ${{ secrets.JIRA_API_TOKEN }} + jira-create-issue: + max: 1 + jira-update-issue: + max: 1 + jira-add-comment: + max: 1 + jira-add-label: + max: 3 +--- + +# Jira maintenance + +Use Jira safe outputs for Jira mutations. +``` + +`JIRA_BASE_URL` is the Jira API base without `/rest/api/3`, such as `https://example.atlassian.net`. The initial authentication mechanism uses an Atlassian account email and API token with HTTP Basic authentication. + +Each output accepts `max` and `staged`. In staged mode, the handler writes a Jira-specific preview without requiring credentials or sending an HTTP request. + +| Frontmatter key | Agent tool | Inputs | +|---|---|---| +| `jira-create-issue` | `jira_create_issue` | Required: `project_key`, `issue_type`, `summary`; optional: `description` | +| `jira-update-issue` | `jira_update_issue` | Required: `issue_key`; at least one of `summary` or `description` | +| `jira-add-comment` | `jira_add_comment` | Required: `issue_key`, `body` | +| `jira-add-label` | `jira_add_label` | Required: `issue_key`, `label` | + +Descriptions and comment bodies remain plain strings at the agent boundary. The runtime converts them deterministically to Atlassian Document Format version 1, preserving paragraphs and line breaks. `jira_add_label` uses Jira's additive field-update operation and does not replace existing labels. + +> [!IMPORTANT] +> Unprefixed tools such as `create_issue`, `update_issue`, `add_comment`, and `add_labels` operate on GitHub. Jira operations always use the `jira_` prefix. + +This initial integration does not support transitions, assignment, custom fields, priorities, components, attachments, issue links, subtasks, label removal, JQL, bulk operations, arbitrary Jira REST calls, or OAuth installation flows. Update, comment, and label operations require a known Jira issue key; they cannot reference a Jira issue created earlier in the same run. + ## Steering Issues (`steer:`) :::caution[Experimental] From 908c554fa8175662db8cde05193d33a7eb0e9740 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 2 Sep 2026 01:59:55 +0000 Subject: [PATCH 3/8] Complete Jira safe output validation Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/safe_outputs_tools.json | 6 ++-- pkg/workflow/js/safe_outputs_tools.json | 6 ++-- .../safe_output_validation_config_test.go | 1 + pkg/workflow/safe_outputs_max_validation.go | 22 ++++++++++++ .../safe_outputs_validation_config.go | 34 +++++++++++++++++++ 5 files changed, 63 insertions(+), 6 deletions(-) diff --git a/actions/setup/js/safe_outputs_tools.json b/actions/setup/js/safe_outputs_tools.json index af82e0028e0..2765491744f 100644 --- a/actions/setup/js/safe_outputs_tools.json +++ b/actions/setup/js/safe_outputs_tools.json @@ -90,7 +90,7 @@ "type": "string", "minLength": 1, "maxLength": 255, - "description": "Jira project key, for example ENG." + "description": "Jira project key that owns the new issue, for example ENG." }, "issue_type": { "type": "string", @@ -102,7 +102,7 @@ "type": "string", "minLength": 1, "maxLength": 255, - "description": "Summary for the new Jira issue." + "description": "Short Jira issue summary, for example Investigate parser failure." }, "description": { "type": "string", @@ -183,7 +183,7 @@ "type": "string", "minLength": 1, "maxLength": 255, - "description": "One Jira label to add. Existing labels are preserved." + "description": "One Jira label to add, for example needs-investigation. Existing labels are preserved." } }, "additionalProperties": false diff --git a/pkg/workflow/js/safe_outputs_tools.json b/pkg/workflow/js/safe_outputs_tools.json index af82e0028e0..2765491744f 100644 --- a/pkg/workflow/js/safe_outputs_tools.json +++ b/pkg/workflow/js/safe_outputs_tools.json @@ -90,7 +90,7 @@ "type": "string", "minLength": 1, "maxLength": 255, - "description": "Jira project key, for example ENG." + "description": "Jira project key that owns the new issue, for example ENG." }, "issue_type": { "type": "string", @@ -102,7 +102,7 @@ "type": "string", "minLength": 1, "maxLength": 255, - "description": "Summary for the new Jira issue." + "description": "Short Jira issue summary, for example Investigate parser failure." }, "description": { "type": "string", @@ -183,7 +183,7 @@ "type": "string", "minLength": 1, "maxLength": 255, - "description": "One Jira label to add. Existing labels are preserved." + "description": "One Jira label to add, for example needs-investigation. Existing labels are preserved." } }, "additionalProperties": false diff --git a/pkg/workflow/safe_output_validation_config_test.go b/pkg/workflow/safe_output_validation_config_test.go index 5c5602682ca..01baba99532 100644 --- a/pkg/workflow/safe_output_validation_config_test.go +++ b/pkg/workflow/safe_output_validation_config_test.go @@ -483,6 +483,7 @@ 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, diff --git a/pkg/workflow/safe_outputs_max_validation.go b/pkg/workflow/safe_outputs_max_validation.go index 55079bdd179..6d91d839b00 100644 --- a/pkg/workflow/safe_outputs_max_validation.go +++ b/pkg/workflow/safe_outputs_max_validation.go @@ -55,6 +55,8 @@ func checkMaxField(toolName string, maxPtr *string) error { // This function uses direct struct field access instead of reflection for performance; // it is on the hot path and called on every compilation. The field ordering matches // the sorted safeOutputFieldMapping keys for deterministic error reporting. +// +//nolint:largefunc // Direct field access intentionally keeps all safe-output max checks together. func validateSafeOutputsMax(config *SafeOutputsConfig) error { if config == nil { return nil @@ -181,6 +183,26 @@ func validateSafeOutputsMax(config *SafeOutputsConfig) error { return err } } + if config.JiraAddComment != nil { + if err := checkMaxField("jira_add_comment", config.JiraAddComment.Max); err != nil { + return err + } + } + if config.JiraAddLabel != nil { + if err := checkMaxField("jira_add_label", config.JiraAddLabel.Max); err != nil { + return err + } + } + if config.JiraCreateIssue != nil { + if err := checkMaxField("jira_create_issue", config.JiraCreateIssue.Max); err != nil { + return err + } + } + if config.JiraUpdateIssue != nil { + if err := checkMaxField("jira_update_issue", config.JiraUpdateIssue.Max); err != nil { + return err + } + } if config.LinkSubIssue != nil { if err := checkMaxField("link_sub_issue", config.LinkSubIssue.Max); err != nil { return err diff --git a/pkg/workflow/safe_outputs_validation_config.go b/pkg/workflow/safe_outputs_validation_config.go index 78e0bcfb7da..297fdd2a132 100644 --- a/pkg/workflow/safe_outputs_validation_config.go +++ b/pkg/workflow/safe_outputs_validation_config.go @@ -102,6 +102,38 @@ var ValidationConfig = map[string]TypeValidationConfig{ "repo": {Type: "string", MaxLength: 256}, // Optional: target repository in format "owner/repo" }, }, + "jira_create_issue": { + DefaultMax: 1, + Fields: map[string]FieldValidation{ + "project_key": {Required: true, Type: "string", Sanitize: true, MinLength: 1, MaxLength: 255}, + "issue_type": {Required: true, Type: "string", Sanitize: true, MinLength: 1, MaxLength: 255}, + "summary": {Required: true, Type: "string", Sanitize: true, MinLength: 1, MaxLength: 255}, + "description": {Type: "string", Sanitize: true, MinLength: 1, MaxLength: 32767}, + }, + }, + "jira_update_issue": { + DefaultMax: 1, + CustomValidation: "requiresOneOf:summary,description", + Fields: map[string]FieldValidation{ + "issue_key": {Required: true, Type: "string", Sanitize: true, MinLength: 1, MaxLength: 255}, + "summary": {Type: "string", Sanitize: true, MinLength: 1, MaxLength: 255}, + "description": {Type: "string", Sanitize: true, MinLength: 1, MaxLength: 32767}, + }, + }, + "jira_add_comment": { + DefaultMax: 1, + Fields: map[string]FieldValidation{ + "issue_key": {Required: true, Type: "string", Sanitize: true, MinLength: 1, MaxLength: 255}, + "body": {Required: true, Type: "string", Sanitize: true, MinLength: 1, MaxLength: 32767}, + }, + }, + "jira_add_label": { + DefaultMax: 1, + Fields: map[string]FieldValidation{ + "issue_key": {Required: true, Type: "string", Sanitize: true, MinLength: 1, MaxLength: 255}, + "label": {Required: true, Type: "string", Sanitize: true, MinLength: 1, MaxLength: 255}, + }, + }, "comment_memory": { DefaultMax: 1, Fields: map[string]FieldValidation{ @@ -564,6 +596,8 @@ var validationConfigJSONCache sync.Map // key: string → value: string // GetValidationConfigJSONWithDataSchema behaves like GetValidationConfigJSONWithDataSchema and additionally // injects a normalized data schema into body-bearing safe-output types. +// +//nolint:largefunc // Existing validation serialization flow remains linear and explicit. func GetValidationConfigJSONWithDataSchema(enabledTypes []string, mentions map[string]any, dataEnabled bool, dataSchema map[string]any) (string, error) { safeOutputValidationLog.Printf("Getting validation config JSON for %d types (mentions=%t)", len(enabledTypes), len(mentions) > 0) From 29d57803578bb139069a1a468798d450b9fe0fd2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 2 Sep 2026 02:20:21 +0000 Subject: [PATCH 4/8] Complete Jira implementation review Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/jira_client.cjs | 3 ++- actions/setup/js/jira_client.test.cjs | 6 ++++++ .../setup/js/safe_output_handler_manager.cjs | 2 +- .../js/safe_output_handler_manager.test.cjs | 2 +- .../setup/js/safe_outputs_handlers.test.cjs | 19 +++++++++++++++++++ 5 files changed, 29 insertions(+), 3 deletions(-) diff --git a/actions/setup/js/jira_client.cjs b/actions/setup/js/jira_client.cjs index 4d67edad194..e6d369ecb57 100644 --- a/actions/setup/js/jira_client.cjs +++ b/actions/setup/js/jira_client.cjs @@ -17,7 +17,8 @@ function normalizeJiraBaseUrl(value) { throw new Error("JIRA_BASE_URL must be a valid URL"); } - const isLocal = url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "::1"; + const hostname = url.hostname.replace(/^\[|\]$/g, ""); + const isLocal = hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1"; if (url.protocol !== "https:" && !(isLocal && url.protocol === "http:")) { throw new Error("JIRA_BASE_URL must use HTTPS"); } diff --git a/actions/setup/js/jira_client.test.cjs b/actions/setup/js/jira_client.test.cjs index 58a56640935..ca06594041e 100644 --- a/actions/setup/js/jira_client.test.cjs +++ b/actions/setup/js/jira_client.test.cjs @@ -19,6 +19,12 @@ describe("jira client", () => { expect(() => normalizeJiraBaseUrl("https://example.atlassian.net?token=value")).toThrow("query string"); }); + it("allows HTTP only for loopback URLs used by tests", () => { + expect(normalizeJiraBaseUrl("http://localhost:3000")).toBe("http://localhost:3000"); + expect(normalizeJiraBaseUrl("http://127.0.0.1:3000")).toBe("http://127.0.0.1:3000"); + expect(normalizeJiraBaseUrl("http://[::1]:3000")).toBe("http://[::1]:3000"); + }); + it("converts plain text and newlines to ADF version 1", () => { expect(textToADF("first\n\nsecond")).toEqual({ type: "doc", diff --git a/actions/setup/js/safe_output_handler_manager.cjs b/actions/setup/js/safe_output_handler_manager.cjs index 0b1cef21e30..ed3efbbe171 100644 --- a/actions/setup/js/safe_output_handler_manager.cjs +++ b/actions/setup/js/safe_output_handler_manager.cjs @@ -132,7 +132,6 @@ const THREAT_WARNING_REVIEWABLE_TYPES = new Set([ "jira_create_issue", "jira_update_issue", "jira_add_comment", - "jira_add_label", "add_comment", "create_pull_request", "comment_memory", @@ -182,6 +181,7 @@ const THREAT_WARNING_ABORT_TYPES = new Set([ "resolve_pull_request_review_thread", "dismiss_pull_request_review", "add_labels", + "jira_add_label", "remove_labels", "add_reviewer", "assign_milestone", diff --git a/actions/setup/js/safe_output_handler_manager.test.cjs b/actions/setup/js/safe_output_handler_manager.test.cjs index 5b995a11aa4..7ebef3c5da2 100644 --- a/actions/setup/js/safe_output_handler_manager.test.cjs +++ b/actions/setup/js/safe_output_handler_manager.test.cjs @@ -844,7 +844,7 @@ describe("Safe Output Handler Manager", () => { expect(result.results[1].success).toBe(true); }); - it.each(["set_issue_type", "set_issue_field", "dispatch_repository", "call_workflow", "upload_artifact"])("should abort %s in detection warning mode", async messageType => { + it.each(["set_issue_type", "set_issue_field", "jira_add_label", "dispatch_repository", "call_workflow", "upload_artifact"])("should abort %s in detection warning mode", async messageType => { process.env.GH_AW_DETECTION_CONCLUSION = "warning"; const handler = vi.fn().mockResolvedValue({ success: true }); const handlers = new Map([[messageType, handler]]); diff --git a/actions/setup/js/safe_outputs_handlers.test.cjs b/actions/setup/js/safe_outputs_handlers.test.cjs index a64b2b0561a..42fc3adcc16 100644 --- a/actions/setup/js/safe_outputs_handlers.test.cjs +++ b/actions/setup/js/safe_outputs_handlers.test.cjs @@ -3944,6 +3944,25 @@ describe("safe_outputs_handlers", () => { }); }); + describe("jiraUpdateIssueHandler", () => { + it.each([{}, { summary: "" }, { description: " " }])("rejects updates without a non-empty field", args => { + const result = handlers.jiraUpdateIssueHandler(args); + expect(result.isError).toBe(true); + expect(JSON.parse(result.content[0].text)).toMatchObject({ + result: "error", + error: expect.stringContaining("summary or description"), + }); + expect(mockAppendSafeOutput).not.toHaveBeenCalled(); + }); + + it("records an update with a non-empty field", () => { + const result = handlers.jiraUpdateIssueHandler({ issue_key: "ENG-123", summary: "Updated" }); + expect(result.isError).toBeUndefined(); + expect(JSON.parse(result.content[0].text).result).toBe("success"); + expect(mockAppendSafeOutput).toHaveBeenCalledWith(expect.objectContaining({ type: "jira_update_issue", issue_key: "ENG-123", summary: "Updated" })); + }); + }); + describe("updateIssueHandler", () => { it("should return intent error when target is triggering (default) and not in issue context", () => { // global.context has eventName: "push" (not an issue context) From 820a6130ec79ed7f2f089ad2d8a4411e5f5f615d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 2 Sep 2026 02:32:52 +0000 Subject: [PATCH 5/8] Add Jira debug logging Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/jira_client.cjs | 16 +++++++++++++++- actions/setup/js/jira_client.test.cjs | 10 +++++++++- actions/setup/js/jira_handlers.cjs | 7 ++++++- actions/setup/js/jira_handlers.test.cjs | 3 +++ 4 files changed, 33 insertions(+), 3 deletions(-) diff --git a/actions/setup/js/jira_client.cjs b/actions/setup/js/jira_client.cjs index e6d369ecb57..2071c1a51bd 100644 --- a/actions/setup/js/jira_client.cjs +++ b/actions/setup/js/jira_client.cjs @@ -4,6 +4,12 @@ const { sanitizeContent } = require("./sanitize_content.cjs"); const JIRA_API_PATH = "/rest/api/3"; +function logJiraDebug(message) { + if (global.core && typeof global.core.debug === "function") { + global.core.debug(message); + } +} + function normalizeJiraBaseUrl(value) { const raw = typeof value === "string" ? value.trim() : ""; if (!raw) { @@ -85,15 +91,18 @@ function createJiraClient(env = process.env, fetchImpl = global.fetch) { const authorization = `Basic ${Buffer.from(`${email}:${token}`, "utf8").toString("base64")}`; const secrets = [token, email, authorization]; + logJiraDebug("Jira client configured with API-token authentication"); return { async request(path, options = {}) { const normalizedPath = path.startsWith("/") ? path : `/${path}`; const url = `${baseUrl}${JIRA_API_PATH}${normalizedPath}`; + const method = options.method || "GET"; + logJiraDebug(`Jira API request started: ${method} ${normalizedPath}`); let response; try { response = await fetchImpl(url, { - method: options.method || "GET", + method, headers: { Accept: "application/json", Authorization: authorization, @@ -102,9 +111,11 @@ function createJiraClient(env = process.env, fetchImpl = global.fetch) { ...(options.body === undefined ? {} : { body: JSON.stringify(options.body) }), }); } catch { + logJiraDebug(`Jira API request failed before receiving a response: ${method} ${normalizedPath}`); throw new Error("Jira API request failed due to a network error"); } + logJiraDebug(`Jira API response received: ${method} ${normalizedPath} status=${response.status}`); const responseText = await response.text(); let responseBody = null; if (responseText) { @@ -112,14 +123,17 @@ function createJiraClient(env = process.env, fetchImpl = global.fetch) { responseBody = JSON.parse(responseText); } catch { if (response.ok) { + logJiraDebug(`Jira API response contained invalid JSON: ${method} ${normalizedPath}`); throw new Error(`Jira API returned an invalid JSON response (${response.status})`); } } } if (!response.ok) { + logJiraDebug(`Jira API request rejected: ${method} ${normalizedPath} status=${response.status}`); throw new Error(formatJiraError(response.status, response.statusText, responseBody, secrets)); } + logJiraDebug(`Jira API request completed: ${method} ${normalizedPath}`); return responseBody; }, }; diff --git a/actions/setup/js/jira_client.test.cjs b/actions/setup/js/jira_client.test.cjs index ca06594041e..786a1849810 100644 --- a/actions/setup/js/jira_client.test.cjs +++ b/actions/setup/js/jira_client.test.cjs @@ -1,9 +1,14 @@ // @ts-check -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const { createJiraClient, formatJiraError, normalizeJiraBaseUrl, textToADF } = require("./jira_client.cjs"); +beforeEach(() => { + global.core = { debug: vi.fn() }; +}); + afterEach(() => { + delete global.core; vi.unstubAllGlobals(); }); @@ -58,6 +63,9 @@ describe("jira client", () => { expect(url).toBe("https://example.atlassian.net/rest/api/3/issue/ENG-1"); expect(options.headers.Authorization).toBe(`Basic ${Buffer.from("jira@example.com:secret-token").toString("base64")}`); expect(options.body).not.toContain("secret-token"); + expect(global.core.debug).toHaveBeenCalledWith("Jira API request started: PUT /issue/ENG-1"); + expect(global.core.debug).toHaveBeenCalledWith("Jira API response received: PUT /issue/ENG-1 status=204"); + expect(global.core.debug.mock.calls.flat().join(" ")).not.toMatch(/secret-token|jira@example\.com|Basic /); }); it("surfaces structured Jira errors without leaking credentials", async () => { diff --git a/actions/setup/js/jira_handlers.cjs b/actions/setup/js/jira_handlers.cjs index 0e6669e39b4..6ececc8c8cb 100644 --- a/actions/setup/js/jira_handlers.cjs +++ b/actions/setup/js/jira_handlers.cjs @@ -27,12 +27,17 @@ function jiraHandler(handlerType, handle) { return createCountGatedHandler({ handlerType, setup: async (_config, _maxCount, isStaged) => { + core.debug(`${handlerType}: initializing handler (staged=${isStaged})`); const client = isStaged ? null : createJiraClient(); return async message => { + core.debug(`${handlerType}: processing request`); try { - return await handle(message || {}, client, isStaged); + const result = await handle(message || {}, client, isStaged); + core.debug(`${handlerType}: request completed successfully`); + return result; } catch (error) { const message = error instanceof Error ? error.message : "Jira operation failed"; + core.debug(`${handlerType}: request failed`); core.error(message); return { success: false, error: message }; } diff --git a/actions/setup/js/jira_handlers.test.cjs b/actions/setup/js/jira_handlers.test.cjs index 470504b8cfa..fe25840a8fb 100644 --- a/actions/setup/js/jira_handlers.test.cjs +++ b/actions/setup/js/jira_handlers.test.cjs @@ -65,6 +65,8 @@ describe("Jira safe-output handlers", () => { }, }); expect(requests[0].body.fields.description.content).toHaveLength(2); + expect(global.core.debug).toHaveBeenCalledWith("jira_create_issue: processing request"); + expect(global.core.debug).toHaveBeenCalledWith("jira_create_issue: request completed successfully"); }); it.each([ @@ -146,6 +148,7 @@ describe("Jira safe-output handlers", () => { expect(result).toMatchObject({ success: false }); expect(result.error).toContain("Invalid project"); expect(result.error).not.toContain("secret-token"); + expect(global.core.debug).toHaveBeenCalledWith("jira_create_issue: request failed"); }); }); From 02528b24082490ed7cf6581a99172f4753a41d6d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 2 Sep 2026 03:05:32 +0000 Subject: [PATCH 6/8] Add ADR for Jira safe outputs --- ...57814-add-first-class-jira-safe-outputs.md | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 docs/adr/57814-add-first-class-jira-safe-outputs.md diff --git a/docs/adr/57814-add-first-class-jira-safe-outputs.md b/docs/adr/57814-add-first-class-jira-safe-outputs.md new file mode 100644 index 00000000000..11972df064b --- /dev/null +++ b/docs/adr/57814-add-first-class-jira-safe-outputs.md @@ -0,0 +1,50 @@ +# ADR-57814: Add First-Class Jira Safe Outputs + +**Date**: 2026-09-02 +**Status**: Draft +**Deciders**: pelikhan, adr-writer agent + +--- + +### Context + +This pull request extends `gh-aw` safe outputs so workflows and agents can perform a limited set of Jira write operations through the same controlled execution path already used for GitHub writes. The PR adds new safe-output tool schemas, dispatcher wiring, runtime handlers, tests, and documentation for creating Jira issues, updating Jira issues, adding Jira comments, and adding Jira labels. The PR body also states explicit constraints: Jira credentials must exist only in privileged post-processing, agent-facing inputs should remain simple text, and the initial integration excludes transitions, assignment, custom fields, label removal, JQL, bulk operations, and same-run references from newly created issues. Because this introduces a new external write surface and a new integration pattern for privileged side effects, the architectural choice should be recorded explicitly. + +### Decision + +We will add first-class Jira safe outputs as explicitly namespaced operations that run only through privileged safe-output processing rather than exposing arbitrary Jira API access to agents. The integration will accept bounded plain-text inputs, convert descriptions and comments to Atlassian Document Format internally, and execute a small supported set of Jira Cloud REST v3 mutations with sanitized validation and error handling. We chose this approach because it gives workflows a useful Jira automation surface while preserving the repository's existing safe-output security model and limiting the blast radius of a new third-party integration. + +### Alternatives Considered + +#### Alternative 1: Reuse Existing Generic GitHub-Oriented Safe Outputs or Unnamespaced Issue Tools + +One option was to keep using the existing issue/comment/label concepts without adding Jira-specific namespacing or dedicated Jira handlers. + +This was considered because it would reduce the number of new tools and avoid expanding the safe-output surface. It was not chosen because the PR evidence shows Jira requires separate credentials, a different API, different payload formats such as ADF, and explicit targeting guidance so GitHub and Jira operations are not confused. + +#### Alternative 2: Expose a More General Jira REST Capability + +Another option was to provide a generic Jira request tool or a broader set of Jira mutations such as transitions, assignments, custom fields, JQL, or same-run references to newly created issues. + +This was considered because it would be more flexible and could reduce future incremental additions. It was not chosen because the PR explicitly scopes the integration to four bounded operations, adds strict schemas and sanitization, and avoids a broad privileged API surface that would be harder to validate, document, and secure. + +### Consequences + +#### Positive +- Workflows gain a supported way to create and update Jira content through the same controlled safe-output path used for other privileged writes. +- Jira credentials stay confined to privileged safe-output processing and are kept out of agent tools, prompts, output, and diagnostics. +- The integration is easier for agents to use because they provide ordinary text while the runtime handles Jira REST v3 details, ADF conversion, and safe error formatting. + +#### Negative +- The codebase takes on a new external integration surface, including Jira-specific client logic, schemas, handlers, tests, and maintenance burden. +- The initial Jira capability is intentionally limited, so users needing transitions, assignments, custom fields, label removal, bulk operations, or same-run references will still need future follow-on work. +- Safe-output threat modeling and review complexity increase because the system now mediates writes to both GitHub and Jira with different semantics. + +#### Neutral +- Jira operations are explicitly namespaced separately from GitHub issue and comment tools, making the distinction part of the product surface. +- Plain-text descriptions and comments are normalized into Atlassian Document Format internally instead of exposing ADF directly to agents. +- Detection, dispatch, documentation, and conformance coverage expand to include the new Jira-specific tool family. + +--- + +*ADR created by [adr-writer agent]. Review and finalize before changing status from Draft to Accepted.* From 83a7f1bc2049cfeea36e8c9402d2cf7e40db615b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 2 Sep 2026 03:34:57 +0000 Subject: [PATCH 7/8] Harden Jira safe output validation Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/jira_client.cjs | 7 ++++++ actions/setup/js/jira_client.test.cjs | 25 ++++++++++++++++++- actions/setup/js/jira_handlers.cjs | 18 +++++++------ actions/setup/js/jira_handlers.test.cjs | 22 ++++++++++++++++ actions/setup/js/safe_outputs_tools.json | 19 +++++++++++--- .../content/docs/reference/safe-outputs.md | 2 +- pkg/workflow/js/safe_outputs_tools.json | 19 +++++++++++--- .../safe_outputs_validation_config.go | 22 ++++++++-------- 8 files changed, 105 insertions(+), 29 deletions(-) diff --git a/actions/setup/js/jira_client.cjs b/actions/setup/js/jira_client.cjs index 2071c1a51bd..8b477dfa643 100644 --- a/actions/setup/js/jira_client.cjs +++ b/actions/setup/js/jira_client.cjs @@ -3,6 +3,7 @@ const { sanitizeContent } = require("./sanitize_content.cjs"); const JIRA_API_PATH = "/rest/api/3"; +const JIRA_REQUEST_TIMEOUT_MS = 30_000; function logJiraDebug(message) { if (global.core && typeof global.core.debug === "function") { @@ -100,9 +101,12 @@ function createJiraClient(env = process.env, fetchImpl = global.fetch) { const method = options.method || "GET"; logJiraDebug(`Jira API request started: ${method} ${normalizedPath}`); let response; + const abortController = new AbortController(); + const timeout = setTimeout(() => abortController.abort(), JIRA_REQUEST_TIMEOUT_MS); try { response = await fetchImpl(url, { method, + signal: abortController.signal, headers: { Accept: "application/json", Authorization: authorization, @@ -113,6 +117,8 @@ function createJiraClient(env = process.env, fetchImpl = global.fetch) { } catch { logJiraDebug(`Jira API request failed before receiving a response: ${method} ${normalizedPath}`); throw new Error("Jira API request failed due to a network error"); + } finally { + clearTimeout(timeout); } logJiraDebug(`Jira API response received: ${method} ${normalizedPath} status=${response.status}`); @@ -141,6 +147,7 @@ function createJiraClient(env = process.env, fetchImpl = global.fetch) { module.exports = { JIRA_API_PATH, + JIRA_REQUEST_TIMEOUT_MS, createJiraClient, formatJiraError, normalizeJiraBaseUrl, diff --git a/actions/setup/js/jira_client.test.cjs b/actions/setup/js/jira_client.test.cjs index 786a1849810..f6cc62291fb 100644 --- a/actions/setup/js/jira_client.test.cjs +++ b/actions/setup/js/jira_client.test.cjs @@ -1,7 +1,7 @@ // @ts-check import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -const { createJiraClient, formatJiraError, normalizeJiraBaseUrl, textToADF } = require("./jira_client.cjs"); +const { createJiraClient, formatJiraError, JIRA_REQUEST_TIMEOUT_MS, normalizeJiraBaseUrl, textToADF } = require("./jira_client.cjs"); beforeEach(() => { global.core = { debug: vi.fn() }; @@ -68,6 +68,29 @@ describe("jira client", () => { expect(global.core.debug.mock.calls.flat().join(" ")).not.toMatch(/secret-token|jira@example\.com|Basic /); }); + it("bounds Jira requests with an abort signal", async () => { + const fetchMock = vi.fn(async (_url, options) => { + expect(options.signal).toBeInstanceOf(AbortSignal); + return { + ok: true, + status: 204, + statusText: "No Content", + text: async () => "", + }; + }); + const client = createJiraClient( + { + JIRA_BASE_URL: "https://example.atlassian.net", + JIRA_USER_EMAIL: "jira@example.com", + JIRA_API_TOKEN: "secret-token", + }, + fetchMock + ); + + await client.request("/issue/ENG-1", { method: "PUT" }); + expect(JIRA_REQUEST_TIMEOUT_MS).toBe(30_000); + }); + it("surfaces structured Jira errors without leaking credentials", async () => { const fetchMock = vi.fn(async () => ({ ok: false, diff --git a/actions/setup/js/jira_handlers.cjs b/actions/setup/js/jira_handlers.cjs index 6ececc8c8cb..d5229b42d88 100644 --- a/actions/setup/js/jira_handlers.cjs +++ b/actions/setup/js/jira_handlers.cjs @@ -1,7 +1,6 @@ // @ts-check const { createCountGatedHandler } = require("./handler_scaffold.cjs"); -const { sanitizeContent } = require("./sanitize_content.cjs"); const { logStagedPreviewInfo } = require("./staged_preview.cjs"); const { createJiraClient, textToADF } = require("./jira_client.cjs"); @@ -9,11 +8,11 @@ function requiredString(value, field, maxLength = 255) { if (typeof value !== "string" || value.trim() === "") { throw new Error(`${field} must be a non-empty string`); } - const sanitized = sanitizeContent(value.trim(), maxLength); - if (!sanitized) { - throw new Error(`${field} must contain valid text`); + const text = value.trim(); + if (text.length > maxLength) { + throw new Error(`${field} must be at most ${maxLength} characters`); } - return sanitized; + return text; } function optionalString(value, field, maxLength = 32767) { @@ -36,10 +35,10 @@ function jiraHandler(handlerType, handle) { core.debug(`${handlerType}: request completed successfully`); return result; } catch (error) { - const message = error instanceof Error ? error.message : "Jira operation failed"; + const errorMessage = error instanceof Error ? error.message : "Jira operation failed"; core.debug(`${handlerType}: request failed`); - core.error(message); - return { success: false, error: message }; + core.error(errorMessage); + return { success: false, error: errorMessage }; } }; }, @@ -126,6 +125,9 @@ const addComment = jiraHandler("jira_add_comment", async (message, client, isSta const addLabel = jiraHandler("jira_add_label", async (message, client, isStaged) => { const issueKey = requiredString(message.issue_key, "issue_key"); const label = requiredString(message.label, "label"); + if (!/^[A-Za-z0-9_.-]+$/.test(label)) { + throw new Error("label must contain only letters, numbers, periods, hyphens, and underscores"); + } if (isStaged) { logStagedPreviewInfo(`Jira add label — Issue: ${issueKey}; Label: ${label}`); diff --git a/actions/setup/js/jira_handlers.test.cjs b/actions/setup/js/jira_handlers.test.cjs index fe25840a8fb..1dbc8053836 100644 --- a/actions/setup/js/jira_handlers.test.cjs +++ b/actions/setup/js/jira_handlers.test.cjs @@ -109,6 +109,19 @@ describe("Jira safe-output handlers", () => { }); }); + it("preserves valid Jira text without GitHub-specific sanitization", async () => { + const handler = await createIssueMain({ max: 1 }); + await handler({ + project_key: "ENG", + issue_type: "Task", + summary: "Fix @deprecated flag", + description: "Use {{version}}.", + }); + + expect(requests[0].body.fields.summary).toBe("Fix @deprecated flag"); + expect(requests[0].body.fields.description.content[0].content[0].text).toBe("Use {{version}}."); + }); + it("adds one Jira label with additive update semantics", async () => { const handler = await addLabelMain({ max: 1 }); const result = await handler({ issue_key: "ENG-123", label: "needs-investigation" }); @@ -118,6 +131,15 @@ describe("Jira safe-output handlers", () => { expect(requests[0].body.fields).toBeUndefined(); }); + it("rejects labels that Jira cannot accept", async () => { + const handler = await addLabelMain({ max: 1 }); + await expect(handler({ issue_key: "ENG-123", label: "needs investigation" })).resolves.toMatchObject({ + success: false, + error: "label must contain only letters, numbers, periods, hyphens, and underscores", + }); + expect(requests).toHaveLength(0); + }); + it.each([ [createIssueMain, { project_key: "ENG", issue_type: "Task", summary: "Preview" }, "Jira create issue"], [updateIssueMain, { issue_key: "ENG-123", summary: "Preview" }, "Jira update issue"], diff --git a/actions/setup/js/safe_outputs_tools.json b/actions/setup/js/safe_outputs_tools.json index 2765491744f..5a2361e7851 100644 --- a/actions/setup/js/safe_outputs_tools.json +++ b/actions/setup/js/safe_outputs_tools.json @@ -90,24 +90,28 @@ "type": "string", "minLength": 1, "maxLength": 255, + "pattern": ".*\\S.*", "description": "Jira project key that owns the new issue, for example ENG." }, "issue_type": { "type": "string", "minLength": 1, "maxLength": 255, + "pattern": ".*\\S.*", "description": "Human-readable Jira issue type name, for example Task or Bug." }, "summary": { "type": "string", "minLength": 1, "maxLength": 255, + "pattern": ".*\\S.*", "description": "Short Jira issue summary, for example Investigate parser failure." }, "description": { "type": "string", "minLength": 1, "maxLength": 32767, + "pattern": ".*\\S.*", "description": "Optional plain-text Jira issue description. Converted to Atlassian Document Format internally." } }, @@ -125,18 +129,21 @@ "type": "string", "minLength": 1, "maxLength": 255, - "description": "Key of the existing Jira issue, for example ENG-123." + "pattern": ".*\\S.*", + "description": "Key of an already-existing Jira issue, for example ENG-123. Issues created by `jira_create_issue` in this run cannot be referenced." }, "summary": { "type": "string", "minLength": 1, "maxLength": 255, + "pattern": ".*\\S.*", "description": "Optional replacement Jira issue summary." }, "description": { "type": "string", "minLength": 1, "maxLength": 32767, + "pattern": ".*\\S.*", "description": "Optional replacement plain-text Jira issue description. Converted to Atlassian Document Format internally." } }, @@ -154,12 +161,14 @@ "type": "string", "minLength": 1, "maxLength": 255, - "description": "Key of the existing Jira issue, for example ENG-123." + "pattern": ".*\\S.*", + "description": "Key of an already-existing Jira issue, for example ENG-123. Issues created by `jira_create_issue` in this run cannot be referenced." }, "body": { "type": "string", "minLength": 1, "maxLength": 32767, + "pattern": ".*\\S.*", "description": "Plain-text comment body. Converted to Atlassian Document Format internally." } }, @@ -177,13 +186,15 @@ "type": "string", "minLength": 1, "maxLength": 255, - "description": "Key of the existing Jira issue, for example ENG-123." + "pattern": ".*\\S.*", + "description": "Key of an already-existing Jira issue, for example ENG-123. Issues created by `jira_create_issue` in this run cannot be referenced." }, "label": { "type": "string", "minLength": 1, "maxLength": 255, - "description": "One Jira label to add, for example needs-investigation. Existing labels are preserved." + "pattern": "^[A-Za-z0-9_.-]+$", + "description": "One Jira label to add, for example needs-investigation. Use letters, numbers, periods, hyphens, and underscores. Existing labels are preserved." } }, "additionalProperties": false diff --git a/docs/src/content/docs/reference/safe-outputs.md b/docs/src/content/docs/reference/safe-outputs.md index acea3649e90..4fd7e255974 100644 --- a/docs/src/content/docs/reference/safe-outputs.md +++ b/docs/src/content/docs/reference/safe-outputs.md @@ -143,7 +143,7 @@ safe-outputs: Use Jira safe outputs for Jira mutations. ``` -`JIRA_BASE_URL` is the Jira API base without `/rest/api/3`, such as `https://example.atlassian.net`. The initial authentication mechanism uses an Atlassian account email and API token with HTTP Basic authentication. +`JIRA_BASE_URL` is the Jira API base without `/rest/api/3`. For unscoped API tokens, use the site URL, such as `https://example.atlassian.net`. For scoped API tokens, use the Atlassian gateway URL, such as `https://api.atlassian.com/ex/jira/`. The initial authentication mechanism uses an Atlassian account email and API token with HTTP Basic authentication. Each output accepts `max` and `staged`. In staged mode, the handler writes a Jira-specific preview without requiring credentials or sending an HTTP request. diff --git a/pkg/workflow/js/safe_outputs_tools.json b/pkg/workflow/js/safe_outputs_tools.json index 2765491744f..5a2361e7851 100644 --- a/pkg/workflow/js/safe_outputs_tools.json +++ b/pkg/workflow/js/safe_outputs_tools.json @@ -90,24 +90,28 @@ "type": "string", "minLength": 1, "maxLength": 255, + "pattern": ".*\\S.*", "description": "Jira project key that owns the new issue, for example ENG." }, "issue_type": { "type": "string", "minLength": 1, "maxLength": 255, + "pattern": ".*\\S.*", "description": "Human-readable Jira issue type name, for example Task or Bug." }, "summary": { "type": "string", "minLength": 1, "maxLength": 255, + "pattern": ".*\\S.*", "description": "Short Jira issue summary, for example Investigate parser failure." }, "description": { "type": "string", "minLength": 1, "maxLength": 32767, + "pattern": ".*\\S.*", "description": "Optional plain-text Jira issue description. Converted to Atlassian Document Format internally." } }, @@ -125,18 +129,21 @@ "type": "string", "minLength": 1, "maxLength": 255, - "description": "Key of the existing Jira issue, for example ENG-123." + "pattern": ".*\\S.*", + "description": "Key of an already-existing Jira issue, for example ENG-123. Issues created by `jira_create_issue` in this run cannot be referenced." }, "summary": { "type": "string", "minLength": 1, "maxLength": 255, + "pattern": ".*\\S.*", "description": "Optional replacement Jira issue summary." }, "description": { "type": "string", "minLength": 1, "maxLength": 32767, + "pattern": ".*\\S.*", "description": "Optional replacement plain-text Jira issue description. Converted to Atlassian Document Format internally." } }, @@ -154,12 +161,14 @@ "type": "string", "minLength": 1, "maxLength": 255, - "description": "Key of the existing Jira issue, for example ENG-123." + "pattern": ".*\\S.*", + "description": "Key of an already-existing Jira issue, for example ENG-123. Issues created by `jira_create_issue` in this run cannot be referenced." }, "body": { "type": "string", "minLength": 1, "maxLength": 32767, + "pattern": ".*\\S.*", "description": "Plain-text comment body. Converted to Atlassian Document Format internally." } }, @@ -177,13 +186,15 @@ "type": "string", "minLength": 1, "maxLength": 255, - "description": "Key of the existing Jira issue, for example ENG-123." + "pattern": ".*\\S.*", + "description": "Key of an already-existing Jira issue, for example ENG-123. Issues created by `jira_create_issue` in this run cannot be referenced." }, "label": { "type": "string", "minLength": 1, "maxLength": 255, - "description": "One Jira label to add, for example needs-investigation. Existing labels are preserved." + "pattern": "^[A-Za-z0-9_.-]+$", + "description": "One Jira label to add, for example needs-investigation. Use letters, numbers, periods, hyphens, and underscores. Existing labels are preserved." } }, "additionalProperties": false diff --git a/pkg/workflow/safe_outputs_validation_config.go b/pkg/workflow/safe_outputs_validation_config.go index 297fdd2a132..855e8baf2df 100644 --- a/pkg/workflow/safe_outputs_validation_config.go +++ b/pkg/workflow/safe_outputs_validation_config.go @@ -105,33 +105,33 @@ var ValidationConfig = map[string]TypeValidationConfig{ "jira_create_issue": { DefaultMax: 1, Fields: map[string]FieldValidation{ - "project_key": {Required: true, Type: "string", Sanitize: true, MinLength: 1, MaxLength: 255}, - "issue_type": {Required: true, Type: "string", Sanitize: true, MinLength: 1, MaxLength: 255}, - "summary": {Required: true, Type: "string", Sanitize: true, MinLength: 1, MaxLength: 255}, - "description": {Type: "string", Sanitize: true, MinLength: 1, MaxLength: 32767}, + "project_key": {Required: true, Type: "string", MinLength: 1, MaxLength: 255, Pattern: ".*\\S.*", PatternError: "must not be empty"}, + "issue_type": {Required: true, Type: "string", MinLength: 1, MaxLength: 255, Pattern: ".*\\S.*", PatternError: "must not be empty"}, + "summary": {Required: true, Type: "string", MinLength: 1, MaxLength: 255, Pattern: ".*\\S.*", PatternError: "must not be empty"}, + "description": {Type: "string", MinLength: 1, MaxLength: 32767, Pattern: ".*\\S.*", PatternError: "must not be empty"}, }, }, "jira_update_issue": { DefaultMax: 1, CustomValidation: "requiresOneOf:summary,description", Fields: map[string]FieldValidation{ - "issue_key": {Required: true, Type: "string", Sanitize: true, MinLength: 1, MaxLength: 255}, - "summary": {Type: "string", Sanitize: true, MinLength: 1, MaxLength: 255}, - "description": {Type: "string", Sanitize: true, MinLength: 1, MaxLength: 32767}, + "issue_key": {Required: true, Type: "string", MinLength: 1, MaxLength: 255, Pattern: ".*\\S.*", PatternError: "must not be empty"}, + "summary": {Type: "string", MinLength: 1, MaxLength: 255, Pattern: ".*\\S.*", PatternError: "must not be empty"}, + "description": {Type: "string", MinLength: 1, MaxLength: 32767, Pattern: ".*\\S.*", PatternError: "must not be empty"}, }, }, "jira_add_comment": { DefaultMax: 1, Fields: map[string]FieldValidation{ - "issue_key": {Required: true, Type: "string", Sanitize: true, MinLength: 1, MaxLength: 255}, - "body": {Required: true, Type: "string", Sanitize: true, MinLength: 1, MaxLength: 32767}, + "issue_key": {Required: true, Type: "string", MinLength: 1, MaxLength: 255, Pattern: ".*\\S.*", PatternError: "must not be empty"}, + "body": {Required: true, Type: "string", MinLength: 1, MaxLength: 32767, Pattern: ".*\\S.*", PatternError: "must not be empty"}, }, }, "jira_add_label": { DefaultMax: 1, Fields: map[string]FieldValidation{ - "issue_key": {Required: true, Type: "string", Sanitize: true, MinLength: 1, MaxLength: 255}, - "label": {Required: true, Type: "string", Sanitize: true, MinLength: 1, MaxLength: 255}, + "issue_key": {Required: true, Type: "string", MinLength: 1, MaxLength: 255, Pattern: ".*\\S.*", PatternError: "must not be empty"}, + "label": {Required: true, Type: "string", MinLength: 1, MaxLength: 255, Pattern: "^[A-Za-z0-9_.-]+$", PatternError: "must contain only letters, numbers, periods, hyphens, and underscores"}, }, }, "comment_memory": { From c1848fe6b2e08617545262c007752ccd9e365cb2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 2 Sep 2026 05:37:14 +0000 Subject: [PATCH 8/8] Resolve merge conflicts with main Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/linear_add_comment.cjs | 1 + 1 file changed, 1 insertion(+) diff --git a/actions/setup/js/linear_add_comment.cjs b/actions/setup/js/linear_add_comment.cjs index e6541cd5c1e..c26650e6ece 100644 --- a/actions/setup/js/linear_add_comment.cjs +++ b/actions/setup/js/linear_add_comment.cjs @@ -16,6 +16,7 @@ const LINEAR_COMMENT_CREATE = `mutation LinearAddComment($input: CommentCreateIn } } }`; + async function main(config = {}) { const target = config.target; if (typeof target !== "string" || target.length > 100 || !LINEAR_ISSUE_PATTERN.test(target)) {