Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/aw/designer-mappings.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down
29 changes: 29 additions & 0 deletions .github/aw/safe-outputs-content.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions actions/setup/js/jira_add_comment.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
// @ts-check

const { addComment } = require("./jira_handlers.cjs");

module.exports = { main: addComment };
5 changes: 5 additions & 0 deletions actions/setup/js/jira_add_label.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
// @ts-check

const { addLabel } = require("./jira_handlers.cjs");

module.exports = { main: addLabel };
155 changes: 155 additions & 0 deletions actions/setup/js/jira_client.cjs
Original file line number Diff line number Diff line change
@@ -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) }),
});
Comment on lines +107 to +116
} 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,
};
126 changes: 126 additions & 0 deletions actions/setup/js/jira_client.test.cjs
Original file line number Diff line number Diff line change
@@ -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");
});
});
5 changes: 5 additions & 0 deletions actions/setup/js/jira_create_issue.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
// @ts-check

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

actions/setup/js/jira_create_issue.cjs:L1: delete: one-file wrapper for each Jira op. A single dispatch module or direct export would remove four nearly identical files.


const { createIssue } = require("./jira_handlers.cjs");

module.exports = { main: createIssue };
Loading
Loading