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
10 changes: 9 additions & 1 deletion .github/scripts/test_aw_issue_clustering_publish.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,7 @@ test("live queue refuses an eleventh issue", async () => {

test("new summaries use attributed issue creation and preserve managed metadata", async () => {
const { setupGlobals, createIssue } = require("../../actions/setup/js/index.cjs");
const { renderIssueParameters } = require("../../actions/setup/js/issue_report.cjs");
const data = corpus();
const calls = [];
const github = {
Expand All @@ -261,6 +262,13 @@ test("new summaries use attributed issue creation and preserve managed metadata"
GH_AW_CALLER_WORKFLOW_ID: "github/gh-aw/aw-issue-clustering",
GH_AW_PROMPTS_DIR: path.resolve(__dirname, "../../actions/setup/md"),
GH_AW_SAFE_OUTPUTS_STAGED: "false",
GITHUB_REPOSITORY: "github/gh-aw",
GITHUB_REPOSITORY_ID: "123",
GITHUB_SERVER_URL: "https://github.com",
GITHUB_RUN_ID: "123",
GITHUB_RUN_ATTEMPT: "1",
GITHUB_JOB: "safe_outputs",
GH_AW_JOB_CHECK_RUN_ID: "456",
};
const previousEnv = Object.fromEntries(Object.keys(env).map(key => [key, process.env[key]]));
const globalKeys = ["core", "github", "context", "exec", "io", "getOctokit", "runtimeFeatures", "hasRuntimeFeature", "getRuntimeFeatureValue"];
Expand All @@ -271,7 +279,7 @@ test("new summaries use attributed issue creation and preserve managed metadata"
setupGlobals(core, github, { repo: { owner: "github", repo: "gh-aw" }, runId: 123, payload: {} }, {}, {}, () => github);
const result = await reconcile(github, "github", "gh-aw", [cluster()], data, false, core, createIssue);
assert.equal(calls.length, 1);
assert.ok(calls[0].body.includes(island(cluster(), 1, repo)));
assert.ok(calls[0].body.includes(renderIssueParameters({ title: "Summary", body: island(cluster(), 1, repo) }).body.trimEnd()));
assert.match(calls[0].body, /> Generated by \[AW Essential Issue Clustering\]/);
assert.match(calls[0].body, /gh-aw-agentic-workflow: AW Essential Issue Clustering/);
assert.ok(calls[0].body.includes("https://github.com/github/gh-aw/actions/runs/123"));
Expand Down
59 changes: 59 additions & 0 deletions actions/setup/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,65 @@ All files are copied from the committed `js/` and `sh/` directories which contai

## Development

### Structured issue reports

All JavaScript GitHub issue creators publish through `js/issue_report.cjs`.
`createReportIssue(client, parameters, report?)` renders the report AST before
calling the supplied authenticated client's issue API. Existing issue Markdown
is imported when no document is supplied. GraphQL work-queue creation uses the
same `renderIssueParameters` boundary, as do staged safe-output previews.
Linear uses the Markdown renderer. Jira uses text-preserving normalization before
converting its literal description to ADF, without replacing ordinary language
characters with visually similar Latin letters.

`js/report_ast.cjs` defines the typed inline and block nodes, normalization, and
GitHub Markdown renderer. The renderer owns escaping, HTTPS link validation,
heading levels, balanced details, code fences, and whitespace. It rejects raw
HTML/Markdown nodes and redacts diagnostic secrets before choosing delimiters.

`js/failure_report.cjs` builds reports from `md/agent_failure_issue.md`.
`js/report_markdown.cjs` imports the runtime template dialect: headings,
paragraphs, emphasis, links, images, bare HTTPS URLs, code, lists, task lists,
tables, quotes, alerts, separators, and details. Link destinations retain balanced
parentheses, and image sources use the same HTTPS validation as links.
Source paragraph newlines and explicit Markdown hard breaks use typed break nodes;
newlines in scalar metadata still normalize to spaces.
Templates are parsed **before** scalar metadata is substituted. Diagnostic
fragments enter through explicit block fields, with headings below the report
sections; unsupported HTML remains literal text. New programmatic report content
should use AST nodes rather than concatenated Markdown. Empty optional blocks
are omitted and rendering is deterministic. Complete fragments are redacted before
tokenization, and URL-domain policies apply to fully normalized content.

`js/report_footer.cjs` builds the footer from the shared attribution template and
model/cost metadata. Attribution, expiration, provenance, failure-match markers,
and the detection preamble are rendered with the document, not appended afterward.
Marker names and fields are validated; marker values cannot close an HTML comment
or inject another metadata field. Ordinary double hyphens in identifiers remain
unchanged. Unknown placeholders in user-configured footer templates remain literal;
internal templates retain strict missing-field validation. Expiration remains a
checked task-list item so existing cleanup and deduplication readers continue to work.

The issue publication importer explicitly enables typed metadata comments to
preserve grouping, execution provenance, managed islands, and work-queue IDs.
Diagnostic imports do not enable them. JSON metadata is serialized without HTML
delimiters while preserving decoded values. Configured mentions use explicit
mention nodes after the existing safe-output allowlist and budget checks; other
text cannot create notifications. Body limits apply after rendering, including
escaping and attribution. A repository-wide regression guards against direct
REST issue-creation calls outside the shared publisher.

Special syntax has semantic nodes rather than interpolated strings:
`closing_reference` records the closing action, issue number, optional
`owner/repo`, and an explicit `enabled` flag. Disabled references render an
escaped `#`, including in PR fallback issues; scalar text never becomes an active
closing directive. `structured_data` accepts JSON values and renders a safely
fenced JSON block. `xml_marker` holds a validated name, scalar value, and optional
key/value fields; `json_marker` holds an actual data object, not serialized JSON.
Footer provenance uses the same XML-marker node and renderer. Structured data
preserves entity-looking strings, redacts secrets, and rejects cycles, getters,
non-finite numbers, and other values JSON would silently drop or coerce.

### JSONL runtime helpers

Use `js/jsonl_helpers.cjs` instead of splitting files or maintaining chunk buffers:
Expand Down
4 changes: 3 additions & 1 deletion actions/setup/js/check_version_updates.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
* Fails the activation job when validation fails.
*/

const { createReportIssue } = require("./issue_report.cjs");

const { withRetry, isTransientError } = require("./error_recovery.cjs");
const { getErrorMessage } = require("./error_helpers.cjs");
const { renderTemplateFromFile, getPromptPath } = require("./messages_core.cjs");
Expand Down Expand Up @@ -188,7 +190,7 @@ async function reportBlockedVersionIssue(compiledVersion) {

const newIssue = await withRetry(
() =>
github.rest.issues.create({
createReportIssue(github, {
owner,
repo,
title,
Expand Down
4 changes: 3 additions & 1 deletion actions/setup/js/check_workflow_recompile_needed.cjs
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
// @ts-check
/// <reference types="@actions/github-script" />

const { createReportIssue } = require("./issue_report.cjs");

const { getErrorMessage } = require("./error_helpers.cjs");
const { getFooterWorkflowRecompileMessage, getFooterWorkflowRecompileCommentMessage, generateXMLMarker, getDetectionCautionAlert } = require("./messages_footer.cjs");
const fs = require("fs");
Expand Down Expand Up @@ -420,7 +422,7 @@ async function main() {
issueBody += "\n\n---\n" + footer + "\n\n" + xmlMarker + "\n";

try {
const newIssue = await github.rest.issues.create({
const newIssue = await createReportIssue(github, {
owner,
repo,
title: RECOMPILE_ISSUE_TITLE,
Expand Down
4 changes: 3 additions & 1 deletion actions/setup/js/create_forecast_issue.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
// @safe-outputs-exempt SEC-004
/// <reference types="@actions/github-script" />

const { createReportIssue } = require("./issue_report.cjs");

const fs = require("node:fs");
const { getPromptPath, renderTemplateFromFile } = require("./messages_core.cjs");
const { getErrorMessage } = require("./error_helpers.cjs");
Expand Down Expand Up @@ -348,7 +350,7 @@ async function main() {
await core.summary.addRaw(summary).write();
}

const createdIssue = await github.rest.issues.create({
const createdIssue = await createReportIssue(github, {
owner: context.repo.owner,
repo: context.repo.repo,
title: isErrorOutcome ? FORECAST_ERROR_ISSUE_TITLE : FORECAST_ISSUE_TITLE,
Expand Down
48 changes: 27 additions & 21 deletions actions/setup/js/create_issue.cjs
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
// @ts-check
/// <reference types="@actions/github-script" />

const { createReportIssue, renderIssueParameters, buildIssueReport } = require("./issue_report.cjs");
const { normalizeReportText, normalizeReportTitle } = require("./report_ast.cjs");

const { sanitizeLabelContent } = require("./sanitize_label_content.cjs");
const { sanitizeTitle, applyTitlePrefix } = require("./sanitize_title.cjs");
const { sanitizeContent } = require("./sanitize_content.cjs");
const { sanitizeContent, expandAllowedAliases } = require("./sanitize_content.cjs");
const { normalizeCloseOlderKey } = require("./generate_footer.cjs");
const { appendExecutionProvenance } = require("./execution_provenance.cjs");
const { formatIssueBody } = require("./issue_body.cjs");
Expand Down Expand Up @@ -87,19 +90,17 @@ async function createIssue(parameters) {
if (issueArrayLimitError) {
throw new Error(`${ERR_VALIDATION}: ${issueArrayLimitError}`);
}
const body = formatIssueBody(parameters.body, { repo: { owner, repo } });
if (body.length > MAX_GITHUB_BODY_LENGTH) {
throw new Error(`${ERR_VALIDATION}: Issue body exceeds GitHub's maximum length of ${MAX_GITHUB_BODY_LENGTH} characters including attribution`);
}
/** @type {IssueParameters} */
const request = { ...parameters, owner, repo, body };
const report = buildIssueReport(parameters.title, formatIssueBody(parameters.body, { repo: { owner, repo } }));
const rendered = renderIssueParameters({ title: parameters.title }, report);
/** @type {IssueParameters & {title: string, body: string}} */
const request = { ...parameters, owner, repo, ...rendered };
if (labels) request.labels = labels;
if (assignees) request.assignees = assignees;
if (isStagedMode()) {
core.info(`Staged: would create issue in ${owner}/${repo} with title: ${parameters.title}`);
core.info(`Staged: would create issue in ${owner}/${repo} with title: ${request.title}`);
return { staged: true, preview: request };
}
const { data: issue } = await withRetry(() => github.rest.issues.create(request), RATE_LIMIT_RETRY_CONFIG, `create_issue in ${owner}/${repo}`);
const { data: issue } = await withRetry(() => createReportIssue(github, request, report), RATE_LIMIT_RETRY_CONFIG, `create_issue in ${owner}/${repo}`);
core.info(`Created issue ${owner}/${repo}#${issue.number}: ${issue.html_url}`);
return { staged: false, issue };
}
Expand Down Expand Up @@ -237,7 +238,7 @@ async function findOrCreateParentIssue({ githubClient, groupId, owner, repo, tit
const template = createParentIssueTemplate(groupId, titlePrefix, workflowName, workflowSourceURL, expiresHours);
const { data: parentIssue } = await withRetry(
() =>
githubClient.rest.issues.create({
createReportIssue(githubClient, {
owner,
repo,
title: template.title,
Expand Down Expand Up @@ -994,7 +995,7 @@ async function main(config = {}) {
processedBody = removeDuplicateTitleFromDescription(title, processedBody);

// Sanitize body content to neutralize @mentions, URLs, and other security risks
processedBody = sanitizeContent(processedBody, { allowedAliases: allowedMentionAliases, maxMentions });
processedBody = sanitizeContent(normalizeReportText(processedBody), { allowedAliases: allowedMentionAliases, maxMentions });

const bodyLines = processedBody.split("\n");

Expand All @@ -1021,7 +1022,7 @@ async function main(config = {}) {
}

// Apply title prefix (only if it doesn't already exist)
title = applyTitlePrefix(title, titlePrefix);
title = normalizeReportTitle(applyTitlePrefix(title, titlePrefix));

const normalizedTitle = normalizeTitleForDedup(title);

Expand Down Expand Up @@ -1107,13 +1108,15 @@ async function main(config = {}) {
const workflowId = process.env.GH_AW_WORKFLOW_ID ?? "";
const callerWorkflowId = process.env.GH_AW_CALLER_WORKFLOW_ID ?? "";
const runUrl = buildWorkflowRunUrl(context, context.repo);
const body = formatIssueBody(bodyLines.join("\n"), {
const formattedBody = formatIssueBody(bodyLines.join("\n"), {
repo: repoParts,
footer: includeFooter,
bodyFooter: config.body_footer,
expiresHours,
closeOlderKey,
});
const report = buildIssueReport(title, formattedBody, { allowedMentions: expandAllowedAliases(allowedMentionAliases) });
const { body } = renderIssueParameters({ title }, report);

// Reserve a max-count slot synchronously before any async pre-creation work.
// There is no await between check and increment, so concurrent invocations
Expand Down Expand Up @@ -1216,14 +1219,17 @@ async function main(config = {}) {
}
const { data: issue } = await withRetry(
() =>
githubClient.rest.issues.create({
owner: repoParts.owner,
repo: repoParts.repo,
title,
body,
labels,
assignees,
}),
createReportIssue(
githubClient,
{
owner: repoParts.owner,
repo: repoParts.repo,
title,
labels,
assignees,
},
report
),
RATE_LIMIT_RETRY_CONFIG,
`create_issue in ${qualifiedItemRepo}`
);
Expand Down
35 changes: 33 additions & 2 deletions actions/setup/js/create_issue.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,26 @@ describe("create_issue", () => {
});

describe("basic issue creation", () => {
it.each([{ allowedBotMentions: [] }, { allowedBotMentions: ["copilot"] }])("normalizes complete source fragments before URL policy with allowed mentions $allowedBotMentions", async ({ allowedBotMentions }) => {
vi.stubEnv("GH_AW_ALLOWED_DOMAINS", "github.com");
vi.stubEnv("GH_AW_SECRET_NAMES", "REPORT_REFERENCE");
vi.stubEnv("SECRET_REPORT_REFERENCE", "report-secret#123");
const handler = await main({ allowedMentionAliases: allowedBotMentions, footer: false });
const result = await handler({
title: "Normalized report",
body: "Agent job: build \nConclusion: failure\nreport-secret&#35;123 https:/&amp;amp;amp;amp;amp;ZeroWidthSpace;/outside.invalid\n\n![Chart](https://github.com/o/r/chart(a).png)\n\nSee https://github.com/o/r/create_issue.cjs#L1118.\n\n@copilot @other",
});
expect(result.success).toBe(true);
const body = mockGithub.rest.issues.create.mock.calls[0][0].body;
expect(body).not.toContain("report-secret");
expect(body).not.toContain("https://outside.invalid");
expect(body).not.toContain("ZeroWidthSpace");
expect(body).toContain("Agent job: build \nConclusion: failure");
expect(body).toContain("![Chart](https://github.com/o/r/chart%28a%29.png)");
expect(body).toContain("](https://github.com/o/r/create_issue.cjs#L1118).");
expect(body).toContain(allowedBotMentions.length ? "@copilot `@other`" : "`@copilot` `@other`");
});

it.each(requiredExecutionEnvKeys)("should not publish an issue when %s is missing", async key => {
await expectMissingRuntimeFailure(async () => {
const handler = await main({});
Expand Down Expand Up @@ -163,7 +183,7 @@ describe("create_issue", () => {
title: "Tracker regression",
})
);
expect(request.body).toContain("Issue content with <b>safe HTML</b>");
expect(request.body).toContain("Issue content with &lt;b&gt;safe HTML&lt;/b&gt;");
expect(request.body).not.toContain("<script>");
expect(request.body).toContain("(script)");
expectExecutionProvenance(request.body);
Expand Down Expand Up @@ -221,7 +241,7 @@ describe("create_issue", () => {
});

const createCall = mockGithub.rest.issues.create.mock.calls[0][0];
expect(createCall.body).toContain("Test body content\n\nRequired footer from Test Workflow: https://github.com/test-owner/test-repo/actions/runs/12345");
expect(createCall.body).toContain("Test body content\n\nRequired footer from Test Workflow: [https://github.com/test-owner/test-repo/actions/runs/12345](https://github.com/test-owner/test-repo/actions/runs/12345)");
expect(createCall.body).not.toContain("> Generated by");
expect(createCall.body.indexOf("Required footer from Test Workflow")).toBeLessThan(createCall.body.indexOf("<!-- gh-aw-workflow-id:"));
});
Expand Down Expand Up @@ -1588,6 +1608,17 @@ describe("create_issue", () => {
expect(createCall.body).not.toContain("`@copilot`");
expect(createCall.body).toContain("`@someone-else`");
});

it.each([
["copilot-swe-agent", "copilot"],
["org/team.name", "org/team.name"],
])("preserves configured alias %s through AST rendering", async (allowed, mention) => {
const handler = await main({ mentions: { allowTeamMembers: false, allowContext: false, allowed: [allowed, "someone-else"], max: 1 } });
await handler({ title: "Configured mention", body: `Ask @${mention} and @someone-else; code \`@${mention}\` stays inert.` });
const body = mockGithub.rest.issues.create.mock.calls[0][0].body;
expect(body).toContain(`Ask @${mention} and \`@someone-else\``);
expect(body).toContain(`code \`@${mention}\` stays inert.`);
});
});

describe("retry on rate limit errors", () => {
Expand Down
4 changes: 3 additions & 1 deletion actions/setup/js/create_pull_request.cjs
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
// @ts-check
/// <reference types="@actions/github-script" />

const { createReportIssue } = require("./issue_report.cjs");

/** @type {typeof import("fs")} */
const fs = require("fs");
/** @type {typeof import("crypto")} */
Expand Down Expand Up @@ -387,7 +389,7 @@ async function createFallbackIssue(githubClient, repoParts, title, body, labels,
const innerCreate = async () => {
try {
assertUnchanged();
const response = await githubClient.rest.issues.create(payload);
const response = await createReportIssue(githubClient, payload);
assertUnchanged();
return { ...response, issueRepoParts: { owner: payload.owner, repo: payload.repo } };
} catch (error) {
Expand Down
2 changes: 1 addition & 1 deletion actions/setup/js/create_pull_request.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -2359,7 +2359,7 @@ ${diffs}
expect(createCall.body).toContain("/compare/main...");
expect(createCall.body).not.toContain("gh run download");
expect(createCall.body).not.toContain("git am --3way");
expect(createCall.body).toContain("Your pull request is ready to create! 🎉 ✅");
expect(createCall.body).toContain("Your pull request is ready to create\\! 🎉 ✅");
expect(createCall.body).toContain("The original pull request description is below.");
expect(createCall.body.indexOf("Your pull request is ready to create! 🎉 ✅")).toBeLessThan(createCall.body.indexOf("Test body"));
expect(createCall.body.indexOf("Test body")).toBeLessThan(createCall.body.indexOf("Protected files"));
Expand Down
4 changes: 3 additions & 1 deletion actions/setup/js/create_steering_issue.cjs
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
// @ts-check
/// <reference types="@actions/github-script" />

const { createReportIssue } = require("./issue_report.cjs");

const { getPromptPath, renderTemplateFromFile } = require("./messages_core.cjs");
const { buildGeneratedFooter } = require("./markdown_body_helpers.cjs");
const { sanitizeTitle } = require("./sanitize_title.cjs");
Expand Down Expand Up @@ -30,7 +32,7 @@ async function main() {
});
const body = `${steeringBody}\n\n${footer}`;

const { data: issue } = await github.rest.issues.create({
const { data: issue } = await createReportIssue(github, {
owner: context.repo.owner,
repo: context.repo.repo,
title,
Expand Down
2 changes: 1 addition & 1 deletion actions/setup/js/create_steering_issue.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ describe("create_steering_issue", () => {
await main();

const body = global.github.rest.issues.create.mock.calls[0][0].body;
expect(body).toContain("> Generated by Test workflow: https://github.com/owner/repo/actions/runs/123");
expect(body).toContain("> Generated by Test workflow: [https://github.com/owner/repo/actions/runs/123](https://github.com/owner/repo/actions/runs/123)");
expect(body).toContain("<!-- gh-aw-agentic-workflow: Test workflow, id: 123, run: https://github.com/owner/repo/actions/runs/123 -->");
});

Expand Down
Loading
Loading