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
5 changes: 5 additions & 0 deletions .changeset/minor-add-linear-safe-outputs.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

14 changes: 14 additions & 0 deletions actions/setup/js/generate_safe_outputs_tools.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -383,6 +383,20 @@ async function main() {
enhancedTool.description = updateAddCommentDescription(enhancedTool.description, config.add_comment);
}

if (tool.name === "linear_update_issue") {
const linearUpdateConfig = config.linear_update_issue;
const properties = enhancedTool.inputSchema?.properties;
if (properties && linearUpdateConfig && typeof linearUpdateConfig === "object") {
if (!("allow_title" in linearUpdateConfig) || linearUpdateConfig.allow_title !== true) {
delete properties.title;
}
if (!("allow_body" in linearUpdateConfig) || linearUpdateConfig.allow_body !== true) {
delete properties.body;
}
enhancedTool.inputSchema.anyOf = Object.keys(properties).map(field => ({ required: [field] }));
}
}

// Add repo parameter to inputSchema if configured
const repoParam = toolsMeta.repo_params?.[tool.name];
if (repoParam) {
Expand Down
53 changes: 53 additions & 0 deletions actions/setup/js/linear_add_comment.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
// @ts-check
/// <reference types="@actions/github-script" />

const { sanitizeContent } = require("./sanitize_content.cjs");
const { LINEAR_ISSUE_PATTERN, linearGraphQL } = require("./linear_graphql.cjs");
const { isStagedMode } = require("./safe_output_helpers.cjs");
const { logStagedPreviewInfo } = require("./staged_preview.cjs");
const { ERR_API, ERR_CONFIG, ERR_VALIDATION } = require("./error_codes.cjs");

const LINEAR_COMMENT_CREATE = `mutation LinearAddComment($input: CommentCreateInput!) {
commentCreate(input: $input) {
success
comment {
id
body
}
}
}`;
async function main(config = {}) {
const target = config.target;
if (typeof target !== "string" || target.length > 100 || !LINEAR_ISSUE_PATTERN.test(target)) {
throw new Error(`${ERR_CONFIG}: linear_add_comment requires a valid configured target`);
}

return async function handleLinearAddComment(item) {
if (typeof item?.body !== "string" || !item.body.trim()) {
throw new Error(`${ERR_VALIDATION}: linear_add_comment body is required`);
}
if (item.body.length > 65000) {
throw new Error(`${ERR_VALIDATION}: linear_add_comment body exceeds 65000 characters`);
}
const body = sanitizeContent(item.body);
if (!body.trim()) {
throw new Error(`${ERR_VALIDATION}: linear_add_comment body is empty after sanitization`);
}

if (isStagedMode(config)) {
logStagedPreviewInfo(`Would add a comment to Linear issue ${target}`);
return { success: true, staged: true, target };
}

const data = await linearGraphQL(LINEAR_COMMENT_CREATE, {
input: { issueId: target, body },
});
const payload = data?.commentCreate;
if (payload?.success !== true || !payload.comment) {
throw new Error(`${ERR_API}: Linear commentCreate did not return a successful comment`);
}
return { success: true, id: payload.comment.id, target };
};
}

module.exports = { LINEAR_COMMENT_CREATE, main };
66 changes: 66 additions & 0 deletions actions/setup/js/linear_create_issue.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
// @ts-check
/// <reference types="@actions/github-script" />

const { sanitizeTitle } = require("./sanitize_title.cjs");
const { sanitizeContent } = require("./sanitize_content.cjs");
const { linearGraphQL } = require("./linear_graphql.cjs");
const { isStagedMode } = require("./safe_output_helpers.cjs");
const { logStagedPreviewInfo } = require("./staged_preview.cjs");
const { ERR_API, ERR_CONFIG, ERR_VALIDATION } = require("./error_codes.cjs");

const LINEAR_CREATE_ISSUE = `mutation LinearCreateIssue($input: IssueCreateInput!) {
issueCreate(input: $input) {
success
issue {
id
identifier
title
}
}
}`;

async function main(config = {}) {
const teamId = config.team_id;
if (typeof teamId !== "string" || !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(teamId)) {
throw new Error(`${ERR_CONFIG}: linear_create_issue requires a valid configured team ID`);
}

return async function handleLinearCreateIssue(item) {
if (typeof item?.title !== "string" || !item.title.trim()) {
throw new Error(`${ERR_VALIDATION}: linear_create_issue title is required`);
}
if (typeof item?.body !== "string" || !item.body.trim()) {
throw new Error(`${ERR_VALIDATION}: linear_create_issue body is required`);
}
if (item.title.length > 128 || item.body.length > 65000 || item.body.length < 20) {
throw new Error(`${ERR_VALIDATION}: linear_create_issue content is outside the configured field limits`);
}

const title = sanitizeTitle(item.title);
const description = sanitizeContent(item.body);
if (!title) {
throw new Error(`${ERR_VALIDATION}: linear_create_issue title is empty after sanitization`);
}

if (isStagedMode(config)) {
logStagedPreviewInfo(`Would create Linear issue "${title}"`);
return { success: true, staged: true, title };
}

const data = await linearGraphQL(LINEAR_CREATE_ISSUE, {
input: { teamId, title, description },
});
const payload = data?.issueCreate;
if (payload?.success !== true || !payload.issue) {
throw new Error(`${ERR_API}: Linear issueCreate did not return a successful issue`);
}
return {
success: true,
id: payload.issue.id,
identifier: payload.issue.identifier,
title: payload.issue.title,
};
};
}

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

const { ERR_API, ERR_CONFIG, ERR_PARSE } = require("./error_codes.cjs");

const LINEAR_GRAPHQL_ENDPOINT = "https://api.linear.app/graphql";
const LINEAR_ISSUE_PATTERN = /^([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|[A-Z][A-Z0-9]{0,15}-[1-9][0-9]*)$/i;

function redactToken(value, token) {
const text = String(value || "");
return token ? text.split(token).join("***") : text;
}

async function linearGraphQL(query, variables, token = process.env.GH_AW_LINEAR_TOKEN) {
if (!token) {
throw new Error(`${ERR_CONFIG}: Linear API token is not configured`);
}

let response;
try {
response = await fetch(LINEAR_GRAPHQL_ENDPOINT, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: token,
},
body: JSON.stringify({ query, variables }),
signal: AbortSignal.timeout(30_000),
});
} catch {
throw new Error(`${ERR_API}: Linear request failed: network error`);
}

if (!response.ok) {
const detail = response.status === 429 ? "rate limit exceeded" : `HTTP ${response.status}`;
throw new Error(`${ERR_API}: Linear request failed: ${detail}`);
}

let payload;
try {
payload = await response.json();
} catch {
throw new Error(`${ERR_PARSE}: Linear returned a malformed JSON response`);
}

if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
throw new Error(`${ERR_PARSE}: Linear returned an invalid GraphQL response`);
}
if (Array.isArray(payload.errors) && payload.errors.length > 0) {
const message = redactToken(payload.errors[0]?.message || "unknown GraphQL error", token).slice(0, 500);
throw new Error(`${ERR_API}: Linear GraphQL operation failed: ${message}`);
}

return payload.data;
}

module.exports = { LINEAR_GRAPHQL_ENDPOINT, LINEAR_ISSUE_PATTERN, linearGraphQL };
101 changes: 101 additions & 0 deletions actions/setup/js/linear_safe_outputs.test.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createRequire } from "module";

const require = createRequire(import.meta.url);
const { LINEAR_GRAPHQL_ENDPOINT, linearGraphQL } = require("./linear_graphql.cjs");
const { LINEAR_CREATE_ISSUE, main: createIssue } = require("./linear_create_issue.cjs");
const { LINEAR_COMMENT_CREATE, main: addComment } = require("./linear_add_comment.cjs");
const { LINEAR_UPDATE_ISSUE, main: updateIssue } = require("./linear_update_issue.cjs");

global.core = { info: vi.fn(), warning: vi.fn(), debug: vi.fn() };

function response(payload, status = 200) {
return {
ok: status >= 200 && status < 300,
status,
json: vi.fn().mockResolvedValue(payload),
};
}

describe("Linear safe outputs", () => {
beforeEach(() => {
process.env.GH_AW_LINEAR_TOKEN = "linear-secret";
global.fetch = vi.fn();
vi.clearAllMocks();
});

afterEach(() => {
delete process.env.GH_AW_LINEAR_TOKEN;
delete global.fetch;
});

it("posts fixed GraphQL documents with variables and raw API-key authorization", async () => {
fetch.mockResolvedValue(response({ data: { issueCreate: { success: true, issue: { id: "id", identifier: "ENG-1", title: "Safe title" } } } }));
const handler = await createIssue({ team_id: "9cfb482a-81e3-4154-b5b9-2c805e70a02d" });
await handler({ title: "Safe title", body: "Detailed hello to @user" });

expect(fetch).toHaveBeenCalledWith(
LINEAR_GRAPHQL_ENDPOINT,
expect.objectContaining({
method: "POST",
headers: { "Content-Type": "application/json", Authorization: "linear-secret" },
})
);
const request = JSON.parse(fetch.mock.calls[0][1].body);
expect(request.query).toBe(LINEAR_CREATE_ISSUE);
expect(request.query).not.toContain("Safe title");
expect(request.variables.input).toEqual({
teamId: "9cfb482a-81e3-4154-b5b9-2c805e70a02d",
title: "Safe title",
description: "Detailed hello to `@user`",
});
});

it("creates a comment against only the configured target", async () => {
fetch.mockResolvedValue(response({ data: { commentCreate: { success: true, comment: { id: "comment-id", body: "body" } } } }));
const handler = await addComment({ target: "ENG-123" });
await handler({ body: "Comment @team" });

const request = JSON.parse(fetch.mock.calls[0][1].body);
expect(request.query).toBe(LINEAR_COMMENT_CREATE);
expect(request.variables).toEqual({ input: { issueId: "ENG-123", body: "Comment `@team`" } });
});

it("updates only enabled fields against the configured target", async () => {
fetch.mockResolvedValue(response({ data: { issueUpdate: { success: true, issue: { id: "id", identifier: "ENG-123", title: "New" } } } }));
const handler = await updateIssue({ target: "ENG-123", allow_title: true });
await expect(handler({ body: "not enabled" })).rejects.toThrow("body updates are not enabled");
await handler({ title: "New @owner" });

const request = JSON.parse(fetch.mock.calls[0][1].body);
expect(request.query).toBe(LINEAR_UPDATE_ISSUE);
expect(request.variables).toEqual({ id: "ENG-123", input: { title: "New `@owner`" } });
});

it("does not perform network requests in staged mode", async () => {
const handler = await addComment({ target: "ENG-123", staged: true });
await expect(handler({ body: "Preview" })).resolves.toMatchObject({ success: true, staged: true });
expect(fetch).not.toHaveBeenCalled();
});

it("rejects HTTP, malformed JSON, GraphQL, and unsuccessful mutation responses", async () => {
fetch.mockResolvedValueOnce(response({}, 429));
await expect(linearGraphQL("query Fixed { viewer { id } }", {})).rejects.toThrow("rate limit exceeded");

fetch.mockResolvedValueOnce({ ok: true, status: 200, json: vi.fn().mockRejectedValue(new Error("bad")) });
await expect(linearGraphQL("query Fixed { viewer { id } }", {})).rejects.toThrow("malformed JSON");

fetch.mockResolvedValueOnce(response({ errors: [{ message: "denied linear-secret" }] }));
await expect(linearGraphQL("query Fixed { viewer { id } }", {})).rejects.not.toThrow("linear-secret");

fetch.mockResolvedValueOnce(response({ data: { issueCreate: { success: false, issue: null } } }));
const handler = await createIssue({ team_id: "9cfb482a-81e3-4154-b5b9-2c805e70a02d" });
await expect(handler({ title: "Title", body: "Body with enough detail" })).rejects.toThrow("did not return a successful issue");
});

it("rejects oversized content instead of truncating it", async () => {
const handler = await addComment({ target: "ENG-123" });
await expect(handler({ body: "x".repeat(65001) })).rejects.toThrow("exceeds 65000 characters");
expect(fetch).not.toHaveBeenCalled();
});
});
78 changes: 78 additions & 0 deletions actions/setup/js/linear_update_issue.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
// @ts-check
/// <reference types="@actions/github-script" />

const { sanitizeTitle } = require("./sanitize_title.cjs");
const { sanitizeContent } = require("./sanitize_content.cjs");
const { LINEAR_ISSUE_PATTERN, linearGraphQL } = require("./linear_graphql.cjs");
const { isStagedMode } = require("./safe_output_helpers.cjs");
const { logStagedPreviewInfo } = require("./staged_preview.cjs");
const { ERR_API, ERR_CONFIG, ERR_VALIDATION } = require("./error_codes.cjs");

const LINEAR_UPDATE_ISSUE = `mutation LinearUpdateIssue($id: String!, $input: IssueUpdateInput!) {
issueUpdate(id: $id, input: $input) {
success
issue {
id
identifier
title
}
}
}`;

async function main(config = {}) {
const target = config.target;
if (typeof target !== "string" || target.length > 100 || !LINEAR_ISSUE_PATTERN.test(target)) {
throw new Error(`${ERR_CONFIG}: linear_update_issue requires a valid configured target`);
}
if (config.allow_title !== true && config.allow_body !== true) {
throw new Error(`${ERR_CONFIG}: linear_update_issue must enable title or body updates`);
}

return async function handleLinearUpdateIssue(item) {
if (item?.title === undefined && item?.body === undefined) {
throw new Error(`${ERR_VALIDATION}: linear_update_issue requires title or body`);
}
if (item.title !== undefined && config.allow_title !== true) {
throw new Error(`${ERR_VALIDATION}: linear_update_issue title updates are not enabled`);
}
if (item.body !== undefined && config.allow_body !== true) {
throw new Error(`${ERR_VALIDATION}: linear_update_issue body updates are not enabled`);
}
if (item.title !== undefined && (typeof item.title !== "string" || !item.title.trim() || item.title.length > 128)) {
throw new Error(`${ERR_VALIDATION}: linear_update_issue title must be a non-empty string of at most 128 characters`);
}
if (item.body !== undefined && (typeof item.body !== "string" || item.body.length > 65000)) {
throw new Error(`${ERR_VALIDATION}: linear_update_issue body must be a string of at most 65000 characters`);
}

const input = {};
if (item.title !== undefined) {
input.title = sanitizeTitle(item.title);
if (!input.title) {
throw new Error(`${ERR_VALIDATION}: linear_update_issue title is empty after sanitization`);
}
}
if (item.body !== undefined) {
input.description = sanitizeContent(item.body);
}

if (isStagedMode(config)) {
logStagedPreviewInfo(`Would update Linear issue ${target}`);
return { success: true, staged: true, target };
}

const data = await linearGraphQL(LINEAR_UPDATE_ISSUE, { id: target, input });
const payload = data?.issueUpdate;
if (payload?.success !== true || !payload.issue) {
throw new Error(`${ERR_API}: Linear issueUpdate did not return a successful issue`);
}
return {
success: true,
id: payload.issue.id,
identifier: payload.issue.identifier,
title: payload.issue.title,
};
};
}

module.exports = { LINEAR_UPDATE_ISSUE, main };
Loading
Loading