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/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..8b477dfa643 --- /dev/null +++ b/actions/setup/js/jira_client.cjs @@ -0,0 +1,155 @@ +// @ts-check + +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") { + global.core.debug(message); + } +} + +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 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"); + } + 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]; + 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; + 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, + "Content-Type": "application/json", + }, + ...(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"); + } finally { + clearTimeout(timeout); + } + + logJiraDebug(`Jira API response received: ${method} ${normalizedPath} status=${response.status}`); + const responseText = await response.text(); + let responseBody = null; + if (responseText) { + try { + 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; + }, + }; +} + +module.exports = { + JIRA_API_PATH, + JIRA_REQUEST_TIMEOUT_MS, + 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..f6cc62291fb --- /dev/null +++ b/actions/setup/js/jira_client.test.cjs @@ -0,0 +1,126 @@ +// @ts-check +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const { createJiraClient, formatJiraError, JIRA_REQUEST_TIMEOUT_MS, normalizeJiraBaseUrl, textToADF } = require("./jira_client.cjs"); + +beforeEach(() => { + global.core = { debug: vi.fn() }; +}); + +afterEach(() => { + delete global.core; + 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("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", + 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"); + 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("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, + 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..d5229b42d88 --- /dev/null +++ b/actions/setup/js/jira_handlers.cjs @@ -0,0 +1,144 @@ +// @ts-check + +const { createCountGatedHandler } = require("./handler_scaffold.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 text = value.trim(); + if (text.length > maxLength) { + throw new Error(`${field} must be at most ${maxLength} characters`); + } + return text; +} + +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) => { + core.debug(`${handlerType}: initializing handler (staged=${isStaged})`); + const client = isStaged ? null : createJiraClient(); + return async message => { + core.debug(`${handlerType}: processing request`); + try { + const result = await handle(message || {}, client, isStaged); + core.debug(`${handlerType}: request completed successfully`); + return result; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : "Jira operation failed"; + core.debug(`${handlerType}: request failed`); + core.error(errorMessage); + return { success: false, error: errorMessage }; + } + }; + }, + }); +} + +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 (!/^[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}`); + 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..1dbc8053836 --- /dev/null +++ b/actions/setup/js/jira_handlers.test.cjs @@ -0,0 +1,184 @@ +// @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); + expect(global.core.debug).toHaveBeenCalledWith("jira_create_issue: processing request"); + expect(global.core.debug).toHaveBeenCalledWith("jira_create_issue: request completed successfully"); + }); + + 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("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" }); + + 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("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"], + [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"); + expect(global.core.debug).toHaveBeenCalledWith("jira_create_issue: request failed"); + }); +}); + +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/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)) { diff --git a/actions/setup/js/safe_output_handler_manager.cjs b/actions/setup/js/safe_output_handler_manager.cjs index 2a1413cd035..cc8e632ce0c 100644 --- a/actions/setup/js/safe_output_handler_manager.cjs +++ b/actions/setup/js/safe_output_handler_manager.cjs @@ -41,6 +41,10 @@ const HANDLER_MAP = { linear_add_comment: "./linear_add_comment.cjs", linear_update_issue: "./linear_update_issue.cjs", 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", @@ -131,6 +135,9 @@ const THREAT_WARNING_REVIEWABLE_TYPES = new Set([ "linear_create_issue", "linear_add_comment", "create_issue", + "jira_create_issue", + "jira_update_issue", + "jira_add_comment", "add_comment", "create_pull_request", "comment_memory", @@ -181,6 +188,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.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_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) diff --git a/actions/setup/js/safe_outputs_tools.json b/actions/setup/js/safe_outputs_tools.json index b5c18a131f8..ae38dee2ed4 100644 --- a/actions/setup/js/safe_outputs_tools.json +++ b/actions/setup/js/safe_outputs_tools.json @@ -145,6 +145,127 @@ "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, + "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." + } + }, + "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, + "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." + } + }, + "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, + "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." + } + }, + "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, + "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, + "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 + } + }, { "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/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.* 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 9500b140a8b..9c6553f8537 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 | @@ -138,6 +147,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`. 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. + +| 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] 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 71b627e9640..aca11c5002f 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." + } + ] + }, "linear-create-issue": { "type": "object", "description": "Experimental. Create Linear issues through the isolated safe_outputs job.", 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 b5c18a131f8..ae38dee2ed4 100644 --- a/pkg/workflow/js/safe_outputs_tools.json +++ b/pkg/workflow/js/safe_outputs_tools.json @@ -145,6 +145,127 @@ "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, + "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." + } + }, + "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, + "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." + } + }, + "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, + "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." + } + }, + "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, + "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, + "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 + } + }, { "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 77ca56b4292..900b122e853 100644 --- a/pkg/workflow/safe_output_handlers.go +++ b/pkg/workflow/safe_output_handlers.go @@ -51,6 +51,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_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_config_extraction.go b/pkg/workflow/safe_outputs_config_extraction.go index 14b5c29d3e4..483894501fe 100644 --- a/pkg/workflow/safe_outputs_config_extraction.go +++ b/pkg/workflow/safe_outputs_config_extraction.go @@ -43,7 +43,7 @@ package workflow // extractSafeOutputsConfig extracts output configuration from frontmatter // -//nolint:largefunc // Legacy extraction remains centralized while handler parsers are incrementally migrated. +//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") @@ -65,6 +65,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 5ca97f98dc6..7c87cf25a04 100644 --- a/pkg/workflow/safe_outputs_config_types.go +++ b/pkg/workflow/safe_outputs_config_types.go @@ -60,6 +60,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 8dfea42ae6f..bd592a9276e 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, linearHandlerRegistry, 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 7d5750ace1f..5766e361050 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"}}, {name: "linearHandlerRegistry", registry: linearHandlerRegistry, wantKeys: []string{"linear_create_issue", "linear_add_comment", "linear_update_issue"}}, @@ -100,6 +101,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_max_validation.go b/pkg/workflow/safe_outputs_max_validation.go index 221bfec427e..8f6cbb72313 100644 --- a/pkg/workflow/safe_outputs_max_validation.go +++ b/pkg/workflow/safe_outputs_max_validation.go @@ -56,7 +56,7 @@ func checkMaxField(toolName string, maxPtr *string) error { // 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 keeps this hot-path validation allocation-free. +//nolint:largefunc // Direct field access intentionally keeps all safe-output max checks together. func validateSafeOutputsMax(config *SafeOutputsConfig) error { if config == nil { return nil @@ -183,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_state.go b/pkg/workflow/safe_outputs_state.go index 51039b8b6f4..340622d50f9 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 || @@ -108,6 +109,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 || diff --git a/pkg/workflow/safe_outputs_validation_config.go b/pkg/workflow/safe_outputs_validation_config.go index da2fe5b5e9b..65076ce9285 100644 --- a/pkg/workflow/safe_outputs_validation_config.go +++ b/pkg/workflow/safe_outputs_validation_config.go @@ -127,6 +127,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", 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", 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", 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", 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": { DefaultMax: 1, Fields: map[string]FieldValidation{ @@ -590,7 +622,7 @@ var validationConfigJSONCache sync.Map // key: string → value: string // GetValidationConfigJSONWithDataSchema behaves like GetValidationConfigJSONWithDataSchema and additionally // injects a normalized data schema into body-bearing safe-output types. // -//nolint:largefunc // Validation schema assembly remains centralized for deterministic caching. +//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)