From 48c9c7eafa470c17da016d810147d1c01a05de1a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 11 Dec 2025 03:29:43 +0000 Subject: [PATCH 1/5] Initial plan From be3069027727a6b62b286dad11e179adf18a1331 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 11 Dec 2025 03:41:58 +0000 Subject: [PATCH 2/5] Add title deduplication helper and apply to safe outputs Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/workflow/js/create_discussion.cjs | 8 +- pkg/workflow/js/create_issue.cjs | 5 + pkg/workflow/js/create_pull_request.cjs | 8 +- pkg/workflow/js/create_pull_request.test.cjs | 4 +- pkg/workflow/js/remove_duplicate_title.cjs | 50 +++ .../js/remove_duplicate_title.test.cjs | 360 ++++++++++++++++++ pkg/workflow/js/update_runner.cjs | 16 +- 7 files changed, 445 insertions(+), 6 deletions(-) create mode 100644 pkg/workflow/js/remove_duplicate_title.cjs create mode 100644 pkg/workflow/js/remove_duplicate_title.test.cjs diff --git a/pkg/workflow/js/create_discussion.cjs b/pkg/workflow/js/create_discussion.cjs index 14cc337d1ce..204d7ee54e6 100644 --- a/pkg/workflow/js/create_discussion.cjs +++ b/pkg/workflow/js/create_discussion.cjs @@ -7,6 +7,7 @@ const { closeOlderDiscussions } = require("./close_older_discussions.cjs"); const { replaceTemporaryIdReferences, loadTemporaryIdMap } = require("./temporary_id.cjs"); const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require("./repo_helpers.cjs"); const { addExpirationComment } = require("./expiration_helpers.cjs"); +const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); /** * Fetch repository ID and discussion categories for a repository @@ -247,7 +248,12 @@ async function main() { let title = createDiscussionItem.title ? replaceTemporaryIdReferences(createDiscussionItem.title.trim(), temporaryIdMap, itemRepo) : ""; // Replace temporary ID references in body (with defensive null check) const bodyText = createDiscussionItem.body || ""; - let bodyLines = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo).split("\n"); + let processedBody = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo); + + // Remove duplicate title from description if it starts with a header matching the title + processedBody = removeDuplicateTitleFromDescription(title, processedBody); + + let bodyLines = processedBody.split("\n"); if (!title) { title = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo) || "Agent Output"; } diff --git a/pkg/workflow/js/create_issue.cjs b/pkg/workflow/js/create_issue.cjs index 573dcdb5582..91956c93ea9 100644 --- a/pkg/workflow/js/create_issue.cjs +++ b/pkg/workflow/js/create_issue.cjs @@ -15,6 +15,7 @@ const { } = require("./temporary_id.cjs"); const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require("./repo_helpers.cjs"); const { addExpirationComment } = require("./expiration_helpers.cjs"); +const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); async function main() { // Initialize outputs to empty strings to ensure they're always set @@ -179,6 +180,10 @@ async function main() { // Replace temporary ID references in the body using already-created issues let processedBody = replaceTemporaryIdReferences(createIssueItem.body, temporaryIdMap, itemRepo); + + // Remove duplicate title from description if it starts with a header matching the title + processedBody = removeDuplicateTitleFromDescription(title, processedBody); + let bodyLines = processedBody.split("\n"); if (!title) { diff --git a/pkg/workflow/js/create_pull_request.cjs b/pkg/workflow/js/create_pull_request.cjs index c4d2f286d96..9e75228b359 100644 --- a/pkg/workflow/js/create_pull_request.cjs +++ b/pkg/workflow/js/create_pull_request.cjs @@ -8,6 +8,7 @@ const crypto = require("crypto"); const { updateActivationComment } = require("./update_activation_comment.cjs"); const { getTrackerID } = require("./get_tracker_id.cjs"); const { addExpirationComment } = require("./expiration_helpers.cjs"); +const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); /** * Generate a patch preview with max 500 lines and 2000 chars for issue body @@ -274,7 +275,12 @@ async function main() { // Extract title, body, and branch from the JSON item let title = pullRequestItem.title.trim(); - let bodyLines = pullRequestItem.body.split("\n"); + let processedBody = pullRequestItem.body; + + // Remove duplicate title from description if it starts with a header matching the title + processedBody = removeDuplicateTitleFromDescription(title, processedBody); + + let bodyLines = processedBody.split("\n"); let branchName = pullRequestItem.branch ? pullRequestItem.branch.trim() : null; // If no title was found, use a default diff --git a/pkg/workflow/js/create_pull_request.test.cjs b/pkg/workflow/js/create_pull_request.test.cjs index e22bad69031..c2beb91094a 100644 --- a/pkg/workflow/js/create_pull_request.test.cjs +++ b/pkg/workflow/js/create_pull_request.test.cjs @@ -18,10 +18,11 @@ const createTestableFunction = scriptContent => { scriptBody = scriptBody.replace(/const \{ updateActivationComment \} = require\("\.\/update_activation_comment\.cjs"\);?\s*/g, ""); scriptBody = scriptBody.replace(/const \{ getTrackerID \} = require\("\.\/get_tracker_id\.cjs"\);?\s*/g, ""); scriptBody = scriptBody.replace(/const \{ addExpirationComment \} = require\("\.\/expiration_helpers\.cjs"\);?\s*/g, ""); + scriptBody = scriptBody.replace(/const \{ removeDuplicateTitleFromDescription \} = require\("\.\/remove_duplicate_title\.cjs"\);?\s*/g, ""); // Create a testable function that has the same logic but can be called with dependencies return new Function(` - const { fs, crypto, github, core, context, process, console, updateActivationComment, getTrackerID, addExpirationComment } = arguments[0]; + const { fs, crypto, github, core, context, process, console, updateActivationComment, getTrackerID, addExpirationComment, removeDuplicateTitleFromDescription } = arguments[0]; ${scriptBody} @@ -165,6 +166,7 @@ describe("create_pull_request.cjs", () => { return ""; }), addExpirationComment: vi.fn(), + removeDuplicateTitleFromDescription: vi.fn((title, description) => description), }; }); diff --git a/pkg/workflow/js/remove_duplicate_title.cjs b/pkg/workflow/js/remove_duplicate_title.cjs new file mode 100644 index 00000000000..b4041667ee2 --- /dev/null +++ b/pkg/workflow/js/remove_duplicate_title.cjs @@ -0,0 +1,50 @@ +// @ts-check +/** + * Remove duplicate title from description + * @module remove_duplicate_title + */ + +/** + * Removes duplicate title from the beginning of description content. + * If the description starts with a header (# or ## or ### etc.) that matches + * the title, it will be removed along with any trailing newlines. + * + * @param {string} title - The title text to match and remove + * @param {string} description - The description content that may contain duplicate title + * @returns {string} The description with duplicate title removed + */ +function removeDuplicateTitleFromDescription(title, description) { + // Handle null/undefined/empty inputs + if (!title || typeof title !== "string") { + return description || ""; + } + if (!description || typeof description !== "string") { + return ""; + } + + const trimmedTitle = title.trim(); + const trimmedDescription = description.trim(); + + if (!trimmedTitle || !trimmedDescription) { + return trimmedDescription; + } + + // Match any header level (# to ######) followed by the title at the start + // This regex matches: + // - Start of string + // - One or more # characters + // - One or more spaces + // - The exact title (escaped for regex special chars) + // - Optional trailing spaces + // - Optional newlines after the header + const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); + + if (headerRegex.test(trimmedDescription)) { + return trimmedDescription.replace(headerRegex, "").trim(); + } + + return trimmedDescription; +} + +module.exports = { removeDuplicateTitleFromDescription }; diff --git a/pkg/workflow/js/remove_duplicate_title.test.cjs b/pkg/workflow/js/remove_duplicate_title.test.cjs new file mode 100644 index 00000000000..da1bbaff5e0 --- /dev/null +++ b/pkg/workflow/js/remove_duplicate_title.test.cjs @@ -0,0 +1,360 @@ +import { describe, it, expect } from "vitest"; + +// Import the function to test +const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); + +describe("remove_duplicate_title.cjs", () => { + describe("removeDuplicateTitleFromDescription", () => { + // Basic functionality tests + describe("basic functionality", () => { + it("should remove H1 header matching title", () => { + const title = "Bug Report"; + const description = "# Bug Report\n\nThis is the body of the report."; + const expected = "This is the body of the report."; + expect(removeDuplicateTitleFromDescription(title, description)).toBe(expected); + }); + + it("should remove H2 header matching title", () => { + const title = "Feature Request"; + const description = "## Feature Request\n\nThis is the feature description."; + const expected = "This is the feature description."; + expect(removeDuplicateTitleFromDescription(title, description)).toBe(expected); + }); + + it("should remove H3 header matching title", () => { + const title = "Documentation Update"; + const description = "### Documentation Update\n\nThis is the documentation."; + const expected = "This is the documentation."; + expect(removeDuplicateTitleFromDescription(title, description)).toBe(expected); + }); + + it("should remove H4 header matching title", () => { + const title = "Refactoring"; + const description = "#### Refactoring\n\nThis is the refactoring plan."; + const expected = "This is the refactoring plan."; + expect(removeDuplicateTitleFromDescription(title, description)).toBe(expected); + }); + + it("should remove H5 header matching title", () => { + const title = "Test"; + const description = "##### Test\n\nThis is the test description."; + const expected = "This is the test description."; + expect(removeDuplicateTitleFromDescription(title, description)).toBe(expected); + }); + + it("should remove H6 header matching title", () => { + const title = "Note"; + const description = "###### Note\n\nThis is the note content."; + const expected = "This is the note content."; + expect(removeDuplicateTitleFromDescription(title, description)).toBe(expected); + }); + }); + + // Case insensitivity tests + describe("case insensitivity", () => { + it("should match title case-insensitively", () => { + const title = "Bug Report"; + const description = "# bug report\n\nBody content."; + const expected = "Body content."; + expect(removeDuplicateTitleFromDescription(title, description)).toBe(expected); + }); + + it("should match title with different casing", () => { + const title = "BUG REPORT"; + const description = "# Bug Report\n\nBody content."; + const expected = "Body content."; + expect(removeDuplicateTitleFromDescription(title, description)).toBe(expected); + }); + + it("should match lowercase title", () => { + const title = "feature request"; + const description = "# Feature Request\n\nBody content."; + const expected = "Body content."; + expect(removeDuplicateTitleFromDescription(title, description)).toBe(expected); + }); + }); + + // Whitespace handling tests + describe("whitespace handling", () => { + it("should handle extra spaces after hash", () => { + const title = "Title"; + const description = "# Title\n\nBody content."; + const expected = "Body content."; + expect(removeDuplicateTitleFromDescription(title, description)).toBe(expected); + }); + + it("should handle trailing spaces after title", () => { + const title = "Title"; + const description = "# Title \n\nBody content."; + const expected = "Body content."; + expect(removeDuplicateTitleFromDescription(title, description)).toBe(expected); + }); + + it("should handle multiple newlines after header", () => { + const title = "Title"; + const description = "# Title\n\n\n\nBody content."; + const expected = "Body content."; + expect(removeDuplicateTitleFromDescription(title, description)).toBe(expected); + }); + + it("should handle CRLF line endings", () => { + const title = "Title"; + const description = "# Title\r\n\r\nBody content."; + const expected = "Body content."; + expect(removeDuplicateTitleFromDescription(title, description)).toBe(expected); + }); + + it("should trim leading/trailing whitespace from inputs", () => { + const title = " Title "; + const description = " # Title\n\nBody content. "; + const expected = "Body content."; + expect(removeDuplicateTitleFromDescription(title, description)).toBe(expected); + }); + }); + + // Non-matching cases + describe("non-matching cases", () => { + it("should not remove header when title doesn't match", () => { + const title = "Bug Report"; + const description = "# Feature Request\n\nBody content."; + expect(removeDuplicateTitleFromDescription(title, description)).toBe(description); + }); + + it("should not remove header when it's not at the start", () => { + const title = "Title"; + const description = "Some text\n\n# Title\n\nBody content."; + expect(removeDuplicateTitleFromDescription(title, description)).toBe(description); + }); + + it("should not remove title if it's not in a header", () => { + const title = "Title"; + const description = "Title\n\nBody content."; + expect(removeDuplicateTitleFromDescription(title, description)).toBe(description); + }); + + it("should preserve description when no header matches", () => { + const title = "Title"; + const description = "This is just body content without headers."; + expect(removeDuplicateTitleFromDescription(title, description)).toBe(description); + }); + }); + + // Special characters in title + describe("special characters in title", () => { + it("should handle title with parentheses", () => { + const title = "Bug Report (Important)"; + const description = "# Bug Report (Important)\n\nBody content."; + const expected = "Body content."; + expect(removeDuplicateTitleFromDescription(title, description)).toBe(expected); + }); + + it("should handle title with brackets", () => { + const title = "Feature [v2.0]"; + const description = "# Feature [v2.0]\n\nBody content."; + const expected = "Body content."; + expect(removeDuplicateTitleFromDescription(title, description)).toBe(expected); + }); + + it("should handle title with dots and asterisks", () => { + const title = "Fix *.txt files"; + const description = "# Fix *.txt files\n\nBody content."; + const expected = "Body content."; + expect(removeDuplicateTitleFromDescription(title, description)).toBe(expected); + }); + + it("should handle title with plus and question marks", () => { + const title = "C++ Update?"; + const description = "# C++ Update?\n\nBody content."; + const expected = "Body content."; + expect(removeDuplicateTitleFromDescription(title, description)).toBe(expected); + }); + + it("should handle title with dollar signs", () => { + const title = "Fix $VAR usage"; + const description = "# Fix $VAR usage\n\nBody content."; + const expected = "Body content."; + expect(removeDuplicateTitleFromDescription(title, description)).toBe(expected); + }); + + it("should handle title with carets and pipes", () => { + const title = "Test ^pattern|filter"; + const description = "# Test ^pattern|filter\n\nBody content."; + const expected = "Body content."; + expect(removeDuplicateTitleFromDescription(title, description)).toBe(expected); + }); + + it("should handle title with curly braces", () => { + const title = "Fix {key: value}"; + const description = "# Fix {key: value}\n\nBody content."; + const expected = "Body content."; + expect(removeDuplicateTitleFromDescription(title, description)).toBe(expected); + }); + + it("should handle title with backslashes", () => { + const title = "Path\\to\\file"; + const description = "# Path\\to\\file\n\nBody content."; + const expected = "Body content."; + expect(removeDuplicateTitleFromDescription(title, description)).toBe(expected); + }); + }); + + // Edge cases + describe("edge cases", () => { + it("should return empty string when both inputs are empty", () => { + expect(removeDuplicateTitleFromDescription("", "")).toBe(""); + }); + + it("should return empty string when description is empty", () => { + expect(removeDuplicateTitleFromDescription("Title", "")).toBe(""); + }); + + it("should return description when title is empty", () => { + const description = "# Some Header\n\nBody content."; + expect(removeDuplicateTitleFromDescription("", description)).toBe(description); + }); + + it("should handle null title", () => { + const description = "# Title\n\nBody content."; + expect(removeDuplicateTitleFromDescription(null, description)).toBe(description); + }); + + it("should handle undefined title", () => { + const description = "# Title\n\nBody content."; + expect(removeDuplicateTitleFromDescription(undefined, description)).toBe(description); + }); + + it("should handle null description", () => { + expect(removeDuplicateTitleFromDescription("Title", null)).toBe(""); + }); + + it("should handle undefined description", () => { + expect(removeDuplicateTitleFromDescription("Title", undefined)).toBe(""); + }); + + it("should handle non-string title", () => { + const description = "# 123\n\nBody content."; + expect(removeDuplicateTitleFromDescription(123, description)).toBe(description); + }); + + it("should handle non-string description", () => { + expect(removeDuplicateTitleFromDescription("Title", 123)).toBe(""); + }); + }); + + // Complex scenarios + describe("complex scenarios", () => { + it("should handle description with only the header", () => { + const title = "Title"; + const description = "# Title"; + expect(removeDuplicateTitleFromDescription(title, description)).toBe(""); + }); + + it("should handle description with header and no content", () => { + const title = "Title"; + const description = "# Title\n\n"; + expect(removeDuplicateTitleFromDescription(title, description)).toBe(""); + }); + + it("should preserve other headers in the description", () => { + const title = "Main Title"; + const description = "# Main Title\n\n## Section 1\n\nContent here.\n\n## Section 2\n\nMore content."; + const expected = "## Section 1\n\nContent here.\n\n## Section 2\n\nMore content."; + expect(removeDuplicateTitleFromDescription(title, description)).toBe(expected); + }); + + it("should handle description with multiple paragraphs", () => { + const title = "Bug Report"; + const description = "# Bug Report\n\nFirst paragraph.\n\nSecond paragraph.\n\nThird paragraph."; + const expected = "First paragraph.\n\nSecond paragraph.\n\nThird paragraph."; + expect(removeDuplicateTitleFromDescription(title, description)).toBe(expected); + }); + + it("should handle description with code blocks", () => { + const title = "Code Fix"; + const description = "# Code Fix\n\n```js\nconst x = 1;\n```\n\nExplanation."; + const expected = "```js\nconst x = 1;\n```\n\nExplanation."; + expect(removeDuplicateTitleFromDescription(title, description)).toBe(expected); + }); + + it("should handle description with lists", () => { + const title = "Tasks"; + const description = "# Tasks\n\n- Task 1\n- Task 2\n- Task 3"; + const expected = "- Task 1\n- Task 2\n- Task 3"; + expect(removeDuplicateTitleFromDescription(title, description)).toBe(expected); + }); + + it("should handle title with numbers", () => { + const title = "Version 2.0 Release"; + const description = "# Version 2.0 Release\n\nRelease notes here."; + const expected = "Release notes here."; + expect(removeDuplicateTitleFromDescription(title, description)).toBe(expected); + }); + + it("should handle very long titles", () => { + const title = "This is a very long title that contains many words and should still be matched correctly"; + const description = `# ${title}\n\nBody content.`; + const expected = "Body content."; + expect(removeDuplicateTitleFromDescription(title, description)).toBe(expected); + }); + + it("should handle emoji in title", () => { + const title = "🐛 Bug Report"; + const description = "# 🐛 Bug Report\n\nBody content."; + const expected = "Body content."; + expect(removeDuplicateTitleFromDescription(title, description)).toBe(expected); + }); + + it("should handle unicode characters in title", () => { + const title = "Прошу исправить ошибку"; + const description = "# Прошу исправить ошибку\n\nОписание проблемы."; + const expected = "Описание проблемы."; + expect(removeDuplicateTitleFromDescription(title, description)).toBe(expected); + }); + }); + + // Real-world examples + describe("real-world examples", () => { + it("should handle GitHub issue format", () => { + const title = "Feature: Add dark mode"; + const description = "# Feature: Add dark mode\n\n## Description\n\nWe need dark mode support.\n\n## Acceptance Criteria\n\n- [ ] Dark mode toggle\n- [ ] Persistent preference"; + const expected = "## Description\n\nWe need dark mode support.\n\n## Acceptance Criteria\n\n- [ ] Dark mode toggle\n- [ ] Persistent preference"; + expect(removeDuplicateTitleFromDescription(title, description)).toBe(expected); + }); + + it("should handle pull request format", () => { + const title = "Fix authentication bug"; + const description = "# Fix authentication bug\n\n## Changes\n\n- Updated auth flow\n- Added tests\n\n## Testing\n\nManually tested all scenarios."; + const expected = "## Changes\n\n- Updated auth flow\n- Added tests\n\n## Testing\n\nManually tested all scenarios."; + expect(removeDuplicateTitleFromDescription(title, description)).toBe(expected); + }); + + it("should handle discussion format", () => { + const title = "How to configure X?"; + const description = "# How to configure X?\n\nI'm trying to configure X but can't find the documentation.\n\nCan someone help?"; + const expected = "I'm trying to configure X but can't find the documentation.\n\nCan someone help?"; + expect(removeDuplicateTitleFromDescription(title, description)).toBe(expected); + }); + }); + + // Performance considerations + describe("performance", () => { + it("should handle large descriptions efficiently", () => { + const title = "Title"; + const largeBody = "Body content.\n".repeat(1000); + const description = `# Title\n\n${largeBody}`; + const result = removeDuplicateTitleFromDescription(title, description); + expect(result).toBe(largeBody.trim()); + }); + + it("should handle multiple consecutive calls", () => { + const title = "Title"; + const description = "# Title\n\nBody content."; + const expected = "Body content."; + + for (let i = 0; i < 100; i++) { + expect(removeDuplicateTitleFromDescription(title, description)).toBe(expected); + } + }); + }); + }); +}); diff --git a/pkg/workflow/js/update_runner.cjs b/pkg/workflow/js/update_runner.cjs index 974bf475349..7530ff74ee3 100644 --- a/pkg/workflow/js/update_runner.cjs +++ b/pkg/workflow/js/update_runner.cjs @@ -14,6 +14,7 @@ const { loadAgentOutput } = require("./load_agent_output.cjs"); const { generateStagedPreview } = require("./staged_preview.cjs"); +const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); /** * @typedef {Object} UpdateRunnerConfig @@ -104,10 +105,12 @@ function buildUpdateData(params) { } // Handle title update + let titleForDedup = null; if (canUpdateTitle && item.title !== undefined) { const trimmedTitle = typeof item.title === "string" ? item.title.trim() : ""; if (trimmedTitle.length > 0) { updateData.title = trimmedTitle; + titleForDedup = trimmedTitle; hasUpdates = true; logMessages.push(`Will update title to: ${trimmedTitle}`); } else { @@ -115,12 +118,19 @@ function buildUpdateData(params) { } } - // Handle body update (basic - without operation logic) + // Handle body update (with title deduplication) if (canUpdateBody && item.body !== undefined) { if (typeof item.body === "string") { - updateData.body = item.body; + let processedBody = item.body; + + // If we're updating the title at the same time, remove duplicate title from body + if (titleForDedup) { + processedBody = removeDuplicateTitleFromDescription(titleForDedup, processedBody); + } + + updateData.body = processedBody; hasUpdates = true; - logMessages.push(`Will update body (length: ${item.body.length})`); + logMessages.push(`Will update body (length: ${processedBody.length})`); } else { logMessages.push("Invalid body value: must be a string"); } From 1c2f74fc9062b7738986f4f5b294d7b695ef18de Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 11 Dec 2025 03:53:02 +0000 Subject: [PATCH 3/5] Recompile workflows with title deduplication Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .github/workflows/artifacts-summary.lock.yml | 386 +----- .github/workflows/audit-workflows.lock.yml | 386 +----- .github/workflows/blog-auditor.lock.yml | 386 +----- .../breaking-change-checker.lock.yml | 282 +---- .github/workflows/changeset.lock.yml | 428 +------ .github/workflows/ci-coach.lock.yml | 134 +- .github/workflows/ci-doctor.lock.yml | 282 +---- .../cli-consistency-checker.lock.yml | 282 +---- .../workflows/cli-version-checker.lock.yml | 282 +---- .github/workflows/cloclo.lock.yml | 134 +- .../commit-changes-analyzer.lock.yml | 386 +----- .../workflows/copilot-agent-analysis.lock.yml | 386 +----- .../copilot-pr-merged-report.lock.yml | 386 +----- .../copilot-pr-nlp-analysis.lock.yml | 386 +----- .../copilot-pr-prompt-analysis.lock.yml | 386 +----- .../copilot-session-insights.lock.yml | 386 +----- .github/workflows/daily-code-metrics.lock.yml | 386 +----- .../daily-copilot-token-report.lock.yml | 386 +----- .github/workflows/daily-doc-updater.lock.yml | 134 +- .github/workflows/daily-file-diet.lock.yml | 282 +---- .../workflows/daily-firewall-report.lock.yml | 386 +----- .../workflows/daily-issues-report.lock.yml | 386 +----- .../daily-multi-device-docs-tester.lock.yml | 282 +---- .github/workflows/daily-news.lock.yml | 386 +----- .../daily-performance-summary.lock.yml | 386 +----- .../workflows/daily-repo-chronicle.lock.yml | 386 +----- .github/workflows/daily-team-status.lock.yml | 386 +----- .../workflows/daily-workflow-updater.lock.yml | 134 +- .github/workflows/deep-report.lock.yml | 386 +----- .../workflows/dependabot-go-checker.lock.yml | 282 +---- .github/workflows/dev.lock.yml | 134 +- .../developer-docs-consolidator.lock.yml | 520 +------- .github/workflows/dictation-prompt.lock.yml | 134 +- .github/workflows/docs-noob-tester.lock.yml | 386 +----- .../duplicate-code-detector.lock.yml | 282 +---- .../example-workflow-analyzer.lock.yml | 386 +----- .../github-mcp-structural-analysis.lock.yml | 386 +----- .../github-mcp-tools-report.lock.yml | 520 +------- .../workflows/glossary-maintainer.lock.yml | 134 +- .github/workflows/go-fan.lock.yml | 386 +----- .github/workflows/go-logger.lock.yml | 134 +- .../workflows/go-pattern-detector.lock.yml | 282 +---- .../workflows/instructions-janitor.lock.yml | 134 +- .github/workflows/issue-arborist.lock.yml | 386 +----- .../workflows/layout-spec-maintainer.lock.yml | 134 +- .github/workflows/lockfile-stats.lock.yml | 386 +----- .github/workflows/mcp-inspector.lock.yml | 386 +----- .github/workflows/org-health-report.lock.yml | 386 +----- .github/workflows/plan.lock.yml | 282 +---- .github/workflows/poem-bot.lock.yml | 1092 +---------------- .github/workflows/portfolio-analyst.lock.yml | 386 +----- .../workflows/pr-nitpick-reviewer.lock.yml | 386 +----- .../prompt-clustering-analysis.lock.yml | 386 +----- .github/workflows/python-data-charts.lock.yml | 386 +----- .github/workflows/q.lock.yml | 134 +- .github/workflows/repo-tree-map.lock.yml | 386 +----- .../repository-quality-improver.lock.yml | 386 +----- .github/workflows/research.lock.yml | 386 +----- .github/workflows/safe-output-health.lock.yml | 386 +----- .../schema-consistency-checker.lock.yml | 386 +----- .github/workflows/security-fix-pr.lock.yml | 134 +- .../semantic-function-refactor.lock.yml | 282 +---- .github/workflows/smoke-claude.lock.yml | 282 +---- .github/workflows/smoke-codex.lock.yml | 282 +---- .../smoke-copilot-no-firewall.lock.yml | 710 +---------- .../smoke-copilot-playwright.lock.yml | 282 +---- .../smoke-copilot-safe-inputs.lock.yml | 282 +---- .github/workflows/smoke-copilot.lock.yml | 282 +---- .github/workflows/smoke-detector.lock.yml | 282 +---- .github/workflows/spec-kit-execute.lock.yml | 134 +- .github/workflows/spec-kit-executor.lock.yml | 134 +- .github/workflows/speckit-dispatcher.lock.yml | 282 +---- .../workflows/stale-repo-identifier.lock.yml | 282 +---- .../workflows/static-analysis-report.lock.yml | 386 +----- .github/workflows/super-linter.lock.yml | 282 +---- .../workflows/technical-doc-writer.lock.yml | 134 +- .../test-discussion-expires.lock.yml | 386 +----- .../workflows/test-python-safe-input.lock.yml | 282 +---- .github/workflows/tidy.lock.yml | 134 +- .github/workflows/typist.lock.yml | 386 +----- .github/workflows/unbloat-docs.lock.yml | 134 +- .github/workflows/video-analyzer.lock.yml | 282 +---- .../workflows/weekly-issue-summary.lock.yml | 386 +----- pkg/workflow/js/create_pull_request.test.cjs | 5 +- .../js/remove_duplicate_title.test.cjs | 9 +- pkg/workflow/js/update_runner.cjs | 4 +- 86 files changed, 973 insertions(+), 25851 deletions(-) diff --git a/.github/workflows/artifacts-summary.lock.yml b/.github/workflows/artifacts-summary.lock.yml index 2407bce3a40..32254e5cfeb 100644 --- a/.github/workflows/artifacts-summary.lock.yml +++ b/.github/workflows/artifacts-summary.lock.yml @@ -6185,381 +6185,13 @@ jobs: with: github-token: ${{ steps.app-token.outputs.token }} script: | - const fs = require("fs"); - const crypto = require("crypto"); - const MAX_LOG_CONTENT_LENGTH = 10000; - function truncateForLogging(content) { - if (content.length <= MAX_LOG_CONTENT_LENGTH) { - return content; - } - return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; - } - function loadAgentOutput() { - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; - if (!agentOutputFile) { - core.info("No GH_AW_AGENT_OUTPUT environment variable found"); - return { success: false }; - } - let outputContent; - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - return { success: false, error: errorMessage }; - } - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return { success: false }; - } - core.info(`Agent output content length: ${outputContent.length}`); - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); - return { success: false, error: errorMessage }; - } - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); - return { success: false }; - } - return { success: true, items: validatedOutput.items }; - } - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - function getMessages() { - const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES; - if (!messagesEnv) { - return null; - } - try { - return JSON.parse(messagesEnv); - } catch (error) { - core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`); - return null; - } - } - function renderTemplate(template, context) { - return template.replace(/\{(\w+)\}/g, (match, key) => { - const value = context[key]; - return value !== undefined && value !== null ? String(value) : match; - }); - } - function toSnakeCase(obj) { - const result = {}; - for (const [key, value] of Object.entries(obj)) { - const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase(); - result[snakeKey] = value; - result[key] = value; - } - return result; - } - function getCloseOlderDiscussionMessage(ctx) { - const messages = getMessages(); - const templateContext = toSnakeCase(ctx); - const defaultMessage = `⚓ Avast! This discussion be marked as **outdated** by [{workflow_name}]({run_url}). - 🗺️ A newer treasure map awaits ye at **[Discussion #{new_discussion_number}]({new_discussion_url})**. - Fair winds, matey! 🏴‍☠️`; - return messages?.closeOlderDiscussion - ? renderTemplate(messages.closeOlderDiscussion, templateContext) - : renderTemplate(defaultMessage, templateContext); - } - const MAX_CLOSE_COUNT = 10; - const GRAPHQL_DELAY_MS = 500; - function delay(ms) { - return new Promise(resolve => setTimeout(resolve, ms)); - } - async function searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, excludeNumber) { - let searchQuery = `repo:${owner}/${repo} is:open`; - if (titlePrefix) { - const escapedPrefix = titlePrefix.replace(/"/g, '\\"'); - searchQuery += ` in:title "${escapedPrefix}"`; - } - if (labels && labels.length > 0) { - for (const label of labels) { - const escapedLabel = label.replace(/"/g, '\\"'); - searchQuery += ` label:"${escapedLabel}"`; - } - } - const result = await github.graphql( - ` - query($searchTerms: String!, $first: Int!) { - search(query: $searchTerms, type: DISCUSSION, first: $first) { - nodes { - ... on Discussion { - id - number - title - url - category { - id - } - labels(first: 100) { - nodes { - name - } - } - closed - } - } - } - }`, - { searchTerms: searchQuery, first: 50 } - ); - if (!result || !result.search || !result.search.nodes) { - return []; - } - return result.search.nodes - .filter( - d => { - if (!d || d.number === excludeNumber || d.closed) { - return false; - } - if (titlePrefix && d.title && !d.title.startsWith(titlePrefix)) { - return false; - } - if (labels && labels.length > 0) { - const discussionLabels = d.labels?.nodes?.map(( l) => l.name) || []; - const hasAllLabels = labels.every(label => discussionLabels.includes(label)); - if (!hasAllLabels) { - return false; - } - } - if (categoryId && (!d.category || d.category.id !== categoryId)) { - return false; - } - return true; - } - ) - .map( - d => ({ - id: d.id, - number: d.number, - title: d.title, - url: d.url, - }) - ); - } - async function addDiscussionComment(github, discussionId, message) { - const result = await github.graphql( - ` - mutation($dId: ID!, $body: String!) { - addDiscussionComment(input: { discussionId: $dId, body: $body }) { - comment { - id - url - } - } - }`, - { dId: discussionId, body: message } - ); - return result.addDiscussionComment.comment; - } - async function closeDiscussionAsOutdated(github, discussionId) { - const result = await github.graphql( - ` - mutation($dId: ID!) { - closeDiscussion(input: { discussionId: $dId, reason: OUTDATED }) { - discussion { - id - url - } - } - }`, - { dId: discussionId } - ); - return result.closeDiscussion.discussion; - } - async function closeOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion, workflowName, runUrl) { - const searchCriteria = []; - if (titlePrefix) searchCriteria.push(`title prefix: "${titlePrefix}"`); - if (labels && labels.length > 0) searchCriteria.push(`labels: [${labels.join(", ")}]`); - core.info(`Searching for older discussions with ${searchCriteria.join(" and ")}`); - const olderDiscussions = await searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion.number); - if (olderDiscussions.length === 0) { - core.info("No older discussions found to close"); - return []; - } - core.info(`Found ${olderDiscussions.length} older discussion(s) to close`); - const discussionsToClose = olderDiscussions.slice(0, MAX_CLOSE_COUNT); - if (olderDiscussions.length > MAX_CLOSE_COUNT) { - core.warning(`Found ${olderDiscussions.length} older discussions, but only closing the first ${MAX_CLOSE_COUNT}`); - } - const closedDiscussions = []; - for (let i = 0; i < discussionsToClose.length; i++) { - const discussion = discussionsToClose[i]; - try { - const closingMessage = getCloseOlderDiscussionMessage({ - newDiscussionUrl: newDiscussion.url, - newDiscussionNumber: newDiscussion.number, - workflowName, - runUrl, - }); - core.info(`Adding closing comment to discussion #${discussion.number}`); - await addDiscussionComment(github, discussion.id, closingMessage); - core.info(`Closing discussion #${discussion.number} as outdated`); - await closeDiscussionAsOutdated(github, discussion.id); - closedDiscussions.push({ - number: discussion.number, - url: discussion.url, - }); - core.info(`✓ Closed discussion #${discussion.number}: ${discussion.url}`); - } catch (error) { - core.error(`✗ Failed to close discussion #${discussion.number}: ${error instanceof Error ? error.message : String(error)}`); - } - if (i < discussionsToClose.length - 1) { - await delay(GRAPHQL_DELAY_MS); - } - } - return closedDiscussions; - } - const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; - function generateTemporaryId() { - return "aw_" + crypto.randomBytes(6).toString("hex"); - } - function isTemporaryId(value) { - if (typeof value === "string") { - return /^aw_[0-9a-f]{12}$/i.test(value); - } - return false; - } - function normalizeTemporaryId(tempId) { - return String(tempId).toLowerCase(); - } - function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); - if (resolved !== undefined) { - if (currentRepo && resolved.repo === currentRepo) { - return `#${resolved.number}`; - } - return `${resolved.repo}#${resolved.number}`; - } - return match; - }); - } - function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); - if (issueNumber !== undefined) { - return `#${issueNumber}`; - } - return match; - }); - } - function loadTemporaryIdMap() { - const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; - if (!mapJson || mapJson === "{}") { - return new Map(); - } - try { - const mapObject = JSON.parse(mapJson); - const result = new Map(); - for (const [key, value] of Object.entries(mapObject)) { - const normalizedKey = normalizeTemporaryId(key); - if (typeof value === "number") { - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - result.set(normalizedKey, { repo: contextRepo, number: value }); - } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { - result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); - } - } - return result; - } catch (error) { - if (typeof core !== "undefined") { - core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); - } - return new Map(); - } - } - function resolveIssueNumber(value, temporaryIdMap) { - if (value === undefined || value === null) { - return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; - } - const valueStr = String(value); - if (isTemporaryId(valueStr)) { - const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); - if (resolvedPair !== undefined) { - return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; - } - return { - resolved: null, - wasTemporaryId: true, - errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, - }; - } - const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); - if (isNaN(issueNumber) || issueNumber <= 0) { - return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; - } - const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; - return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; - } - function serializeTemporaryIdMap(tempIdMap) { - const obj = Object.fromEntries(tempIdMap); - return JSON.stringify(obj); - } - function parseAllowedRepos() { - const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; - const set = new Set(); - if (allowedReposEnv) { - allowedReposEnv - .split(",") - .map(repo => repo.trim()) - .filter(repo => repo) - .forEach(repo => set.add(repo)); - } - return set; - } - function getDefaultTargetRepo() { - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - if (targetRepoSlug) { - return targetRepoSlug; - } - return `${context.repo.owner}/${context.repo.repo}`; - } - function validateRepo(repo, defaultRepo, allowedRepos) { - if (repo === defaultRepo) { - return { valid: true, error: null }; - } - if (allowedRepos.has(repo)) { - return { valid: true, error: null }; - } - return { - valid: false, - error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, - }; - } - function parseRepoSlug(repoSlug) { - const parts = repoSlug.split("/"); - if (parts.length !== 2 || !parts[0] || !parts[1]) { - return null; - } - return { owner: parts[0], repo: parts[1] }; - } - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { getTrackerID } = require("./get_tracker_id.cjs"); + const { closeOlderDiscussions } = require("./close_older_discussions.cjs"); + const { replaceTemporaryIdReferences, loadTemporaryIdMap } = require("./temporary_id.cjs"); + const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require("./repo_helpers.cjs"); + const { addExpirationComment } = require("./expiration_helpers.cjs"); + const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); async function fetchRepoDiscussionInfo(owner, repo) { const repositoryQuery = ` query($owner: String!, $repo: String!) { @@ -6744,7 +6376,9 @@ jobs: ); let title = createDiscussionItem.title ? replaceTemporaryIdReferences(createDiscussionItem.title.trim(), temporaryIdMap, itemRepo) : ""; const bodyText = createDiscussionItem.body || ""; - let bodyLines = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo).split("\n"); + let processedBody = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo); + processedBody = removeDuplicateTitleFromDescription(title, processedBody); + let bodyLines = processedBody.split("\n"); if (!title) { title = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo) || "Agent Output"; } diff --git a/.github/workflows/audit-workflows.lock.yml b/.github/workflows/audit-workflows.lock.yml index 8f36fb5cd45..4230223dd7d 100644 --- a/.github/workflows/audit-workflows.lock.yml +++ b/.github/workflows/audit-workflows.lock.yml @@ -7007,381 +7007,13 @@ jobs: with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - const fs = require("fs"); - const crypto = require("crypto"); - const MAX_LOG_CONTENT_LENGTH = 10000; - function truncateForLogging(content) { - if (content.length <= MAX_LOG_CONTENT_LENGTH) { - return content; - } - return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; - } - function loadAgentOutput() { - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; - if (!agentOutputFile) { - core.info("No GH_AW_AGENT_OUTPUT environment variable found"); - return { success: false }; - } - let outputContent; - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - return { success: false, error: errorMessage }; - } - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return { success: false }; - } - core.info(`Agent output content length: ${outputContent.length}`); - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); - return { success: false, error: errorMessage }; - } - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); - return { success: false }; - } - return { success: true, items: validatedOutput.items }; - } - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - function getMessages() { - const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES; - if (!messagesEnv) { - return null; - } - try { - return JSON.parse(messagesEnv); - } catch (error) { - core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`); - return null; - } - } - function renderTemplate(template, context) { - return template.replace(/\{(\w+)\}/g, (match, key) => { - const value = context[key]; - return value !== undefined && value !== null ? String(value) : match; - }); - } - function toSnakeCase(obj) { - const result = {}; - for (const [key, value] of Object.entries(obj)) { - const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase(); - result[snakeKey] = value; - result[key] = value; - } - return result; - } - function getCloseOlderDiscussionMessage(ctx) { - const messages = getMessages(); - const templateContext = toSnakeCase(ctx); - const defaultMessage = `⚓ Avast! This discussion be marked as **outdated** by [{workflow_name}]({run_url}). - 🗺️ A newer treasure map awaits ye at **[Discussion #{new_discussion_number}]({new_discussion_url})**. - Fair winds, matey! 🏴‍☠️`; - return messages?.closeOlderDiscussion - ? renderTemplate(messages.closeOlderDiscussion, templateContext) - : renderTemplate(defaultMessage, templateContext); - } - const MAX_CLOSE_COUNT = 10; - const GRAPHQL_DELAY_MS = 500; - function delay(ms) { - return new Promise(resolve => setTimeout(resolve, ms)); - } - async function searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, excludeNumber) { - let searchQuery = `repo:${owner}/${repo} is:open`; - if (titlePrefix) { - const escapedPrefix = titlePrefix.replace(/"/g, '\\"'); - searchQuery += ` in:title "${escapedPrefix}"`; - } - if (labels && labels.length > 0) { - for (const label of labels) { - const escapedLabel = label.replace(/"/g, '\\"'); - searchQuery += ` label:"${escapedLabel}"`; - } - } - const result = await github.graphql( - ` - query($searchTerms: String!, $first: Int!) { - search(query: $searchTerms, type: DISCUSSION, first: $first) { - nodes { - ... on Discussion { - id - number - title - url - category { - id - } - labels(first: 100) { - nodes { - name - } - } - closed - } - } - } - }`, - { searchTerms: searchQuery, first: 50 } - ); - if (!result || !result.search || !result.search.nodes) { - return []; - } - return result.search.nodes - .filter( - d => { - if (!d || d.number === excludeNumber || d.closed) { - return false; - } - if (titlePrefix && d.title && !d.title.startsWith(titlePrefix)) { - return false; - } - if (labels && labels.length > 0) { - const discussionLabels = d.labels?.nodes?.map(( l) => l.name) || []; - const hasAllLabels = labels.every(label => discussionLabels.includes(label)); - if (!hasAllLabels) { - return false; - } - } - if (categoryId && (!d.category || d.category.id !== categoryId)) { - return false; - } - return true; - } - ) - .map( - d => ({ - id: d.id, - number: d.number, - title: d.title, - url: d.url, - }) - ); - } - async function addDiscussionComment(github, discussionId, message) { - const result = await github.graphql( - ` - mutation($dId: ID!, $body: String!) { - addDiscussionComment(input: { discussionId: $dId, body: $body }) { - comment { - id - url - } - } - }`, - { dId: discussionId, body: message } - ); - return result.addDiscussionComment.comment; - } - async function closeDiscussionAsOutdated(github, discussionId) { - const result = await github.graphql( - ` - mutation($dId: ID!) { - closeDiscussion(input: { discussionId: $dId, reason: OUTDATED }) { - discussion { - id - url - } - } - }`, - { dId: discussionId } - ); - return result.closeDiscussion.discussion; - } - async function closeOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion, workflowName, runUrl) { - const searchCriteria = []; - if (titlePrefix) searchCriteria.push(`title prefix: "${titlePrefix}"`); - if (labels && labels.length > 0) searchCriteria.push(`labels: [${labels.join(", ")}]`); - core.info(`Searching for older discussions with ${searchCriteria.join(" and ")}`); - const olderDiscussions = await searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion.number); - if (olderDiscussions.length === 0) { - core.info("No older discussions found to close"); - return []; - } - core.info(`Found ${olderDiscussions.length} older discussion(s) to close`); - const discussionsToClose = olderDiscussions.slice(0, MAX_CLOSE_COUNT); - if (olderDiscussions.length > MAX_CLOSE_COUNT) { - core.warning(`Found ${olderDiscussions.length} older discussions, but only closing the first ${MAX_CLOSE_COUNT}`); - } - const closedDiscussions = []; - for (let i = 0; i < discussionsToClose.length; i++) { - const discussion = discussionsToClose[i]; - try { - const closingMessage = getCloseOlderDiscussionMessage({ - newDiscussionUrl: newDiscussion.url, - newDiscussionNumber: newDiscussion.number, - workflowName, - runUrl, - }); - core.info(`Adding closing comment to discussion #${discussion.number}`); - await addDiscussionComment(github, discussion.id, closingMessage); - core.info(`Closing discussion #${discussion.number} as outdated`); - await closeDiscussionAsOutdated(github, discussion.id); - closedDiscussions.push({ - number: discussion.number, - url: discussion.url, - }); - core.info(`✓ Closed discussion #${discussion.number}: ${discussion.url}`); - } catch (error) { - core.error(`✗ Failed to close discussion #${discussion.number}: ${error instanceof Error ? error.message : String(error)}`); - } - if (i < discussionsToClose.length - 1) { - await delay(GRAPHQL_DELAY_MS); - } - } - return closedDiscussions; - } - const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; - function generateTemporaryId() { - return "aw_" + crypto.randomBytes(6).toString("hex"); - } - function isTemporaryId(value) { - if (typeof value === "string") { - return /^aw_[0-9a-f]{12}$/i.test(value); - } - return false; - } - function normalizeTemporaryId(tempId) { - return String(tempId).toLowerCase(); - } - function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); - if (resolved !== undefined) { - if (currentRepo && resolved.repo === currentRepo) { - return `#${resolved.number}`; - } - return `${resolved.repo}#${resolved.number}`; - } - return match; - }); - } - function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); - if (issueNumber !== undefined) { - return `#${issueNumber}`; - } - return match; - }); - } - function loadTemporaryIdMap() { - const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; - if (!mapJson || mapJson === "{}") { - return new Map(); - } - try { - const mapObject = JSON.parse(mapJson); - const result = new Map(); - for (const [key, value] of Object.entries(mapObject)) { - const normalizedKey = normalizeTemporaryId(key); - if (typeof value === "number") { - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - result.set(normalizedKey, { repo: contextRepo, number: value }); - } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { - result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); - } - } - return result; - } catch (error) { - if (typeof core !== "undefined") { - core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); - } - return new Map(); - } - } - function resolveIssueNumber(value, temporaryIdMap) { - if (value === undefined || value === null) { - return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; - } - const valueStr = String(value); - if (isTemporaryId(valueStr)) { - const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); - if (resolvedPair !== undefined) { - return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; - } - return { - resolved: null, - wasTemporaryId: true, - errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, - }; - } - const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); - if (isNaN(issueNumber) || issueNumber <= 0) { - return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; - } - const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; - return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; - } - function serializeTemporaryIdMap(tempIdMap) { - const obj = Object.fromEntries(tempIdMap); - return JSON.stringify(obj); - } - function parseAllowedRepos() { - const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; - const set = new Set(); - if (allowedReposEnv) { - allowedReposEnv - .split(",") - .map(repo => repo.trim()) - .filter(repo => repo) - .forEach(repo => set.add(repo)); - } - return set; - } - function getDefaultTargetRepo() { - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - if (targetRepoSlug) { - return targetRepoSlug; - } - return `${context.repo.owner}/${context.repo.repo}`; - } - function validateRepo(repo, defaultRepo, allowedRepos) { - if (repo === defaultRepo) { - return { valid: true, error: null }; - } - if (allowedRepos.has(repo)) { - return { valid: true, error: null }; - } - return { - valid: false, - error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, - }; - } - function parseRepoSlug(repoSlug) { - const parts = repoSlug.split("/"); - if (parts.length !== 2 || !parts[0] || !parts[1]) { - return null; - } - return { owner: parts[0], repo: parts[1] }; - } - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { getTrackerID } = require("./get_tracker_id.cjs"); + const { closeOlderDiscussions } = require("./close_older_discussions.cjs"); + const { replaceTemporaryIdReferences, loadTemporaryIdMap } = require("./temporary_id.cjs"); + const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require("./repo_helpers.cjs"); + const { addExpirationComment } = require("./expiration_helpers.cjs"); + const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); async function fetchRepoDiscussionInfo(owner, repo) { const repositoryQuery = ` query($owner: String!, $repo: String!) { @@ -7566,7 +7198,9 @@ jobs: ); let title = createDiscussionItem.title ? replaceTemporaryIdReferences(createDiscussionItem.title.trim(), temporaryIdMap, itemRepo) : ""; const bodyText = createDiscussionItem.body || ""; - let bodyLines = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo).split("\n"); + let processedBody = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo); + processedBody = removeDuplicateTitleFromDescription(title, processedBody); + let bodyLines = processedBody.split("\n"); if (!title) { title = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo) || "Agent Output"; } diff --git a/.github/workflows/blog-auditor.lock.yml b/.github/workflows/blog-auditor.lock.yml index 25cb5e80bb1..4d06a418735 100644 --- a/.github/workflows/blog-auditor.lock.yml +++ b/.github/workflows/blog-auditor.lock.yml @@ -6053,381 +6053,13 @@ jobs: with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - const fs = require("fs"); - const crypto = require("crypto"); - const MAX_LOG_CONTENT_LENGTH = 10000; - function truncateForLogging(content) { - if (content.length <= MAX_LOG_CONTENT_LENGTH) { - return content; - } - return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; - } - function loadAgentOutput() { - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; - if (!agentOutputFile) { - core.info("No GH_AW_AGENT_OUTPUT environment variable found"); - return { success: false }; - } - let outputContent; - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - return { success: false, error: errorMessage }; - } - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return { success: false }; - } - core.info(`Agent output content length: ${outputContent.length}`); - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); - return { success: false, error: errorMessage }; - } - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); - return { success: false }; - } - return { success: true, items: validatedOutput.items }; - } - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - function getMessages() { - const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES; - if (!messagesEnv) { - return null; - } - try { - return JSON.parse(messagesEnv); - } catch (error) { - core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`); - return null; - } - } - function renderTemplate(template, context) { - return template.replace(/\{(\w+)\}/g, (match, key) => { - const value = context[key]; - return value !== undefined && value !== null ? String(value) : match; - }); - } - function toSnakeCase(obj) { - const result = {}; - for (const [key, value] of Object.entries(obj)) { - const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase(); - result[snakeKey] = value; - result[key] = value; - } - return result; - } - function getCloseOlderDiscussionMessage(ctx) { - const messages = getMessages(); - const templateContext = toSnakeCase(ctx); - const defaultMessage = `⚓ Avast! This discussion be marked as **outdated** by [{workflow_name}]({run_url}). - 🗺️ A newer treasure map awaits ye at **[Discussion #{new_discussion_number}]({new_discussion_url})**. - Fair winds, matey! 🏴‍☠️`; - return messages?.closeOlderDiscussion - ? renderTemplate(messages.closeOlderDiscussion, templateContext) - : renderTemplate(defaultMessage, templateContext); - } - const MAX_CLOSE_COUNT = 10; - const GRAPHQL_DELAY_MS = 500; - function delay(ms) { - return new Promise(resolve => setTimeout(resolve, ms)); - } - async function searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, excludeNumber) { - let searchQuery = `repo:${owner}/${repo} is:open`; - if (titlePrefix) { - const escapedPrefix = titlePrefix.replace(/"/g, '\\"'); - searchQuery += ` in:title "${escapedPrefix}"`; - } - if (labels && labels.length > 0) { - for (const label of labels) { - const escapedLabel = label.replace(/"/g, '\\"'); - searchQuery += ` label:"${escapedLabel}"`; - } - } - const result = await github.graphql( - ` - query($searchTerms: String!, $first: Int!) { - search(query: $searchTerms, type: DISCUSSION, first: $first) { - nodes { - ... on Discussion { - id - number - title - url - category { - id - } - labels(first: 100) { - nodes { - name - } - } - closed - } - } - } - }`, - { searchTerms: searchQuery, first: 50 } - ); - if (!result || !result.search || !result.search.nodes) { - return []; - } - return result.search.nodes - .filter( - d => { - if (!d || d.number === excludeNumber || d.closed) { - return false; - } - if (titlePrefix && d.title && !d.title.startsWith(titlePrefix)) { - return false; - } - if (labels && labels.length > 0) { - const discussionLabels = d.labels?.nodes?.map(( l) => l.name) || []; - const hasAllLabels = labels.every(label => discussionLabels.includes(label)); - if (!hasAllLabels) { - return false; - } - } - if (categoryId && (!d.category || d.category.id !== categoryId)) { - return false; - } - return true; - } - ) - .map( - d => ({ - id: d.id, - number: d.number, - title: d.title, - url: d.url, - }) - ); - } - async function addDiscussionComment(github, discussionId, message) { - const result = await github.graphql( - ` - mutation($dId: ID!, $body: String!) { - addDiscussionComment(input: { discussionId: $dId, body: $body }) { - comment { - id - url - } - } - }`, - { dId: discussionId, body: message } - ); - return result.addDiscussionComment.comment; - } - async function closeDiscussionAsOutdated(github, discussionId) { - const result = await github.graphql( - ` - mutation($dId: ID!) { - closeDiscussion(input: { discussionId: $dId, reason: OUTDATED }) { - discussion { - id - url - } - } - }`, - { dId: discussionId } - ); - return result.closeDiscussion.discussion; - } - async function closeOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion, workflowName, runUrl) { - const searchCriteria = []; - if (titlePrefix) searchCriteria.push(`title prefix: "${titlePrefix}"`); - if (labels && labels.length > 0) searchCriteria.push(`labels: [${labels.join(", ")}]`); - core.info(`Searching for older discussions with ${searchCriteria.join(" and ")}`); - const olderDiscussions = await searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion.number); - if (olderDiscussions.length === 0) { - core.info("No older discussions found to close"); - return []; - } - core.info(`Found ${olderDiscussions.length} older discussion(s) to close`); - const discussionsToClose = olderDiscussions.slice(0, MAX_CLOSE_COUNT); - if (olderDiscussions.length > MAX_CLOSE_COUNT) { - core.warning(`Found ${olderDiscussions.length} older discussions, but only closing the first ${MAX_CLOSE_COUNT}`); - } - const closedDiscussions = []; - for (let i = 0; i < discussionsToClose.length; i++) { - const discussion = discussionsToClose[i]; - try { - const closingMessage = getCloseOlderDiscussionMessage({ - newDiscussionUrl: newDiscussion.url, - newDiscussionNumber: newDiscussion.number, - workflowName, - runUrl, - }); - core.info(`Adding closing comment to discussion #${discussion.number}`); - await addDiscussionComment(github, discussion.id, closingMessage); - core.info(`Closing discussion #${discussion.number} as outdated`); - await closeDiscussionAsOutdated(github, discussion.id); - closedDiscussions.push({ - number: discussion.number, - url: discussion.url, - }); - core.info(`✓ Closed discussion #${discussion.number}: ${discussion.url}`); - } catch (error) { - core.error(`✗ Failed to close discussion #${discussion.number}: ${error instanceof Error ? error.message : String(error)}`); - } - if (i < discussionsToClose.length - 1) { - await delay(GRAPHQL_DELAY_MS); - } - } - return closedDiscussions; - } - const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; - function generateTemporaryId() { - return "aw_" + crypto.randomBytes(6).toString("hex"); - } - function isTemporaryId(value) { - if (typeof value === "string") { - return /^aw_[0-9a-f]{12}$/i.test(value); - } - return false; - } - function normalizeTemporaryId(tempId) { - return String(tempId).toLowerCase(); - } - function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); - if (resolved !== undefined) { - if (currentRepo && resolved.repo === currentRepo) { - return `#${resolved.number}`; - } - return `${resolved.repo}#${resolved.number}`; - } - return match; - }); - } - function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); - if (issueNumber !== undefined) { - return `#${issueNumber}`; - } - return match; - }); - } - function loadTemporaryIdMap() { - const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; - if (!mapJson || mapJson === "{}") { - return new Map(); - } - try { - const mapObject = JSON.parse(mapJson); - const result = new Map(); - for (const [key, value] of Object.entries(mapObject)) { - const normalizedKey = normalizeTemporaryId(key); - if (typeof value === "number") { - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - result.set(normalizedKey, { repo: contextRepo, number: value }); - } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { - result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); - } - } - return result; - } catch (error) { - if (typeof core !== "undefined") { - core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); - } - return new Map(); - } - } - function resolveIssueNumber(value, temporaryIdMap) { - if (value === undefined || value === null) { - return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; - } - const valueStr = String(value); - if (isTemporaryId(valueStr)) { - const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); - if (resolvedPair !== undefined) { - return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; - } - return { - resolved: null, - wasTemporaryId: true, - errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, - }; - } - const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); - if (isNaN(issueNumber) || issueNumber <= 0) { - return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; - } - const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; - return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; - } - function serializeTemporaryIdMap(tempIdMap) { - const obj = Object.fromEntries(tempIdMap); - return JSON.stringify(obj); - } - function parseAllowedRepos() { - const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; - const set = new Set(); - if (allowedReposEnv) { - allowedReposEnv - .split(",") - .map(repo => repo.trim()) - .filter(repo => repo) - .forEach(repo => set.add(repo)); - } - return set; - } - function getDefaultTargetRepo() { - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - if (targetRepoSlug) { - return targetRepoSlug; - } - return `${context.repo.owner}/${context.repo.repo}`; - } - function validateRepo(repo, defaultRepo, allowedRepos) { - if (repo === defaultRepo) { - return { valid: true, error: null }; - } - if (allowedRepos.has(repo)) { - return { valid: true, error: null }; - } - return { - valid: false, - error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, - }; - } - function parseRepoSlug(repoSlug) { - const parts = repoSlug.split("/"); - if (parts.length !== 2 || !parts[0] || !parts[1]) { - return null; - } - return { owner: parts[0], repo: parts[1] }; - } - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { getTrackerID } = require("./get_tracker_id.cjs"); + const { closeOlderDiscussions } = require("./close_older_discussions.cjs"); + const { replaceTemporaryIdReferences, loadTemporaryIdMap } = require("./temporary_id.cjs"); + const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require("./repo_helpers.cjs"); + const { addExpirationComment } = require("./expiration_helpers.cjs"); + const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); async function fetchRepoDiscussionInfo(owner, repo) { const repositoryQuery = ` query($owner: String!, $repo: String!) { @@ -6612,7 +6244,9 @@ jobs: ); let title = createDiscussionItem.title ? replaceTemporaryIdReferences(createDiscussionItem.title.trim(), temporaryIdMap, itemRepo) : ""; const bodyText = createDiscussionItem.body || ""; - let bodyLines = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo).split("\n"); + let processedBody = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo); + processedBody = removeDuplicateTitleFromDescription(title, processedBody); + let bodyLines = processedBody.split("\n"); if (!title) { title = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo) || "Agent Output"; } diff --git a/.github/workflows/breaking-change-checker.lock.yml b/.github/workflows/breaking-change-checker.lock.yml index b0d2f76b01e..be041c82c68 100644 --- a/.github/workflows/breaking-change-checker.lock.yml +++ b/.github/workflows/breaking-change-checker.lock.yml @@ -6243,272 +6243,21 @@ jobs: with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - function sanitizeLabelContent(content) { - if (!content || typeof content !== "string") { - return ""; - } - let sanitized = content.trim(); - sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); - sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); - sanitized = sanitized.replace( - /(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, - (_m, p1, p2) => `${p1}\`@${p2}\`` - ); - sanitized = sanitized.replace(/[<>&'"]/g, ""); - return sanitized.trim(); - } - const fs = require("fs"); - const crypto = require("crypto"); - const MAX_LOG_CONTENT_LENGTH = 10000; - function truncateForLogging(content) { - if (content.length <= MAX_LOG_CONTENT_LENGTH) { - return content; - } - return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; - } - function loadAgentOutput() { - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; - if (!agentOutputFile) { - core.info("No GH_AW_AGENT_OUTPUT environment variable found"); - return { success: false }; - } - let outputContent; - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - return { success: false, error: errorMessage }; - } - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return { success: false }; - } - core.info(`Agent output content length: ${outputContent.length}`); - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); - return { success: false, error: errorMessage }; - } - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); - return { success: false }; - } - return { success: true, items: validatedOutput.items }; - } - async function generateStagedPreview(options) { - const { title, description, items, renderItem } = options; - let summaryContent = `## 🎭 Staged Mode: ${title} Preview\n\n`; - summaryContent += `${description}\n\n`; - for (let i = 0; i < items.length; i++) { - const item = items[i]; - summaryContent += renderItem(item, i); - summaryContent += "---\n\n"; - } - try { - await core.summary.addRaw(summaryContent).write(); - core.info(summaryContent); - core.info(`📝 ${title} preview written to step summary`); - } catch (error) { - core.setFailed(error instanceof Error ? error : String(error)); - } - } - function generateXMLMarker(workflowName, runUrl) { - const engineId = process.env.GH_AW_ENGINE_ID || ""; - const engineVersion = process.env.GH_AW_ENGINE_VERSION || ""; - const engineModel = process.env.GH_AW_ENGINE_MODEL || ""; - const trackerId = process.env.GH_AW_TRACKER_ID || ""; - const parts = []; - parts.push(`agentic-workflow: ${workflowName}`); - if (trackerId) { - parts.push(`tracker-id: ${trackerId}`); - } - if (engineId) { - parts.push(`engine: ${engineId}`); - } - if (engineVersion) { - parts.push(`version: ${engineVersion}`); - } - if (engineModel) { - parts.push(`model: ${engineModel}`); - } - parts.push(`run: ${runUrl}`); - return ``; - } - function generateFooter( - workflowName, - runUrl, - workflowSource, - workflowSourceURL, - triggeringIssueNumber, - triggeringPRNumber, - triggeringDiscussionNumber - ) { - let footer = `\n\n> AI generated by [${workflowName}](${runUrl})`; - if (triggeringIssueNumber) { - footer += ` for #${triggeringIssueNumber}`; - } else if (triggeringPRNumber) { - footer += ` for #${triggeringPRNumber}`; - } else if (triggeringDiscussionNumber) { - footer += ` for discussion #${triggeringDiscussionNumber}`; - } - if (workflowSource && workflowSourceURL) { - footer += `\n>\n> To add this workflow in your repository, run \`gh aw add ${workflowSource}\`. See [usage guide](https://githubnext.github.io/gh-aw/tools/cli/).`; - } - footer += "\n\n" + generateXMLMarker(workflowName, runUrl); - footer += "\n"; - return footer; - } - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; - function generateTemporaryId() { - return "aw_" + crypto.randomBytes(6).toString("hex"); - } - function isTemporaryId(value) { - if (typeof value === "string") { - return /^aw_[0-9a-f]{12}$/i.test(value); - } - return false; - } - function normalizeTemporaryId(tempId) { - return String(tempId).toLowerCase(); - } - function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); - if (resolved !== undefined) { - if (currentRepo && resolved.repo === currentRepo) { - return `#${resolved.number}`; - } - return `${resolved.repo}#${resolved.number}`; - } - return match; - }); - } - function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); - if (issueNumber !== undefined) { - return `#${issueNumber}`; - } - return match; - }); - } - function loadTemporaryIdMap() { - const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; - if (!mapJson || mapJson === "{}") { - return new Map(); - } - try { - const mapObject = JSON.parse(mapJson); - const result = new Map(); - for (const [key, value] of Object.entries(mapObject)) { - const normalizedKey = normalizeTemporaryId(key); - if (typeof value === "number") { - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - result.set(normalizedKey, { repo: contextRepo, number: value }); - } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { - result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); - } - } - return result; - } catch (error) { - if (typeof core !== "undefined") { - core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); - } - return new Map(); - } - } - function resolveIssueNumber(value, temporaryIdMap) { - if (value === undefined || value === null) { - return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; - } - const valueStr = String(value); - if (isTemporaryId(valueStr)) { - const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); - if (resolvedPair !== undefined) { - return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; - } - return { - resolved: null, - wasTemporaryId: true, - errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, - }; - } - const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); - if (isNaN(issueNumber) || issueNumber <= 0) { - return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; - } - const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; - return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; - } - function serializeTemporaryIdMap(tempIdMap) { - const obj = Object.fromEntries(tempIdMap); - return JSON.stringify(obj); - } - function parseAllowedRepos() { - const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; - const set = new Set(); - if (allowedReposEnv) { - allowedReposEnv - .split(",") - .map(repo => repo.trim()) - .filter(repo => repo) - .forEach(repo => set.add(repo)); - } - return set; - } - function getDefaultTargetRepo() { - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - if (targetRepoSlug) { - return targetRepoSlug; - } - return `${context.repo.owner}/${context.repo.repo}`; - } - function validateRepo(repo, defaultRepo, allowedRepos) { - if (repo === defaultRepo) { - return { valid: true, error: null }; - } - if (allowedRepos.has(repo)) { - return { valid: true, error: null }; - } - return { - valid: false, - error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, - }; - } - function parseRepoSlug(repoSlug) { - const parts = repoSlug.split("/"); - if (parts.length !== 2 || !parts[0] || !parts[1]) { - return null; - } - return { owner: parts[0], repo: parts[1] }; - } - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } + const { sanitizeLabelContent } = require("./sanitize_label_content.cjs"); + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { generateStagedPreview } = require("./staged_preview.cjs"); + const { generateFooter } = require("./generate_footer.cjs"); + const { getTrackerID } = require("./get_tracker_id.cjs"); + const { + generateTemporaryId, + isTemporaryId, + normalizeTemporaryId, + replaceTemporaryIdReferences, + serializeTemporaryIdMap, + } = require("./temporary_id.cjs"); + const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require("./repo_helpers.cjs"); + const { addExpirationComment } = require("./expiration_helpers.cjs"); + const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); async function main() { core.setOutput("issue_number", ""); core.setOutput("issue_url", ""); @@ -6641,6 +6390,7 @@ jobs: .filter((label, index, arr) => arr.indexOf(label) === index); let title = createIssueItem.title ? createIssueItem.title.trim() : ""; let processedBody = replaceTemporaryIdReferences(createIssueItem.body, temporaryIdMap, itemRepo); + processedBody = removeDuplicateTitleFromDescription(title, processedBody); let bodyLines = processedBody.split("\n"); if (!title) { title = createIssueItem.body || "Agent Output"; diff --git a/.github/workflows/changeset.lock.yml b/.github/workflows/changeset.lock.yml index 49405e53947..c7a4f0aeb50 100644 --- a/.github/workflows/changeset.lock.yml +++ b/.github/workflows/changeset.lock.yml @@ -7540,432 +7540,8 @@ jobs: with: github-token: ${{ steps.app-token.outputs.token }} script: | - const fs = require("fs"); - const MAX_LOG_CONTENT_LENGTH = 10000; - function truncateForLogging(content) { - if (content.length <= MAX_LOG_CONTENT_LENGTH) { - return content; - } - return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; - } - function loadAgentOutput() { - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; - if (!agentOutputFile) { - core.info("No GH_AW_AGENT_OUTPUT environment variable found"); - return { success: false }; - } - let outputContent; - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - return { success: false, error: errorMessage }; - } - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return { success: false }; - } - core.info(`Agent output content length: ${outputContent.length}`); - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); - return { success: false, error: errorMessage }; - } - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); - return { success: false }; - } - return { success: true, items: validatedOutput.items }; - } - async function generateStagedPreview(options) { - const { title, description, items, renderItem } = options; - let summaryContent = `## 🎭 Staged Mode: ${title} Preview\n\n`; - summaryContent += `${description}\n\n`; - for (let i = 0; i < items.length; i++) { - const item = items[i]; - summaryContent += renderItem(item, i); - summaryContent += "---\n\n"; - } - try { - await core.summary.addRaw(summaryContent).write(); - core.info(summaryContent); - core.info(`📝 ${title} preview written to step summary`); - } catch (error) { - core.setFailed(error instanceof Error ? error : String(error)); - } - } - function resolveTargetNumber(params) { - const { updateTarget, item, numberField, isValidContext, contextNumber, displayName } = params; - if (updateTarget === "*") { - const explicitNumber = item[numberField]; - if (explicitNumber) { - const parsed = parseInt(explicitNumber, 10); - if (isNaN(parsed) || parsed <= 0) { - return { success: false, error: `Invalid ${numberField} specified: ${explicitNumber}` }; - } - return { success: true, number: parsed }; - } else { - return { success: false, error: `Target is "*" but no ${numberField} specified in update item` }; - } - } else if (updateTarget && updateTarget !== "triggering") { - const parsed = parseInt(updateTarget, 10); - if (isNaN(parsed) || parsed <= 0) { - return { success: false, error: `Invalid ${displayName} number in target configuration: ${updateTarget}` }; - } - return { success: true, number: parsed }; - } else { - if (isValidContext && contextNumber) { - return { success: true, number: contextNumber }; - } - return { success: false, error: `Could not determine ${displayName} number` }; - } - } - function buildUpdateData(params) { - const { item, canUpdateStatus, canUpdateTitle, canUpdateBody, supportsStatus } = params; - const updateData = {}; - let hasUpdates = false; - const logMessages = []; - if (supportsStatus && canUpdateStatus && item.status !== undefined) { - if (item.status === "open" || item.status === "closed") { - updateData.state = item.status; - hasUpdates = true; - logMessages.push(`Will update status to: ${item.status}`); - } else { - logMessages.push(`Invalid status value: ${item.status}. Must be 'open' or 'closed'`); - } - } - if (canUpdateTitle && item.title !== undefined) { - const trimmedTitle = typeof item.title === "string" ? item.title.trim() : ""; - if (trimmedTitle.length > 0) { - updateData.title = trimmedTitle; - hasUpdates = true; - logMessages.push(`Will update title to: ${trimmedTitle}`); - } else { - logMessages.push("Invalid title value: must be a non-empty string"); - } - } - if (canUpdateBody && item.body !== undefined) { - if (typeof item.body === "string") { - updateData.body = item.body; - hasUpdates = true; - logMessages.push(`Will update body (length: ${item.body.length})`); - } else { - logMessages.push("Invalid body value: must be a string"); - } - } - return { hasUpdates, updateData, logMessages }; - } - async function runUpdateWorkflow(config) { - const { - itemType, - displayName, - displayNamePlural, - numberField, - outputNumberKey, - outputUrlKey, - isValidContext, - getContextNumber, - supportsStatus, - supportsOperation, - renderStagedItem, - executeUpdate, - getSummaryLine, - } = config; - const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; - const result = loadAgentOutput(); - if (!result.success) { - return; - } - const updateItems = result.items.filter( item => item.type === itemType); - if (updateItems.length === 0) { - core.info(`No ${itemType} items found in agent output`); - return; - } - core.info(`Found ${updateItems.length} ${itemType} item(s)`); - if (isStaged) { - await generateStagedPreview({ - title: `Update ${displayNamePlural.charAt(0).toUpperCase() + displayNamePlural.slice(1)}`, - description: `The following ${displayName} updates would be applied if staged mode was disabled:`, - items: updateItems, - renderItem: renderStagedItem, - }); - return; - } - const updateTarget = process.env.GH_AW_UPDATE_TARGET || "triggering"; - const canUpdateStatus = process.env.GH_AW_UPDATE_STATUS === "true"; - const canUpdateTitle = process.env.GH_AW_UPDATE_TITLE === "true"; - const canUpdateBody = process.env.GH_AW_UPDATE_BODY === "true"; - core.info(`Update target configuration: ${updateTarget}`); - if (supportsStatus) { - core.info(`Can update status: ${canUpdateStatus}, title: ${canUpdateTitle}, body: ${canUpdateBody}`); - } else { - core.info(`Can update title: ${canUpdateTitle}, body: ${canUpdateBody}`); - } - const contextIsValid = isValidContext(context.eventName, context.payload); - const contextNumber = getContextNumber(context.payload); - if (updateTarget === "triggering" && !contextIsValid) { - core.info(`Target is "triggering" but not running in ${displayName} context, skipping ${displayName} update`); - return; - } - const updatedItems = []; - for (let i = 0; i < updateItems.length; i++) { - const updateItem = updateItems[i]; - core.info(`Processing ${itemType} item ${i + 1}/${updateItems.length}`); - const targetResult = resolveTargetNumber({ - updateTarget, - item: updateItem, - numberField, - isValidContext: contextIsValid, - contextNumber, - displayName, - }); - if (!targetResult.success) { - core.info(targetResult.error); - continue; - } - const targetNumber = targetResult.number; - core.info(`Updating ${displayName} #${targetNumber}`); - const { hasUpdates, updateData, logMessages } = buildUpdateData({ - item: updateItem, - canUpdateStatus, - canUpdateTitle, - canUpdateBody, - supportsStatus, - }); - for (const msg of logMessages) { - core.info(msg); - } - if (supportsOperation && canUpdateBody && updateItem.body !== undefined && typeof updateItem.body === "string") { - updateData._operation = updateItem.operation || "append"; - updateData._rawBody = updateItem.body; - } - if (!hasUpdates) { - core.info("No valid updates to apply for this item"); - continue; - } - try { - const updatedItem = await executeUpdate(github, context, targetNumber, updateData); - core.info(`Updated ${displayName} #${updatedItem.number}: ${updatedItem.html_url}`); - updatedItems.push(updatedItem); - if (i === updateItems.length - 1) { - core.setOutput(outputNumberKey, updatedItem.number); - core.setOutput(outputUrlKey, updatedItem.html_url); - } - } catch (error) { - core.error(`✗ Failed to update ${displayName} #${targetNumber}: ${error instanceof Error ? error.message : String(error)}`); - throw error; - } - } - if (updatedItems.length > 0) { - let summaryContent = `\n\n## Updated ${displayNamePlural.charAt(0).toUpperCase() + displayNamePlural.slice(1)}\n`; - for (const item of updatedItems) { - summaryContent += getSummaryLine(item); - } - await core.summary.addRaw(summaryContent).write(); - } - core.info(`Successfully updated ${updatedItems.length} ${displayName}(s)`); - return updatedItems; - } - function createRenderStagedItem(config) { - const { entityName, numberField, targetLabel, currentTargetText, includeOperation = false } = config; - return function renderStagedItem(item, index) { - let content = `### ${entityName} Update ${index + 1}\n`; - if (item[numberField]) { - content += `**${targetLabel}** #${item[numberField]}\n\n`; - } else { - content += `**Target:** ${currentTargetText}\n\n`; - } - if (item.title !== undefined) { - content += `**New Title:** ${item.title}\n\n`; - } - if (item.body !== undefined) { - if (includeOperation) { - const operation = item.operation || "append"; - content += `**Operation:** ${operation}\n`; - content += `**Body Content:**\n${item.body}\n\n`; - } else { - content += `**New Body:**\n${item.body}\n\n`; - } - } - if (item.status !== undefined) { - content += `**New Status:** ${item.status}\n\n`; - } - return content; - }; - } - function createGetSummaryLine(config) { - const { entityPrefix } = config; - return function getSummaryLine(item) { - return `- ${entityPrefix} #${item.number}: [${item.title}](${item.html_url})\n`; - }; - } - function getMessages() { - const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES; - if (!messagesEnv) { - return null; - } - try { - return JSON.parse(messagesEnv); - } catch (error) { - core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`); - return null; - } - } - function renderTemplate(template, context) { - return template.replace(/\{(\w+)\}/g, (match, key) => { - const value = context[key]; - return value !== undefined && value !== null ? String(value) : match; - }); - } - function toSnakeCase(obj) { - const result = {}; - for (const [key, value] of Object.entries(obj)) { - const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase(); - result[snakeKey] = value; - result[key] = value; - } - return result; - } - function getFooterMessage(ctx) { - const messages = getMessages(); - const templateContext = toSnakeCase(ctx); - const defaultFooter = "> Ahoy! This treasure was crafted by [🏴‍☠️ {workflow_name}]({run_url})"; - let footer = messages?.footer ? renderTemplate(messages.footer, templateContext) : renderTemplate(defaultFooter, templateContext); - if (ctx.triggeringNumber) { - footer += ` fer issue #{triggering_number} 🗺️`.replace("{triggering_number}", String(ctx.triggeringNumber)); - } - return footer; - } - function getFooterInstallMessage(ctx) { - if (!ctx.workflowSource || !ctx.workflowSourceUrl) { - return ""; - } - const messages = getMessages(); - const templateContext = toSnakeCase(ctx); - const defaultInstall = - "> Arr! To plunder this workflow fer yer own ship, run `gh aw add {workflow_source}`. Chart yer course at [🦜 {workflow_source_url}]({workflow_source_url})!"; - return messages?.footerInstall - ? renderTemplate(messages.footerInstall, templateContext) - : renderTemplate(defaultInstall, templateContext); - } - function generateXMLMarker(workflowName, runUrl) { - const engineId = process.env.GH_AW_ENGINE_ID || ""; - const engineVersion = process.env.GH_AW_ENGINE_VERSION || ""; - const engineModel = process.env.GH_AW_ENGINE_MODEL || ""; - const trackerId = process.env.GH_AW_TRACKER_ID || ""; - const parts = []; - parts.push(`agentic-workflow: ${workflowName}`); - if (trackerId) { - parts.push(`tracker-id: ${trackerId}`); - } - if (engineId) { - parts.push(`engine: ${engineId}`); - } - if (engineVersion) { - parts.push(`version: ${engineVersion}`); - } - if (engineModel) { - parts.push(`model: ${engineModel}`); - } - parts.push(`run: ${runUrl}`); - return ``; - } - function generateFooterWithMessages( - workflowName, - runUrl, - workflowSource, - workflowSourceURL, - triggeringIssueNumber, - triggeringPRNumber, - triggeringDiscussionNumber - ) { - let triggeringNumber; - if (triggeringIssueNumber) { - triggeringNumber = triggeringIssueNumber; - } else if (triggeringPRNumber) { - triggeringNumber = triggeringPRNumber; - } else if (triggeringDiscussionNumber) { - triggeringNumber = `discussion #${triggeringDiscussionNumber}`; - } - const ctx = { - workflowName, - runUrl, - workflowSource, - workflowSourceUrl: workflowSourceURL, - triggeringNumber, - }; - let footer = "\n\n" + getFooterMessage(ctx); - const installMessage = getFooterInstallMessage(ctx); - if (installMessage) { - footer += "\n>\n" + installMessage; - } - footer += "\n\n" + generateXMLMarker(workflowName, runUrl); - footer += "\n"; - return footer; - } - function buildAIFooter(workflowName, runUrl) { - return "\n\n" + getFooterMessage({ workflowName, runUrl }); - } - function buildIslandStartMarker(runId) { - return ``; - } - function buildIslandEndMarker(runId) { - return ``; - } - function findIsland(body, runId) { - const startMarker = buildIslandStartMarker(runId); - const endMarker = buildIslandEndMarker(runId); - const startIndex = body.indexOf(startMarker); - if (startIndex === -1) { - return { found: false, startIndex: -1, endIndex: -1 }; - } - const endIndex = body.indexOf(endMarker, startIndex); - if (endIndex === -1) { - return { found: false, startIndex: -1, endIndex: -1 }; - } - return { found: true, startIndex, endIndex: endIndex + endMarker.length }; - } - function updatePRBody(params) { - const { currentBody, newContent, operation, workflowName, runUrl, runId } = params; - const aiFooter = buildAIFooter(workflowName, runUrl); - if (operation === "replace") { - core.info("Operation: replace (full body replacement)"); - return newContent; - } - if (operation === "replace-island") { - const island = findIsland(currentBody, runId); - if (island.found) { - core.info(`Operation: replace-island (updating existing island for run ${runId})`); - const startMarker = buildIslandStartMarker(runId); - const endMarker = buildIslandEndMarker(runId); - const islandContent = `${startMarker}\n${newContent}${aiFooter}\n${endMarker}`; - const before = currentBody.substring(0, island.startIndex); - const after = currentBody.substring(island.endIndex); - return before + islandContent + after; - } else { - core.info(`Operation: replace-island (island not found for run ${runId}, falling back to append)`); - const startMarker = buildIslandStartMarker(runId); - const endMarker = buildIslandEndMarker(runId); - const islandContent = `${startMarker}\n${newContent}${aiFooter}\n${endMarker}`; - const appendSection = `\n\n---\n\n${islandContent}`; - return currentBody + appendSection; - } - } - if (operation === "prepend") { - core.info("Operation: prepend (add to start with separator)"); - const prependSection = `${newContent}${aiFooter}\n\n---\n\n`; - return prependSection + currentBody; - } - core.info("Operation: append (add to end with separator)"); - const appendSection = `\n\n---\n\n${newContent}${aiFooter}`; - return currentBody + appendSection; - } + const { runUpdateWorkflow, createRenderStagedItem, createGetSummaryLine } = require("./update_runner.cjs"); + const { updatePRBody } = require("./update_pr_description_helpers.cjs"); function isPRContext(eventName, payload) { const isPR = eventName === "pull_request" || diff --git a/.github/workflows/ci-coach.lock.yml b/.github/workflows/ci-coach.lock.yml index e23e2391b8e..11cf0b4d38f 100644 --- a/.github/workflows/ci-coach.lock.yml +++ b/.github/workflows/ci-coach.lock.yml @@ -7321,132 +7321,10 @@ jobs: script: | const fs = require("fs"); const crypto = require("crypto"); - async function updateActivationComment(github, context, core, itemUrl, itemNumber, itemType = "pull_request") { - const itemLabel = itemType === "issue" ? "issue" : "pull request"; - const linkMessage = - itemType === "issue" - ? `\n\n✅ Issue created: [#${itemNumber}](${itemUrl})` - : `\n\n✅ Pull request created: [#${itemNumber}](${itemUrl})`; - await updateActivationCommentWithMessage(github, context, core, linkMessage, itemLabel); - } - async function updateActivationCommentWithCommit(github, context, core, commitSha, commitUrl) { - const shortSha = commitSha.substring(0, 7); - const message = `\n\n✅ Commit pushed: [\`${shortSha}\`](${commitUrl})`; - await updateActivationCommentWithMessage(github, context, core, message, "commit"); - } - async function updateActivationCommentWithMessage(github, context, core, message, label = "") { - const commentId = process.env.GH_AW_COMMENT_ID; - const commentRepo = process.env.GH_AW_COMMENT_REPO; - if (!commentId) { - core.info("No activation comment to update (GH_AW_COMMENT_ID not set)"); - return; - } - core.info(`Updating activation comment ${commentId}`); - let repoOwner = context.repo.owner; - let repoName = context.repo.repo; - if (commentRepo) { - const parts = commentRepo.split("/"); - if (parts.length === 2) { - repoOwner = parts[0]; - repoName = parts[1]; - } else { - core.warning(`Invalid comment repo format: ${commentRepo}, expected "owner/repo". Falling back to context.repo.`); - } - } - core.info(`Updating comment in ${repoOwner}/${repoName}`); - const isDiscussionComment = commentId.startsWith("DC_"); - try { - if (isDiscussionComment) { - const currentComment = await github.graphql( - ` - query($commentId: ID!) { - node(id: $commentId) { - ... on DiscussionComment { - body - } - } - }`, - { commentId: commentId } - ); - if (!currentComment?.node?.body) { - core.warning("Unable to fetch current comment body, comment may have been deleted or is inaccessible"); - return; - } - const currentBody = currentComment.node.body; - const updatedBody = currentBody + message; - const result = await github.graphql( - ` - mutation($commentId: ID!, $body: String!) { - updateDiscussionComment(input: { commentId: $commentId, body: $body }) { - comment { - id - url - } - } - }`, - { commentId: commentId, body: updatedBody } - ); - const comment = result.updateDiscussionComment.comment; - const successMessage = label - ? `Successfully updated discussion comment with ${label} link` - : "Successfully updated discussion comment"; - core.info(successMessage); - core.info(`Comment ID: ${comment.id}`); - core.info(`Comment URL: ${comment.url}`); - } else { - const currentComment = await github.request("GET /repos/{owner}/{repo}/issues/comments/{comment_id}", { - owner: repoOwner, - repo: repoName, - comment_id: parseInt(commentId, 10), - headers: { - Accept: "application/vnd.github+json", - }, - }); - if (!currentComment?.data?.body) { - core.warning("Unable to fetch current comment body, comment may have been deleted"); - return; - } - const currentBody = currentComment.data.body; - const updatedBody = currentBody + message; - const response = await github.request("PATCH /repos/{owner}/{repo}/issues/comments/{comment_id}", { - owner: repoOwner, - repo: repoName, - comment_id: parseInt(commentId, 10), - body: updatedBody, - headers: { - Accept: "application/vnd.github+json", - }, - }); - const successMessage = label ? `Successfully updated comment with ${label} link` : "Successfully updated comment"; - core.info(successMessage); - core.info(`Comment ID: ${response.data.id}`); - core.info(`Comment URL: ${response.data.html_url}`); - } - } catch (error) { - core.warning(`Failed to update activation comment: ${error instanceof Error ? error.message : String(error)}`); - } - } - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } + const { updateActivationComment } = require("./update_activation_comment.cjs"); + const { getTrackerID } = require("./get_tracker_id.cjs"); + const { addExpirationComment } = require("./expiration_helpers.cjs"); + const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); function generatePatchPreview(patchContent) { if (!patchContent || !patchContent.trim()) { return ""; @@ -7638,7 +7516,9 @@ jobs: return; } let title = pullRequestItem.title.trim(); - let bodyLines = pullRequestItem.body.split("\n"); + let processedBody = pullRequestItem.body; + processedBody = removeDuplicateTitleFromDescription(title, processedBody); + let bodyLines = processedBody.split("\n"); let branchName = pullRequestItem.branch ? pullRequestItem.branch.trim() : null; if (!title) { title = "Agent Output"; diff --git a/.github/workflows/ci-doctor.lock.yml b/.github/workflows/ci-doctor.lock.yml index 0688ffa78b5..4e629385318 100644 --- a/.github/workflows/ci-doctor.lock.yml +++ b/.github/workflows/ci-doctor.lock.yml @@ -6946,272 +6946,21 @@ jobs: with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - function sanitizeLabelContent(content) { - if (!content || typeof content !== "string") { - return ""; - } - let sanitized = content.trim(); - sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); - sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); - sanitized = sanitized.replace( - /(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, - (_m, p1, p2) => `${p1}\`@${p2}\`` - ); - sanitized = sanitized.replace(/[<>&'"]/g, ""); - return sanitized.trim(); - } - const fs = require("fs"); - const crypto = require("crypto"); - const MAX_LOG_CONTENT_LENGTH = 10000; - function truncateForLogging(content) { - if (content.length <= MAX_LOG_CONTENT_LENGTH) { - return content; - } - return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; - } - function loadAgentOutput() { - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; - if (!agentOutputFile) { - core.info("No GH_AW_AGENT_OUTPUT environment variable found"); - return { success: false }; - } - let outputContent; - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - return { success: false, error: errorMessage }; - } - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return { success: false }; - } - core.info(`Agent output content length: ${outputContent.length}`); - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); - return { success: false, error: errorMessage }; - } - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); - return { success: false }; - } - return { success: true, items: validatedOutput.items }; - } - async function generateStagedPreview(options) { - const { title, description, items, renderItem } = options; - let summaryContent = `## 🎭 Staged Mode: ${title} Preview\n\n`; - summaryContent += `${description}\n\n`; - for (let i = 0; i < items.length; i++) { - const item = items[i]; - summaryContent += renderItem(item, i); - summaryContent += "---\n\n"; - } - try { - await core.summary.addRaw(summaryContent).write(); - core.info(summaryContent); - core.info(`📝 ${title} preview written to step summary`); - } catch (error) { - core.setFailed(error instanceof Error ? error : String(error)); - } - } - function generateXMLMarker(workflowName, runUrl) { - const engineId = process.env.GH_AW_ENGINE_ID || ""; - const engineVersion = process.env.GH_AW_ENGINE_VERSION || ""; - const engineModel = process.env.GH_AW_ENGINE_MODEL || ""; - const trackerId = process.env.GH_AW_TRACKER_ID || ""; - const parts = []; - parts.push(`agentic-workflow: ${workflowName}`); - if (trackerId) { - parts.push(`tracker-id: ${trackerId}`); - } - if (engineId) { - parts.push(`engine: ${engineId}`); - } - if (engineVersion) { - parts.push(`version: ${engineVersion}`); - } - if (engineModel) { - parts.push(`model: ${engineModel}`); - } - parts.push(`run: ${runUrl}`); - return ``; - } - function generateFooter( - workflowName, - runUrl, - workflowSource, - workflowSourceURL, - triggeringIssueNumber, - triggeringPRNumber, - triggeringDiscussionNumber - ) { - let footer = `\n\n> AI generated by [${workflowName}](${runUrl})`; - if (triggeringIssueNumber) { - footer += ` for #${triggeringIssueNumber}`; - } else if (triggeringPRNumber) { - footer += ` for #${triggeringPRNumber}`; - } else if (triggeringDiscussionNumber) { - footer += ` for discussion #${triggeringDiscussionNumber}`; - } - if (workflowSource && workflowSourceURL) { - footer += `\n>\n> To add this workflow in your repository, run \`gh aw add ${workflowSource}\`. See [usage guide](https://githubnext.github.io/gh-aw/tools/cli/).`; - } - footer += "\n\n" + generateXMLMarker(workflowName, runUrl); - footer += "\n"; - return footer; - } - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; - function generateTemporaryId() { - return "aw_" + crypto.randomBytes(6).toString("hex"); - } - function isTemporaryId(value) { - if (typeof value === "string") { - return /^aw_[0-9a-f]{12}$/i.test(value); - } - return false; - } - function normalizeTemporaryId(tempId) { - return String(tempId).toLowerCase(); - } - function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); - if (resolved !== undefined) { - if (currentRepo && resolved.repo === currentRepo) { - return `#${resolved.number}`; - } - return `${resolved.repo}#${resolved.number}`; - } - return match; - }); - } - function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); - if (issueNumber !== undefined) { - return `#${issueNumber}`; - } - return match; - }); - } - function loadTemporaryIdMap() { - const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; - if (!mapJson || mapJson === "{}") { - return new Map(); - } - try { - const mapObject = JSON.parse(mapJson); - const result = new Map(); - for (const [key, value] of Object.entries(mapObject)) { - const normalizedKey = normalizeTemporaryId(key); - if (typeof value === "number") { - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - result.set(normalizedKey, { repo: contextRepo, number: value }); - } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { - result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); - } - } - return result; - } catch (error) { - if (typeof core !== "undefined") { - core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); - } - return new Map(); - } - } - function resolveIssueNumber(value, temporaryIdMap) { - if (value === undefined || value === null) { - return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; - } - const valueStr = String(value); - if (isTemporaryId(valueStr)) { - const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); - if (resolvedPair !== undefined) { - return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; - } - return { - resolved: null, - wasTemporaryId: true, - errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, - }; - } - const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); - if (isNaN(issueNumber) || issueNumber <= 0) { - return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; - } - const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; - return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; - } - function serializeTemporaryIdMap(tempIdMap) { - const obj = Object.fromEntries(tempIdMap); - return JSON.stringify(obj); - } - function parseAllowedRepos() { - const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; - const set = new Set(); - if (allowedReposEnv) { - allowedReposEnv - .split(",") - .map(repo => repo.trim()) - .filter(repo => repo) - .forEach(repo => set.add(repo)); - } - return set; - } - function getDefaultTargetRepo() { - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - if (targetRepoSlug) { - return targetRepoSlug; - } - return `${context.repo.owner}/${context.repo.repo}`; - } - function validateRepo(repo, defaultRepo, allowedRepos) { - if (repo === defaultRepo) { - return { valid: true, error: null }; - } - if (allowedRepos.has(repo)) { - return { valid: true, error: null }; - } - return { - valid: false, - error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, - }; - } - function parseRepoSlug(repoSlug) { - const parts = repoSlug.split("/"); - if (parts.length !== 2 || !parts[0] || !parts[1]) { - return null; - } - return { owner: parts[0], repo: parts[1] }; - } - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } + const { sanitizeLabelContent } = require("./sanitize_label_content.cjs"); + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { generateStagedPreview } = require("./staged_preview.cjs"); + const { generateFooter } = require("./generate_footer.cjs"); + const { getTrackerID } = require("./get_tracker_id.cjs"); + const { + generateTemporaryId, + isTemporaryId, + normalizeTemporaryId, + replaceTemporaryIdReferences, + serializeTemporaryIdMap, + } = require("./temporary_id.cjs"); + const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require("./repo_helpers.cjs"); + const { addExpirationComment } = require("./expiration_helpers.cjs"); + const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); async function main() { core.setOutput("issue_number", ""); core.setOutput("issue_url", ""); @@ -7344,6 +7093,7 @@ jobs: .filter((label, index, arr) => arr.indexOf(label) === index); let title = createIssueItem.title ? createIssueItem.title.trim() : ""; let processedBody = replaceTemporaryIdReferences(createIssueItem.body, temporaryIdMap, itemRepo); + processedBody = removeDuplicateTitleFromDescription(title, processedBody); let bodyLines = processedBody.split("\n"); if (!title) { title = createIssueItem.body || "Agent Output"; diff --git a/.github/workflows/cli-consistency-checker.lock.yml b/.github/workflows/cli-consistency-checker.lock.yml index 068d1b39652..f868ed25b45 100644 --- a/.github/workflows/cli-consistency-checker.lock.yml +++ b/.github/workflows/cli-consistency-checker.lock.yml @@ -6232,272 +6232,21 @@ jobs: with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - function sanitizeLabelContent(content) { - if (!content || typeof content !== "string") { - return ""; - } - let sanitized = content.trim(); - sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); - sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); - sanitized = sanitized.replace( - /(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, - (_m, p1, p2) => `${p1}\`@${p2}\`` - ); - sanitized = sanitized.replace(/[<>&'"]/g, ""); - return sanitized.trim(); - } - const fs = require("fs"); - const crypto = require("crypto"); - const MAX_LOG_CONTENT_LENGTH = 10000; - function truncateForLogging(content) { - if (content.length <= MAX_LOG_CONTENT_LENGTH) { - return content; - } - return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; - } - function loadAgentOutput() { - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; - if (!agentOutputFile) { - core.info("No GH_AW_AGENT_OUTPUT environment variable found"); - return { success: false }; - } - let outputContent; - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - return { success: false, error: errorMessage }; - } - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return { success: false }; - } - core.info(`Agent output content length: ${outputContent.length}`); - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); - return { success: false, error: errorMessage }; - } - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); - return { success: false }; - } - return { success: true, items: validatedOutput.items }; - } - async function generateStagedPreview(options) { - const { title, description, items, renderItem } = options; - let summaryContent = `## 🎭 Staged Mode: ${title} Preview\n\n`; - summaryContent += `${description}\n\n`; - for (let i = 0; i < items.length; i++) { - const item = items[i]; - summaryContent += renderItem(item, i); - summaryContent += "---\n\n"; - } - try { - await core.summary.addRaw(summaryContent).write(); - core.info(summaryContent); - core.info(`📝 ${title} preview written to step summary`); - } catch (error) { - core.setFailed(error instanceof Error ? error : String(error)); - } - } - function generateXMLMarker(workflowName, runUrl) { - const engineId = process.env.GH_AW_ENGINE_ID || ""; - const engineVersion = process.env.GH_AW_ENGINE_VERSION || ""; - const engineModel = process.env.GH_AW_ENGINE_MODEL || ""; - const trackerId = process.env.GH_AW_TRACKER_ID || ""; - const parts = []; - parts.push(`agentic-workflow: ${workflowName}`); - if (trackerId) { - parts.push(`tracker-id: ${trackerId}`); - } - if (engineId) { - parts.push(`engine: ${engineId}`); - } - if (engineVersion) { - parts.push(`version: ${engineVersion}`); - } - if (engineModel) { - parts.push(`model: ${engineModel}`); - } - parts.push(`run: ${runUrl}`); - return ``; - } - function generateFooter( - workflowName, - runUrl, - workflowSource, - workflowSourceURL, - triggeringIssueNumber, - triggeringPRNumber, - triggeringDiscussionNumber - ) { - let footer = `\n\n> AI generated by [${workflowName}](${runUrl})`; - if (triggeringIssueNumber) { - footer += ` for #${triggeringIssueNumber}`; - } else if (triggeringPRNumber) { - footer += ` for #${triggeringPRNumber}`; - } else if (triggeringDiscussionNumber) { - footer += ` for discussion #${triggeringDiscussionNumber}`; - } - if (workflowSource && workflowSourceURL) { - footer += `\n>\n> To add this workflow in your repository, run \`gh aw add ${workflowSource}\`. See [usage guide](https://githubnext.github.io/gh-aw/tools/cli/).`; - } - footer += "\n\n" + generateXMLMarker(workflowName, runUrl); - footer += "\n"; - return footer; - } - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; - function generateTemporaryId() { - return "aw_" + crypto.randomBytes(6).toString("hex"); - } - function isTemporaryId(value) { - if (typeof value === "string") { - return /^aw_[0-9a-f]{12}$/i.test(value); - } - return false; - } - function normalizeTemporaryId(tempId) { - return String(tempId).toLowerCase(); - } - function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); - if (resolved !== undefined) { - if (currentRepo && resolved.repo === currentRepo) { - return `#${resolved.number}`; - } - return `${resolved.repo}#${resolved.number}`; - } - return match; - }); - } - function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); - if (issueNumber !== undefined) { - return `#${issueNumber}`; - } - return match; - }); - } - function loadTemporaryIdMap() { - const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; - if (!mapJson || mapJson === "{}") { - return new Map(); - } - try { - const mapObject = JSON.parse(mapJson); - const result = new Map(); - for (const [key, value] of Object.entries(mapObject)) { - const normalizedKey = normalizeTemporaryId(key); - if (typeof value === "number") { - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - result.set(normalizedKey, { repo: contextRepo, number: value }); - } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { - result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); - } - } - return result; - } catch (error) { - if (typeof core !== "undefined") { - core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); - } - return new Map(); - } - } - function resolveIssueNumber(value, temporaryIdMap) { - if (value === undefined || value === null) { - return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; - } - const valueStr = String(value); - if (isTemporaryId(valueStr)) { - const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); - if (resolvedPair !== undefined) { - return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; - } - return { - resolved: null, - wasTemporaryId: true, - errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, - }; - } - const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); - if (isNaN(issueNumber) || issueNumber <= 0) { - return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; - } - const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; - return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; - } - function serializeTemporaryIdMap(tempIdMap) { - const obj = Object.fromEntries(tempIdMap); - return JSON.stringify(obj); - } - function parseAllowedRepos() { - const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; - const set = new Set(); - if (allowedReposEnv) { - allowedReposEnv - .split(",") - .map(repo => repo.trim()) - .filter(repo => repo) - .forEach(repo => set.add(repo)); - } - return set; - } - function getDefaultTargetRepo() { - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - if (targetRepoSlug) { - return targetRepoSlug; - } - return `${context.repo.owner}/${context.repo.repo}`; - } - function validateRepo(repo, defaultRepo, allowedRepos) { - if (repo === defaultRepo) { - return { valid: true, error: null }; - } - if (allowedRepos.has(repo)) { - return { valid: true, error: null }; - } - return { - valid: false, - error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, - }; - } - function parseRepoSlug(repoSlug) { - const parts = repoSlug.split("/"); - if (parts.length !== 2 || !parts[0] || !parts[1]) { - return null; - } - return { owner: parts[0], repo: parts[1] }; - } - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } + const { sanitizeLabelContent } = require("./sanitize_label_content.cjs"); + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { generateStagedPreview } = require("./staged_preview.cjs"); + const { generateFooter } = require("./generate_footer.cjs"); + const { getTrackerID } = require("./get_tracker_id.cjs"); + const { + generateTemporaryId, + isTemporaryId, + normalizeTemporaryId, + replaceTemporaryIdReferences, + serializeTemporaryIdMap, + } = require("./temporary_id.cjs"); + const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require("./repo_helpers.cjs"); + const { addExpirationComment } = require("./expiration_helpers.cjs"); + const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); async function main() { core.setOutput("issue_number", ""); core.setOutput("issue_url", ""); @@ -6630,6 +6379,7 @@ jobs: .filter((label, index, arr) => arr.indexOf(label) === index); let title = createIssueItem.title ? createIssueItem.title.trim() : ""; let processedBody = replaceTemporaryIdReferences(createIssueItem.body, temporaryIdMap, itemRepo); + processedBody = removeDuplicateTitleFromDescription(title, processedBody); let bodyLines = processedBody.split("\n"); if (!title) { title = createIssueItem.body || "Agent Output"; diff --git a/.github/workflows/cli-version-checker.lock.yml b/.github/workflows/cli-version-checker.lock.yml index f5d009ef3d4..c9a7bd0ed1a 100644 --- a/.github/workflows/cli-version-checker.lock.yml +++ b/.github/workflows/cli-version-checker.lock.yml @@ -6005,272 +6005,21 @@ jobs: with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - function sanitizeLabelContent(content) { - if (!content || typeof content !== "string") { - return ""; - } - let sanitized = content.trim(); - sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); - sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); - sanitized = sanitized.replace( - /(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, - (_m, p1, p2) => `${p1}\`@${p2}\`` - ); - sanitized = sanitized.replace(/[<>&'"]/g, ""); - return sanitized.trim(); - } - const fs = require("fs"); - const crypto = require("crypto"); - const MAX_LOG_CONTENT_LENGTH = 10000; - function truncateForLogging(content) { - if (content.length <= MAX_LOG_CONTENT_LENGTH) { - return content; - } - return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; - } - function loadAgentOutput() { - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; - if (!agentOutputFile) { - core.info("No GH_AW_AGENT_OUTPUT environment variable found"); - return { success: false }; - } - let outputContent; - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - return { success: false, error: errorMessage }; - } - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return { success: false }; - } - core.info(`Agent output content length: ${outputContent.length}`); - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); - return { success: false, error: errorMessage }; - } - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); - return { success: false }; - } - return { success: true, items: validatedOutput.items }; - } - async function generateStagedPreview(options) { - const { title, description, items, renderItem } = options; - let summaryContent = `## 🎭 Staged Mode: ${title} Preview\n\n`; - summaryContent += `${description}\n\n`; - for (let i = 0; i < items.length; i++) { - const item = items[i]; - summaryContent += renderItem(item, i); - summaryContent += "---\n\n"; - } - try { - await core.summary.addRaw(summaryContent).write(); - core.info(summaryContent); - core.info(`📝 ${title} preview written to step summary`); - } catch (error) { - core.setFailed(error instanceof Error ? error : String(error)); - } - } - function generateXMLMarker(workflowName, runUrl) { - const engineId = process.env.GH_AW_ENGINE_ID || ""; - const engineVersion = process.env.GH_AW_ENGINE_VERSION || ""; - const engineModel = process.env.GH_AW_ENGINE_MODEL || ""; - const trackerId = process.env.GH_AW_TRACKER_ID || ""; - const parts = []; - parts.push(`agentic-workflow: ${workflowName}`); - if (trackerId) { - parts.push(`tracker-id: ${trackerId}`); - } - if (engineId) { - parts.push(`engine: ${engineId}`); - } - if (engineVersion) { - parts.push(`version: ${engineVersion}`); - } - if (engineModel) { - parts.push(`model: ${engineModel}`); - } - parts.push(`run: ${runUrl}`); - return ``; - } - function generateFooter( - workflowName, - runUrl, - workflowSource, - workflowSourceURL, - triggeringIssueNumber, - triggeringPRNumber, - triggeringDiscussionNumber - ) { - let footer = `\n\n> AI generated by [${workflowName}](${runUrl})`; - if (triggeringIssueNumber) { - footer += ` for #${triggeringIssueNumber}`; - } else if (triggeringPRNumber) { - footer += ` for #${triggeringPRNumber}`; - } else if (triggeringDiscussionNumber) { - footer += ` for discussion #${triggeringDiscussionNumber}`; - } - if (workflowSource && workflowSourceURL) { - footer += `\n>\n> To add this workflow in your repository, run \`gh aw add ${workflowSource}\`. See [usage guide](https://githubnext.github.io/gh-aw/tools/cli/).`; - } - footer += "\n\n" + generateXMLMarker(workflowName, runUrl); - footer += "\n"; - return footer; - } - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; - function generateTemporaryId() { - return "aw_" + crypto.randomBytes(6).toString("hex"); - } - function isTemporaryId(value) { - if (typeof value === "string") { - return /^aw_[0-9a-f]{12}$/i.test(value); - } - return false; - } - function normalizeTemporaryId(tempId) { - return String(tempId).toLowerCase(); - } - function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); - if (resolved !== undefined) { - if (currentRepo && resolved.repo === currentRepo) { - return `#${resolved.number}`; - } - return `${resolved.repo}#${resolved.number}`; - } - return match; - }); - } - function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); - if (issueNumber !== undefined) { - return `#${issueNumber}`; - } - return match; - }); - } - function loadTemporaryIdMap() { - const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; - if (!mapJson || mapJson === "{}") { - return new Map(); - } - try { - const mapObject = JSON.parse(mapJson); - const result = new Map(); - for (const [key, value] of Object.entries(mapObject)) { - const normalizedKey = normalizeTemporaryId(key); - if (typeof value === "number") { - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - result.set(normalizedKey, { repo: contextRepo, number: value }); - } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { - result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); - } - } - return result; - } catch (error) { - if (typeof core !== "undefined") { - core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); - } - return new Map(); - } - } - function resolveIssueNumber(value, temporaryIdMap) { - if (value === undefined || value === null) { - return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; - } - const valueStr = String(value); - if (isTemporaryId(valueStr)) { - const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); - if (resolvedPair !== undefined) { - return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; - } - return { - resolved: null, - wasTemporaryId: true, - errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, - }; - } - const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); - if (isNaN(issueNumber) || issueNumber <= 0) { - return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; - } - const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; - return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; - } - function serializeTemporaryIdMap(tempIdMap) { - const obj = Object.fromEntries(tempIdMap); - return JSON.stringify(obj); - } - function parseAllowedRepos() { - const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; - const set = new Set(); - if (allowedReposEnv) { - allowedReposEnv - .split(",") - .map(repo => repo.trim()) - .filter(repo => repo) - .forEach(repo => set.add(repo)); - } - return set; - } - function getDefaultTargetRepo() { - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - if (targetRepoSlug) { - return targetRepoSlug; - } - return `${context.repo.owner}/${context.repo.repo}`; - } - function validateRepo(repo, defaultRepo, allowedRepos) { - if (repo === defaultRepo) { - return { valid: true, error: null }; - } - if (allowedRepos.has(repo)) { - return { valid: true, error: null }; - } - return { - valid: false, - error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, - }; - } - function parseRepoSlug(repoSlug) { - const parts = repoSlug.split("/"); - if (parts.length !== 2 || !parts[0] || !parts[1]) { - return null; - } - return { owner: parts[0], repo: parts[1] }; - } - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } + const { sanitizeLabelContent } = require("./sanitize_label_content.cjs"); + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { generateStagedPreview } = require("./staged_preview.cjs"); + const { generateFooter } = require("./generate_footer.cjs"); + const { getTrackerID } = require("./get_tracker_id.cjs"); + const { + generateTemporaryId, + isTemporaryId, + normalizeTemporaryId, + replaceTemporaryIdReferences, + serializeTemporaryIdMap, + } = require("./temporary_id.cjs"); + const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require("./repo_helpers.cjs"); + const { addExpirationComment } = require("./expiration_helpers.cjs"); + const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); async function main() { core.setOutput("issue_number", ""); core.setOutput("issue_url", ""); @@ -6403,6 +6152,7 @@ jobs: .filter((label, index, arr) => arr.indexOf(label) === index); let title = createIssueItem.title ? createIssueItem.title.trim() : ""; let processedBody = replaceTemporaryIdReferences(createIssueItem.body, temporaryIdMap, itemRepo); + processedBody = removeDuplicateTitleFromDescription(title, processedBody); let bodyLines = processedBody.split("\n"); if (!title) { title = createIssueItem.body || "Agent Output"; diff --git a/.github/workflows/cloclo.lock.yml b/.github/workflows/cloclo.lock.yml index 21a1eda678a..6db5204950c 100644 --- a/.github/workflows/cloclo.lock.yml +++ b/.github/workflows/cloclo.lock.yml @@ -7521,132 +7521,10 @@ jobs: script: | const fs = require("fs"); const crypto = require("crypto"); - async function updateActivationComment(github, context, core, itemUrl, itemNumber, itemType = "pull_request") { - const itemLabel = itemType === "issue" ? "issue" : "pull request"; - const linkMessage = - itemType === "issue" - ? `\n\n✅ Issue created: [#${itemNumber}](${itemUrl})` - : `\n\n✅ Pull request created: [#${itemNumber}](${itemUrl})`; - await updateActivationCommentWithMessage(github, context, core, linkMessage, itemLabel); - } - async function updateActivationCommentWithCommit(github, context, core, commitSha, commitUrl) { - const shortSha = commitSha.substring(0, 7); - const message = `\n\n✅ Commit pushed: [\`${shortSha}\`](${commitUrl})`; - await updateActivationCommentWithMessage(github, context, core, message, "commit"); - } - async function updateActivationCommentWithMessage(github, context, core, message, label = "") { - const commentId = process.env.GH_AW_COMMENT_ID; - const commentRepo = process.env.GH_AW_COMMENT_REPO; - if (!commentId) { - core.info("No activation comment to update (GH_AW_COMMENT_ID not set)"); - return; - } - core.info(`Updating activation comment ${commentId}`); - let repoOwner = context.repo.owner; - let repoName = context.repo.repo; - if (commentRepo) { - const parts = commentRepo.split("/"); - if (parts.length === 2) { - repoOwner = parts[0]; - repoName = parts[1]; - } else { - core.warning(`Invalid comment repo format: ${commentRepo}, expected "owner/repo". Falling back to context.repo.`); - } - } - core.info(`Updating comment in ${repoOwner}/${repoName}`); - const isDiscussionComment = commentId.startsWith("DC_"); - try { - if (isDiscussionComment) { - const currentComment = await github.graphql( - ` - query($commentId: ID!) { - node(id: $commentId) { - ... on DiscussionComment { - body - } - } - }`, - { commentId: commentId } - ); - if (!currentComment?.node?.body) { - core.warning("Unable to fetch current comment body, comment may have been deleted or is inaccessible"); - return; - } - const currentBody = currentComment.node.body; - const updatedBody = currentBody + message; - const result = await github.graphql( - ` - mutation($commentId: ID!, $body: String!) { - updateDiscussionComment(input: { commentId: $commentId, body: $body }) { - comment { - id - url - } - } - }`, - { commentId: commentId, body: updatedBody } - ); - const comment = result.updateDiscussionComment.comment; - const successMessage = label - ? `Successfully updated discussion comment with ${label} link` - : "Successfully updated discussion comment"; - core.info(successMessage); - core.info(`Comment ID: ${comment.id}`); - core.info(`Comment URL: ${comment.url}`); - } else { - const currentComment = await github.request("GET /repos/{owner}/{repo}/issues/comments/{comment_id}", { - owner: repoOwner, - repo: repoName, - comment_id: parseInt(commentId, 10), - headers: { - Accept: "application/vnd.github+json", - }, - }); - if (!currentComment?.data?.body) { - core.warning("Unable to fetch current comment body, comment may have been deleted"); - return; - } - const currentBody = currentComment.data.body; - const updatedBody = currentBody + message; - const response = await github.request("PATCH /repos/{owner}/{repo}/issues/comments/{comment_id}", { - owner: repoOwner, - repo: repoName, - comment_id: parseInt(commentId, 10), - body: updatedBody, - headers: { - Accept: "application/vnd.github+json", - }, - }); - const successMessage = label ? `Successfully updated comment with ${label} link` : "Successfully updated comment"; - core.info(successMessage); - core.info(`Comment ID: ${response.data.id}`); - core.info(`Comment URL: ${response.data.html_url}`); - } - } catch (error) { - core.warning(`Failed to update activation comment: ${error instanceof Error ? error.message : String(error)}`); - } - } - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } + const { updateActivationComment } = require("./update_activation_comment.cjs"); + const { getTrackerID } = require("./get_tracker_id.cjs"); + const { addExpirationComment } = require("./expiration_helpers.cjs"); + const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); function generatePatchPreview(patchContent) { if (!patchContent || !patchContent.trim()) { return ""; @@ -7838,7 +7716,9 @@ jobs: return; } let title = pullRequestItem.title.trim(); - let bodyLines = pullRequestItem.body.split("\n"); + let processedBody = pullRequestItem.body; + processedBody = removeDuplicateTitleFromDescription(title, processedBody); + let bodyLines = processedBody.split("\n"); let branchName = pullRequestItem.branch ? pullRequestItem.branch.trim() : null; if (!title) { title = "Agent Output"; diff --git a/.github/workflows/commit-changes-analyzer.lock.yml b/.github/workflows/commit-changes-analyzer.lock.yml index 879bba0b0ac..8eabdb0feca 100644 --- a/.github/workflows/commit-changes-analyzer.lock.yml +++ b/.github/workflows/commit-changes-analyzer.lock.yml @@ -5927,381 +5927,13 @@ jobs: with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - const fs = require("fs"); - const crypto = require("crypto"); - const MAX_LOG_CONTENT_LENGTH = 10000; - function truncateForLogging(content) { - if (content.length <= MAX_LOG_CONTENT_LENGTH) { - return content; - } - return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; - } - function loadAgentOutput() { - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; - if (!agentOutputFile) { - core.info("No GH_AW_AGENT_OUTPUT environment variable found"); - return { success: false }; - } - let outputContent; - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - return { success: false, error: errorMessage }; - } - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return { success: false }; - } - core.info(`Agent output content length: ${outputContent.length}`); - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); - return { success: false, error: errorMessage }; - } - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); - return { success: false }; - } - return { success: true, items: validatedOutput.items }; - } - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - function getMessages() { - const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES; - if (!messagesEnv) { - return null; - } - try { - return JSON.parse(messagesEnv); - } catch (error) { - core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`); - return null; - } - } - function renderTemplate(template, context) { - return template.replace(/\{(\w+)\}/g, (match, key) => { - const value = context[key]; - return value !== undefined && value !== null ? String(value) : match; - }); - } - function toSnakeCase(obj) { - const result = {}; - for (const [key, value] of Object.entries(obj)) { - const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase(); - result[snakeKey] = value; - result[key] = value; - } - return result; - } - function getCloseOlderDiscussionMessage(ctx) { - const messages = getMessages(); - const templateContext = toSnakeCase(ctx); - const defaultMessage = `⚓ Avast! This discussion be marked as **outdated** by [{workflow_name}]({run_url}). - 🗺️ A newer treasure map awaits ye at **[Discussion #{new_discussion_number}]({new_discussion_url})**. - Fair winds, matey! 🏴‍☠️`; - return messages?.closeOlderDiscussion - ? renderTemplate(messages.closeOlderDiscussion, templateContext) - : renderTemplate(defaultMessage, templateContext); - } - const MAX_CLOSE_COUNT = 10; - const GRAPHQL_DELAY_MS = 500; - function delay(ms) { - return new Promise(resolve => setTimeout(resolve, ms)); - } - async function searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, excludeNumber) { - let searchQuery = `repo:${owner}/${repo} is:open`; - if (titlePrefix) { - const escapedPrefix = titlePrefix.replace(/"/g, '\\"'); - searchQuery += ` in:title "${escapedPrefix}"`; - } - if (labels && labels.length > 0) { - for (const label of labels) { - const escapedLabel = label.replace(/"/g, '\\"'); - searchQuery += ` label:"${escapedLabel}"`; - } - } - const result = await github.graphql( - ` - query($searchTerms: String!, $first: Int!) { - search(query: $searchTerms, type: DISCUSSION, first: $first) { - nodes { - ... on Discussion { - id - number - title - url - category { - id - } - labels(first: 100) { - nodes { - name - } - } - closed - } - } - } - }`, - { searchTerms: searchQuery, first: 50 } - ); - if (!result || !result.search || !result.search.nodes) { - return []; - } - return result.search.nodes - .filter( - d => { - if (!d || d.number === excludeNumber || d.closed) { - return false; - } - if (titlePrefix && d.title && !d.title.startsWith(titlePrefix)) { - return false; - } - if (labels && labels.length > 0) { - const discussionLabels = d.labels?.nodes?.map(( l) => l.name) || []; - const hasAllLabels = labels.every(label => discussionLabels.includes(label)); - if (!hasAllLabels) { - return false; - } - } - if (categoryId && (!d.category || d.category.id !== categoryId)) { - return false; - } - return true; - } - ) - .map( - d => ({ - id: d.id, - number: d.number, - title: d.title, - url: d.url, - }) - ); - } - async function addDiscussionComment(github, discussionId, message) { - const result = await github.graphql( - ` - mutation($dId: ID!, $body: String!) { - addDiscussionComment(input: { discussionId: $dId, body: $body }) { - comment { - id - url - } - } - }`, - { dId: discussionId, body: message } - ); - return result.addDiscussionComment.comment; - } - async function closeDiscussionAsOutdated(github, discussionId) { - const result = await github.graphql( - ` - mutation($dId: ID!) { - closeDiscussion(input: { discussionId: $dId, reason: OUTDATED }) { - discussion { - id - url - } - } - }`, - { dId: discussionId } - ); - return result.closeDiscussion.discussion; - } - async function closeOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion, workflowName, runUrl) { - const searchCriteria = []; - if (titlePrefix) searchCriteria.push(`title prefix: "${titlePrefix}"`); - if (labels && labels.length > 0) searchCriteria.push(`labels: [${labels.join(", ")}]`); - core.info(`Searching for older discussions with ${searchCriteria.join(" and ")}`); - const olderDiscussions = await searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion.number); - if (olderDiscussions.length === 0) { - core.info("No older discussions found to close"); - return []; - } - core.info(`Found ${olderDiscussions.length} older discussion(s) to close`); - const discussionsToClose = olderDiscussions.slice(0, MAX_CLOSE_COUNT); - if (olderDiscussions.length > MAX_CLOSE_COUNT) { - core.warning(`Found ${olderDiscussions.length} older discussions, but only closing the first ${MAX_CLOSE_COUNT}`); - } - const closedDiscussions = []; - for (let i = 0; i < discussionsToClose.length; i++) { - const discussion = discussionsToClose[i]; - try { - const closingMessage = getCloseOlderDiscussionMessage({ - newDiscussionUrl: newDiscussion.url, - newDiscussionNumber: newDiscussion.number, - workflowName, - runUrl, - }); - core.info(`Adding closing comment to discussion #${discussion.number}`); - await addDiscussionComment(github, discussion.id, closingMessage); - core.info(`Closing discussion #${discussion.number} as outdated`); - await closeDiscussionAsOutdated(github, discussion.id); - closedDiscussions.push({ - number: discussion.number, - url: discussion.url, - }); - core.info(`✓ Closed discussion #${discussion.number}: ${discussion.url}`); - } catch (error) { - core.error(`✗ Failed to close discussion #${discussion.number}: ${error instanceof Error ? error.message : String(error)}`); - } - if (i < discussionsToClose.length - 1) { - await delay(GRAPHQL_DELAY_MS); - } - } - return closedDiscussions; - } - const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; - function generateTemporaryId() { - return "aw_" + crypto.randomBytes(6).toString("hex"); - } - function isTemporaryId(value) { - if (typeof value === "string") { - return /^aw_[0-9a-f]{12}$/i.test(value); - } - return false; - } - function normalizeTemporaryId(tempId) { - return String(tempId).toLowerCase(); - } - function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); - if (resolved !== undefined) { - if (currentRepo && resolved.repo === currentRepo) { - return `#${resolved.number}`; - } - return `${resolved.repo}#${resolved.number}`; - } - return match; - }); - } - function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); - if (issueNumber !== undefined) { - return `#${issueNumber}`; - } - return match; - }); - } - function loadTemporaryIdMap() { - const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; - if (!mapJson || mapJson === "{}") { - return new Map(); - } - try { - const mapObject = JSON.parse(mapJson); - const result = new Map(); - for (const [key, value] of Object.entries(mapObject)) { - const normalizedKey = normalizeTemporaryId(key); - if (typeof value === "number") { - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - result.set(normalizedKey, { repo: contextRepo, number: value }); - } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { - result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); - } - } - return result; - } catch (error) { - if (typeof core !== "undefined") { - core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); - } - return new Map(); - } - } - function resolveIssueNumber(value, temporaryIdMap) { - if (value === undefined || value === null) { - return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; - } - const valueStr = String(value); - if (isTemporaryId(valueStr)) { - const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); - if (resolvedPair !== undefined) { - return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; - } - return { - resolved: null, - wasTemporaryId: true, - errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, - }; - } - const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); - if (isNaN(issueNumber) || issueNumber <= 0) { - return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; - } - const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; - return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; - } - function serializeTemporaryIdMap(tempIdMap) { - const obj = Object.fromEntries(tempIdMap); - return JSON.stringify(obj); - } - function parseAllowedRepos() { - const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; - const set = new Set(); - if (allowedReposEnv) { - allowedReposEnv - .split(",") - .map(repo => repo.trim()) - .filter(repo => repo) - .forEach(repo => set.add(repo)); - } - return set; - } - function getDefaultTargetRepo() { - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - if (targetRepoSlug) { - return targetRepoSlug; - } - return `${context.repo.owner}/${context.repo.repo}`; - } - function validateRepo(repo, defaultRepo, allowedRepos) { - if (repo === defaultRepo) { - return { valid: true, error: null }; - } - if (allowedRepos.has(repo)) { - return { valid: true, error: null }; - } - return { - valid: false, - error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, - }; - } - function parseRepoSlug(repoSlug) { - const parts = repoSlug.split("/"); - if (parts.length !== 2 || !parts[0] || !parts[1]) { - return null; - } - return { owner: parts[0], repo: parts[1] }; - } - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { getTrackerID } = require("./get_tracker_id.cjs"); + const { closeOlderDiscussions } = require("./close_older_discussions.cjs"); + const { replaceTemporaryIdReferences, loadTemporaryIdMap } = require("./temporary_id.cjs"); + const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require("./repo_helpers.cjs"); + const { addExpirationComment } = require("./expiration_helpers.cjs"); + const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); async function fetchRepoDiscussionInfo(owner, repo) { const repositoryQuery = ` query($owner: String!, $repo: String!) { @@ -6486,7 +6118,9 @@ jobs: ); let title = createDiscussionItem.title ? replaceTemporaryIdReferences(createDiscussionItem.title.trim(), temporaryIdMap, itemRepo) : ""; const bodyText = createDiscussionItem.body || ""; - let bodyLines = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo).split("\n"); + let processedBody = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo); + processedBody = removeDuplicateTitleFromDescription(title, processedBody); + let bodyLines = processedBody.split("\n"); if (!title) { title = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo) || "Agent Output"; } diff --git a/.github/workflows/copilot-agent-analysis.lock.yml b/.github/workflows/copilot-agent-analysis.lock.yml index 41925904724..b5e70d1d0a2 100644 --- a/.github/workflows/copilot-agent-analysis.lock.yml +++ b/.github/workflows/copilot-agent-analysis.lock.yml @@ -6681,381 +6681,13 @@ jobs: with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - const fs = require("fs"); - const crypto = require("crypto"); - const MAX_LOG_CONTENT_LENGTH = 10000; - function truncateForLogging(content) { - if (content.length <= MAX_LOG_CONTENT_LENGTH) { - return content; - } - return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; - } - function loadAgentOutput() { - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; - if (!agentOutputFile) { - core.info("No GH_AW_AGENT_OUTPUT environment variable found"); - return { success: false }; - } - let outputContent; - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - return { success: false, error: errorMessage }; - } - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return { success: false }; - } - core.info(`Agent output content length: ${outputContent.length}`); - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); - return { success: false, error: errorMessage }; - } - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); - return { success: false }; - } - return { success: true, items: validatedOutput.items }; - } - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - function getMessages() { - const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES; - if (!messagesEnv) { - return null; - } - try { - return JSON.parse(messagesEnv); - } catch (error) { - core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`); - return null; - } - } - function renderTemplate(template, context) { - return template.replace(/\{(\w+)\}/g, (match, key) => { - const value = context[key]; - return value !== undefined && value !== null ? String(value) : match; - }); - } - function toSnakeCase(obj) { - const result = {}; - for (const [key, value] of Object.entries(obj)) { - const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase(); - result[snakeKey] = value; - result[key] = value; - } - return result; - } - function getCloseOlderDiscussionMessage(ctx) { - const messages = getMessages(); - const templateContext = toSnakeCase(ctx); - const defaultMessage = `⚓ Avast! This discussion be marked as **outdated** by [{workflow_name}]({run_url}). - 🗺️ A newer treasure map awaits ye at **[Discussion #{new_discussion_number}]({new_discussion_url})**. - Fair winds, matey! 🏴‍☠️`; - return messages?.closeOlderDiscussion - ? renderTemplate(messages.closeOlderDiscussion, templateContext) - : renderTemplate(defaultMessage, templateContext); - } - const MAX_CLOSE_COUNT = 10; - const GRAPHQL_DELAY_MS = 500; - function delay(ms) { - return new Promise(resolve => setTimeout(resolve, ms)); - } - async function searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, excludeNumber) { - let searchQuery = `repo:${owner}/${repo} is:open`; - if (titlePrefix) { - const escapedPrefix = titlePrefix.replace(/"/g, '\\"'); - searchQuery += ` in:title "${escapedPrefix}"`; - } - if (labels && labels.length > 0) { - for (const label of labels) { - const escapedLabel = label.replace(/"/g, '\\"'); - searchQuery += ` label:"${escapedLabel}"`; - } - } - const result = await github.graphql( - ` - query($searchTerms: String!, $first: Int!) { - search(query: $searchTerms, type: DISCUSSION, first: $first) { - nodes { - ... on Discussion { - id - number - title - url - category { - id - } - labels(first: 100) { - nodes { - name - } - } - closed - } - } - } - }`, - { searchTerms: searchQuery, first: 50 } - ); - if (!result || !result.search || !result.search.nodes) { - return []; - } - return result.search.nodes - .filter( - d => { - if (!d || d.number === excludeNumber || d.closed) { - return false; - } - if (titlePrefix && d.title && !d.title.startsWith(titlePrefix)) { - return false; - } - if (labels && labels.length > 0) { - const discussionLabels = d.labels?.nodes?.map(( l) => l.name) || []; - const hasAllLabels = labels.every(label => discussionLabels.includes(label)); - if (!hasAllLabels) { - return false; - } - } - if (categoryId && (!d.category || d.category.id !== categoryId)) { - return false; - } - return true; - } - ) - .map( - d => ({ - id: d.id, - number: d.number, - title: d.title, - url: d.url, - }) - ); - } - async function addDiscussionComment(github, discussionId, message) { - const result = await github.graphql( - ` - mutation($dId: ID!, $body: String!) { - addDiscussionComment(input: { discussionId: $dId, body: $body }) { - comment { - id - url - } - } - }`, - { dId: discussionId, body: message } - ); - return result.addDiscussionComment.comment; - } - async function closeDiscussionAsOutdated(github, discussionId) { - const result = await github.graphql( - ` - mutation($dId: ID!) { - closeDiscussion(input: { discussionId: $dId, reason: OUTDATED }) { - discussion { - id - url - } - } - }`, - { dId: discussionId } - ); - return result.closeDiscussion.discussion; - } - async function closeOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion, workflowName, runUrl) { - const searchCriteria = []; - if (titlePrefix) searchCriteria.push(`title prefix: "${titlePrefix}"`); - if (labels && labels.length > 0) searchCriteria.push(`labels: [${labels.join(", ")}]`); - core.info(`Searching for older discussions with ${searchCriteria.join(" and ")}`); - const olderDiscussions = await searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion.number); - if (olderDiscussions.length === 0) { - core.info("No older discussions found to close"); - return []; - } - core.info(`Found ${olderDiscussions.length} older discussion(s) to close`); - const discussionsToClose = olderDiscussions.slice(0, MAX_CLOSE_COUNT); - if (olderDiscussions.length > MAX_CLOSE_COUNT) { - core.warning(`Found ${olderDiscussions.length} older discussions, but only closing the first ${MAX_CLOSE_COUNT}`); - } - const closedDiscussions = []; - for (let i = 0; i < discussionsToClose.length; i++) { - const discussion = discussionsToClose[i]; - try { - const closingMessage = getCloseOlderDiscussionMessage({ - newDiscussionUrl: newDiscussion.url, - newDiscussionNumber: newDiscussion.number, - workflowName, - runUrl, - }); - core.info(`Adding closing comment to discussion #${discussion.number}`); - await addDiscussionComment(github, discussion.id, closingMessage); - core.info(`Closing discussion #${discussion.number} as outdated`); - await closeDiscussionAsOutdated(github, discussion.id); - closedDiscussions.push({ - number: discussion.number, - url: discussion.url, - }); - core.info(`✓ Closed discussion #${discussion.number}: ${discussion.url}`); - } catch (error) { - core.error(`✗ Failed to close discussion #${discussion.number}: ${error instanceof Error ? error.message : String(error)}`); - } - if (i < discussionsToClose.length - 1) { - await delay(GRAPHQL_DELAY_MS); - } - } - return closedDiscussions; - } - const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; - function generateTemporaryId() { - return "aw_" + crypto.randomBytes(6).toString("hex"); - } - function isTemporaryId(value) { - if (typeof value === "string") { - return /^aw_[0-9a-f]{12}$/i.test(value); - } - return false; - } - function normalizeTemporaryId(tempId) { - return String(tempId).toLowerCase(); - } - function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); - if (resolved !== undefined) { - if (currentRepo && resolved.repo === currentRepo) { - return `#${resolved.number}`; - } - return `${resolved.repo}#${resolved.number}`; - } - return match; - }); - } - function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); - if (issueNumber !== undefined) { - return `#${issueNumber}`; - } - return match; - }); - } - function loadTemporaryIdMap() { - const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; - if (!mapJson || mapJson === "{}") { - return new Map(); - } - try { - const mapObject = JSON.parse(mapJson); - const result = new Map(); - for (const [key, value] of Object.entries(mapObject)) { - const normalizedKey = normalizeTemporaryId(key); - if (typeof value === "number") { - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - result.set(normalizedKey, { repo: contextRepo, number: value }); - } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { - result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); - } - } - return result; - } catch (error) { - if (typeof core !== "undefined") { - core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); - } - return new Map(); - } - } - function resolveIssueNumber(value, temporaryIdMap) { - if (value === undefined || value === null) { - return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; - } - const valueStr = String(value); - if (isTemporaryId(valueStr)) { - const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); - if (resolvedPair !== undefined) { - return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; - } - return { - resolved: null, - wasTemporaryId: true, - errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, - }; - } - const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); - if (isNaN(issueNumber) || issueNumber <= 0) { - return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; - } - const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; - return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; - } - function serializeTemporaryIdMap(tempIdMap) { - const obj = Object.fromEntries(tempIdMap); - return JSON.stringify(obj); - } - function parseAllowedRepos() { - const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; - const set = new Set(); - if (allowedReposEnv) { - allowedReposEnv - .split(",") - .map(repo => repo.trim()) - .filter(repo => repo) - .forEach(repo => set.add(repo)); - } - return set; - } - function getDefaultTargetRepo() { - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - if (targetRepoSlug) { - return targetRepoSlug; - } - return `${context.repo.owner}/${context.repo.repo}`; - } - function validateRepo(repo, defaultRepo, allowedRepos) { - if (repo === defaultRepo) { - return { valid: true, error: null }; - } - if (allowedRepos.has(repo)) { - return { valid: true, error: null }; - } - return { - valid: false, - error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, - }; - } - function parseRepoSlug(repoSlug) { - const parts = repoSlug.split("/"); - if (parts.length !== 2 || !parts[0] || !parts[1]) { - return null; - } - return { owner: parts[0], repo: parts[1] }; - } - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { getTrackerID } = require("./get_tracker_id.cjs"); + const { closeOlderDiscussions } = require("./close_older_discussions.cjs"); + const { replaceTemporaryIdReferences, loadTemporaryIdMap } = require("./temporary_id.cjs"); + const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require("./repo_helpers.cjs"); + const { addExpirationComment } = require("./expiration_helpers.cjs"); + const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); async function fetchRepoDiscussionInfo(owner, repo) { const repositoryQuery = ` query($owner: String!, $repo: String!) { @@ -7240,7 +6872,9 @@ jobs: ); let title = createDiscussionItem.title ? replaceTemporaryIdReferences(createDiscussionItem.title.trim(), temporaryIdMap, itemRepo) : ""; const bodyText = createDiscussionItem.body || ""; - let bodyLines = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo).split("\n"); + let processedBody = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo); + processedBody = removeDuplicateTitleFromDescription(title, processedBody); + let bodyLines = processedBody.split("\n"); if (!title) { title = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo) || "Agent Output"; } diff --git a/.github/workflows/copilot-pr-merged-report.lock.yml b/.github/workflows/copilot-pr-merged-report.lock.yml index b6ec6081b61..726c0624ef6 100644 --- a/.github/workflows/copilot-pr-merged-report.lock.yml +++ b/.github/workflows/copilot-pr-merged-report.lock.yml @@ -7680,381 +7680,13 @@ jobs: with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - const fs = require("fs"); - const crypto = require("crypto"); - const MAX_LOG_CONTENT_LENGTH = 10000; - function truncateForLogging(content) { - if (content.length <= MAX_LOG_CONTENT_LENGTH) { - return content; - } - return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; - } - function loadAgentOutput() { - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; - if (!agentOutputFile) { - core.info("No GH_AW_AGENT_OUTPUT environment variable found"); - return { success: false }; - } - let outputContent; - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - return { success: false, error: errorMessage }; - } - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return { success: false }; - } - core.info(`Agent output content length: ${outputContent.length}`); - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); - return { success: false, error: errorMessage }; - } - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); - return { success: false }; - } - return { success: true, items: validatedOutput.items }; - } - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - function getMessages() { - const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES; - if (!messagesEnv) { - return null; - } - try { - return JSON.parse(messagesEnv); - } catch (error) { - core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`); - return null; - } - } - function renderTemplate(template, context) { - return template.replace(/\{(\w+)\}/g, (match, key) => { - const value = context[key]; - return value !== undefined && value !== null ? String(value) : match; - }); - } - function toSnakeCase(obj) { - const result = {}; - for (const [key, value] of Object.entries(obj)) { - const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase(); - result[snakeKey] = value; - result[key] = value; - } - return result; - } - function getCloseOlderDiscussionMessage(ctx) { - const messages = getMessages(); - const templateContext = toSnakeCase(ctx); - const defaultMessage = `⚓ Avast! This discussion be marked as **outdated** by [{workflow_name}]({run_url}). - 🗺️ A newer treasure map awaits ye at **[Discussion #{new_discussion_number}]({new_discussion_url})**. - Fair winds, matey! 🏴‍☠️`; - return messages?.closeOlderDiscussion - ? renderTemplate(messages.closeOlderDiscussion, templateContext) - : renderTemplate(defaultMessage, templateContext); - } - const MAX_CLOSE_COUNT = 10; - const GRAPHQL_DELAY_MS = 500; - function delay(ms) { - return new Promise(resolve => setTimeout(resolve, ms)); - } - async function searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, excludeNumber) { - let searchQuery = `repo:${owner}/${repo} is:open`; - if (titlePrefix) { - const escapedPrefix = titlePrefix.replace(/"/g, '\\"'); - searchQuery += ` in:title "${escapedPrefix}"`; - } - if (labels && labels.length > 0) { - for (const label of labels) { - const escapedLabel = label.replace(/"/g, '\\"'); - searchQuery += ` label:"${escapedLabel}"`; - } - } - const result = await github.graphql( - ` - query($searchTerms: String!, $first: Int!) { - search(query: $searchTerms, type: DISCUSSION, first: $first) { - nodes { - ... on Discussion { - id - number - title - url - category { - id - } - labels(first: 100) { - nodes { - name - } - } - closed - } - } - } - }`, - { searchTerms: searchQuery, first: 50 } - ); - if (!result || !result.search || !result.search.nodes) { - return []; - } - return result.search.nodes - .filter( - d => { - if (!d || d.number === excludeNumber || d.closed) { - return false; - } - if (titlePrefix && d.title && !d.title.startsWith(titlePrefix)) { - return false; - } - if (labels && labels.length > 0) { - const discussionLabels = d.labels?.nodes?.map(( l) => l.name) || []; - const hasAllLabels = labels.every(label => discussionLabels.includes(label)); - if (!hasAllLabels) { - return false; - } - } - if (categoryId && (!d.category || d.category.id !== categoryId)) { - return false; - } - return true; - } - ) - .map( - d => ({ - id: d.id, - number: d.number, - title: d.title, - url: d.url, - }) - ); - } - async function addDiscussionComment(github, discussionId, message) { - const result = await github.graphql( - ` - mutation($dId: ID!, $body: String!) { - addDiscussionComment(input: { discussionId: $dId, body: $body }) { - comment { - id - url - } - } - }`, - { dId: discussionId, body: message } - ); - return result.addDiscussionComment.comment; - } - async function closeDiscussionAsOutdated(github, discussionId) { - const result = await github.graphql( - ` - mutation($dId: ID!) { - closeDiscussion(input: { discussionId: $dId, reason: OUTDATED }) { - discussion { - id - url - } - } - }`, - { dId: discussionId } - ); - return result.closeDiscussion.discussion; - } - async function closeOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion, workflowName, runUrl) { - const searchCriteria = []; - if (titlePrefix) searchCriteria.push(`title prefix: "${titlePrefix}"`); - if (labels && labels.length > 0) searchCriteria.push(`labels: [${labels.join(", ")}]`); - core.info(`Searching for older discussions with ${searchCriteria.join(" and ")}`); - const olderDiscussions = await searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion.number); - if (olderDiscussions.length === 0) { - core.info("No older discussions found to close"); - return []; - } - core.info(`Found ${olderDiscussions.length} older discussion(s) to close`); - const discussionsToClose = olderDiscussions.slice(0, MAX_CLOSE_COUNT); - if (olderDiscussions.length > MAX_CLOSE_COUNT) { - core.warning(`Found ${olderDiscussions.length} older discussions, but only closing the first ${MAX_CLOSE_COUNT}`); - } - const closedDiscussions = []; - for (let i = 0; i < discussionsToClose.length; i++) { - const discussion = discussionsToClose[i]; - try { - const closingMessage = getCloseOlderDiscussionMessage({ - newDiscussionUrl: newDiscussion.url, - newDiscussionNumber: newDiscussion.number, - workflowName, - runUrl, - }); - core.info(`Adding closing comment to discussion #${discussion.number}`); - await addDiscussionComment(github, discussion.id, closingMessage); - core.info(`Closing discussion #${discussion.number} as outdated`); - await closeDiscussionAsOutdated(github, discussion.id); - closedDiscussions.push({ - number: discussion.number, - url: discussion.url, - }); - core.info(`✓ Closed discussion #${discussion.number}: ${discussion.url}`); - } catch (error) { - core.error(`✗ Failed to close discussion #${discussion.number}: ${error instanceof Error ? error.message : String(error)}`); - } - if (i < discussionsToClose.length - 1) { - await delay(GRAPHQL_DELAY_MS); - } - } - return closedDiscussions; - } - const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; - function generateTemporaryId() { - return "aw_" + crypto.randomBytes(6).toString("hex"); - } - function isTemporaryId(value) { - if (typeof value === "string") { - return /^aw_[0-9a-f]{12}$/i.test(value); - } - return false; - } - function normalizeTemporaryId(tempId) { - return String(tempId).toLowerCase(); - } - function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); - if (resolved !== undefined) { - if (currentRepo && resolved.repo === currentRepo) { - return `#${resolved.number}`; - } - return `${resolved.repo}#${resolved.number}`; - } - return match; - }); - } - function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); - if (issueNumber !== undefined) { - return `#${issueNumber}`; - } - return match; - }); - } - function loadTemporaryIdMap() { - const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; - if (!mapJson || mapJson === "{}") { - return new Map(); - } - try { - const mapObject = JSON.parse(mapJson); - const result = new Map(); - for (const [key, value] of Object.entries(mapObject)) { - const normalizedKey = normalizeTemporaryId(key); - if (typeof value === "number") { - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - result.set(normalizedKey, { repo: contextRepo, number: value }); - } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { - result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); - } - } - return result; - } catch (error) { - if (typeof core !== "undefined") { - core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); - } - return new Map(); - } - } - function resolveIssueNumber(value, temporaryIdMap) { - if (value === undefined || value === null) { - return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; - } - const valueStr = String(value); - if (isTemporaryId(valueStr)) { - const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); - if (resolvedPair !== undefined) { - return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; - } - return { - resolved: null, - wasTemporaryId: true, - errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, - }; - } - const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); - if (isNaN(issueNumber) || issueNumber <= 0) { - return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; - } - const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; - return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; - } - function serializeTemporaryIdMap(tempIdMap) { - const obj = Object.fromEntries(tempIdMap); - return JSON.stringify(obj); - } - function parseAllowedRepos() { - const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; - const set = new Set(); - if (allowedReposEnv) { - allowedReposEnv - .split(",") - .map(repo => repo.trim()) - .filter(repo => repo) - .forEach(repo => set.add(repo)); - } - return set; - } - function getDefaultTargetRepo() { - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - if (targetRepoSlug) { - return targetRepoSlug; - } - return `${context.repo.owner}/${context.repo.repo}`; - } - function validateRepo(repo, defaultRepo, allowedRepos) { - if (repo === defaultRepo) { - return { valid: true, error: null }; - } - if (allowedRepos.has(repo)) { - return { valid: true, error: null }; - } - return { - valid: false, - error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, - }; - } - function parseRepoSlug(repoSlug) { - const parts = repoSlug.split("/"); - if (parts.length !== 2 || !parts[0] || !parts[1]) { - return null; - } - return { owner: parts[0], repo: parts[1] }; - } - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { getTrackerID } = require("./get_tracker_id.cjs"); + const { closeOlderDiscussions } = require("./close_older_discussions.cjs"); + const { replaceTemporaryIdReferences, loadTemporaryIdMap } = require("./temporary_id.cjs"); + const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require("./repo_helpers.cjs"); + const { addExpirationComment } = require("./expiration_helpers.cjs"); + const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); async function fetchRepoDiscussionInfo(owner, repo) { const repositoryQuery = ` query($owner: String!, $repo: String!) { @@ -8239,7 +7871,9 @@ jobs: ); let title = createDiscussionItem.title ? replaceTemporaryIdReferences(createDiscussionItem.title.trim(), temporaryIdMap, itemRepo) : ""; const bodyText = createDiscussionItem.body || ""; - let bodyLines = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo).split("\n"); + let processedBody = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo); + processedBody = removeDuplicateTitleFromDescription(title, processedBody); + let bodyLines = processedBody.split("\n"); if (!title) { title = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo) || "Agent Output"; } diff --git a/.github/workflows/copilot-pr-nlp-analysis.lock.yml b/.github/workflows/copilot-pr-nlp-analysis.lock.yml index b3cb90ab69b..c3a1c13d237 100644 --- a/.github/workflows/copilot-pr-nlp-analysis.lock.yml +++ b/.github/workflows/copilot-pr-nlp-analysis.lock.yml @@ -7787,381 +7787,13 @@ jobs: with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - const fs = require("fs"); - const crypto = require("crypto"); - const MAX_LOG_CONTENT_LENGTH = 10000; - function truncateForLogging(content) { - if (content.length <= MAX_LOG_CONTENT_LENGTH) { - return content; - } - return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; - } - function loadAgentOutput() { - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; - if (!agentOutputFile) { - core.info("No GH_AW_AGENT_OUTPUT environment variable found"); - return { success: false }; - } - let outputContent; - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - return { success: false, error: errorMessage }; - } - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return { success: false }; - } - core.info(`Agent output content length: ${outputContent.length}`); - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); - return { success: false, error: errorMessage }; - } - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); - return { success: false }; - } - return { success: true, items: validatedOutput.items }; - } - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - function getMessages() { - const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES; - if (!messagesEnv) { - return null; - } - try { - return JSON.parse(messagesEnv); - } catch (error) { - core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`); - return null; - } - } - function renderTemplate(template, context) { - return template.replace(/\{(\w+)\}/g, (match, key) => { - const value = context[key]; - return value !== undefined && value !== null ? String(value) : match; - }); - } - function toSnakeCase(obj) { - const result = {}; - for (const [key, value] of Object.entries(obj)) { - const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase(); - result[snakeKey] = value; - result[key] = value; - } - return result; - } - function getCloseOlderDiscussionMessage(ctx) { - const messages = getMessages(); - const templateContext = toSnakeCase(ctx); - const defaultMessage = `⚓ Avast! This discussion be marked as **outdated** by [{workflow_name}]({run_url}). - 🗺️ A newer treasure map awaits ye at **[Discussion #{new_discussion_number}]({new_discussion_url})**. - Fair winds, matey! 🏴‍☠️`; - return messages?.closeOlderDiscussion - ? renderTemplate(messages.closeOlderDiscussion, templateContext) - : renderTemplate(defaultMessage, templateContext); - } - const MAX_CLOSE_COUNT = 10; - const GRAPHQL_DELAY_MS = 500; - function delay(ms) { - return new Promise(resolve => setTimeout(resolve, ms)); - } - async function searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, excludeNumber) { - let searchQuery = `repo:${owner}/${repo} is:open`; - if (titlePrefix) { - const escapedPrefix = titlePrefix.replace(/"/g, '\\"'); - searchQuery += ` in:title "${escapedPrefix}"`; - } - if (labels && labels.length > 0) { - for (const label of labels) { - const escapedLabel = label.replace(/"/g, '\\"'); - searchQuery += ` label:"${escapedLabel}"`; - } - } - const result = await github.graphql( - ` - query($searchTerms: String!, $first: Int!) { - search(query: $searchTerms, type: DISCUSSION, first: $first) { - nodes { - ... on Discussion { - id - number - title - url - category { - id - } - labels(first: 100) { - nodes { - name - } - } - closed - } - } - } - }`, - { searchTerms: searchQuery, first: 50 } - ); - if (!result || !result.search || !result.search.nodes) { - return []; - } - return result.search.nodes - .filter( - d => { - if (!d || d.number === excludeNumber || d.closed) { - return false; - } - if (titlePrefix && d.title && !d.title.startsWith(titlePrefix)) { - return false; - } - if (labels && labels.length > 0) { - const discussionLabels = d.labels?.nodes?.map(( l) => l.name) || []; - const hasAllLabels = labels.every(label => discussionLabels.includes(label)); - if (!hasAllLabels) { - return false; - } - } - if (categoryId && (!d.category || d.category.id !== categoryId)) { - return false; - } - return true; - } - ) - .map( - d => ({ - id: d.id, - number: d.number, - title: d.title, - url: d.url, - }) - ); - } - async function addDiscussionComment(github, discussionId, message) { - const result = await github.graphql( - ` - mutation($dId: ID!, $body: String!) { - addDiscussionComment(input: { discussionId: $dId, body: $body }) { - comment { - id - url - } - } - }`, - { dId: discussionId, body: message } - ); - return result.addDiscussionComment.comment; - } - async function closeDiscussionAsOutdated(github, discussionId) { - const result = await github.graphql( - ` - mutation($dId: ID!) { - closeDiscussion(input: { discussionId: $dId, reason: OUTDATED }) { - discussion { - id - url - } - } - }`, - { dId: discussionId } - ); - return result.closeDiscussion.discussion; - } - async function closeOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion, workflowName, runUrl) { - const searchCriteria = []; - if (titlePrefix) searchCriteria.push(`title prefix: "${titlePrefix}"`); - if (labels && labels.length > 0) searchCriteria.push(`labels: [${labels.join(", ")}]`); - core.info(`Searching for older discussions with ${searchCriteria.join(" and ")}`); - const olderDiscussions = await searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion.number); - if (olderDiscussions.length === 0) { - core.info("No older discussions found to close"); - return []; - } - core.info(`Found ${olderDiscussions.length} older discussion(s) to close`); - const discussionsToClose = olderDiscussions.slice(0, MAX_CLOSE_COUNT); - if (olderDiscussions.length > MAX_CLOSE_COUNT) { - core.warning(`Found ${olderDiscussions.length} older discussions, but only closing the first ${MAX_CLOSE_COUNT}`); - } - const closedDiscussions = []; - for (let i = 0; i < discussionsToClose.length; i++) { - const discussion = discussionsToClose[i]; - try { - const closingMessage = getCloseOlderDiscussionMessage({ - newDiscussionUrl: newDiscussion.url, - newDiscussionNumber: newDiscussion.number, - workflowName, - runUrl, - }); - core.info(`Adding closing comment to discussion #${discussion.number}`); - await addDiscussionComment(github, discussion.id, closingMessage); - core.info(`Closing discussion #${discussion.number} as outdated`); - await closeDiscussionAsOutdated(github, discussion.id); - closedDiscussions.push({ - number: discussion.number, - url: discussion.url, - }); - core.info(`✓ Closed discussion #${discussion.number}: ${discussion.url}`); - } catch (error) { - core.error(`✗ Failed to close discussion #${discussion.number}: ${error instanceof Error ? error.message : String(error)}`); - } - if (i < discussionsToClose.length - 1) { - await delay(GRAPHQL_DELAY_MS); - } - } - return closedDiscussions; - } - const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; - function generateTemporaryId() { - return "aw_" + crypto.randomBytes(6).toString("hex"); - } - function isTemporaryId(value) { - if (typeof value === "string") { - return /^aw_[0-9a-f]{12}$/i.test(value); - } - return false; - } - function normalizeTemporaryId(tempId) { - return String(tempId).toLowerCase(); - } - function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); - if (resolved !== undefined) { - if (currentRepo && resolved.repo === currentRepo) { - return `#${resolved.number}`; - } - return `${resolved.repo}#${resolved.number}`; - } - return match; - }); - } - function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); - if (issueNumber !== undefined) { - return `#${issueNumber}`; - } - return match; - }); - } - function loadTemporaryIdMap() { - const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; - if (!mapJson || mapJson === "{}") { - return new Map(); - } - try { - const mapObject = JSON.parse(mapJson); - const result = new Map(); - for (const [key, value] of Object.entries(mapObject)) { - const normalizedKey = normalizeTemporaryId(key); - if (typeof value === "number") { - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - result.set(normalizedKey, { repo: contextRepo, number: value }); - } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { - result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); - } - } - return result; - } catch (error) { - if (typeof core !== "undefined") { - core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); - } - return new Map(); - } - } - function resolveIssueNumber(value, temporaryIdMap) { - if (value === undefined || value === null) { - return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; - } - const valueStr = String(value); - if (isTemporaryId(valueStr)) { - const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); - if (resolvedPair !== undefined) { - return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; - } - return { - resolved: null, - wasTemporaryId: true, - errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, - }; - } - const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); - if (isNaN(issueNumber) || issueNumber <= 0) { - return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; - } - const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; - return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; - } - function serializeTemporaryIdMap(tempIdMap) { - const obj = Object.fromEntries(tempIdMap); - return JSON.stringify(obj); - } - function parseAllowedRepos() { - const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; - const set = new Set(); - if (allowedReposEnv) { - allowedReposEnv - .split(",") - .map(repo => repo.trim()) - .filter(repo => repo) - .forEach(repo => set.add(repo)); - } - return set; - } - function getDefaultTargetRepo() { - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - if (targetRepoSlug) { - return targetRepoSlug; - } - return `${context.repo.owner}/${context.repo.repo}`; - } - function validateRepo(repo, defaultRepo, allowedRepos) { - if (repo === defaultRepo) { - return { valid: true, error: null }; - } - if (allowedRepos.has(repo)) { - return { valid: true, error: null }; - } - return { - valid: false, - error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, - }; - } - function parseRepoSlug(repoSlug) { - const parts = repoSlug.split("/"); - if (parts.length !== 2 || !parts[0] || !parts[1]) { - return null; - } - return { owner: parts[0], repo: parts[1] }; - } - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { getTrackerID } = require("./get_tracker_id.cjs"); + const { closeOlderDiscussions } = require("./close_older_discussions.cjs"); + const { replaceTemporaryIdReferences, loadTemporaryIdMap } = require("./temporary_id.cjs"); + const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require("./repo_helpers.cjs"); + const { addExpirationComment } = require("./expiration_helpers.cjs"); + const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); async function fetchRepoDiscussionInfo(owner, repo) { const repositoryQuery = ` query($owner: String!, $repo: String!) { @@ -8346,7 +7978,9 @@ jobs: ); let title = createDiscussionItem.title ? replaceTemporaryIdReferences(createDiscussionItem.title.trim(), temporaryIdMap, itemRepo) : ""; const bodyText = createDiscussionItem.body || ""; - let bodyLines = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo).split("\n"); + let processedBody = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo); + processedBody = removeDuplicateTitleFromDescription(title, processedBody); + let bodyLines = processedBody.split("\n"); if (!title) { title = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo) || "Agent Output"; } diff --git a/.github/workflows/copilot-pr-prompt-analysis.lock.yml b/.github/workflows/copilot-pr-prompt-analysis.lock.yml index dbf04560652..3b8ea450ff3 100644 --- a/.github/workflows/copilot-pr-prompt-analysis.lock.yml +++ b/.github/workflows/copilot-pr-prompt-analysis.lock.yml @@ -6802,381 +6802,13 @@ jobs: with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - const fs = require("fs"); - const crypto = require("crypto"); - const MAX_LOG_CONTENT_LENGTH = 10000; - function truncateForLogging(content) { - if (content.length <= MAX_LOG_CONTENT_LENGTH) { - return content; - } - return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; - } - function loadAgentOutput() { - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; - if (!agentOutputFile) { - core.info("No GH_AW_AGENT_OUTPUT environment variable found"); - return { success: false }; - } - let outputContent; - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - return { success: false, error: errorMessage }; - } - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return { success: false }; - } - core.info(`Agent output content length: ${outputContent.length}`); - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); - return { success: false, error: errorMessage }; - } - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); - return { success: false }; - } - return { success: true, items: validatedOutput.items }; - } - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - function getMessages() { - const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES; - if (!messagesEnv) { - return null; - } - try { - return JSON.parse(messagesEnv); - } catch (error) { - core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`); - return null; - } - } - function renderTemplate(template, context) { - return template.replace(/\{(\w+)\}/g, (match, key) => { - const value = context[key]; - return value !== undefined && value !== null ? String(value) : match; - }); - } - function toSnakeCase(obj) { - const result = {}; - for (const [key, value] of Object.entries(obj)) { - const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase(); - result[snakeKey] = value; - result[key] = value; - } - return result; - } - function getCloseOlderDiscussionMessage(ctx) { - const messages = getMessages(); - const templateContext = toSnakeCase(ctx); - const defaultMessage = `⚓ Avast! This discussion be marked as **outdated** by [{workflow_name}]({run_url}). - 🗺️ A newer treasure map awaits ye at **[Discussion #{new_discussion_number}]({new_discussion_url})**. - Fair winds, matey! 🏴‍☠️`; - return messages?.closeOlderDiscussion - ? renderTemplate(messages.closeOlderDiscussion, templateContext) - : renderTemplate(defaultMessage, templateContext); - } - const MAX_CLOSE_COUNT = 10; - const GRAPHQL_DELAY_MS = 500; - function delay(ms) { - return new Promise(resolve => setTimeout(resolve, ms)); - } - async function searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, excludeNumber) { - let searchQuery = `repo:${owner}/${repo} is:open`; - if (titlePrefix) { - const escapedPrefix = titlePrefix.replace(/"/g, '\\"'); - searchQuery += ` in:title "${escapedPrefix}"`; - } - if (labels && labels.length > 0) { - for (const label of labels) { - const escapedLabel = label.replace(/"/g, '\\"'); - searchQuery += ` label:"${escapedLabel}"`; - } - } - const result = await github.graphql( - ` - query($searchTerms: String!, $first: Int!) { - search(query: $searchTerms, type: DISCUSSION, first: $first) { - nodes { - ... on Discussion { - id - number - title - url - category { - id - } - labels(first: 100) { - nodes { - name - } - } - closed - } - } - } - }`, - { searchTerms: searchQuery, first: 50 } - ); - if (!result || !result.search || !result.search.nodes) { - return []; - } - return result.search.nodes - .filter( - d => { - if (!d || d.number === excludeNumber || d.closed) { - return false; - } - if (titlePrefix && d.title && !d.title.startsWith(titlePrefix)) { - return false; - } - if (labels && labels.length > 0) { - const discussionLabels = d.labels?.nodes?.map(( l) => l.name) || []; - const hasAllLabels = labels.every(label => discussionLabels.includes(label)); - if (!hasAllLabels) { - return false; - } - } - if (categoryId && (!d.category || d.category.id !== categoryId)) { - return false; - } - return true; - } - ) - .map( - d => ({ - id: d.id, - number: d.number, - title: d.title, - url: d.url, - }) - ); - } - async function addDiscussionComment(github, discussionId, message) { - const result = await github.graphql( - ` - mutation($dId: ID!, $body: String!) { - addDiscussionComment(input: { discussionId: $dId, body: $body }) { - comment { - id - url - } - } - }`, - { dId: discussionId, body: message } - ); - return result.addDiscussionComment.comment; - } - async function closeDiscussionAsOutdated(github, discussionId) { - const result = await github.graphql( - ` - mutation($dId: ID!) { - closeDiscussion(input: { discussionId: $dId, reason: OUTDATED }) { - discussion { - id - url - } - } - }`, - { dId: discussionId } - ); - return result.closeDiscussion.discussion; - } - async function closeOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion, workflowName, runUrl) { - const searchCriteria = []; - if (titlePrefix) searchCriteria.push(`title prefix: "${titlePrefix}"`); - if (labels && labels.length > 0) searchCriteria.push(`labels: [${labels.join(", ")}]`); - core.info(`Searching for older discussions with ${searchCriteria.join(" and ")}`); - const olderDiscussions = await searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion.number); - if (olderDiscussions.length === 0) { - core.info("No older discussions found to close"); - return []; - } - core.info(`Found ${olderDiscussions.length} older discussion(s) to close`); - const discussionsToClose = olderDiscussions.slice(0, MAX_CLOSE_COUNT); - if (olderDiscussions.length > MAX_CLOSE_COUNT) { - core.warning(`Found ${olderDiscussions.length} older discussions, but only closing the first ${MAX_CLOSE_COUNT}`); - } - const closedDiscussions = []; - for (let i = 0; i < discussionsToClose.length; i++) { - const discussion = discussionsToClose[i]; - try { - const closingMessage = getCloseOlderDiscussionMessage({ - newDiscussionUrl: newDiscussion.url, - newDiscussionNumber: newDiscussion.number, - workflowName, - runUrl, - }); - core.info(`Adding closing comment to discussion #${discussion.number}`); - await addDiscussionComment(github, discussion.id, closingMessage); - core.info(`Closing discussion #${discussion.number} as outdated`); - await closeDiscussionAsOutdated(github, discussion.id); - closedDiscussions.push({ - number: discussion.number, - url: discussion.url, - }); - core.info(`✓ Closed discussion #${discussion.number}: ${discussion.url}`); - } catch (error) { - core.error(`✗ Failed to close discussion #${discussion.number}: ${error instanceof Error ? error.message : String(error)}`); - } - if (i < discussionsToClose.length - 1) { - await delay(GRAPHQL_DELAY_MS); - } - } - return closedDiscussions; - } - const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; - function generateTemporaryId() { - return "aw_" + crypto.randomBytes(6).toString("hex"); - } - function isTemporaryId(value) { - if (typeof value === "string") { - return /^aw_[0-9a-f]{12}$/i.test(value); - } - return false; - } - function normalizeTemporaryId(tempId) { - return String(tempId).toLowerCase(); - } - function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); - if (resolved !== undefined) { - if (currentRepo && resolved.repo === currentRepo) { - return `#${resolved.number}`; - } - return `${resolved.repo}#${resolved.number}`; - } - return match; - }); - } - function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); - if (issueNumber !== undefined) { - return `#${issueNumber}`; - } - return match; - }); - } - function loadTemporaryIdMap() { - const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; - if (!mapJson || mapJson === "{}") { - return new Map(); - } - try { - const mapObject = JSON.parse(mapJson); - const result = new Map(); - for (const [key, value] of Object.entries(mapObject)) { - const normalizedKey = normalizeTemporaryId(key); - if (typeof value === "number") { - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - result.set(normalizedKey, { repo: contextRepo, number: value }); - } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { - result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); - } - } - return result; - } catch (error) { - if (typeof core !== "undefined") { - core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); - } - return new Map(); - } - } - function resolveIssueNumber(value, temporaryIdMap) { - if (value === undefined || value === null) { - return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; - } - const valueStr = String(value); - if (isTemporaryId(valueStr)) { - const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); - if (resolvedPair !== undefined) { - return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; - } - return { - resolved: null, - wasTemporaryId: true, - errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, - }; - } - const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); - if (isNaN(issueNumber) || issueNumber <= 0) { - return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; - } - const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; - return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; - } - function serializeTemporaryIdMap(tempIdMap) { - const obj = Object.fromEntries(tempIdMap); - return JSON.stringify(obj); - } - function parseAllowedRepos() { - const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; - const set = new Set(); - if (allowedReposEnv) { - allowedReposEnv - .split(",") - .map(repo => repo.trim()) - .filter(repo => repo) - .forEach(repo => set.add(repo)); - } - return set; - } - function getDefaultTargetRepo() { - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - if (targetRepoSlug) { - return targetRepoSlug; - } - return `${context.repo.owner}/${context.repo.repo}`; - } - function validateRepo(repo, defaultRepo, allowedRepos) { - if (repo === defaultRepo) { - return { valid: true, error: null }; - } - if (allowedRepos.has(repo)) { - return { valid: true, error: null }; - } - return { - valid: false, - error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, - }; - } - function parseRepoSlug(repoSlug) { - const parts = repoSlug.split("/"); - if (parts.length !== 2 || !parts[0] || !parts[1]) { - return null; - } - return { owner: parts[0], repo: parts[1] }; - } - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { getTrackerID } = require("./get_tracker_id.cjs"); + const { closeOlderDiscussions } = require("./close_older_discussions.cjs"); + const { replaceTemporaryIdReferences, loadTemporaryIdMap } = require("./temporary_id.cjs"); + const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require("./repo_helpers.cjs"); + const { addExpirationComment } = require("./expiration_helpers.cjs"); + const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); async function fetchRepoDiscussionInfo(owner, repo) { const repositoryQuery = ` query($owner: String!, $repo: String!) { @@ -7361,7 +6993,9 @@ jobs: ); let title = createDiscussionItem.title ? replaceTemporaryIdReferences(createDiscussionItem.title.trim(), temporaryIdMap, itemRepo) : ""; const bodyText = createDiscussionItem.body || ""; - let bodyLines = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo).split("\n"); + let processedBody = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo); + processedBody = removeDuplicateTitleFromDescription(title, processedBody); + let bodyLines = processedBody.split("\n"); if (!title) { title = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo) || "Agent Output"; } diff --git a/.github/workflows/copilot-session-insights.lock.yml b/.github/workflows/copilot-session-insights.lock.yml index 689c106dff0..0cb2e0d6d18 100644 --- a/.github/workflows/copilot-session-insights.lock.yml +++ b/.github/workflows/copilot-session-insights.lock.yml @@ -8099,381 +8099,13 @@ jobs: with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - const fs = require("fs"); - const crypto = require("crypto"); - const MAX_LOG_CONTENT_LENGTH = 10000; - function truncateForLogging(content) { - if (content.length <= MAX_LOG_CONTENT_LENGTH) { - return content; - } - return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; - } - function loadAgentOutput() { - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; - if (!agentOutputFile) { - core.info("No GH_AW_AGENT_OUTPUT environment variable found"); - return { success: false }; - } - let outputContent; - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - return { success: false, error: errorMessage }; - } - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return { success: false }; - } - core.info(`Agent output content length: ${outputContent.length}`); - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); - return { success: false, error: errorMessage }; - } - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); - return { success: false }; - } - return { success: true, items: validatedOutput.items }; - } - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - function getMessages() { - const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES; - if (!messagesEnv) { - return null; - } - try { - return JSON.parse(messagesEnv); - } catch (error) { - core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`); - return null; - } - } - function renderTemplate(template, context) { - return template.replace(/\{(\w+)\}/g, (match, key) => { - const value = context[key]; - return value !== undefined && value !== null ? String(value) : match; - }); - } - function toSnakeCase(obj) { - const result = {}; - for (const [key, value] of Object.entries(obj)) { - const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase(); - result[snakeKey] = value; - result[key] = value; - } - return result; - } - function getCloseOlderDiscussionMessage(ctx) { - const messages = getMessages(); - const templateContext = toSnakeCase(ctx); - const defaultMessage = `⚓ Avast! This discussion be marked as **outdated** by [{workflow_name}]({run_url}). - 🗺️ A newer treasure map awaits ye at **[Discussion #{new_discussion_number}]({new_discussion_url})**. - Fair winds, matey! 🏴‍☠️`; - return messages?.closeOlderDiscussion - ? renderTemplate(messages.closeOlderDiscussion, templateContext) - : renderTemplate(defaultMessage, templateContext); - } - const MAX_CLOSE_COUNT = 10; - const GRAPHQL_DELAY_MS = 500; - function delay(ms) { - return new Promise(resolve => setTimeout(resolve, ms)); - } - async function searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, excludeNumber) { - let searchQuery = `repo:${owner}/${repo} is:open`; - if (titlePrefix) { - const escapedPrefix = titlePrefix.replace(/"/g, '\\"'); - searchQuery += ` in:title "${escapedPrefix}"`; - } - if (labels && labels.length > 0) { - for (const label of labels) { - const escapedLabel = label.replace(/"/g, '\\"'); - searchQuery += ` label:"${escapedLabel}"`; - } - } - const result = await github.graphql( - ` - query($searchTerms: String!, $first: Int!) { - search(query: $searchTerms, type: DISCUSSION, first: $first) { - nodes { - ... on Discussion { - id - number - title - url - category { - id - } - labels(first: 100) { - nodes { - name - } - } - closed - } - } - } - }`, - { searchTerms: searchQuery, first: 50 } - ); - if (!result || !result.search || !result.search.nodes) { - return []; - } - return result.search.nodes - .filter( - d => { - if (!d || d.number === excludeNumber || d.closed) { - return false; - } - if (titlePrefix && d.title && !d.title.startsWith(titlePrefix)) { - return false; - } - if (labels && labels.length > 0) { - const discussionLabels = d.labels?.nodes?.map(( l) => l.name) || []; - const hasAllLabels = labels.every(label => discussionLabels.includes(label)); - if (!hasAllLabels) { - return false; - } - } - if (categoryId && (!d.category || d.category.id !== categoryId)) { - return false; - } - return true; - } - ) - .map( - d => ({ - id: d.id, - number: d.number, - title: d.title, - url: d.url, - }) - ); - } - async function addDiscussionComment(github, discussionId, message) { - const result = await github.graphql( - ` - mutation($dId: ID!, $body: String!) { - addDiscussionComment(input: { discussionId: $dId, body: $body }) { - comment { - id - url - } - } - }`, - { dId: discussionId, body: message } - ); - return result.addDiscussionComment.comment; - } - async function closeDiscussionAsOutdated(github, discussionId) { - const result = await github.graphql( - ` - mutation($dId: ID!) { - closeDiscussion(input: { discussionId: $dId, reason: OUTDATED }) { - discussion { - id - url - } - } - }`, - { dId: discussionId } - ); - return result.closeDiscussion.discussion; - } - async function closeOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion, workflowName, runUrl) { - const searchCriteria = []; - if (titlePrefix) searchCriteria.push(`title prefix: "${titlePrefix}"`); - if (labels && labels.length > 0) searchCriteria.push(`labels: [${labels.join(", ")}]`); - core.info(`Searching for older discussions with ${searchCriteria.join(" and ")}`); - const olderDiscussions = await searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion.number); - if (olderDiscussions.length === 0) { - core.info("No older discussions found to close"); - return []; - } - core.info(`Found ${olderDiscussions.length} older discussion(s) to close`); - const discussionsToClose = olderDiscussions.slice(0, MAX_CLOSE_COUNT); - if (olderDiscussions.length > MAX_CLOSE_COUNT) { - core.warning(`Found ${olderDiscussions.length} older discussions, but only closing the first ${MAX_CLOSE_COUNT}`); - } - const closedDiscussions = []; - for (let i = 0; i < discussionsToClose.length; i++) { - const discussion = discussionsToClose[i]; - try { - const closingMessage = getCloseOlderDiscussionMessage({ - newDiscussionUrl: newDiscussion.url, - newDiscussionNumber: newDiscussion.number, - workflowName, - runUrl, - }); - core.info(`Adding closing comment to discussion #${discussion.number}`); - await addDiscussionComment(github, discussion.id, closingMessage); - core.info(`Closing discussion #${discussion.number} as outdated`); - await closeDiscussionAsOutdated(github, discussion.id); - closedDiscussions.push({ - number: discussion.number, - url: discussion.url, - }); - core.info(`✓ Closed discussion #${discussion.number}: ${discussion.url}`); - } catch (error) { - core.error(`✗ Failed to close discussion #${discussion.number}: ${error instanceof Error ? error.message : String(error)}`); - } - if (i < discussionsToClose.length - 1) { - await delay(GRAPHQL_DELAY_MS); - } - } - return closedDiscussions; - } - const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; - function generateTemporaryId() { - return "aw_" + crypto.randomBytes(6).toString("hex"); - } - function isTemporaryId(value) { - if (typeof value === "string") { - return /^aw_[0-9a-f]{12}$/i.test(value); - } - return false; - } - function normalizeTemporaryId(tempId) { - return String(tempId).toLowerCase(); - } - function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); - if (resolved !== undefined) { - if (currentRepo && resolved.repo === currentRepo) { - return `#${resolved.number}`; - } - return `${resolved.repo}#${resolved.number}`; - } - return match; - }); - } - function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); - if (issueNumber !== undefined) { - return `#${issueNumber}`; - } - return match; - }); - } - function loadTemporaryIdMap() { - const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; - if (!mapJson || mapJson === "{}") { - return new Map(); - } - try { - const mapObject = JSON.parse(mapJson); - const result = new Map(); - for (const [key, value] of Object.entries(mapObject)) { - const normalizedKey = normalizeTemporaryId(key); - if (typeof value === "number") { - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - result.set(normalizedKey, { repo: contextRepo, number: value }); - } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { - result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); - } - } - return result; - } catch (error) { - if (typeof core !== "undefined") { - core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); - } - return new Map(); - } - } - function resolveIssueNumber(value, temporaryIdMap) { - if (value === undefined || value === null) { - return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; - } - const valueStr = String(value); - if (isTemporaryId(valueStr)) { - const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); - if (resolvedPair !== undefined) { - return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; - } - return { - resolved: null, - wasTemporaryId: true, - errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, - }; - } - const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); - if (isNaN(issueNumber) || issueNumber <= 0) { - return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; - } - const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; - return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; - } - function serializeTemporaryIdMap(tempIdMap) { - const obj = Object.fromEntries(tempIdMap); - return JSON.stringify(obj); - } - function parseAllowedRepos() { - const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; - const set = new Set(); - if (allowedReposEnv) { - allowedReposEnv - .split(",") - .map(repo => repo.trim()) - .filter(repo => repo) - .forEach(repo => set.add(repo)); - } - return set; - } - function getDefaultTargetRepo() { - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - if (targetRepoSlug) { - return targetRepoSlug; - } - return `${context.repo.owner}/${context.repo.repo}`; - } - function validateRepo(repo, defaultRepo, allowedRepos) { - if (repo === defaultRepo) { - return { valid: true, error: null }; - } - if (allowedRepos.has(repo)) { - return { valid: true, error: null }; - } - return { - valid: false, - error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, - }; - } - function parseRepoSlug(repoSlug) { - const parts = repoSlug.split("/"); - if (parts.length !== 2 || !parts[0] || !parts[1]) { - return null; - } - return { owner: parts[0], repo: parts[1] }; - } - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { getTrackerID } = require("./get_tracker_id.cjs"); + const { closeOlderDiscussions } = require("./close_older_discussions.cjs"); + const { replaceTemporaryIdReferences, loadTemporaryIdMap } = require("./temporary_id.cjs"); + const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require("./repo_helpers.cjs"); + const { addExpirationComment } = require("./expiration_helpers.cjs"); + const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); async function fetchRepoDiscussionInfo(owner, repo) { const repositoryQuery = ` query($owner: String!, $repo: String!) { @@ -8658,7 +8290,9 @@ jobs: ); let title = createDiscussionItem.title ? replaceTemporaryIdReferences(createDiscussionItem.title.trim(), temporaryIdMap, itemRepo) : ""; const bodyText = createDiscussionItem.body || ""; - let bodyLines = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo).split("\n"); + let processedBody = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo); + processedBody = removeDuplicateTitleFromDescription(title, processedBody); + let bodyLines = processedBody.split("\n"); if (!title) { title = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo) || "Agent Output"; } diff --git a/.github/workflows/daily-code-metrics.lock.yml b/.github/workflows/daily-code-metrics.lock.yml index bcd70b84d7a..2b41f388068 100644 --- a/.github/workflows/daily-code-metrics.lock.yml +++ b/.github/workflows/daily-code-metrics.lock.yml @@ -7136,381 +7136,13 @@ jobs: with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - const fs = require("fs"); - const crypto = require("crypto"); - const MAX_LOG_CONTENT_LENGTH = 10000; - function truncateForLogging(content) { - if (content.length <= MAX_LOG_CONTENT_LENGTH) { - return content; - } - return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; - } - function loadAgentOutput() { - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; - if (!agentOutputFile) { - core.info("No GH_AW_AGENT_OUTPUT environment variable found"); - return { success: false }; - } - let outputContent; - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - return { success: false, error: errorMessage }; - } - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return { success: false }; - } - core.info(`Agent output content length: ${outputContent.length}`); - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); - return { success: false, error: errorMessage }; - } - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); - return { success: false }; - } - return { success: true, items: validatedOutput.items }; - } - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - function getMessages() { - const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES; - if (!messagesEnv) { - return null; - } - try { - return JSON.parse(messagesEnv); - } catch (error) { - core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`); - return null; - } - } - function renderTemplate(template, context) { - return template.replace(/\{(\w+)\}/g, (match, key) => { - const value = context[key]; - return value !== undefined && value !== null ? String(value) : match; - }); - } - function toSnakeCase(obj) { - const result = {}; - for (const [key, value] of Object.entries(obj)) { - const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase(); - result[snakeKey] = value; - result[key] = value; - } - return result; - } - function getCloseOlderDiscussionMessage(ctx) { - const messages = getMessages(); - const templateContext = toSnakeCase(ctx); - const defaultMessage = `⚓ Avast! This discussion be marked as **outdated** by [{workflow_name}]({run_url}). - 🗺️ A newer treasure map awaits ye at **[Discussion #{new_discussion_number}]({new_discussion_url})**. - Fair winds, matey! 🏴‍☠️`; - return messages?.closeOlderDiscussion - ? renderTemplate(messages.closeOlderDiscussion, templateContext) - : renderTemplate(defaultMessage, templateContext); - } - const MAX_CLOSE_COUNT = 10; - const GRAPHQL_DELAY_MS = 500; - function delay(ms) { - return new Promise(resolve => setTimeout(resolve, ms)); - } - async function searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, excludeNumber) { - let searchQuery = `repo:${owner}/${repo} is:open`; - if (titlePrefix) { - const escapedPrefix = titlePrefix.replace(/"/g, '\\"'); - searchQuery += ` in:title "${escapedPrefix}"`; - } - if (labels && labels.length > 0) { - for (const label of labels) { - const escapedLabel = label.replace(/"/g, '\\"'); - searchQuery += ` label:"${escapedLabel}"`; - } - } - const result = await github.graphql( - ` - query($searchTerms: String!, $first: Int!) { - search(query: $searchTerms, type: DISCUSSION, first: $first) { - nodes { - ... on Discussion { - id - number - title - url - category { - id - } - labels(first: 100) { - nodes { - name - } - } - closed - } - } - } - }`, - { searchTerms: searchQuery, first: 50 } - ); - if (!result || !result.search || !result.search.nodes) { - return []; - } - return result.search.nodes - .filter( - d => { - if (!d || d.number === excludeNumber || d.closed) { - return false; - } - if (titlePrefix && d.title && !d.title.startsWith(titlePrefix)) { - return false; - } - if (labels && labels.length > 0) { - const discussionLabels = d.labels?.nodes?.map(( l) => l.name) || []; - const hasAllLabels = labels.every(label => discussionLabels.includes(label)); - if (!hasAllLabels) { - return false; - } - } - if (categoryId && (!d.category || d.category.id !== categoryId)) { - return false; - } - return true; - } - ) - .map( - d => ({ - id: d.id, - number: d.number, - title: d.title, - url: d.url, - }) - ); - } - async function addDiscussionComment(github, discussionId, message) { - const result = await github.graphql( - ` - mutation($dId: ID!, $body: String!) { - addDiscussionComment(input: { discussionId: $dId, body: $body }) { - comment { - id - url - } - } - }`, - { dId: discussionId, body: message } - ); - return result.addDiscussionComment.comment; - } - async function closeDiscussionAsOutdated(github, discussionId) { - const result = await github.graphql( - ` - mutation($dId: ID!) { - closeDiscussion(input: { discussionId: $dId, reason: OUTDATED }) { - discussion { - id - url - } - } - }`, - { dId: discussionId } - ); - return result.closeDiscussion.discussion; - } - async function closeOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion, workflowName, runUrl) { - const searchCriteria = []; - if (titlePrefix) searchCriteria.push(`title prefix: "${titlePrefix}"`); - if (labels && labels.length > 0) searchCriteria.push(`labels: [${labels.join(", ")}]`); - core.info(`Searching for older discussions with ${searchCriteria.join(" and ")}`); - const olderDiscussions = await searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion.number); - if (olderDiscussions.length === 0) { - core.info("No older discussions found to close"); - return []; - } - core.info(`Found ${olderDiscussions.length} older discussion(s) to close`); - const discussionsToClose = olderDiscussions.slice(0, MAX_CLOSE_COUNT); - if (olderDiscussions.length > MAX_CLOSE_COUNT) { - core.warning(`Found ${olderDiscussions.length} older discussions, but only closing the first ${MAX_CLOSE_COUNT}`); - } - const closedDiscussions = []; - for (let i = 0; i < discussionsToClose.length; i++) { - const discussion = discussionsToClose[i]; - try { - const closingMessage = getCloseOlderDiscussionMessage({ - newDiscussionUrl: newDiscussion.url, - newDiscussionNumber: newDiscussion.number, - workflowName, - runUrl, - }); - core.info(`Adding closing comment to discussion #${discussion.number}`); - await addDiscussionComment(github, discussion.id, closingMessage); - core.info(`Closing discussion #${discussion.number} as outdated`); - await closeDiscussionAsOutdated(github, discussion.id); - closedDiscussions.push({ - number: discussion.number, - url: discussion.url, - }); - core.info(`✓ Closed discussion #${discussion.number}: ${discussion.url}`); - } catch (error) { - core.error(`✗ Failed to close discussion #${discussion.number}: ${error instanceof Error ? error.message : String(error)}`); - } - if (i < discussionsToClose.length - 1) { - await delay(GRAPHQL_DELAY_MS); - } - } - return closedDiscussions; - } - const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; - function generateTemporaryId() { - return "aw_" + crypto.randomBytes(6).toString("hex"); - } - function isTemporaryId(value) { - if (typeof value === "string") { - return /^aw_[0-9a-f]{12}$/i.test(value); - } - return false; - } - function normalizeTemporaryId(tempId) { - return String(tempId).toLowerCase(); - } - function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); - if (resolved !== undefined) { - if (currentRepo && resolved.repo === currentRepo) { - return `#${resolved.number}`; - } - return `${resolved.repo}#${resolved.number}`; - } - return match; - }); - } - function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); - if (issueNumber !== undefined) { - return `#${issueNumber}`; - } - return match; - }); - } - function loadTemporaryIdMap() { - const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; - if (!mapJson || mapJson === "{}") { - return new Map(); - } - try { - const mapObject = JSON.parse(mapJson); - const result = new Map(); - for (const [key, value] of Object.entries(mapObject)) { - const normalizedKey = normalizeTemporaryId(key); - if (typeof value === "number") { - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - result.set(normalizedKey, { repo: contextRepo, number: value }); - } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { - result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); - } - } - return result; - } catch (error) { - if (typeof core !== "undefined") { - core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); - } - return new Map(); - } - } - function resolveIssueNumber(value, temporaryIdMap) { - if (value === undefined || value === null) { - return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; - } - const valueStr = String(value); - if (isTemporaryId(valueStr)) { - const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); - if (resolvedPair !== undefined) { - return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; - } - return { - resolved: null, - wasTemporaryId: true, - errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, - }; - } - const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); - if (isNaN(issueNumber) || issueNumber <= 0) { - return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; - } - const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; - return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; - } - function serializeTemporaryIdMap(tempIdMap) { - const obj = Object.fromEntries(tempIdMap); - return JSON.stringify(obj); - } - function parseAllowedRepos() { - const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; - const set = new Set(); - if (allowedReposEnv) { - allowedReposEnv - .split(",") - .map(repo => repo.trim()) - .filter(repo => repo) - .forEach(repo => set.add(repo)); - } - return set; - } - function getDefaultTargetRepo() { - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - if (targetRepoSlug) { - return targetRepoSlug; - } - return `${context.repo.owner}/${context.repo.repo}`; - } - function validateRepo(repo, defaultRepo, allowedRepos) { - if (repo === defaultRepo) { - return { valid: true, error: null }; - } - if (allowedRepos.has(repo)) { - return { valid: true, error: null }; - } - return { - valid: false, - error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, - }; - } - function parseRepoSlug(repoSlug) { - const parts = repoSlug.split("/"); - if (parts.length !== 2 || !parts[0] || !parts[1]) { - return null; - } - return { owner: parts[0], repo: parts[1] }; - } - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { getTrackerID } = require("./get_tracker_id.cjs"); + const { closeOlderDiscussions } = require("./close_older_discussions.cjs"); + const { replaceTemporaryIdReferences, loadTemporaryIdMap } = require("./temporary_id.cjs"); + const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require("./repo_helpers.cjs"); + const { addExpirationComment } = require("./expiration_helpers.cjs"); + const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); async function fetchRepoDiscussionInfo(owner, repo) { const repositoryQuery = ` query($owner: String!, $repo: String!) { @@ -7695,7 +7327,9 @@ jobs: ); let title = createDiscussionItem.title ? replaceTemporaryIdReferences(createDiscussionItem.title.trim(), temporaryIdMap, itemRepo) : ""; const bodyText = createDiscussionItem.body || ""; - let bodyLines = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo).split("\n"); + let processedBody = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo); + processedBody = removeDuplicateTitleFromDescription(title, processedBody); + let bodyLines = processedBody.split("\n"); if (!title) { title = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo) || "Agent Output"; } diff --git a/.github/workflows/daily-copilot-token-report.lock.yml b/.github/workflows/daily-copilot-token-report.lock.yml index 2560bf4a269..00099a61a68 100644 --- a/.github/workflows/daily-copilot-token-report.lock.yml +++ b/.github/workflows/daily-copilot-token-report.lock.yml @@ -7956,381 +7956,13 @@ jobs: with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - const fs = require("fs"); - const crypto = require("crypto"); - const MAX_LOG_CONTENT_LENGTH = 10000; - function truncateForLogging(content) { - if (content.length <= MAX_LOG_CONTENT_LENGTH) { - return content; - } - return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; - } - function loadAgentOutput() { - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; - if (!agentOutputFile) { - core.info("No GH_AW_AGENT_OUTPUT environment variable found"); - return { success: false }; - } - let outputContent; - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - return { success: false, error: errorMessage }; - } - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return { success: false }; - } - core.info(`Agent output content length: ${outputContent.length}`); - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); - return { success: false, error: errorMessage }; - } - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); - return { success: false }; - } - return { success: true, items: validatedOutput.items }; - } - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - function getMessages() { - const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES; - if (!messagesEnv) { - return null; - } - try { - return JSON.parse(messagesEnv); - } catch (error) { - core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`); - return null; - } - } - function renderTemplate(template, context) { - return template.replace(/\{(\w+)\}/g, (match, key) => { - const value = context[key]; - return value !== undefined && value !== null ? String(value) : match; - }); - } - function toSnakeCase(obj) { - const result = {}; - for (const [key, value] of Object.entries(obj)) { - const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase(); - result[snakeKey] = value; - result[key] = value; - } - return result; - } - function getCloseOlderDiscussionMessage(ctx) { - const messages = getMessages(); - const templateContext = toSnakeCase(ctx); - const defaultMessage = `⚓ Avast! This discussion be marked as **outdated** by [{workflow_name}]({run_url}). - 🗺️ A newer treasure map awaits ye at **[Discussion #{new_discussion_number}]({new_discussion_url})**. - Fair winds, matey! 🏴‍☠️`; - return messages?.closeOlderDiscussion - ? renderTemplate(messages.closeOlderDiscussion, templateContext) - : renderTemplate(defaultMessage, templateContext); - } - const MAX_CLOSE_COUNT = 10; - const GRAPHQL_DELAY_MS = 500; - function delay(ms) { - return new Promise(resolve => setTimeout(resolve, ms)); - } - async function searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, excludeNumber) { - let searchQuery = `repo:${owner}/${repo} is:open`; - if (titlePrefix) { - const escapedPrefix = titlePrefix.replace(/"/g, '\\"'); - searchQuery += ` in:title "${escapedPrefix}"`; - } - if (labels && labels.length > 0) { - for (const label of labels) { - const escapedLabel = label.replace(/"/g, '\\"'); - searchQuery += ` label:"${escapedLabel}"`; - } - } - const result = await github.graphql( - ` - query($searchTerms: String!, $first: Int!) { - search(query: $searchTerms, type: DISCUSSION, first: $first) { - nodes { - ... on Discussion { - id - number - title - url - category { - id - } - labels(first: 100) { - nodes { - name - } - } - closed - } - } - } - }`, - { searchTerms: searchQuery, first: 50 } - ); - if (!result || !result.search || !result.search.nodes) { - return []; - } - return result.search.nodes - .filter( - d => { - if (!d || d.number === excludeNumber || d.closed) { - return false; - } - if (titlePrefix && d.title && !d.title.startsWith(titlePrefix)) { - return false; - } - if (labels && labels.length > 0) { - const discussionLabels = d.labels?.nodes?.map(( l) => l.name) || []; - const hasAllLabels = labels.every(label => discussionLabels.includes(label)); - if (!hasAllLabels) { - return false; - } - } - if (categoryId && (!d.category || d.category.id !== categoryId)) { - return false; - } - return true; - } - ) - .map( - d => ({ - id: d.id, - number: d.number, - title: d.title, - url: d.url, - }) - ); - } - async function addDiscussionComment(github, discussionId, message) { - const result = await github.graphql( - ` - mutation($dId: ID!, $body: String!) { - addDiscussionComment(input: { discussionId: $dId, body: $body }) { - comment { - id - url - } - } - }`, - { dId: discussionId, body: message } - ); - return result.addDiscussionComment.comment; - } - async function closeDiscussionAsOutdated(github, discussionId) { - const result = await github.graphql( - ` - mutation($dId: ID!) { - closeDiscussion(input: { discussionId: $dId, reason: OUTDATED }) { - discussion { - id - url - } - } - }`, - { dId: discussionId } - ); - return result.closeDiscussion.discussion; - } - async function closeOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion, workflowName, runUrl) { - const searchCriteria = []; - if (titlePrefix) searchCriteria.push(`title prefix: "${titlePrefix}"`); - if (labels && labels.length > 0) searchCriteria.push(`labels: [${labels.join(", ")}]`); - core.info(`Searching for older discussions with ${searchCriteria.join(" and ")}`); - const olderDiscussions = await searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion.number); - if (olderDiscussions.length === 0) { - core.info("No older discussions found to close"); - return []; - } - core.info(`Found ${olderDiscussions.length} older discussion(s) to close`); - const discussionsToClose = olderDiscussions.slice(0, MAX_CLOSE_COUNT); - if (olderDiscussions.length > MAX_CLOSE_COUNT) { - core.warning(`Found ${olderDiscussions.length} older discussions, but only closing the first ${MAX_CLOSE_COUNT}`); - } - const closedDiscussions = []; - for (let i = 0; i < discussionsToClose.length; i++) { - const discussion = discussionsToClose[i]; - try { - const closingMessage = getCloseOlderDiscussionMessage({ - newDiscussionUrl: newDiscussion.url, - newDiscussionNumber: newDiscussion.number, - workflowName, - runUrl, - }); - core.info(`Adding closing comment to discussion #${discussion.number}`); - await addDiscussionComment(github, discussion.id, closingMessage); - core.info(`Closing discussion #${discussion.number} as outdated`); - await closeDiscussionAsOutdated(github, discussion.id); - closedDiscussions.push({ - number: discussion.number, - url: discussion.url, - }); - core.info(`✓ Closed discussion #${discussion.number}: ${discussion.url}`); - } catch (error) { - core.error(`✗ Failed to close discussion #${discussion.number}: ${error instanceof Error ? error.message : String(error)}`); - } - if (i < discussionsToClose.length - 1) { - await delay(GRAPHQL_DELAY_MS); - } - } - return closedDiscussions; - } - const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; - function generateTemporaryId() { - return "aw_" + crypto.randomBytes(6).toString("hex"); - } - function isTemporaryId(value) { - if (typeof value === "string") { - return /^aw_[0-9a-f]{12}$/i.test(value); - } - return false; - } - function normalizeTemporaryId(tempId) { - return String(tempId).toLowerCase(); - } - function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); - if (resolved !== undefined) { - if (currentRepo && resolved.repo === currentRepo) { - return `#${resolved.number}`; - } - return `${resolved.repo}#${resolved.number}`; - } - return match; - }); - } - function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); - if (issueNumber !== undefined) { - return `#${issueNumber}`; - } - return match; - }); - } - function loadTemporaryIdMap() { - const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; - if (!mapJson || mapJson === "{}") { - return new Map(); - } - try { - const mapObject = JSON.parse(mapJson); - const result = new Map(); - for (const [key, value] of Object.entries(mapObject)) { - const normalizedKey = normalizeTemporaryId(key); - if (typeof value === "number") { - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - result.set(normalizedKey, { repo: contextRepo, number: value }); - } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { - result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); - } - } - return result; - } catch (error) { - if (typeof core !== "undefined") { - core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); - } - return new Map(); - } - } - function resolveIssueNumber(value, temporaryIdMap) { - if (value === undefined || value === null) { - return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; - } - const valueStr = String(value); - if (isTemporaryId(valueStr)) { - const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); - if (resolvedPair !== undefined) { - return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; - } - return { - resolved: null, - wasTemporaryId: true, - errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, - }; - } - const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); - if (isNaN(issueNumber) || issueNumber <= 0) { - return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; - } - const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; - return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; - } - function serializeTemporaryIdMap(tempIdMap) { - const obj = Object.fromEntries(tempIdMap); - return JSON.stringify(obj); - } - function parseAllowedRepos() { - const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; - const set = new Set(); - if (allowedReposEnv) { - allowedReposEnv - .split(",") - .map(repo => repo.trim()) - .filter(repo => repo) - .forEach(repo => set.add(repo)); - } - return set; - } - function getDefaultTargetRepo() { - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - if (targetRepoSlug) { - return targetRepoSlug; - } - return `${context.repo.owner}/${context.repo.repo}`; - } - function validateRepo(repo, defaultRepo, allowedRepos) { - if (repo === defaultRepo) { - return { valid: true, error: null }; - } - if (allowedRepos.has(repo)) { - return { valid: true, error: null }; - } - return { - valid: false, - error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, - }; - } - function parseRepoSlug(repoSlug) { - const parts = repoSlug.split("/"); - if (parts.length !== 2 || !parts[0] || !parts[1]) { - return null; - } - return { owner: parts[0], repo: parts[1] }; - } - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { getTrackerID } = require("./get_tracker_id.cjs"); + const { closeOlderDiscussions } = require("./close_older_discussions.cjs"); + const { replaceTemporaryIdReferences, loadTemporaryIdMap } = require("./temporary_id.cjs"); + const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require("./repo_helpers.cjs"); + const { addExpirationComment } = require("./expiration_helpers.cjs"); + const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); async function fetchRepoDiscussionInfo(owner, repo) { const repositoryQuery = ` query($owner: String!, $repo: String!) { @@ -8515,7 +8147,9 @@ jobs: ); let title = createDiscussionItem.title ? replaceTemporaryIdReferences(createDiscussionItem.title.trim(), temporaryIdMap, itemRepo) : ""; const bodyText = createDiscussionItem.body || ""; - let bodyLines = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo).split("\n"); + let processedBody = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo); + processedBody = removeDuplicateTitleFromDescription(title, processedBody); + let bodyLines = processedBody.split("\n"); if (!title) { title = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo) || "Agent Output"; } diff --git a/.github/workflows/daily-doc-updater.lock.yml b/.github/workflows/daily-doc-updater.lock.yml index 1f5dc7e62c9..531dc99ccda 100644 --- a/.github/workflows/daily-doc-updater.lock.yml +++ b/.github/workflows/daily-doc-updater.lock.yml @@ -5774,132 +5774,10 @@ jobs: script: | const fs = require("fs"); const crypto = require("crypto"); - async function updateActivationComment(github, context, core, itemUrl, itemNumber, itemType = "pull_request") { - const itemLabel = itemType === "issue" ? "issue" : "pull request"; - const linkMessage = - itemType === "issue" - ? `\n\n✅ Issue created: [#${itemNumber}](${itemUrl})` - : `\n\n✅ Pull request created: [#${itemNumber}](${itemUrl})`; - await updateActivationCommentWithMessage(github, context, core, linkMessage, itemLabel); - } - async function updateActivationCommentWithCommit(github, context, core, commitSha, commitUrl) { - const shortSha = commitSha.substring(0, 7); - const message = `\n\n✅ Commit pushed: [\`${shortSha}\`](${commitUrl})`; - await updateActivationCommentWithMessage(github, context, core, message, "commit"); - } - async function updateActivationCommentWithMessage(github, context, core, message, label = "") { - const commentId = process.env.GH_AW_COMMENT_ID; - const commentRepo = process.env.GH_AW_COMMENT_REPO; - if (!commentId) { - core.info("No activation comment to update (GH_AW_COMMENT_ID not set)"); - return; - } - core.info(`Updating activation comment ${commentId}`); - let repoOwner = context.repo.owner; - let repoName = context.repo.repo; - if (commentRepo) { - const parts = commentRepo.split("/"); - if (parts.length === 2) { - repoOwner = parts[0]; - repoName = parts[1]; - } else { - core.warning(`Invalid comment repo format: ${commentRepo}, expected "owner/repo". Falling back to context.repo.`); - } - } - core.info(`Updating comment in ${repoOwner}/${repoName}`); - const isDiscussionComment = commentId.startsWith("DC_"); - try { - if (isDiscussionComment) { - const currentComment = await github.graphql( - ` - query($commentId: ID!) { - node(id: $commentId) { - ... on DiscussionComment { - body - } - } - }`, - { commentId: commentId } - ); - if (!currentComment?.node?.body) { - core.warning("Unable to fetch current comment body, comment may have been deleted or is inaccessible"); - return; - } - const currentBody = currentComment.node.body; - const updatedBody = currentBody + message; - const result = await github.graphql( - ` - mutation($commentId: ID!, $body: String!) { - updateDiscussionComment(input: { commentId: $commentId, body: $body }) { - comment { - id - url - } - } - }`, - { commentId: commentId, body: updatedBody } - ); - const comment = result.updateDiscussionComment.comment; - const successMessage = label - ? `Successfully updated discussion comment with ${label} link` - : "Successfully updated discussion comment"; - core.info(successMessage); - core.info(`Comment ID: ${comment.id}`); - core.info(`Comment URL: ${comment.url}`); - } else { - const currentComment = await github.request("GET /repos/{owner}/{repo}/issues/comments/{comment_id}", { - owner: repoOwner, - repo: repoName, - comment_id: parseInt(commentId, 10), - headers: { - Accept: "application/vnd.github+json", - }, - }); - if (!currentComment?.data?.body) { - core.warning("Unable to fetch current comment body, comment may have been deleted"); - return; - } - const currentBody = currentComment.data.body; - const updatedBody = currentBody + message; - const response = await github.request("PATCH /repos/{owner}/{repo}/issues/comments/{comment_id}", { - owner: repoOwner, - repo: repoName, - comment_id: parseInt(commentId, 10), - body: updatedBody, - headers: { - Accept: "application/vnd.github+json", - }, - }); - const successMessage = label ? `Successfully updated comment with ${label} link` : "Successfully updated comment"; - core.info(successMessage); - core.info(`Comment ID: ${response.data.id}`); - core.info(`Comment URL: ${response.data.html_url}`); - } - } catch (error) { - core.warning(`Failed to update activation comment: ${error instanceof Error ? error.message : String(error)}`); - } - } - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } + const { updateActivationComment } = require("./update_activation_comment.cjs"); + const { getTrackerID } = require("./get_tracker_id.cjs"); + const { addExpirationComment } = require("./expiration_helpers.cjs"); + const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); function generatePatchPreview(patchContent) { if (!patchContent || !patchContent.trim()) { return ""; @@ -6091,7 +5969,9 @@ jobs: return; } let title = pullRequestItem.title.trim(); - let bodyLines = pullRequestItem.body.split("\n"); + let processedBody = pullRequestItem.body; + processedBody = removeDuplicateTitleFromDescription(title, processedBody); + let bodyLines = processedBody.split("\n"); let branchName = pullRequestItem.branch ? pullRequestItem.branch.trim() : null; if (!title) { title = "Agent Output"; diff --git a/.github/workflows/daily-file-diet.lock.yml b/.github/workflows/daily-file-diet.lock.yml index 44daa608e98..a709e82c835 100644 --- a/.github/workflows/daily-file-diet.lock.yml +++ b/.github/workflows/daily-file-diet.lock.yml @@ -6024,272 +6024,21 @@ jobs: with: github-token: ${{ steps.app-token.outputs.token }} script: | - function sanitizeLabelContent(content) { - if (!content || typeof content !== "string") { - return ""; - } - let sanitized = content.trim(); - sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); - sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); - sanitized = sanitized.replace( - /(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, - (_m, p1, p2) => `${p1}\`@${p2}\`` - ); - sanitized = sanitized.replace(/[<>&'"]/g, ""); - return sanitized.trim(); - } - const fs = require("fs"); - const crypto = require("crypto"); - const MAX_LOG_CONTENT_LENGTH = 10000; - function truncateForLogging(content) { - if (content.length <= MAX_LOG_CONTENT_LENGTH) { - return content; - } - return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; - } - function loadAgentOutput() { - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; - if (!agentOutputFile) { - core.info("No GH_AW_AGENT_OUTPUT environment variable found"); - return { success: false }; - } - let outputContent; - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - return { success: false, error: errorMessage }; - } - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return { success: false }; - } - core.info(`Agent output content length: ${outputContent.length}`); - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); - return { success: false, error: errorMessage }; - } - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); - return { success: false }; - } - return { success: true, items: validatedOutput.items }; - } - async function generateStagedPreview(options) { - const { title, description, items, renderItem } = options; - let summaryContent = `## 🎭 Staged Mode: ${title} Preview\n\n`; - summaryContent += `${description}\n\n`; - for (let i = 0; i < items.length; i++) { - const item = items[i]; - summaryContent += renderItem(item, i); - summaryContent += "---\n\n"; - } - try { - await core.summary.addRaw(summaryContent).write(); - core.info(summaryContent); - core.info(`📝 ${title} preview written to step summary`); - } catch (error) { - core.setFailed(error instanceof Error ? error : String(error)); - } - } - function generateXMLMarker(workflowName, runUrl) { - const engineId = process.env.GH_AW_ENGINE_ID || ""; - const engineVersion = process.env.GH_AW_ENGINE_VERSION || ""; - const engineModel = process.env.GH_AW_ENGINE_MODEL || ""; - const trackerId = process.env.GH_AW_TRACKER_ID || ""; - const parts = []; - parts.push(`agentic-workflow: ${workflowName}`); - if (trackerId) { - parts.push(`tracker-id: ${trackerId}`); - } - if (engineId) { - parts.push(`engine: ${engineId}`); - } - if (engineVersion) { - parts.push(`version: ${engineVersion}`); - } - if (engineModel) { - parts.push(`model: ${engineModel}`); - } - parts.push(`run: ${runUrl}`); - return ``; - } - function generateFooter( - workflowName, - runUrl, - workflowSource, - workflowSourceURL, - triggeringIssueNumber, - triggeringPRNumber, - triggeringDiscussionNumber - ) { - let footer = `\n\n> AI generated by [${workflowName}](${runUrl})`; - if (triggeringIssueNumber) { - footer += ` for #${triggeringIssueNumber}`; - } else if (triggeringPRNumber) { - footer += ` for #${triggeringPRNumber}`; - } else if (triggeringDiscussionNumber) { - footer += ` for discussion #${triggeringDiscussionNumber}`; - } - if (workflowSource && workflowSourceURL) { - footer += `\n>\n> To add this workflow in your repository, run \`gh aw add ${workflowSource}\`. See [usage guide](https://githubnext.github.io/gh-aw/tools/cli/).`; - } - footer += "\n\n" + generateXMLMarker(workflowName, runUrl); - footer += "\n"; - return footer; - } - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; - function generateTemporaryId() { - return "aw_" + crypto.randomBytes(6).toString("hex"); - } - function isTemporaryId(value) { - if (typeof value === "string") { - return /^aw_[0-9a-f]{12}$/i.test(value); - } - return false; - } - function normalizeTemporaryId(tempId) { - return String(tempId).toLowerCase(); - } - function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); - if (resolved !== undefined) { - if (currentRepo && resolved.repo === currentRepo) { - return `#${resolved.number}`; - } - return `${resolved.repo}#${resolved.number}`; - } - return match; - }); - } - function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); - if (issueNumber !== undefined) { - return `#${issueNumber}`; - } - return match; - }); - } - function loadTemporaryIdMap() { - const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; - if (!mapJson || mapJson === "{}") { - return new Map(); - } - try { - const mapObject = JSON.parse(mapJson); - const result = new Map(); - for (const [key, value] of Object.entries(mapObject)) { - const normalizedKey = normalizeTemporaryId(key); - if (typeof value === "number") { - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - result.set(normalizedKey, { repo: contextRepo, number: value }); - } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { - result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); - } - } - return result; - } catch (error) { - if (typeof core !== "undefined") { - core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); - } - return new Map(); - } - } - function resolveIssueNumber(value, temporaryIdMap) { - if (value === undefined || value === null) { - return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; - } - const valueStr = String(value); - if (isTemporaryId(valueStr)) { - const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); - if (resolvedPair !== undefined) { - return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; - } - return { - resolved: null, - wasTemporaryId: true, - errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, - }; - } - const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); - if (isNaN(issueNumber) || issueNumber <= 0) { - return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; - } - const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; - return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; - } - function serializeTemporaryIdMap(tempIdMap) { - const obj = Object.fromEntries(tempIdMap); - return JSON.stringify(obj); - } - function parseAllowedRepos() { - const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; - const set = new Set(); - if (allowedReposEnv) { - allowedReposEnv - .split(",") - .map(repo => repo.trim()) - .filter(repo => repo) - .forEach(repo => set.add(repo)); - } - return set; - } - function getDefaultTargetRepo() { - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - if (targetRepoSlug) { - return targetRepoSlug; - } - return `${context.repo.owner}/${context.repo.repo}`; - } - function validateRepo(repo, defaultRepo, allowedRepos) { - if (repo === defaultRepo) { - return { valid: true, error: null }; - } - if (allowedRepos.has(repo)) { - return { valid: true, error: null }; - } - return { - valid: false, - error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, - }; - } - function parseRepoSlug(repoSlug) { - const parts = repoSlug.split("/"); - if (parts.length !== 2 || !parts[0] || !parts[1]) { - return null; - } - return { owner: parts[0], repo: parts[1] }; - } - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } + const { sanitizeLabelContent } = require("./sanitize_label_content.cjs"); + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { generateStagedPreview } = require("./staged_preview.cjs"); + const { generateFooter } = require("./generate_footer.cjs"); + const { getTrackerID } = require("./get_tracker_id.cjs"); + const { + generateTemporaryId, + isTemporaryId, + normalizeTemporaryId, + replaceTemporaryIdReferences, + serializeTemporaryIdMap, + } = require("./temporary_id.cjs"); + const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require("./repo_helpers.cjs"); + const { addExpirationComment } = require("./expiration_helpers.cjs"); + const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); async function main() { core.setOutput("issue_number", ""); core.setOutput("issue_url", ""); @@ -6422,6 +6171,7 @@ jobs: .filter((label, index, arr) => arr.indexOf(label) === index); let title = createIssueItem.title ? createIssueItem.title.trim() : ""; let processedBody = replaceTemporaryIdReferences(createIssueItem.body, temporaryIdMap, itemRepo); + processedBody = removeDuplicateTitleFromDescription(title, processedBody); let bodyLines = processedBody.split("\n"); if (!title) { title = createIssueItem.body || "Agent Output"; diff --git a/.github/workflows/daily-firewall-report.lock.yml b/.github/workflows/daily-firewall-report.lock.yml index 24776fcbd87..47048257b30 100644 --- a/.github/workflows/daily-firewall-report.lock.yml +++ b/.github/workflows/daily-firewall-report.lock.yml @@ -7251,381 +7251,13 @@ jobs: with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - const fs = require("fs"); - const crypto = require("crypto"); - const MAX_LOG_CONTENT_LENGTH = 10000; - function truncateForLogging(content) { - if (content.length <= MAX_LOG_CONTENT_LENGTH) { - return content; - } - return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; - } - function loadAgentOutput() { - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; - if (!agentOutputFile) { - core.info("No GH_AW_AGENT_OUTPUT environment variable found"); - return { success: false }; - } - let outputContent; - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - return { success: false, error: errorMessage }; - } - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return { success: false }; - } - core.info(`Agent output content length: ${outputContent.length}`); - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); - return { success: false, error: errorMessage }; - } - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); - return { success: false }; - } - return { success: true, items: validatedOutput.items }; - } - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - function getMessages() { - const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES; - if (!messagesEnv) { - return null; - } - try { - return JSON.parse(messagesEnv); - } catch (error) { - core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`); - return null; - } - } - function renderTemplate(template, context) { - return template.replace(/\{(\w+)\}/g, (match, key) => { - const value = context[key]; - return value !== undefined && value !== null ? String(value) : match; - }); - } - function toSnakeCase(obj) { - const result = {}; - for (const [key, value] of Object.entries(obj)) { - const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase(); - result[snakeKey] = value; - result[key] = value; - } - return result; - } - function getCloseOlderDiscussionMessage(ctx) { - const messages = getMessages(); - const templateContext = toSnakeCase(ctx); - const defaultMessage = `⚓ Avast! This discussion be marked as **outdated** by [{workflow_name}]({run_url}). - 🗺️ A newer treasure map awaits ye at **[Discussion #{new_discussion_number}]({new_discussion_url})**. - Fair winds, matey! 🏴‍☠️`; - return messages?.closeOlderDiscussion - ? renderTemplate(messages.closeOlderDiscussion, templateContext) - : renderTemplate(defaultMessage, templateContext); - } - const MAX_CLOSE_COUNT = 10; - const GRAPHQL_DELAY_MS = 500; - function delay(ms) { - return new Promise(resolve => setTimeout(resolve, ms)); - } - async function searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, excludeNumber) { - let searchQuery = `repo:${owner}/${repo} is:open`; - if (titlePrefix) { - const escapedPrefix = titlePrefix.replace(/"/g, '\\"'); - searchQuery += ` in:title "${escapedPrefix}"`; - } - if (labels && labels.length > 0) { - for (const label of labels) { - const escapedLabel = label.replace(/"/g, '\\"'); - searchQuery += ` label:"${escapedLabel}"`; - } - } - const result = await github.graphql( - ` - query($searchTerms: String!, $first: Int!) { - search(query: $searchTerms, type: DISCUSSION, first: $first) { - nodes { - ... on Discussion { - id - number - title - url - category { - id - } - labels(first: 100) { - nodes { - name - } - } - closed - } - } - } - }`, - { searchTerms: searchQuery, first: 50 } - ); - if (!result || !result.search || !result.search.nodes) { - return []; - } - return result.search.nodes - .filter( - d => { - if (!d || d.number === excludeNumber || d.closed) { - return false; - } - if (titlePrefix && d.title && !d.title.startsWith(titlePrefix)) { - return false; - } - if (labels && labels.length > 0) { - const discussionLabels = d.labels?.nodes?.map(( l) => l.name) || []; - const hasAllLabels = labels.every(label => discussionLabels.includes(label)); - if (!hasAllLabels) { - return false; - } - } - if (categoryId && (!d.category || d.category.id !== categoryId)) { - return false; - } - return true; - } - ) - .map( - d => ({ - id: d.id, - number: d.number, - title: d.title, - url: d.url, - }) - ); - } - async function addDiscussionComment(github, discussionId, message) { - const result = await github.graphql( - ` - mutation($dId: ID!, $body: String!) { - addDiscussionComment(input: { discussionId: $dId, body: $body }) { - comment { - id - url - } - } - }`, - { dId: discussionId, body: message } - ); - return result.addDiscussionComment.comment; - } - async function closeDiscussionAsOutdated(github, discussionId) { - const result = await github.graphql( - ` - mutation($dId: ID!) { - closeDiscussion(input: { discussionId: $dId, reason: OUTDATED }) { - discussion { - id - url - } - } - }`, - { dId: discussionId } - ); - return result.closeDiscussion.discussion; - } - async function closeOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion, workflowName, runUrl) { - const searchCriteria = []; - if (titlePrefix) searchCriteria.push(`title prefix: "${titlePrefix}"`); - if (labels && labels.length > 0) searchCriteria.push(`labels: [${labels.join(", ")}]`); - core.info(`Searching for older discussions with ${searchCriteria.join(" and ")}`); - const olderDiscussions = await searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion.number); - if (olderDiscussions.length === 0) { - core.info("No older discussions found to close"); - return []; - } - core.info(`Found ${olderDiscussions.length} older discussion(s) to close`); - const discussionsToClose = olderDiscussions.slice(0, MAX_CLOSE_COUNT); - if (olderDiscussions.length > MAX_CLOSE_COUNT) { - core.warning(`Found ${olderDiscussions.length} older discussions, but only closing the first ${MAX_CLOSE_COUNT}`); - } - const closedDiscussions = []; - for (let i = 0; i < discussionsToClose.length; i++) { - const discussion = discussionsToClose[i]; - try { - const closingMessage = getCloseOlderDiscussionMessage({ - newDiscussionUrl: newDiscussion.url, - newDiscussionNumber: newDiscussion.number, - workflowName, - runUrl, - }); - core.info(`Adding closing comment to discussion #${discussion.number}`); - await addDiscussionComment(github, discussion.id, closingMessage); - core.info(`Closing discussion #${discussion.number} as outdated`); - await closeDiscussionAsOutdated(github, discussion.id); - closedDiscussions.push({ - number: discussion.number, - url: discussion.url, - }); - core.info(`✓ Closed discussion #${discussion.number}: ${discussion.url}`); - } catch (error) { - core.error(`✗ Failed to close discussion #${discussion.number}: ${error instanceof Error ? error.message : String(error)}`); - } - if (i < discussionsToClose.length - 1) { - await delay(GRAPHQL_DELAY_MS); - } - } - return closedDiscussions; - } - const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; - function generateTemporaryId() { - return "aw_" + crypto.randomBytes(6).toString("hex"); - } - function isTemporaryId(value) { - if (typeof value === "string") { - return /^aw_[0-9a-f]{12}$/i.test(value); - } - return false; - } - function normalizeTemporaryId(tempId) { - return String(tempId).toLowerCase(); - } - function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); - if (resolved !== undefined) { - if (currentRepo && resolved.repo === currentRepo) { - return `#${resolved.number}`; - } - return `${resolved.repo}#${resolved.number}`; - } - return match; - }); - } - function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); - if (issueNumber !== undefined) { - return `#${issueNumber}`; - } - return match; - }); - } - function loadTemporaryIdMap() { - const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; - if (!mapJson || mapJson === "{}") { - return new Map(); - } - try { - const mapObject = JSON.parse(mapJson); - const result = new Map(); - for (const [key, value] of Object.entries(mapObject)) { - const normalizedKey = normalizeTemporaryId(key); - if (typeof value === "number") { - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - result.set(normalizedKey, { repo: contextRepo, number: value }); - } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { - result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); - } - } - return result; - } catch (error) { - if (typeof core !== "undefined") { - core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); - } - return new Map(); - } - } - function resolveIssueNumber(value, temporaryIdMap) { - if (value === undefined || value === null) { - return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; - } - const valueStr = String(value); - if (isTemporaryId(valueStr)) { - const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); - if (resolvedPair !== undefined) { - return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; - } - return { - resolved: null, - wasTemporaryId: true, - errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, - }; - } - const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); - if (isNaN(issueNumber) || issueNumber <= 0) { - return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; - } - const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; - return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; - } - function serializeTemporaryIdMap(tempIdMap) { - const obj = Object.fromEntries(tempIdMap); - return JSON.stringify(obj); - } - function parseAllowedRepos() { - const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; - const set = new Set(); - if (allowedReposEnv) { - allowedReposEnv - .split(",") - .map(repo => repo.trim()) - .filter(repo => repo) - .forEach(repo => set.add(repo)); - } - return set; - } - function getDefaultTargetRepo() { - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - if (targetRepoSlug) { - return targetRepoSlug; - } - return `${context.repo.owner}/${context.repo.repo}`; - } - function validateRepo(repo, defaultRepo, allowedRepos) { - if (repo === defaultRepo) { - return { valid: true, error: null }; - } - if (allowedRepos.has(repo)) { - return { valid: true, error: null }; - } - return { - valid: false, - error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, - }; - } - function parseRepoSlug(repoSlug) { - const parts = repoSlug.split("/"); - if (parts.length !== 2 || !parts[0] || !parts[1]) { - return null; - } - return { owner: parts[0], repo: parts[1] }; - } - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { getTrackerID } = require("./get_tracker_id.cjs"); + const { closeOlderDiscussions } = require("./close_older_discussions.cjs"); + const { replaceTemporaryIdReferences, loadTemporaryIdMap } = require("./temporary_id.cjs"); + const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require("./repo_helpers.cjs"); + const { addExpirationComment } = require("./expiration_helpers.cjs"); + const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); async function fetchRepoDiscussionInfo(owner, repo) { const repositoryQuery = ` query($owner: String!, $repo: String!) { @@ -7810,7 +7442,9 @@ jobs: ); let title = createDiscussionItem.title ? replaceTemporaryIdReferences(createDiscussionItem.title.trim(), temporaryIdMap, itemRepo) : ""; const bodyText = createDiscussionItem.body || ""; - let bodyLines = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo).split("\n"); + let processedBody = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo); + processedBody = removeDuplicateTitleFromDescription(title, processedBody); + let bodyLines = processedBody.split("\n"); if (!title) { title = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo) || "Agent Output"; } diff --git a/.github/workflows/daily-issues-report.lock.yml b/.github/workflows/daily-issues-report.lock.yml index c9d59fe196e..4d9f38f2a17 100644 --- a/.github/workflows/daily-issues-report.lock.yml +++ b/.github/workflows/daily-issues-report.lock.yml @@ -7963,381 +7963,13 @@ jobs: with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - const fs = require("fs"); - const crypto = require("crypto"); - const MAX_LOG_CONTENT_LENGTH = 10000; - function truncateForLogging(content) { - if (content.length <= MAX_LOG_CONTENT_LENGTH) { - return content; - } - return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; - } - function loadAgentOutput() { - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; - if (!agentOutputFile) { - core.info("No GH_AW_AGENT_OUTPUT environment variable found"); - return { success: false }; - } - let outputContent; - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - return { success: false, error: errorMessage }; - } - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return { success: false }; - } - core.info(`Agent output content length: ${outputContent.length}`); - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); - return { success: false, error: errorMessage }; - } - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); - return { success: false }; - } - return { success: true, items: validatedOutput.items }; - } - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - function getMessages() { - const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES; - if (!messagesEnv) { - return null; - } - try { - return JSON.parse(messagesEnv); - } catch (error) { - core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`); - return null; - } - } - function renderTemplate(template, context) { - return template.replace(/\{(\w+)\}/g, (match, key) => { - const value = context[key]; - return value !== undefined && value !== null ? String(value) : match; - }); - } - function toSnakeCase(obj) { - const result = {}; - for (const [key, value] of Object.entries(obj)) { - const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase(); - result[snakeKey] = value; - result[key] = value; - } - return result; - } - function getCloseOlderDiscussionMessage(ctx) { - const messages = getMessages(); - const templateContext = toSnakeCase(ctx); - const defaultMessage = `⚓ Avast! This discussion be marked as **outdated** by [{workflow_name}]({run_url}). - 🗺️ A newer treasure map awaits ye at **[Discussion #{new_discussion_number}]({new_discussion_url})**. - Fair winds, matey! 🏴‍☠️`; - return messages?.closeOlderDiscussion - ? renderTemplate(messages.closeOlderDiscussion, templateContext) - : renderTemplate(defaultMessage, templateContext); - } - const MAX_CLOSE_COUNT = 10; - const GRAPHQL_DELAY_MS = 500; - function delay(ms) { - return new Promise(resolve => setTimeout(resolve, ms)); - } - async function searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, excludeNumber) { - let searchQuery = `repo:${owner}/${repo} is:open`; - if (titlePrefix) { - const escapedPrefix = titlePrefix.replace(/"/g, '\\"'); - searchQuery += ` in:title "${escapedPrefix}"`; - } - if (labels && labels.length > 0) { - for (const label of labels) { - const escapedLabel = label.replace(/"/g, '\\"'); - searchQuery += ` label:"${escapedLabel}"`; - } - } - const result = await github.graphql( - ` - query($searchTerms: String!, $first: Int!) { - search(query: $searchTerms, type: DISCUSSION, first: $first) { - nodes { - ... on Discussion { - id - number - title - url - category { - id - } - labels(first: 100) { - nodes { - name - } - } - closed - } - } - } - }`, - { searchTerms: searchQuery, first: 50 } - ); - if (!result || !result.search || !result.search.nodes) { - return []; - } - return result.search.nodes - .filter( - d => { - if (!d || d.number === excludeNumber || d.closed) { - return false; - } - if (titlePrefix && d.title && !d.title.startsWith(titlePrefix)) { - return false; - } - if (labels && labels.length > 0) { - const discussionLabels = d.labels?.nodes?.map(( l) => l.name) || []; - const hasAllLabels = labels.every(label => discussionLabels.includes(label)); - if (!hasAllLabels) { - return false; - } - } - if (categoryId && (!d.category || d.category.id !== categoryId)) { - return false; - } - return true; - } - ) - .map( - d => ({ - id: d.id, - number: d.number, - title: d.title, - url: d.url, - }) - ); - } - async function addDiscussionComment(github, discussionId, message) { - const result = await github.graphql( - ` - mutation($dId: ID!, $body: String!) { - addDiscussionComment(input: { discussionId: $dId, body: $body }) { - comment { - id - url - } - } - }`, - { dId: discussionId, body: message } - ); - return result.addDiscussionComment.comment; - } - async function closeDiscussionAsOutdated(github, discussionId) { - const result = await github.graphql( - ` - mutation($dId: ID!) { - closeDiscussion(input: { discussionId: $dId, reason: OUTDATED }) { - discussion { - id - url - } - } - }`, - { dId: discussionId } - ); - return result.closeDiscussion.discussion; - } - async function closeOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion, workflowName, runUrl) { - const searchCriteria = []; - if (titlePrefix) searchCriteria.push(`title prefix: "${titlePrefix}"`); - if (labels && labels.length > 0) searchCriteria.push(`labels: [${labels.join(", ")}]`); - core.info(`Searching for older discussions with ${searchCriteria.join(" and ")}`); - const olderDiscussions = await searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion.number); - if (olderDiscussions.length === 0) { - core.info("No older discussions found to close"); - return []; - } - core.info(`Found ${olderDiscussions.length} older discussion(s) to close`); - const discussionsToClose = olderDiscussions.slice(0, MAX_CLOSE_COUNT); - if (olderDiscussions.length > MAX_CLOSE_COUNT) { - core.warning(`Found ${olderDiscussions.length} older discussions, but only closing the first ${MAX_CLOSE_COUNT}`); - } - const closedDiscussions = []; - for (let i = 0; i < discussionsToClose.length; i++) { - const discussion = discussionsToClose[i]; - try { - const closingMessage = getCloseOlderDiscussionMessage({ - newDiscussionUrl: newDiscussion.url, - newDiscussionNumber: newDiscussion.number, - workflowName, - runUrl, - }); - core.info(`Adding closing comment to discussion #${discussion.number}`); - await addDiscussionComment(github, discussion.id, closingMessage); - core.info(`Closing discussion #${discussion.number} as outdated`); - await closeDiscussionAsOutdated(github, discussion.id); - closedDiscussions.push({ - number: discussion.number, - url: discussion.url, - }); - core.info(`✓ Closed discussion #${discussion.number}: ${discussion.url}`); - } catch (error) { - core.error(`✗ Failed to close discussion #${discussion.number}: ${error instanceof Error ? error.message : String(error)}`); - } - if (i < discussionsToClose.length - 1) { - await delay(GRAPHQL_DELAY_MS); - } - } - return closedDiscussions; - } - const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; - function generateTemporaryId() { - return "aw_" + crypto.randomBytes(6).toString("hex"); - } - function isTemporaryId(value) { - if (typeof value === "string") { - return /^aw_[0-9a-f]{12}$/i.test(value); - } - return false; - } - function normalizeTemporaryId(tempId) { - return String(tempId).toLowerCase(); - } - function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); - if (resolved !== undefined) { - if (currentRepo && resolved.repo === currentRepo) { - return `#${resolved.number}`; - } - return `${resolved.repo}#${resolved.number}`; - } - return match; - }); - } - function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); - if (issueNumber !== undefined) { - return `#${issueNumber}`; - } - return match; - }); - } - function loadTemporaryIdMap() { - const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; - if (!mapJson || mapJson === "{}") { - return new Map(); - } - try { - const mapObject = JSON.parse(mapJson); - const result = new Map(); - for (const [key, value] of Object.entries(mapObject)) { - const normalizedKey = normalizeTemporaryId(key); - if (typeof value === "number") { - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - result.set(normalizedKey, { repo: contextRepo, number: value }); - } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { - result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); - } - } - return result; - } catch (error) { - if (typeof core !== "undefined") { - core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); - } - return new Map(); - } - } - function resolveIssueNumber(value, temporaryIdMap) { - if (value === undefined || value === null) { - return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; - } - const valueStr = String(value); - if (isTemporaryId(valueStr)) { - const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); - if (resolvedPair !== undefined) { - return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; - } - return { - resolved: null, - wasTemporaryId: true, - errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, - }; - } - const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); - if (isNaN(issueNumber) || issueNumber <= 0) { - return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; - } - const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; - return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; - } - function serializeTemporaryIdMap(tempIdMap) { - const obj = Object.fromEntries(tempIdMap); - return JSON.stringify(obj); - } - function parseAllowedRepos() { - const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; - const set = new Set(); - if (allowedReposEnv) { - allowedReposEnv - .split(",") - .map(repo => repo.trim()) - .filter(repo => repo) - .forEach(repo => set.add(repo)); - } - return set; - } - function getDefaultTargetRepo() { - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - if (targetRepoSlug) { - return targetRepoSlug; - } - return `${context.repo.owner}/${context.repo.repo}`; - } - function validateRepo(repo, defaultRepo, allowedRepos) { - if (repo === defaultRepo) { - return { valid: true, error: null }; - } - if (allowedRepos.has(repo)) { - return { valid: true, error: null }; - } - return { - valid: false, - error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, - }; - } - function parseRepoSlug(repoSlug) { - const parts = repoSlug.split("/"); - if (parts.length !== 2 || !parts[0] || !parts[1]) { - return null; - } - return { owner: parts[0], repo: parts[1] }; - } - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { getTrackerID } = require("./get_tracker_id.cjs"); + const { closeOlderDiscussions } = require("./close_older_discussions.cjs"); + const { replaceTemporaryIdReferences, loadTemporaryIdMap } = require("./temporary_id.cjs"); + const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require("./repo_helpers.cjs"); + const { addExpirationComment } = require("./expiration_helpers.cjs"); + const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); async function fetchRepoDiscussionInfo(owner, repo) { const repositoryQuery = ` query($owner: String!, $repo: String!) { @@ -8522,7 +8154,9 @@ jobs: ); let title = createDiscussionItem.title ? replaceTemporaryIdReferences(createDiscussionItem.title.trim(), temporaryIdMap, itemRepo) : ""; const bodyText = createDiscussionItem.body || ""; - let bodyLines = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo).split("\n"); + let processedBody = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo); + processedBody = removeDuplicateTitleFromDescription(title, processedBody); + let bodyLines = processedBody.split("\n"); if (!title) { title = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo) || "Agent Output"; } diff --git a/.github/workflows/daily-multi-device-docs-tester.lock.yml b/.github/workflows/daily-multi-device-docs-tester.lock.yml index e6baffdf4b7..89734c2e847 100644 --- a/.github/workflows/daily-multi-device-docs-tester.lock.yml +++ b/.github/workflows/daily-multi-device-docs-tester.lock.yml @@ -5642,272 +5642,21 @@ jobs: with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - function sanitizeLabelContent(content) { - if (!content || typeof content !== "string") { - return ""; - } - let sanitized = content.trim(); - sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); - sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); - sanitized = sanitized.replace( - /(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, - (_m, p1, p2) => `${p1}\`@${p2}\`` - ); - sanitized = sanitized.replace(/[<>&'"]/g, ""); - return sanitized.trim(); - } - const fs = require("fs"); - const crypto = require("crypto"); - const MAX_LOG_CONTENT_LENGTH = 10000; - function truncateForLogging(content) { - if (content.length <= MAX_LOG_CONTENT_LENGTH) { - return content; - } - return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; - } - function loadAgentOutput() { - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; - if (!agentOutputFile) { - core.info("No GH_AW_AGENT_OUTPUT environment variable found"); - return { success: false }; - } - let outputContent; - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - return { success: false, error: errorMessage }; - } - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return { success: false }; - } - core.info(`Agent output content length: ${outputContent.length}`); - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); - return { success: false, error: errorMessage }; - } - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); - return { success: false }; - } - return { success: true, items: validatedOutput.items }; - } - async function generateStagedPreview(options) { - const { title, description, items, renderItem } = options; - let summaryContent = `## 🎭 Staged Mode: ${title} Preview\n\n`; - summaryContent += `${description}\n\n`; - for (let i = 0; i < items.length; i++) { - const item = items[i]; - summaryContent += renderItem(item, i); - summaryContent += "---\n\n"; - } - try { - await core.summary.addRaw(summaryContent).write(); - core.info(summaryContent); - core.info(`📝 ${title} preview written to step summary`); - } catch (error) { - core.setFailed(error instanceof Error ? error : String(error)); - } - } - function generateXMLMarker(workflowName, runUrl) { - const engineId = process.env.GH_AW_ENGINE_ID || ""; - const engineVersion = process.env.GH_AW_ENGINE_VERSION || ""; - const engineModel = process.env.GH_AW_ENGINE_MODEL || ""; - const trackerId = process.env.GH_AW_TRACKER_ID || ""; - const parts = []; - parts.push(`agentic-workflow: ${workflowName}`); - if (trackerId) { - parts.push(`tracker-id: ${trackerId}`); - } - if (engineId) { - parts.push(`engine: ${engineId}`); - } - if (engineVersion) { - parts.push(`version: ${engineVersion}`); - } - if (engineModel) { - parts.push(`model: ${engineModel}`); - } - parts.push(`run: ${runUrl}`); - return ``; - } - function generateFooter( - workflowName, - runUrl, - workflowSource, - workflowSourceURL, - triggeringIssueNumber, - triggeringPRNumber, - triggeringDiscussionNumber - ) { - let footer = `\n\n> AI generated by [${workflowName}](${runUrl})`; - if (triggeringIssueNumber) { - footer += ` for #${triggeringIssueNumber}`; - } else if (triggeringPRNumber) { - footer += ` for #${triggeringPRNumber}`; - } else if (triggeringDiscussionNumber) { - footer += ` for discussion #${triggeringDiscussionNumber}`; - } - if (workflowSource && workflowSourceURL) { - footer += `\n>\n> To add this workflow in your repository, run \`gh aw add ${workflowSource}\`. See [usage guide](https://githubnext.github.io/gh-aw/tools/cli/).`; - } - footer += "\n\n" + generateXMLMarker(workflowName, runUrl); - footer += "\n"; - return footer; - } - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; - function generateTemporaryId() { - return "aw_" + crypto.randomBytes(6).toString("hex"); - } - function isTemporaryId(value) { - if (typeof value === "string") { - return /^aw_[0-9a-f]{12}$/i.test(value); - } - return false; - } - function normalizeTemporaryId(tempId) { - return String(tempId).toLowerCase(); - } - function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); - if (resolved !== undefined) { - if (currentRepo && resolved.repo === currentRepo) { - return `#${resolved.number}`; - } - return `${resolved.repo}#${resolved.number}`; - } - return match; - }); - } - function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); - if (issueNumber !== undefined) { - return `#${issueNumber}`; - } - return match; - }); - } - function loadTemporaryIdMap() { - const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; - if (!mapJson || mapJson === "{}") { - return new Map(); - } - try { - const mapObject = JSON.parse(mapJson); - const result = new Map(); - for (const [key, value] of Object.entries(mapObject)) { - const normalizedKey = normalizeTemporaryId(key); - if (typeof value === "number") { - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - result.set(normalizedKey, { repo: contextRepo, number: value }); - } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { - result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); - } - } - return result; - } catch (error) { - if (typeof core !== "undefined") { - core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); - } - return new Map(); - } - } - function resolveIssueNumber(value, temporaryIdMap) { - if (value === undefined || value === null) { - return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; - } - const valueStr = String(value); - if (isTemporaryId(valueStr)) { - const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); - if (resolvedPair !== undefined) { - return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; - } - return { - resolved: null, - wasTemporaryId: true, - errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, - }; - } - const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); - if (isNaN(issueNumber) || issueNumber <= 0) { - return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; - } - const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; - return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; - } - function serializeTemporaryIdMap(tempIdMap) { - const obj = Object.fromEntries(tempIdMap); - return JSON.stringify(obj); - } - function parseAllowedRepos() { - const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; - const set = new Set(); - if (allowedReposEnv) { - allowedReposEnv - .split(",") - .map(repo => repo.trim()) - .filter(repo => repo) - .forEach(repo => set.add(repo)); - } - return set; - } - function getDefaultTargetRepo() { - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - if (targetRepoSlug) { - return targetRepoSlug; - } - return `${context.repo.owner}/${context.repo.repo}`; - } - function validateRepo(repo, defaultRepo, allowedRepos) { - if (repo === defaultRepo) { - return { valid: true, error: null }; - } - if (allowedRepos.has(repo)) { - return { valid: true, error: null }; - } - return { - valid: false, - error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, - }; - } - function parseRepoSlug(repoSlug) { - const parts = repoSlug.split("/"); - if (parts.length !== 2 || !parts[0] || !parts[1]) { - return null; - } - return { owner: parts[0], repo: parts[1] }; - } - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } + const { sanitizeLabelContent } = require("./sanitize_label_content.cjs"); + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { generateStagedPreview } = require("./staged_preview.cjs"); + const { generateFooter } = require("./generate_footer.cjs"); + const { getTrackerID } = require("./get_tracker_id.cjs"); + const { + generateTemporaryId, + isTemporaryId, + normalizeTemporaryId, + replaceTemporaryIdReferences, + serializeTemporaryIdMap, + } = require("./temporary_id.cjs"); + const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require("./repo_helpers.cjs"); + const { addExpirationComment } = require("./expiration_helpers.cjs"); + const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); async function main() { core.setOutput("issue_number", ""); core.setOutput("issue_url", ""); @@ -6040,6 +5789,7 @@ jobs: .filter((label, index, arr) => arr.indexOf(label) === index); let title = createIssueItem.title ? createIssueItem.title.trim() : ""; let processedBody = replaceTemporaryIdReferences(createIssueItem.body, temporaryIdMap, itemRepo); + processedBody = removeDuplicateTitleFromDescription(title, processedBody); let bodyLines = processedBody.split("\n"); if (!title) { title = createIssueItem.body || "Agent Output"; diff --git a/.github/workflows/daily-news.lock.yml b/.github/workflows/daily-news.lock.yml index 04c9d4e08dd..c91dc6e17a7 100644 --- a/.github/workflows/daily-news.lock.yml +++ b/.github/workflows/daily-news.lock.yml @@ -7715,381 +7715,13 @@ jobs: with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - const fs = require("fs"); - const crypto = require("crypto"); - const MAX_LOG_CONTENT_LENGTH = 10000; - function truncateForLogging(content) { - if (content.length <= MAX_LOG_CONTENT_LENGTH) { - return content; - } - return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; - } - function loadAgentOutput() { - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; - if (!agentOutputFile) { - core.info("No GH_AW_AGENT_OUTPUT environment variable found"); - return { success: false }; - } - let outputContent; - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - return { success: false, error: errorMessage }; - } - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return { success: false }; - } - core.info(`Agent output content length: ${outputContent.length}`); - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); - return { success: false, error: errorMessage }; - } - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); - return { success: false }; - } - return { success: true, items: validatedOutput.items }; - } - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - function getMessages() { - const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES; - if (!messagesEnv) { - return null; - } - try { - return JSON.parse(messagesEnv); - } catch (error) { - core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`); - return null; - } - } - function renderTemplate(template, context) { - return template.replace(/\{(\w+)\}/g, (match, key) => { - const value = context[key]; - return value !== undefined && value !== null ? String(value) : match; - }); - } - function toSnakeCase(obj) { - const result = {}; - for (const [key, value] of Object.entries(obj)) { - const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase(); - result[snakeKey] = value; - result[key] = value; - } - return result; - } - function getCloseOlderDiscussionMessage(ctx) { - const messages = getMessages(); - const templateContext = toSnakeCase(ctx); - const defaultMessage = `⚓ Avast! This discussion be marked as **outdated** by [{workflow_name}]({run_url}). - 🗺️ A newer treasure map awaits ye at **[Discussion #{new_discussion_number}]({new_discussion_url})**. - Fair winds, matey! 🏴‍☠️`; - return messages?.closeOlderDiscussion - ? renderTemplate(messages.closeOlderDiscussion, templateContext) - : renderTemplate(defaultMessage, templateContext); - } - const MAX_CLOSE_COUNT = 10; - const GRAPHQL_DELAY_MS = 500; - function delay(ms) { - return new Promise(resolve => setTimeout(resolve, ms)); - } - async function searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, excludeNumber) { - let searchQuery = `repo:${owner}/${repo} is:open`; - if (titlePrefix) { - const escapedPrefix = titlePrefix.replace(/"/g, '\\"'); - searchQuery += ` in:title "${escapedPrefix}"`; - } - if (labels && labels.length > 0) { - for (const label of labels) { - const escapedLabel = label.replace(/"/g, '\\"'); - searchQuery += ` label:"${escapedLabel}"`; - } - } - const result = await github.graphql( - ` - query($searchTerms: String!, $first: Int!) { - search(query: $searchTerms, type: DISCUSSION, first: $first) { - nodes { - ... on Discussion { - id - number - title - url - category { - id - } - labels(first: 100) { - nodes { - name - } - } - closed - } - } - } - }`, - { searchTerms: searchQuery, first: 50 } - ); - if (!result || !result.search || !result.search.nodes) { - return []; - } - return result.search.nodes - .filter( - d => { - if (!d || d.number === excludeNumber || d.closed) { - return false; - } - if (titlePrefix && d.title && !d.title.startsWith(titlePrefix)) { - return false; - } - if (labels && labels.length > 0) { - const discussionLabels = d.labels?.nodes?.map(( l) => l.name) || []; - const hasAllLabels = labels.every(label => discussionLabels.includes(label)); - if (!hasAllLabels) { - return false; - } - } - if (categoryId && (!d.category || d.category.id !== categoryId)) { - return false; - } - return true; - } - ) - .map( - d => ({ - id: d.id, - number: d.number, - title: d.title, - url: d.url, - }) - ); - } - async function addDiscussionComment(github, discussionId, message) { - const result = await github.graphql( - ` - mutation($dId: ID!, $body: String!) { - addDiscussionComment(input: { discussionId: $dId, body: $body }) { - comment { - id - url - } - } - }`, - { dId: discussionId, body: message } - ); - return result.addDiscussionComment.comment; - } - async function closeDiscussionAsOutdated(github, discussionId) { - const result = await github.graphql( - ` - mutation($dId: ID!) { - closeDiscussion(input: { discussionId: $dId, reason: OUTDATED }) { - discussion { - id - url - } - } - }`, - { dId: discussionId } - ); - return result.closeDiscussion.discussion; - } - async function closeOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion, workflowName, runUrl) { - const searchCriteria = []; - if (titlePrefix) searchCriteria.push(`title prefix: "${titlePrefix}"`); - if (labels && labels.length > 0) searchCriteria.push(`labels: [${labels.join(", ")}]`); - core.info(`Searching for older discussions with ${searchCriteria.join(" and ")}`); - const olderDiscussions = await searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion.number); - if (olderDiscussions.length === 0) { - core.info("No older discussions found to close"); - return []; - } - core.info(`Found ${olderDiscussions.length} older discussion(s) to close`); - const discussionsToClose = olderDiscussions.slice(0, MAX_CLOSE_COUNT); - if (olderDiscussions.length > MAX_CLOSE_COUNT) { - core.warning(`Found ${olderDiscussions.length} older discussions, but only closing the first ${MAX_CLOSE_COUNT}`); - } - const closedDiscussions = []; - for (let i = 0; i < discussionsToClose.length; i++) { - const discussion = discussionsToClose[i]; - try { - const closingMessage = getCloseOlderDiscussionMessage({ - newDiscussionUrl: newDiscussion.url, - newDiscussionNumber: newDiscussion.number, - workflowName, - runUrl, - }); - core.info(`Adding closing comment to discussion #${discussion.number}`); - await addDiscussionComment(github, discussion.id, closingMessage); - core.info(`Closing discussion #${discussion.number} as outdated`); - await closeDiscussionAsOutdated(github, discussion.id); - closedDiscussions.push({ - number: discussion.number, - url: discussion.url, - }); - core.info(`✓ Closed discussion #${discussion.number}: ${discussion.url}`); - } catch (error) { - core.error(`✗ Failed to close discussion #${discussion.number}: ${error instanceof Error ? error.message : String(error)}`); - } - if (i < discussionsToClose.length - 1) { - await delay(GRAPHQL_DELAY_MS); - } - } - return closedDiscussions; - } - const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; - function generateTemporaryId() { - return "aw_" + crypto.randomBytes(6).toString("hex"); - } - function isTemporaryId(value) { - if (typeof value === "string") { - return /^aw_[0-9a-f]{12}$/i.test(value); - } - return false; - } - function normalizeTemporaryId(tempId) { - return String(tempId).toLowerCase(); - } - function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); - if (resolved !== undefined) { - if (currentRepo && resolved.repo === currentRepo) { - return `#${resolved.number}`; - } - return `${resolved.repo}#${resolved.number}`; - } - return match; - }); - } - function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); - if (issueNumber !== undefined) { - return `#${issueNumber}`; - } - return match; - }); - } - function loadTemporaryIdMap() { - const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; - if (!mapJson || mapJson === "{}") { - return new Map(); - } - try { - const mapObject = JSON.parse(mapJson); - const result = new Map(); - for (const [key, value] of Object.entries(mapObject)) { - const normalizedKey = normalizeTemporaryId(key); - if (typeof value === "number") { - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - result.set(normalizedKey, { repo: contextRepo, number: value }); - } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { - result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); - } - } - return result; - } catch (error) { - if (typeof core !== "undefined") { - core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); - } - return new Map(); - } - } - function resolveIssueNumber(value, temporaryIdMap) { - if (value === undefined || value === null) { - return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; - } - const valueStr = String(value); - if (isTemporaryId(valueStr)) { - const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); - if (resolvedPair !== undefined) { - return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; - } - return { - resolved: null, - wasTemporaryId: true, - errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, - }; - } - const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); - if (isNaN(issueNumber) || issueNumber <= 0) { - return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; - } - const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; - return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; - } - function serializeTemporaryIdMap(tempIdMap) { - const obj = Object.fromEntries(tempIdMap); - return JSON.stringify(obj); - } - function parseAllowedRepos() { - const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; - const set = new Set(); - if (allowedReposEnv) { - allowedReposEnv - .split(",") - .map(repo => repo.trim()) - .filter(repo => repo) - .forEach(repo => set.add(repo)); - } - return set; - } - function getDefaultTargetRepo() { - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - if (targetRepoSlug) { - return targetRepoSlug; - } - return `${context.repo.owner}/${context.repo.repo}`; - } - function validateRepo(repo, defaultRepo, allowedRepos) { - if (repo === defaultRepo) { - return { valid: true, error: null }; - } - if (allowedRepos.has(repo)) { - return { valid: true, error: null }; - } - return { - valid: false, - error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, - }; - } - function parseRepoSlug(repoSlug) { - const parts = repoSlug.split("/"); - if (parts.length !== 2 || !parts[0] || !parts[1]) { - return null; - } - return { owner: parts[0], repo: parts[1] }; - } - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { getTrackerID } = require("./get_tracker_id.cjs"); + const { closeOlderDiscussions } = require("./close_older_discussions.cjs"); + const { replaceTemporaryIdReferences, loadTemporaryIdMap } = require("./temporary_id.cjs"); + const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require("./repo_helpers.cjs"); + const { addExpirationComment } = require("./expiration_helpers.cjs"); + const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); async function fetchRepoDiscussionInfo(owner, repo) { const repositoryQuery = ` query($owner: String!, $repo: String!) { @@ -8274,7 +7906,9 @@ jobs: ); let title = createDiscussionItem.title ? replaceTemporaryIdReferences(createDiscussionItem.title.trim(), temporaryIdMap, itemRepo) : ""; const bodyText = createDiscussionItem.body || ""; - let bodyLines = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo).split("\n"); + let processedBody = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo); + processedBody = removeDuplicateTitleFromDescription(title, processedBody); + let bodyLines = processedBody.split("\n"); if (!title) { title = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo) || "Agent Output"; } diff --git a/.github/workflows/daily-performance-summary.lock.yml b/.github/workflows/daily-performance-summary.lock.yml index 9a8ea74c52c..43537f4a2ff 100644 --- a/.github/workflows/daily-performance-summary.lock.yml +++ b/.github/workflows/daily-performance-summary.lock.yml @@ -9201,381 +9201,13 @@ jobs: with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - const fs = require("fs"); - const crypto = require("crypto"); - const MAX_LOG_CONTENT_LENGTH = 10000; - function truncateForLogging(content) { - if (content.length <= MAX_LOG_CONTENT_LENGTH) { - return content; - } - return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; - } - function loadAgentOutput() { - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; - if (!agentOutputFile) { - core.info("No GH_AW_AGENT_OUTPUT environment variable found"); - return { success: false }; - } - let outputContent; - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - return { success: false, error: errorMessage }; - } - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return { success: false }; - } - core.info(`Agent output content length: ${outputContent.length}`); - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); - return { success: false, error: errorMessage }; - } - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); - return { success: false }; - } - return { success: true, items: validatedOutput.items }; - } - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - function getMessages() { - const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES; - if (!messagesEnv) { - return null; - } - try { - return JSON.parse(messagesEnv); - } catch (error) { - core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`); - return null; - } - } - function renderTemplate(template, context) { - return template.replace(/\{(\w+)\}/g, (match, key) => { - const value = context[key]; - return value !== undefined && value !== null ? String(value) : match; - }); - } - function toSnakeCase(obj) { - const result = {}; - for (const [key, value] of Object.entries(obj)) { - const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase(); - result[snakeKey] = value; - result[key] = value; - } - return result; - } - function getCloseOlderDiscussionMessage(ctx) { - const messages = getMessages(); - const templateContext = toSnakeCase(ctx); - const defaultMessage = `⚓ Avast! This discussion be marked as **outdated** by [{workflow_name}]({run_url}). - 🗺️ A newer treasure map awaits ye at **[Discussion #{new_discussion_number}]({new_discussion_url})**. - Fair winds, matey! 🏴‍☠️`; - return messages?.closeOlderDiscussion - ? renderTemplate(messages.closeOlderDiscussion, templateContext) - : renderTemplate(defaultMessage, templateContext); - } - const MAX_CLOSE_COUNT = 10; - const GRAPHQL_DELAY_MS = 500; - function delay(ms) { - return new Promise(resolve => setTimeout(resolve, ms)); - } - async function searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, excludeNumber) { - let searchQuery = `repo:${owner}/${repo} is:open`; - if (titlePrefix) { - const escapedPrefix = titlePrefix.replace(/"/g, '\\"'); - searchQuery += ` in:title "${escapedPrefix}"`; - } - if (labels && labels.length > 0) { - for (const label of labels) { - const escapedLabel = label.replace(/"/g, '\\"'); - searchQuery += ` label:"${escapedLabel}"`; - } - } - const result = await github.graphql( - ` - query($searchTerms: String!, $first: Int!) { - search(query: $searchTerms, type: DISCUSSION, first: $first) { - nodes { - ... on Discussion { - id - number - title - url - category { - id - } - labels(first: 100) { - nodes { - name - } - } - closed - } - } - } - }`, - { searchTerms: searchQuery, first: 50 } - ); - if (!result || !result.search || !result.search.nodes) { - return []; - } - return result.search.nodes - .filter( - d => { - if (!d || d.number === excludeNumber || d.closed) { - return false; - } - if (titlePrefix && d.title && !d.title.startsWith(titlePrefix)) { - return false; - } - if (labels && labels.length > 0) { - const discussionLabels = d.labels?.nodes?.map(( l) => l.name) || []; - const hasAllLabels = labels.every(label => discussionLabels.includes(label)); - if (!hasAllLabels) { - return false; - } - } - if (categoryId && (!d.category || d.category.id !== categoryId)) { - return false; - } - return true; - } - ) - .map( - d => ({ - id: d.id, - number: d.number, - title: d.title, - url: d.url, - }) - ); - } - async function addDiscussionComment(github, discussionId, message) { - const result = await github.graphql( - ` - mutation($dId: ID!, $body: String!) { - addDiscussionComment(input: { discussionId: $dId, body: $body }) { - comment { - id - url - } - } - }`, - { dId: discussionId, body: message } - ); - return result.addDiscussionComment.comment; - } - async function closeDiscussionAsOutdated(github, discussionId) { - const result = await github.graphql( - ` - mutation($dId: ID!) { - closeDiscussion(input: { discussionId: $dId, reason: OUTDATED }) { - discussion { - id - url - } - } - }`, - { dId: discussionId } - ); - return result.closeDiscussion.discussion; - } - async function closeOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion, workflowName, runUrl) { - const searchCriteria = []; - if (titlePrefix) searchCriteria.push(`title prefix: "${titlePrefix}"`); - if (labels && labels.length > 0) searchCriteria.push(`labels: [${labels.join(", ")}]`); - core.info(`Searching for older discussions with ${searchCriteria.join(" and ")}`); - const olderDiscussions = await searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion.number); - if (olderDiscussions.length === 0) { - core.info("No older discussions found to close"); - return []; - } - core.info(`Found ${olderDiscussions.length} older discussion(s) to close`); - const discussionsToClose = olderDiscussions.slice(0, MAX_CLOSE_COUNT); - if (olderDiscussions.length > MAX_CLOSE_COUNT) { - core.warning(`Found ${olderDiscussions.length} older discussions, but only closing the first ${MAX_CLOSE_COUNT}`); - } - const closedDiscussions = []; - for (let i = 0; i < discussionsToClose.length; i++) { - const discussion = discussionsToClose[i]; - try { - const closingMessage = getCloseOlderDiscussionMessage({ - newDiscussionUrl: newDiscussion.url, - newDiscussionNumber: newDiscussion.number, - workflowName, - runUrl, - }); - core.info(`Adding closing comment to discussion #${discussion.number}`); - await addDiscussionComment(github, discussion.id, closingMessage); - core.info(`Closing discussion #${discussion.number} as outdated`); - await closeDiscussionAsOutdated(github, discussion.id); - closedDiscussions.push({ - number: discussion.number, - url: discussion.url, - }); - core.info(`✓ Closed discussion #${discussion.number}: ${discussion.url}`); - } catch (error) { - core.error(`✗ Failed to close discussion #${discussion.number}: ${error instanceof Error ? error.message : String(error)}`); - } - if (i < discussionsToClose.length - 1) { - await delay(GRAPHQL_DELAY_MS); - } - } - return closedDiscussions; - } - const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; - function generateTemporaryId() { - return "aw_" + crypto.randomBytes(6).toString("hex"); - } - function isTemporaryId(value) { - if (typeof value === "string") { - return /^aw_[0-9a-f]{12}$/i.test(value); - } - return false; - } - function normalizeTemporaryId(tempId) { - return String(tempId).toLowerCase(); - } - function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); - if (resolved !== undefined) { - if (currentRepo && resolved.repo === currentRepo) { - return `#${resolved.number}`; - } - return `${resolved.repo}#${resolved.number}`; - } - return match; - }); - } - function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); - if (issueNumber !== undefined) { - return `#${issueNumber}`; - } - return match; - }); - } - function loadTemporaryIdMap() { - const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; - if (!mapJson || mapJson === "{}") { - return new Map(); - } - try { - const mapObject = JSON.parse(mapJson); - const result = new Map(); - for (const [key, value] of Object.entries(mapObject)) { - const normalizedKey = normalizeTemporaryId(key); - if (typeof value === "number") { - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - result.set(normalizedKey, { repo: contextRepo, number: value }); - } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { - result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); - } - } - return result; - } catch (error) { - if (typeof core !== "undefined") { - core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); - } - return new Map(); - } - } - function resolveIssueNumber(value, temporaryIdMap) { - if (value === undefined || value === null) { - return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; - } - const valueStr = String(value); - if (isTemporaryId(valueStr)) { - const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); - if (resolvedPair !== undefined) { - return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; - } - return { - resolved: null, - wasTemporaryId: true, - errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, - }; - } - const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); - if (isNaN(issueNumber) || issueNumber <= 0) { - return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; - } - const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; - return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; - } - function serializeTemporaryIdMap(tempIdMap) { - const obj = Object.fromEntries(tempIdMap); - return JSON.stringify(obj); - } - function parseAllowedRepos() { - const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; - const set = new Set(); - if (allowedReposEnv) { - allowedReposEnv - .split(",") - .map(repo => repo.trim()) - .filter(repo => repo) - .forEach(repo => set.add(repo)); - } - return set; - } - function getDefaultTargetRepo() { - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - if (targetRepoSlug) { - return targetRepoSlug; - } - return `${context.repo.owner}/${context.repo.repo}`; - } - function validateRepo(repo, defaultRepo, allowedRepos) { - if (repo === defaultRepo) { - return { valid: true, error: null }; - } - if (allowedRepos.has(repo)) { - return { valid: true, error: null }; - } - return { - valid: false, - error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, - }; - } - function parseRepoSlug(repoSlug) { - const parts = repoSlug.split("/"); - if (parts.length !== 2 || !parts[0] || !parts[1]) { - return null; - } - return { owner: parts[0], repo: parts[1] }; - } - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { getTrackerID } = require("./get_tracker_id.cjs"); + const { closeOlderDiscussions } = require("./close_older_discussions.cjs"); + const { replaceTemporaryIdReferences, loadTemporaryIdMap } = require("./temporary_id.cjs"); + const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require("./repo_helpers.cjs"); + const { addExpirationComment } = require("./expiration_helpers.cjs"); + const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); async function fetchRepoDiscussionInfo(owner, repo) { const repositoryQuery = ` query($owner: String!, $repo: String!) { @@ -9760,7 +9392,9 @@ jobs: ); let title = createDiscussionItem.title ? replaceTemporaryIdReferences(createDiscussionItem.title.trim(), temporaryIdMap, itemRepo) : ""; const bodyText = createDiscussionItem.body || ""; - let bodyLines = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo).split("\n"); + let processedBody = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo); + processedBody = removeDuplicateTitleFromDescription(title, processedBody); + let bodyLines = processedBody.split("\n"); if (!title) { title = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo) || "Agent Output"; } diff --git a/.github/workflows/daily-repo-chronicle.lock.yml b/.github/workflows/daily-repo-chronicle.lock.yml index 7f82d9cadab..ff26e657ef2 100644 --- a/.github/workflows/daily-repo-chronicle.lock.yml +++ b/.github/workflows/daily-repo-chronicle.lock.yml @@ -7389,381 +7389,13 @@ jobs: with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - const fs = require("fs"); - const crypto = require("crypto"); - const MAX_LOG_CONTENT_LENGTH = 10000; - function truncateForLogging(content) { - if (content.length <= MAX_LOG_CONTENT_LENGTH) { - return content; - } - return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; - } - function loadAgentOutput() { - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; - if (!agentOutputFile) { - core.info("No GH_AW_AGENT_OUTPUT environment variable found"); - return { success: false }; - } - let outputContent; - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - return { success: false, error: errorMessage }; - } - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return { success: false }; - } - core.info(`Agent output content length: ${outputContent.length}`); - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); - return { success: false, error: errorMessage }; - } - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); - return { success: false }; - } - return { success: true, items: validatedOutput.items }; - } - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - function getMessages() { - const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES; - if (!messagesEnv) { - return null; - } - try { - return JSON.parse(messagesEnv); - } catch (error) { - core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`); - return null; - } - } - function renderTemplate(template, context) { - return template.replace(/\{(\w+)\}/g, (match, key) => { - const value = context[key]; - return value !== undefined && value !== null ? String(value) : match; - }); - } - function toSnakeCase(obj) { - const result = {}; - for (const [key, value] of Object.entries(obj)) { - const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase(); - result[snakeKey] = value; - result[key] = value; - } - return result; - } - function getCloseOlderDiscussionMessage(ctx) { - const messages = getMessages(); - const templateContext = toSnakeCase(ctx); - const defaultMessage = `⚓ Avast! This discussion be marked as **outdated** by [{workflow_name}]({run_url}). - 🗺️ A newer treasure map awaits ye at **[Discussion #{new_discussion_number}]({new_discussion_url})**. - Fair winds, matey! 🏴‍☠️`; - return messages?.closeOlderDiscussion - ? renderTemplate(messages.closeOlderDiscussion, templateContext) - : renderTemplate(defaultMessage, templateContext); - } - const MAX_CLOSE_COUNT = 10; - const GRAPHQL_DELAY_MS = 500; - function delay(ms) { - return new Promise(resolve => setTimeout(resolve, ms)); - } - async function searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, excludeNumber) { - let searchQuery = `repo:${owner}/${repo} is:open`; - if (titlePrefix) { - const escapedPrefix = titlePrefix.replace(/"/g, '\\"'); - searchQuery += ` in:title "${escapedPrefix}"`; - } - if (labels && labels.length > 0) { - for (const label of labels) { - const escapedLabel = label.replace(/"/g, '\\"'); - searchQuery += ` label:"${escapedLabel}"`; - } - } - const result = await github.graphql( - ` - query($searchTerms: String!, $first: Int!) { - search(query: $searchTerms, type: DISCUSSION, first: $first) { - nodes { - ... on Discussion { - id - number - title - url - category { - id - } - labels(first: 100) { - nodes { - name - } - } - closed - } - } - } - }`, - { searchTerms: searchQuery, first: 50 } - ); - if (!result || !result.search || !result.search.nodes) { - return []; - } - return result.search.nodes - .filter( - d => { - if (!d || d.number === excludeNumber || d.closed) { - return false; - } - if (titlePrefix && d.title && !d.title.startsWith(titlePrefix)) { - return false; - } - if (labels && labels.length > 0) { - const discussionLabels = d.labels?.nodes?.map(( l) => l.name) || []; - const hasAllLabels = labels.every(label => discussionLabels.includes(label)); - if (!hasAllLabels) { - return false; - } - } - if (categoryId && (!d.category || d.category.id !== categoryId)) { - return false; - } - return true; - } - ) - .map( - d => ({ - id: d.id, - number: d.number, - title: d.title, - url: d.url, - }) - ); - } - async function addDiscussionComment(github, discussionId, message) { - const result = await github.graphql( - ` - mutation($dId: ID!, $body: String!) { - addDiscussionComment(input: { discussionId: $dId, body: $body }) { - comment { - id - url - } - } - }`, - { dId: discussionId, body: message } - ); - return result.addDiscussionComment.comment; - } - async function closeDiscussionAsOutdated(github, discussionId) { - const result = await github.graphql( - ` - mutation($dId: ID!) { - closeDiscussion(input: { discussionId: $dId, reason: OUTDATED }) { - discussion { - id - url - } - } - }`, - { dId: discussionId } - ); - return result.closeDiscussion.discussion; - } - async function closeOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion, workflowName, runUrl) { - const searchCriteria = []; - if (titlePrefix) searchCriteria.push(`title prefix: "${titlePrefix}"`); - if (labels && labels.length > 0) searchCriteria.push(`labels: [${labels.join(", ")}]`); - core.info(`Searching for older discussions with ${searchCriteria.join(" and ")}`); - const olderDiscussions = await searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion.number); - if (olderDiscussions.length === 0) { - core.info("No older discussions found to close"); - return []; - } - core.info(`Found ${olderDiscussions.length} older discussion(s) to close`); - const discussionsToClose = olderDiscussions.slice(0, MAX_CLOSE_COUNT); - if (olderDiscussions.length > MAX_CLOSE_COUNT) { - core.warning(`Found ${olderDiscussions.length} older discussions, but only closing the first ${MAX_CLOSE_COUNT}`); - } - const closedDiscussions = []; - for (let i = 0; i < discussionsToClose.length; i++) { - const discussion = discussionsToClose[i]; - try { - const closingMessage = getCloseOlderDiscussionMessage({ - newDiscussionUrl: newDiscussion.url, - newDiscussionNumber: newDiscussion.number, - workflowName, - runUrl, - }); - core.info(`Adding closing comment to discussion #${discussion.number}`); - await addDiscussionComment(github, discussion.id, closingMessage); - core.info(`Closing discussion #${discussion.number} as outdated`); - await closeDiscussionAsOutdated(github, discussion.id); - closedDiscussions.push({ - number: discussion.number, - url: discussion.url, - }); - core.info(`✓ Closed discussion #${discussion.number}: ${discussion.url}`); - } catch (error) { - core.error(`✗ Failed to close discussion #${discussion.number}: ${error instanceof Error ? error.message : String(error)}`); - } - if (i < discussionsToClose.length - 1) { - await delay(GRAPHQL_DELAY_MS); - } - } - return closedDiscussions; - } - const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; - function generateTemporaryId() { - return "aw_" + crypto.randomBytes(6).toString("hex"); - } - function isTemporaryId(value) { - if (typeof value === "string") { - return /^aw_[0-9a-f]{12}$/i.test(value); - } - return false; - } - function normalizeTemporaryId(tempId) { - return String(tempId).toLowerCase(); - } - function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); - if (resolved !== undefined) { - if (currentRepo && resolved.repo === currentRepo) { - return `#${resolved.number}`; - } - return `${resolved.repo}#${resolved.number}`; - } - return match; - }); - } - function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); - if (issueNumber !== undefined) { - return `#${issueNumber}`; - } - return match; - }); - } - function loadTemporaryIdMap() { - const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; - if (!mapJson || mapJson === "{}") { - return new Map(); - } - try { - const mapObject = JSON.parse(mapJson); - const result = new Map(); - for (const [key, value] of Object.entries(mapObject)) { - const normalizedKey = normalizeTemporaryId(key); - if (typeof value === "number") { - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - result.set(normalizedKey, { repo: contextRepo, number: value }); - } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { - result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); - } - } - return result; - } catch (error) { - if (typeof core !== "undefined") { - core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); - } - return new Map(); - } - } - function resolveIssueNumber(value, temporaryIdMap) { - if (value === undefined || value === null) { - return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; - } - const valueStr = String(value); - if (isTemporaryId(valueStr)) { - const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); - if (resolvedPair !== undefined) { - return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; - } - return { - resolved: null, - wasTemporaryId: true, - errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, - }; - } - const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); - if (isNaN(issueNumber) || issueNumber <= 0) { - return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; - } - const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; - return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; - } - function serializeTemporaryIdMap(tempIdMap) { - const obj = Object.fromEntries(tempIdMap); - return JSON.stringify(obj); - } - function parseAllowedRepos() { - const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; - const set = new Set(); - if (allowedReposEnv) { - allowedReposEnv - .split(",") - .map(repo => repo.trim()) - .filter(repo => repo) - .forEach(repo => set.add(repo)); - } - return set; - } - function getDefaultTargetRepo() { - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - if (targetRepoSlug) { - return targetRepoSlug; - } - return `${context.repo.owner}/${context.repo.repo}`; - } - function validateRepo(repo, defaultRepo, allowedRepos) { - if (repo === defaultRepo) { - return { valid: true, error: null }; - } - if (allowedRepos.has(repo)) { - return { valid: true, error: null }; - } - return { - valid: false, - error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, - }; - } - function parseRepoSlug(repoSlug) { - const parts = repoSlug.split("/"); - if (parts.length !== 2 || !parts[0] || !parts[1]) { - return null; - } - return { owner: parts[0], repo: parts[1] }; - } - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { getTrackerID } = require("./get_tracker_id.cjs"); + const { closeOlderDiscussions } = require("./close_older_discussions.cjs"); + const { replaceTemporaryIdReferences, loadTemporaryIdMap } = require("./temporary_id.cjs"); + const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require("./repo_helpers.cjs"); + const { addExpirationComment } = require("./expiration_helpers.cjs"); + const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); async function fetchRepoDiscussionInfo(owner, repo) { const repositoryQuery = ` query($owner: String!, $repo: String!) { @@ -7948,7 +7580,9 @@ jobs: ); let title = createDiscussionItem.title ? replaceTemporaryIdReferences(createDiscussionItem.title.trim(), temporaryIdMap, itemRepo) : ""; const bodyText = createDiscussionItem.body || ""; - let bodyLines = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo).split("\n"); + let processedBody = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo); + processedBody = removeDuplicateTitleFromDescription(title, processedBody); + let bodyLines = processedBody.split("\n"); if (!title) { title = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo) || "Agent Output"; } diff --git a/.github/workflows/daily-team-status.lock.yml b/.github/workflows/daily-team-status.lock.yml index 99dfd0da733..d2fcfe062ab 100644 --- a/.github/workflows/daily-team-status.lock.yml +++ b/.github/workflows/daily-team-status.lock.yml @@ -6005,381 +6005,13 @@ jobs: with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - const fs = require("fs"); - const crypto = require("crypto"); - const MAX_LOG_CONTENT_LENGTH = 10000; - function truncateForLogging(content) { - if (content.length <= MAX_LOG_CONTENT_LENGTH) { - return content; - } - return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; - } - function loadAgentOutput() { - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; - if (!agentOutputFile) { - core.info("No GH_AW_AGENT_OUTPUT environment variable found"); - return { success: false }; - } - let outputContent; - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - return { success: false, error: errorMessage }; - } - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return { success: false }; - } - core.info(`Agent output content length: ${outputContent.length}`); - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); - return { success: false, error: errorMessage }; - } - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); - return { success: false }; - } - return { success: true, items: validatedOutput.items }; - } - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - function getMessages() { - const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES; - if (!messagesEnv) { - return null; - } - try { - return JSON.parse(messagesEnv); - } catch (error) { - core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`); - return null; - } - } - function renderTemplate(template, context) { - return template.replace(/\{(\w+)\}/g, (match, key) => { - const value = context[key]; - return value !== undefined && value !== null ? String(value) : match; - }); - } - function toSnakeCase(obj) { - const result = {}; - for (const [key, value] of Object.entries(obj)) { - const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase(); - result[snakeKey] = value; - result[key] = value; - } - return result; - } - function getCloseOlderDiscussionMessage(ctx) { - const messages = getMessages(); - const templateContext = toSnakeCase(ctx); - const defaultMessage = `⚓ Avast! This discussion be marked as **outdated** by [{workflow_name}]({run_url}). - 🗺️ A newer treasure map awaits ye at **[Discussion #{new_discussion_number}]({new_discussion_url})**. - Fair winds, matey! 🏴‍☠️`; - return messages?.closeOlderDiscussion - ? renderTemplate(messages.closeOlderDiscussion, templateContext) - : renderTemplate(defaultMessage, templateContext); - } - const MAX_CLOSE_COUNT = 10; - const GRAPHQL_DELAY_MS = 500; - function delay(ms) { - return new Promise(resolve => setTimeout(resolve, ms)); - } - async function searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, excludeNumber) { - let searchQuery = `repo:${owner}/${repo} is:open`; - if (titlePrefix) { - const escapedPrefix = titlePrefix.replace(/"/g, '\\"'); - searchQuery += ` in:title "${escapedPrefix}"`; - } - if (labels && labels.length > 0) { - for (const label of labels) { - const escapedLabel = label.replace(/"/g, '\\"'); - searchQuery += ` label:"${escapedLabel}"`; - } - } - const result = await github.graphql( - ` - query($searchTerms: String!, $first: Int!) { - search(query: $searchTerms, type: DISCUSSION, first: $first) { - nodes { - ... on Discussion { - id - number - title - url - category { - id - } - labels(first: 100) { - nodes { - name - } - } - closed - } - } - } - }`, - { searchTerms: searchQuery, first: 50 } - ); - if (!result || !result.search || !result.search.nodes) { - return []; - } - return result.search.nodes - .filter( - d => { - if (!d || d.number === excludeNumber || d.closed) { - return false; - } - if (titlePrefix && d.title && !d.title.startsWith(titlePrefix)) { - return false; - } - if (labels && labels.length > 0) { - const discussionLabels = d.labels?.nodes?.map(( l) => l.name) || []; - const hasAllLabels = labels.every(label => discussionLabels.includes(label)); - if (!hasAllLabels) { - return false; - } - } - if (categoryId && (!d.category || d.category.id !== categoryId)) { - return false; - } - return true; - } - ) - .map( - d => ({ - id: d.id, - number: d.number, - title: d.title, - url: d.url, - }) - ); - } - async function addDiscussionComment(github, discussionId, message) { - const result = await github.graphql( - ` - mutation($dId: ID!, $body: String!) { - addDiscussionComment(input: { discussionId: $dId, body: $body }) { - comment { - id - url - } - } - }`, - { dId: discussionId, body: message } - ); - return result.addDiscussionComment.comment; - } - async function closeDiscussionAsOutdated(github, discussionId) { - const result = await github.graphql( - ` - mutation($dId: ID!) { - closeDiscussion(input: { discussionId: $dId, reason: OUTDATED }) { - discussion { - id - url - } - } - }`, - { dId: discussionId } - ); - return result.closeDiscussion.discussion; - } - async function closeOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion, workflowName, runUrl) { - const searchCriteria = []; - if (titlePrefix) searchCriteria.push(`title prefix: "${titlePrefix}"`); - if (labels && labels.length > 0) searchCriteria.push(`labels: [${labels.join(", ")}]`); - core.info(`Searching for older discussions with ${searchCriteria.join(" and ")}`); - const olderDiscussions = await searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion.number); - if (olderDiscussions.length === 0) { - core.info("No older discussions found to close"); - return []; - } - core.info(`Found ${olderDiscussions.length} older discussion(s) to close`); - const discussionsToClose = olderDiscussions.slice(0, MAX_CLOSE_COUNT); - if (olderDiscussions.length > MAX_CLOSE_COUNT) { - core.warning(`Found ${olderDiscussions.length} older discussions, but only closing the first ${MAX_CLOSE_COUNT}`); - } - const closedDiscussions = []; - for (let i = 0; i < discussionsToClose.length; i++) { - const discussion = discussionsToClose[i]; - try { - const closingMessage = getCloseOlderDiscussionMessage({ - newDiscussionUrl: newDiscussion.url, - newDiscussionNumber: newDiscussion.number, - workflowName, - runUrl, - }); - core.info(`Adding closing comment to discussion #${discussion.number}`); - await addDiscussionComment(github, discussion.id, closingMessage); - core.info(`Closing discussion #${discussion.number} as outdated`); - await closeDiscussionAsOutdated(github, discussion.id); - closedDiscussions.push({ - number: discussion.number, - url: discussion.url, - }); - core.info(`✓ Closed discussion #${discussion.number}: ${discussion.url}`); - } catch (error) { - core.error(`✗ Failed to close discussion #${discussion.number}: ${error instanceof Error ? error.message : String(error)}`); - } - if (i < discussionsToClose.length - 1) { - await delay(GRAPHQL_DELAY_MS); - } - } - return closedDiscussions; - } - const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; - function generateTemporaryId() { - return "aw_" + crypto.randomBytes(6).toString("hex"); - } - function isTemporaryId(value) { - if (typeof value === "string") { - return /^aw_[0-9a-f]{12}$/i.test(value); - } - return false; - } - function normalizeTemporaryId(tempId) { - return String(tempId).toLowerCase(); - } - function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); - if (resolved !== undefined) { - if (currentRepo && resolved.repo === currentRepo) { - return `#${resolved.number}`; - } - return `${resolved.repo}#${resolved.number}`; - } - return match; - }); - } - function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); - if (issueNumber !== undefined) { - return `#${issueNumber}`; - } - return match; - }); - } - function loadTemporaryIdMap() { - const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; - if (!mapJson || mapJson === "{}") { - return new Map(); - } - try { - const mapObject = JSON.parse(mapJson); - const result = new Map(); - for (const [key, value] of Object.entries(mapObject)) { - const normalizedKey = normalizeTemporaryId(key); - if (typeof value === "number") { - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - result.set(normalizedKey, { repo: contextRepo, number: value }); - } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { - result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); - } - } - return result; - } catch (error) { - if (typeof core !== "undefined") { - core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); - } - return new Map(); - } - } - function resolveIssueNumber(value, temporaryIdMap) { - if (value === undefined || value === null) { - return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; - } - const valueStr = String(value); - if (isTemporaryId(valueStr)) { - const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); - if (resolvedPair !== undefined) { - return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; - } - return { - resolved: null, - wasTemporaryId: true, - errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, - }; - } - const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); - if (isNaN(issueNumber) || issueNumber <= 0) { - return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; - } - const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; - return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; - } - function serializeTemporaryIdMap(tempIdMap) { - const obj = Object.fromEntries(tempIdMap); - return JSON.stringify(obj); - } - function parseAllowedRepos() { - const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; - const set = new Set(); - if (allowedReposEnv) { - allowedReposEnv - .split(",") - .map(repo => repo.trim()) - .filter(repo => repo) - .forEach(repo => set.add(repo)); - } - return set; - } - function getDefaultTargetRepo() { - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - if (targetRepoSlug) { - return targetRepoSlug; - } - return `${context.repo.owner}/${context.repo.repo}`; - } - function validateRepo(repo, defaultRepo, allowedRepos) { - if (repo === defaultRepo) { - return { valid: true, error: null }; - } - if (allowedRepos.has(repo)) { - return { valid: true, error: null }; - } - return { - valid: false, - error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, - }; - } - function parseRepoSlug(repoSlug) { - const parts = repoSlug.split("/"); - if (parts.length !== 2 || !parts[0] || !parts[1]) { - return null; - } - return { owner: parts[0], repo: parts[1] }; - } - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { getTrackerID } = require("./get_tracker_id.cjs"); + const { closeOlderDiscussions } = require("./close_older_discussions.cjs"); + const { replaceTemporaryIdReferences, loadTemporaryIdMap } = require("./temporary_id.cjs"); + const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require("./repo_helpers.cjs"); + const { addExpirationComment } = require("./expiration_helpers.cjs"); + const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); async function fetchRepoDiscussionInfo(owner, repo) { const repositoryQuery = ` query($owner: String!, $repo: String!) { @@ -6564,7 +6196,9 @@ jobs: ); let title = createDiscussionItem.title ? replaceTemporaryIdReferences(createDiscussionItem.title.trim(), temporaryIdMap, itemRepo) : ""; const bodyText = createDiscussionItem.body || ""; - let bodyLines = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo).split("\n"); + let processedBody = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo); + processedBody = removeDuplicateTitleFromDescription(title, processedBody); + let bodyLines = processedBody.split("\n"); if (!title) { title = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo) || "Agent Output"; } diff --git a/.github/workflows/daily-workflow-updater.lock.yml b/.github/workflows/daily-workflow-updater.lock.yml index 1779f386a68..aab8b94fbdf 100644 --- a/.github/workflows/daily-workflow-updater.lock.yml +++ b/.github/workflows/daily-workflow-updater.lock.yml @@ -6204,132 +6204,10 @@ jobs: script: | const fs = require("fs"); const crypto = require("crypto"); - async function updateActivationComment(github, context, core, itemUrl, itemNumber, itemType = "pull_request") { - const itemLabel = itemType === "issue" ? "issue" : "pull request"; - const linkMessage = - itemType === "issue" - ? `\n\n✅ Issue created: [#${itemNumber}](${itemUrl})` - : `\n\n✅ Pull request created: [#${itemNumber}](${itemUrl})`; - await updateActivationCommentWithMessage(github, context, core, linkMessage, itemLabel); - } - async function updateActivationCommentWithCommit(github, context, core, commitSha, commitUrl) { - const shortSha = commitSha.substring(0, 7); - const message = `\n\n✅ Commit pushed: [\`${shortSha}\`](${commitUrl})`; - await updateActivationCommentWithMessage(github, context, core, message, "commit"); - } - async function updateActivationCommentWithMessage(github, context, core, message, label = "") { - const commentId = process.env.GH_AW_COMMENT_ID; - const commentRepo = process.env.GH_AW_COMMENT_REPO; - if (!commentId) { - core.info("No activation comment to update (GH_AW_COMMENT_ID not set)"); - return; - } - core.info(`Updating activation comment ${commentId}`); - let repoOwner = context.repo.owner; - let repoName = context.repo.repo; - if (commentRepo) { - const parts = commentRepo.split("/"); - if (parts.length === 2) { - repoOwner = parts[0]; - repoName = parts[1]; - } else { - core.warning(`Invalid comment repo format: ${commentRepo}, expected "owner/repo". Falling back to context.repo.`); - } - } - core.info(`Updating comment in ${repoOwner}/${repoName}`); - const isDiscussionComment = commentId.startsWith("DC_"); - try { - if (isDiscussionComment) { - const currentComment = await github.graphql( - ` - query($commentId: ID!) { - node(id: $commentId) { - ... on DiscussionComment { - body - } - } - }`, - { commentId: commentId } - ); - if (!currentComment?.node?.body) { - core.warning("Unable to fetch current comment body, comment may have been deleted or is inaccessible"); - return; - } - const currentBody = currentComment.node.body; - const updatedBody = currentBody + message; - const result = await github.graphql( - ` - mutation($commentId: ID!, $body: String!) { - updateDiscussionComment(input: { commentId: $commentId, body: $body }) { - comment { - id - url - } - } - }`, - { commentId: commentId, body: updatedBody } - ); - const comment = result.updateDiscussionComment.comment; - const successMessage = label - ? `Successfully updated discussion comment with ${label} link` - : "Successfully updated discussion comment"; - core.info(successMessage); - core.info(`Comment ID: ${comment.id}`); - core.info(`Comment URL: ${comment.url}`); - } else { - const currentComment = await github.request("GET /repos/{owner}/{repo}/issues/comments/{comment_id}", { - owner: repoOwner, - repo: repoName, - comment_id: parseInt(commentId, 10), - headers: { - Accept: "application/vnd.github+json", - }, - }); - if (!currentComment?.data?.body) { - core.warning("Unable to fetch current comment body, comment may have been deleted"); - return; - } - const currentBody = currentComment.data.body; - const updatedBody = currentBody + message; - const response = await github.request("PATCH /repos/{owner}/{repo}/issues/comments/{comment_id}", { - owner: repoOwner, - repo: repoName, - comment_id: parseInt(commentId, 10), - body: updatedBody, - headers: { - Accept: "application/vnd.github+json", - }, - }); - const successMessage = label ? `Successfully updated comment with ${label} link` : "Successfully updated comment"; - core.info(successMessage); - core.info(`Comment ID: ${response.data.id}`); - core.info(`Comment URL: ${response.data.html_url}`); - } - } catch (error) { - core.warning(`Failed to update activation comment: ${error instanceof Error ? error.message : String(error)}`); - } - } - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } + const { updateActivationComment } = require("./update_activation_comment.cjs"); + const { getTrackerID } = require("./get_tracker_id.cjs"); + const { addExpirationComment } = require("./expiration_helpers.cjs"); + const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); function generatePatchPreview(patchContent) { if (!patchContent || !patchContent.trim()) { return ""; @@ -6521,7 +6399,9 @@ jobs: return; } let title = pullRequestItem.title.trim(); - let bodyLines = pullRequestItem.body.split("\n"); + let processedBody = pullRequestItem.body; + processedBody = removeDuplicateTitleFromDescription(title, processedBody); + let bodyLines = processedBody.split("\n"); let branchName = pullRequestItem.branch ? pullRequestItem.branch.trim() : null; if (!title) { title = "Agent Output"; diff --git a/.github/workflows/deep-report.lock.yml b/.github/workflows/deep-report.lock.yml index 7c38e7396b9..9bc6c396099 100644 --- a/.github/workflows/deep-report.lock.yml +++ b/.github/workflows/deep-report.lock.yml @@ -6476,381 +6476,13 @@ jobs: with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - const fs = require("fs"); - const crypto = require("crypto"); - const MAX_LOG_CONTENT_LENGTH = 10000; - function truncateForLogging(content) { - if (content.length <= MAX_LOG_CONTENT_LENGTH) { - return content; - } - return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; - } - function loadAgentOutput() { - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; - if (!agentOutputFile) { - core.info("No GH_AW_AGENT_OUTPUT environment variable found"); - return { success: false }; - } - let outputContent; - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - return { success: false, error: errorMessage }; - } - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return { success: false }; - } - core.info(`Agent output content length: ${outputContent.length}`); - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); - return { success: false, error: errorMessage }; - } - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); - return { success: false }; - } - return { success: true, items: validatedOutput.items }; - } - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - function getMessages() { - const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES; - if (!messagesEnv) { - return null; - } - try { - return JSON.parse(messagesEnv); - } catch (error) { - core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`); - return null; - } - } - function renderTemplate(template, context) { - return template.replace(/\{(\w+)\}/g, (match, key) => { - const value = context[key]; - return value !== undefined && value !== null ? String(value) : match; - }); - } - function toSnakeCase(obj) { - const result = {}; - for (const [key, value] of Object.entries(obj)) { - const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase(); - result[snakeKey] = value; - result[key] = value; - } - return result; - } - function getCloseOlderDiscussionMessage(ctx) { - const messages = getMessages(); - const templateContext = toSnakeCase(ctx); - const defaultMessage = `⚓ Avast! This discussion be marked as **outdated** by [{workflow_name}]({run_url}). - 🗺️ A newer treasure map awaits ye at **[Discussion #{new_discussion_number}]({new_discussion_url})**. - Fair winds, matey! 🏴‍☠️`; - return messages?.closeOlderDiscussion - ? renderTemplate(messages.closeOlderDiscussion, templateContext) - : renderTemplate(defaultMessage, templateContext); - } - const MAX_CLOSE_COUNT = 10; - const GRAPHQL_DELAY_MS = 500; - function delay(ms) { - return new Promise(resolve => setTimeout(resolve, ms)); - } - async function searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, excludeNumber) { - let searchQuery = `repo:${owner}/${repo} is:open`; - if (titlePrefix) { - const escapedPrefix = titlePrefix.replace(/"/g, '\\"'); - searchQuery += ` in:title "${escapedPrefix}"`; - } - if (labels && labels.length > 0) { - for (const label of labels) { - const escapedLabel = label.replace(/"/g, '\\"'); - searchQuery += ` label:"${escapedLabel}"`; - } - } - const result = await github.graphql( - ` - query($searchTerms: String!, $first: Int!) { - search(query: $searchTerms, type: DISCUSSION, first: $first) { - nodes { - ... on Discussion { - id - number - title - url - category { - id - } - labels(first: 100) { - nodes { - name - } - } - closed - } - } - } - }`, - { searchTerms: searchQuery, first: 50 } - ); - if (!result || !result.search || !result.search.nodes) { - return []; - } - return result.search.nodes - .filter( - d => { - if (!d || d.number === excludeNumber || d.closed) { - return false; - } - if (titlePrefix && d.title && !d.title.startsWith(titlePrefix)) { - return false; - } - if (labels && labels.length > 0) { - const discussionLabels = d.labels?.nodes?.map(( l) => l.name) || []; - const hasAllLabels = labels.every(label => discussionLabels.includes(label)); - if (!hasAllLabels) { - return false; - } - } - if (categoryId && (!d.category || d.category.id !== categoryId)) { - return false; - } - return true; - } - ) - .map( - d => ({ - id: d.id, - number: d.number, - title: d.title, - url: d.url, - }) - ); - } - async function addDiscussionComment(github, discussionId, message) { - const result = await github.graphql( - ` - mutation($dId: ID!, $body: String!) { - addDiscussionComment(input: { discussionId: $dId, body: $body }) { - comment { - id - url - } - } - }`, - { dId: discussionId, body: message } - ); - return result.addDiscussionComment.comment; - } - async function closeDiscussionAsOutdated(github, discussionId) { - const result = await github.graphql( - ` - mutation($dId: ID!) { - closeDiscussion(input: { discussionId: $dId, reason: OUTDATED }) { - discussion { - id - url - } - } - }`, - { dId: discussionId } - ); - return result.closeDiscussion.discussion; - } - async function closeOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion, workflowName, runUrl) { - const searchCriteria = []; - if (titlePrefix) searchCriteria.push(`title prefix: "${titlePrefix}"`); - if (labels && labels.length > 0) searchCriteria.push(`labels: [${labels.join(", ")}]`); - core.info(`Searching for older discussions with ${searchCriteria.join(" and ")}`); - const olderDiscussions = await searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion.number); - if (olderDiscussions.length === 0) { - core.info("No older discussions found to close"); - return []; - } - core.info(`Found ${olderDiscussions.length} older discussion(s) to close`); - const discussionsToClose = olderDiscussions.slice(0, MAX_CLOSE_COUNT); - if (olderDiscussions.length > MAX_CLOSE_COUNT) { - core.warning(`Found ${olderDiscussions.length} older discussions, but only closing the first ${MAX_CLOSE_COUNT}`); - } - const closedDiscussions = []; - for (let i = 0; i < discussionsToClose.length; i++) { - const discussion = discussionsToClose[i]; - try { - const closingMessage = getCloseOlderDiscussionMessage({ - newDiscussionUrl: newDiscussion.url, - newDiscussionNumber: newDiscussion.number, - workflowName, - runUrl, - }); - core.info(`Adding closing comment to discussion #${discussion.number}`); - await addDiscussionComment(github, discussion.id, closingMessage); - core.info(`Closing discussion #${discussion.number} as outdated`); - await closeDiscussionAsOutdated(github, discussion.id); - closedDiscussions.push({ - number: discussion.number, - url: discussion.url, - }); - core.info(`✓ Closed discussion #${discussion.number}: ${discussion.url}`); - } catch (error) { - core.error(`✗ Failed to close discussion #${discussion.number}: ${error instanceof Error ? error.message : String(error)}`); - } - if (i < discussionsToClose.length - 1) { - await delay(GRAPHQL_DELAY_MS); - } - } - return closedDiscussions; - } - const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; - function generateTemporaryId() { - return "aw_" + crypto.randomBytes(6).toString("hex"); - } - function isTemporaryId(value) { - if (typeof value === "string") { - return /^aw_[0-9a-f]{12}$/i.test(value); - } - return false; - } - function normalizeTemporaryId(tempId) { - return String(tempId).toLowerCase(); - } - function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); - if (resolved !== undefined) { - if (currentRepo && resolved.repo === currentRepo) { - return `#${resolved.number}`; - } - return `${resolved.repo}#${resolved.number}`; - } - return match; - }); - } - function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); - if (issueNumber !== undefined) { - return `#${issueNumber}`; - } - return match; - }); - } - function loadTemporaryIdMap() { - const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; - if (!mapJson || mapJson === "{}") { - return new Map(); - } - try { - const mapObject = JSON.parse(mapJson); - const result = new Map(); - for (const [key, value] of Object.entries(mapObject)) { - const normalizedKey = normalizeTemporaryId(key); - if (typeof value === "number") { - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - result.set(normalizedKey, { repo: contextRepo, number: value }); - } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { - result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); - } - } - return result; - } catch (error) { - if (typeof core !== "undefined") { - core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); - } - return new Map(); - } - } - function resolveIssueNumber(value, temporaryIdMap) { - if (value === undefined || value === null) { - return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; - } - const valueStr = String(value); - if (isTemporaryId(valueStr)) { - const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); - if (resolvedPair !== undefined) { - return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; - } - return { - resolved: null, - wasTemporaryId: true, - errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, - }; - } - const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); - if (isNaN(issueNumber) || issueNumber <= 0) { - return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; - } - const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; - return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; - } - function serializeTemporaryIdMap(tempIdMap) { - const obj = Object.fromEntries(tempIdMap); - return JSON.stringify(obj); - } - function parseAllowedRepos() { - const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; - const set = new Set(); - if (allowedReposEnv) { - allowedReposEnv - .split(",") - .map(repo => repo.trim()) - .filter(repo => repo) - .forEach(repo => set.add(repo)); - } - return set; - } - function getDefaultTargetRepo() { - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - if (targetRepoSlug) { - return targetRepoSlug; - } - return `${context.repo.owner}/${context.repo.repo}`; - } - function validateRepo(repo, defaultRepo, allowedRepos) { - if (repo === defaultRepo) { - return { valid: true, error: null }; - } - if (allowedRepos.has(repo)) { - return { valid: true, error: null }; - } - return { - valid: false, - error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, - }; - } - function parseRepoSlug(repoSlug) { - const parts = repoSlug.split("/"); - if (parts.length !== 2 || !parts[0] || !parts[1]) { - return null; - } - return { owner: parts[0], repo: parts[1] }; - } - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { getTrackerID } = require("./get_tracker_id.cjs"); + const { closeOlderDiscussions } = require("./close_older_discussions.cjs"); + const { replaceTemporaryIdReferences, loadTemporaryIdMap } = require("./temporary_id.cjs"); + const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require("./repo_helpers.cjs"); + const { addExpirationComment } = require("./expiration_helpers.cjs"); + const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); async function fetchRepoDiscussionInfo(owner, repo) { const repositoryQuery = ` query($owner: String!, $repo: String!) { @@ -7035,7 +6667,9 @@ jobs: ); let title = createDiscussionItem.title ? replaceTemporaryIdReferences(createDiscussionItem.title.trim(), temporaryIdMap, itemRepo) : ""; const bodyText = createDiscussionItem.body || ""; - let bodyLines = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo).split("\n"); + let processedBody = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo); + processedBody = removeDuplicateTitleFromDescription(title, processedBody); + let bodyLines = processedBody.split("\n"); if (!title) { title = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo) || "Agent Output"; } diff --git a/.github/workflows/dependabot-go-checker.lock.yml b/.github/workflows/dependabot-go-checker.lock.yml index 3fdbd27f80a..40aa25ea380 100644 --- a/.github/workflows/dependabot-go-checker.lock.yml +++ b/.github/workflows/dependabot-go-checker.lock.yml @@ -6846,272 +6846,21 @@ jobs: with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - function sanitizeLabelContent(content) { - if (!content || typeof content !== "string") { - return ""; - } - let sanitized = content.trim(); - sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); - sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); - sanitized = sanitized.replace( - /(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, - (_m, p1, p2) => `${p1}\`@${p2}\`` - ); - sanitized = sanitized.replace(/[<>&'"]/g, ""); - return sanitized.trim(); - } - const fs = require("fs"); - const crypto = require("crypto"); - const MAX_LOG_CONTENT_LENGTH = 10000; - function truncateForLogging(content) { - if (content.length <= MAX_LOG_CONTENT_LENGTH) { - return content; - } - return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; - } - function loadAgentOutput() { - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; - if (!agentOutputFile) { - core.info("No GH_AW_AGENT_OUTPUT environment variable found"); - return { success: false }; - } - let outputContent; - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - return { success: false, error: errorMessage }; - } - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return { success: false }; - } - core.info(`Agent output content length: ${outputContent.length}`); - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); - return { success: false, error: errorMessage }; - } - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); - return { success: false }; - } - return { success: true, items: validatedOutput.items }; - } - async function generateStagedPreview(options) { - const { title, description, items, renderItem } = options; - let summaryContent = `## 🎭 Staged Mode: ${title} Preview\n\n`; - summaryContent += `${description}\n\n`; - for (let i = 0; i < items.length; i++) { - const item = items[i]; - summaryContent += renderItem(item, i); - summaryContent += "---\n\n"; - } - try { - await core.summary.addRaw(summaryContent).write(); - core.info(summaryContent); - core.info(`📝 ${title} preview written to step summary`); - } catch (error) { - core.setFailed(error instanceof Error ? error : String(error)); - } - } - function generateXMLMarker(workflowName, runUrl) { - const engineId = process.env.GH_AW_ENGINE_ID || ""; - const engineVersion = process.env.GH_AW_ENGINE_VERSION || ""; - const engineModel = process.env.GH_AW_ENGINE_MODEL || ""; - const trackerId = process.env.GH_AW_TRACKER_ID || ""; - const parts = []; - parts.push(`agentic-workflow: ${workflowName}`); - if (trackerId) { - parts.push(`tracker-id: ${trackerId}`); - } - if (engineId) { - parts.push(`engine: ${engineId}`); - } - if (engineVersion) { - parts.push(`version: ${engineVersion}`); - } - if (engineModel) { - parts.push(`model: ${engineModel}`); - } - parts.push(`run: ${runUrl}`); - return ``; - } - function generateFooter( - workflowName, - runUrl, - workflowSource, - workflowSourceURL, - triggeringIssueNumber, - triggeringPRNumber, - triggeringDiscussionNumber - ) { - let footer = `\n\n> AI generated by [${workflowName}](${runUrl})`; - if (triggeringIssueNumber) { - footer += ` for #${triggeringIssueNumber}`; - } else if (triggeringPRNumber) { - footer += ` for #${triggeringPRNumber}`; - } else if (triggeringDiscussionNumber) { - footer += ` for discussion #${triggeringDiscussionNumber}`; - } - if (workflowSource && workflowSourceURL) { - footer += `\n>\n> To add this workflow in your repository, run \`gh aw add ${workflowSource}\`. See [usage guide](https://githubnext.github.io/gh-aw/tools/cli/).`; - } - footer += "\n\n" + generateXMLMarker(workflowName, runUrl); - footer += "\n"; - return footer; - } - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; - function generateTemporaryId() { - return "aw_" + crypto.randomBytes(6).toString("hex"); - } - function isTemporaryId(value) { - if (typeof value === "string") { - return /^aw_[0-9a-f]{12}$/i.test(value); - } - return false; - } - function normalizeTemporaryId(tempId) { - return String(tempId).toLowerCase(); - } - function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); - if (resolved !== undefined) { - if (currentRepo && resolved.repo === currentRepo) { - return `#${resolved.number}`; - } - return `${resolved.repo}#${resolved.number}`; - } - return match; - }); - } - function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); - if (issueNumber !== undefined) { - return `#${issueNumber}`; - } - return match; - }); - } - function loadTemporaryIdMap() { - const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; - if (!mapJson || mapJson === "{}") { - return new Map(); - } - try { - const mapObject = JSON.parse(mapJson); - const result = new Map(); - for (const [key, value] of Object.entries(mapObject)) { - const normalizedKey = normalizeTemporaryId(key); - if (typeof value === "number") { - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - result.set(normalizedKey, { repo: contextRepo, number: value }); - } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { - result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); - } - } - return result; - } catch (error) { - if (typeof core !== "undefined") { - core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); - } - return new Map(); - } - } - function resolveIssueNumber(value, temporaryIdMap) { - if (value === undefined || value === null) { - return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; - } - const valueStr = String(value); - if (isTemporaryId(valueStr)) { - const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); - if (resolvedPair !== undefined) { - return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; - } - return { - resolved: null, - wasTemporaryId: true, - errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, - }; - } - const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); - if (isNaN(issueNumber) || issueNumber <= 0) { - return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; - } - const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; - return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; - } - function serializeTemporaryIdMap(tempIdMap) { - const obj = Object.fromEntries(tempIdMap); - return JSON.stringify(obj); - } - function parseAllowedRepos() { - const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; - const set = new Set(); - if (allowedReposEnv) { - allowedReposEnv - .split(",") - .map(repo => repo.trim()) - .filter(repo => repo) - .forEach(repo => set.add(repo)); - } - return set; - } - function getDefaultTargetRepo() { - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - if (targetRepoSlug) { - return targetRepoSlug; - } - return `${context.repo.owner}/${context.repo.repo}`; - } - function validateRepo(repo, defaultRepo, allowedRepos) { - if (repo === defaultRepo) { - return { valid: true, error: null }; - } - if (allowedRepos.has(repo)) { - return { valid: true, error: null }; - } - return { - valid: false, - error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, - }; - } - function parseRepoSlug(repoSlug) { - const parts = repoSlug.split("/"); - if (parts.length !== 2 || !parts[0] || !parts[1]) { - return null; - } - return { owner: parts[0], repo: parts[1] }; - } - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } + const { sanitizeLabelContent } = require("./sanitize_label_content.cjs"); + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { generateStagedPreview } = require("./staged_preview.cjs"); + const { generateFooter } = require("./generate_footer.cjs"); + const { getTrackerID } = require("./get_tracker_id.cjs"); + const { + generateTemporaryId, + isTemporaryId, + normalizeTemporaryId, + replaceTemporaryIdReferences, + serializeTemporaryIdMap, + } = require("./temporary_id.cjs"); + const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require("./repo_helpers.cjs"); + const { addExpirationComment } = require("./expiration_helpers.cjs"); + const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); async function main() { core.setOutput("issue_number", ""); core.setOutput("issue_url", ""); @@ -7244,6 +6993,7 @@ jobs: .filter((label, index, arr) => arr.indexOf(label) === index); let title = createIssueItem.title ? createIssueItem.title.trim() : ""; let processedBody = replaceTemporaryIdReferences(createIssueItem.body, temporaryIdMap, itemRepo); + processedBody = removeDuplicateTitleFromDescription(title, processedBody); let bodyLines = processedBody.split("\n"); if (!title) { title = createIssueItem.body || "Agent Output"; diff --git a/.github/workflows/dev.lock.yml b/.github/workflows/dev.lock.yml index 79d74ce7307..126668a2012 100644 --- a/.github/workflows/dev.lock.yml +++ b/.github/workflows/dev.lock.yml @@ -6987,132 +6987,10 @@ jobs: script: | const fs = require("fs"); const crypto = require("crypto"); - async function updateActivationComment(github, context, core, itemUrl, itemNumber, itemType = "pull_request") { - const itemLabel = itemType === "issue" ? "issue" : "pull request"; - const linkMessage = - itemType === "issue" - ? `\n\n✅ Issue created: [#${itemNumber}](${itemUrl})` - : `\n\n✅ Pull request created: [#${itemNumber}](${itemUrl})`; - await updateActivationCommentWithMessage(github, context, core, linkMessage, itemLabel); - } - async function updateActivationCommentWithCommit(github, context, core, commitSha, commitUrl) { - const shortSha = commitSha.substring(0, 7); - const message = `\n\n✅ Commit pushed: [\`${shortSha}\`](${commitUrl})`; - await updateActivationCommentWithMessage(github, context, core, message, "commit"); - } - async function updateActivationCommentWithMessage(github, context, core, message, label = "") { - const commentId = process.env.GH_AW_COMMENT_ID; - const commentRepo = process.env.GH_AW_COMMENT_REPO; - if (!commentId) { - core.info("No activation comment to update (GH_AW_COMMENT_ID not set)"); - return; - } - core.info(`Updating activation comment ${commentId}`); - let repoOwner = context.repo.owner; - let repoName = context.repo.repo; - if (commentRepo) { - const parts = commentRepo.split("/"); - if (parts.length === 2) { - repoOwner = parts[0]; - repoName = parts[1]; - } else { - core.warning(`Invalid comment repo format: ${commentRepo}, expected "owner/repo". Falling back to context.repo.`); - } - } - core.info(`Updating comment in ${repoOwner}/${repoName}`); - const isDiscussionComment = commentId.startsWith("DC_"); - try { - if (isDiscussionComment) { - const currentComment = await github.graphql( - ` - query($commentId: ID!) { - node(id: $commentId) { - ... on DiscussionComment { - body - } - } - }`, - { commentId: commentId } - ); - if (!currentComment?.node?.body) { - core.warning("Unable to fetch current comment body, comment may have been deleted or is inaccessible"); - return; - } - const currentBody = currentComment.node.body; - const updatedBody = currentBody + message; - const result = await github.graphql( - ` - mutation($commentId: ID!, $body: String!) { - updateDiscussionComment(input: { commentId: $commentId, body: $body }) { - comment { - id - url - } - } - }`, - { commentId: commentId, body: updatedBody } - ); - const comment = result.updateDiscussionComment.comment; - const successMessage = label - ? `Successfully updated discussion comment with ${label} link` - : "Successfully updated discussion comment"; - core.info(successMessage); - core.info(`Comment ID: ${comment.id}`); - core.info(`Comment URL: ${comment.url}`); - } else { - const currentComment = await github.request("GET /repos/{owner}/{repo}/issues/comments/{comment_id}", { - owner: repoOwner, - repo: repoName, - comment_id: parseInt(commentId, 10), - headers: { - Accept: "application/vnd.github+json", - }, - }); - if (!currentComment?.data?.body) { - core.warning("Unable to fetch current comment body, comment may have been deleted"); - return; - } - const currentBody = currentComment.data.body; - const updatedBody = currentBody + message; - const response = await github.request("PATCH /repos/{owner}/{repo}/issues/comments/{comment_id}", { - owner: repoOwner, - repo: repoName, - comment_id: parseInt(commentId, 10), - body: updatedBody, - headers: { - Accept: "application/vnd.github+json", - }, - }); - const successMessage = label ? `Successfully updated comment with ${label} link` : "Successfully updated comment"; - core.info(successMessage); - core.info(`Comment ID: ${response.data.id}`); - core.info(`Comment URL: ${response.data.html_url}`); - } - } catch (error) { - core.warning(`Failed to update activation comment: ${error instanceof Error ? error.message : String(error)}`); - } - } - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } + const { updateActivationComment } = require("./update_activation_comment.cjs"); + const { getTrackerID } = require("./get_tracker_id.cjs"); + const { addExpirationComment } = require("./expiration_helpers.cjs"); + const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); function generatePatchPreview(patchContent) { if (!patchContent || !patchContent.trim()) { return ""; @@ -7304,7 +7182,9 @@ jobs: return; } let title = pullRequestItem.title.trim(); - let bodyLines = pullRequestItem.body.split("\n"); + let processedBody = pullRequestItem.body; + processedBody = removeDuplicateTitleFromDescription(title, processedBody); + let bodyLines = processedBody.split("\n"); let branchName = pullRequestItem.branch ? pullRequestItem.branch.trim() : null; if (!title) { title = "Agent Output"; diff --git a/.github/workflows/developer-docs-consolidator.lock.yml b/.github/workflows/developer-docs-consolidator.lock.yml index b1a358a4577..4bb97a9d104 100644 --- a/.github/workflows/developer-docs-consolidator.lock.yml +++ b/.github/workflows/developer-docs-consolidator.lock.yml @@ -6891,381 +6891,13 @@ jobs: with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - const fs = require("fs"); - const crypto = require("crypto"); - const MAX_LOG_CONTENT_LENGTH = 10000; - function truncateForLogging(content) { - if (content.length <= MAX_LOG_CONTENT_LENGTH) { - return content; - } - return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; - } - function loadAgentOutput() { - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; - if (!agentOutputFile) { - core.info("No GH_AW_AGENT_OUTPUT environment variable found"); - return { success: false }; - } - let outputContent; - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - return { success: false, error: errorMessage }; - } - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return { success: false }; - } - core.info(`Agent output content length: ${outputContent.length}`); - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); - return { success: false, error: errorMessage }; - } - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); - return { success: false }; - } - return { success: true, items: validatedOutput.items }; - } - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - function getMessages() { - const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES; - if (!messagesEnv) { - return null; - } - try { - return JSON.parse(messagesEnv); - } catch (error) { - core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`); - return null; - } - } - function renderTemplate(template, context) { - return template.replace(/\{(\w+)\}/g, (match, key) => { - const value = context[key]; - return value !== undefined && value !== null ? String(value) : match; - }); - } - function toSnakeCase(obj) { - const result = {}; - for (const [key, value] of Object.entries(obj)) { - const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase(); - result[snakeKey] = value; - result[key] = value; - } - return result; - } - function getCloseOlderDiscussionMessage(ctx) { - const messages = getMessages(); - const templateContext = toSnakeCase(ctx); - const defaultMessage = `⚓ Avast! This discussion be marked as **outdated** by [{workflow_name}]({run_url}). - 🗺️ A newer treasure map awaits ye at **[Discussion #{new_discussion_number}]({new_discussion_url})**. - Fair winds, matey! 🏴‍☠️`; - return messages?.closeOlderDiscussion - ? renderTemplate(messages.closeOlderDiscussion, templateContext) - : renderTemplate(defaultMessage, templateContext); - } - const MAX_CLOSE_COUNT = 10; - const GRAPHQL_DELAY_MS = 500; - function delay(ms) { - return new Promise(resolve => setTimeout(resolve, ms)); - } - async function searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, excludeNumber) { - let searchQuery = `repo:${owner}/${repo} is:open`; - if (titlePrefix) { - const escapedPrefix = titlePrefix.replace(/"/g, '\\"'); - searchQuery += ` in:title "${escapedPrefix}"`; - } - if (labels && labels.length > 0) { - for (const label of labels) { - const escapedLabel = label.replace(/"/g, '\\"'); - searchQuery += ` label:"${escapedLabel}"`; - } - } - const result = await github.graphql( - ` - query($searchTerms: String!, $first: Int!) { - search(query: $searchTerms, type: DISCUSSION, first: $first) { - nodes { - ... on Discussion { - id - number - title - url - category { - id - } - labels(first: 100) { - nodes { - name - } - } - closed - } - } - } - }`, - { searchTerms: searchQuery, first: 50 } - ); - if (!result || !result.search || !result.search.nodes) { - return []; - } - return result.search.nodes - .filter( - d => { - if (!d || d.number === excludeNumber || d.closed) { - return false; - } - if (titlePrefix && d.title && !d.title.startsWith(titlePrefix)) { - return false; - } - if (labels && labels.length > 0) { - const discussionLabels = d.labels?.nodes?.map(( l) => l.name) || []; - const hasAllLabels = labels.every(label => discussionLabels.includes(label)); - if (!hasAllLabels) { - return false; - } - } - if (categoryId && (!d.category || d.category.id !== categoryId)) { - return false; - } - return true; - } - ) - .map( - d => ({ - id: d.id, - number: d.number, - title: d.title, - url: d.url, - }) - ); - } - async function addDiscussionComment(github, discussionId, message) { - const result = await github.graphql( - ` - mutation($dId: ID!, $body: String!) { - addDiscussionComment(input: { discussionId: $dId, body: $body }) { - comment { - id - url - } - } - }`, - { dId: discussionId, body: message } - ); - return result.addDiscussionComment.comment; - } - async function closeDiscussionAsOutdated(github, discussionId) { - const result = await github.graphql( - ` - mutation($dId: ID!) { - closeDiscussion(input: { discussionId: $dId, reason: OUTDATED }) { - discussion { - id - url - } - } - }`, - { dId: discussionId } - ); - return result.closeDiscussion.discussion; - } - async function closeOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion, workflowName, runUrl) { - const searchCriteria = []; - if (titlePrefix) searchCriteria.push(`title prefix: "${titlePrefix}"`); - if (labels && labels.length > 0) searchCriteria.push(`labels: [${labels.join(", ")}]`); - core.info(`Searching for older discussions with ${searchCriteria.join(" and ")}`); - const olderDiscussions = await searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion.number); - if (olderDiscussions.length === 0) { - core.info("No older discussions found to close"); - return []; - } - core.info(`Found ${olderDiscussions.length} older discussion(s) to close`); - const discussionsToClose = olderDiscussions.slice(0, MAX_CLOSE_COUNT); - if (olderDiscussions.length > MAX_CLOSE_COUNT) { - core.warning(`Found ${olderDiscussions.length} older discussions, but only closing the first ${MAX_CLOSE_COUNT}`); - } - const closedDiscussions = []; - for (let i = 0; i < discussionsToClose.length; i++) { - const discussion = discussionsToClose[i]; - try { - const closingMessage = getCloseOlderDiscussionMessage({ - newDiscussionUrl: newDiscussion.url, - newDiscussionNumber: newDiscussion.number, - workflowName, - runUrl, - }); - core.info(`Adding closing comment to discussion #${discussion.number}`); - await addDiscussionComment(github, discussion.id, closingMessage); - core.info(`Closing discussion #${discussion.number} as outdated`); - await closeDiscussionAsOutdated(github, discussion.id); - closedDiscussions.push({ - number: discussion.number, - url: discussion.url, - }); - core.info(`✓ Closed discussion #${discussion.number}: ${discussion.url}`); - } catch (error) { - core.error(`✗ Failed to close discussion #${discussion.number}: ${error instanceof Error ? error.message : String(error)}`); - } - if (i < discussionsToClose.length - 1) { - await delay(GRAPHQL_DELAY_MS); - } - } - return closedDiscussions; - } - const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; - function generateTemporaryId() { - return "aw_" + crypto.randomBytes(6).toString("hex"); - } - function isTemporaryId(value) { - if (typeof value === "string") { - return /^aw_[0-9a-f]{12}$/i.test(value); - } - return false; - } - function normalizeTemporaryId(tempId) { - return String(tempId).toLowerCase(); - } - function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); - if (resolved !== undefined) { - if (currentRepo && resolved.repo === currentRepo) { - return `#${resolved.number}`; - } - return `${resolved.repo}#${resolved.number}`; - } - return match; - }); - } - function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); - if (issueNumber !== undefined) { - return `#${issueNumber}`; - } - return match; - }); - } - function loadTemporaryIdMap() { - const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; - if (!mapJson || mapJson === "{}") { - return new Map(); - } - try { - const mapObject = JSON.parse(mapJson); - const result = new Map(); - for (const [key, value] of Object.entries(mapObject)) { - const normalizedKey = normalizeTemporaryId(key); - if (typeof value === "number") { - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - result.set(normalizedKey, { repo: contextRepo, number: value }); - } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { - result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); - } - } - return result; - } catch (error) { - if (typeof core !== "undefined") { - core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); - } - return new Map(); - } - } - function resolveIssueNumber(value, temporaryIdMap) { - if (value === undefined || value === null) { - return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; - } - const valueStr = String(value); - if (isTemporaryId(valueStr)) { - const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); - if (resolvedPair !== undefined) { - return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; - } - return { - resolved: null, - wasTemporaryId: true, - errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, - }; - } - const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); - if (isNaN(issueNumber) || issueNumber <= 0) { - return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; - } - const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; - return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; - } - function serializeTemporaryIdMap(tempIdMap) { - const obj = Object.fromEntries(tempIdMap); - return JSON.stringify(obj); - } - function parseAllowedRepos() { - const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; - const set = new Set(); - if (allowedReposEnv) { - allowedReposEnv - .split(",") - .map(repo => repo.trim()) - .filter(repo => repo) - .forEach(repo => set.add(repo)); - } - return set; - } - function getDefaultTargetRepo() { - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - if (targetRepoSlug) { - return targetRepoSlug; - } - return `${context.repo.owner}/${context.repo.repo}`; - } - function validateRepo(repo, defaultRepo, allowedRepos) { - if (repo === defaultRepo) { - return { valid: true, error: null }; - } - if (allowedRepos.has(repo)) { - return { valid: true, error: null }; - } - return { - valid: false, - error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, - }; - } - function parseRepoSlug(repoSlug) { - const parts = repoSlug.split("/"); - if (parts.length !== 2 || !parts[0] || !parts[1]) { - return null; - } - return { owner: parts[0], repo: parts[1] }; - } - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { getTrackerID } = require("./get_tracker_id.cjs"); + const { closeOlderDiscussions } = require("./close_older_discussions.cjs"); + const { replaceTemporaryIdReferences, loadTemporaryIdMap } = require("./temporary_id.cjs"); + const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require("./repo_helpers.cjs"); + const { addExpirationComment } = require("./expiration_helpers.cjs"); + const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); async function fetchRepoDiscussionInfo(owner, repo) { const repositoryQuery = ` query($owner: String!, $repo: String!) { @@ -7450,7 +7082,9 @@ jobs: ); let title = createDiscussionItem.title ? replaceTemporaryIdReferences(createDiscussionItem.title.trim(), temporaryIdMap, itemRepo) : ""; const bodyText = createDiscussionItem.body || ""; - let bodyLines = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo).split("\n"); + let processedBody = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo); + processedBody = removeDuplicateTitleFromDescription(title, processedBody); + let bodyLines = processedBody.split("\n"); if (!title) { title = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo) || "Agent Output"; } @@ -7625,132 +7259,10 @@ jobs: script: | const fs = require("fs"); const crypto = require("crypto"); - async function updateActivationComment(github, context, core, itemUrl, itemNumber, itemType = "pull_request") { - const itemLabel = itemType === "issue" ? "issue" : "pull request"; - const linkMessage = - itemType === "issue" - ? `\n\n✅ Issue created: [#${itemNumber}](${itemUrl})` - : `\n\n✅ Pull request created: [#${itemNumber}](${itemUrl})`; - await updateActivationCommentWithMessage(github, context, core, linkMessage, itemLabel); - } - async function updateActivationCommentWithCommit(github, context, core, commitSha, commitUrl) { - const shortSha = commitSha.substring(0, 7); - const message = `\n\n✅ Commit pushed: [\`${shortSha}\`](${commitUrl})`; - await updateActivationCommentWithMessage(github, context, core, message, "commit"); - } - async function updateActivationCommentWithMessage(github, context, core, message, label = "") { - const commentId = process.env.GH_AW_COMMENT_ID; - const commentRepo = process.env.GH_AW_COMMENT_REPO; - if (!commentId) { - core.info("No activation comment to update (GH_AW_COMMENT_ID not set)"); - return; - } - core.info(`Updating activation comment ${commentId}`); - let repoOwner = context.repo.owner; - let repoName = context.repo.repo; - if (commentRepo) { - const parts = commentRepo.split("/"); - if (parts.length === 2) { - repoOwner = parts[0]; - repoName = parts[1]; - } else { - core.warning(`Invalid comment repo format: ${commentRepo}, expected "owner/repo". Falling back to context.repo.`); - } - } - core.info(`Updating comment in ${repoOwner}/${repoName}`); - const isDiscussionComment = commentId.startsWith("DC_"); - try { - if (isDiscussionComment) { - const currentComment = await github.graphql( - ` - query($commentId: ID!) { - node(id: $commentId) { - ... on DiscussionComment { - body - } - } - }`, - { commentId: commentId } - ); - if (!currentComment?.node?.body) { - core.warning("Unable to fetch current comment body, comment may have been deleted or is inaccessible"); - return; - } - const currentBody = currentComment.node.body; - const updatedBody = currentBody + message; - const result = await github.graphql( - ` - mutation($commentId: ID!, $body: String!) { - updateDiscussionComment(input: { commentId: $commentId, body: $body }) { - comment { - id - url - } - } - }`, - { commentId: commentId, body: updatedBody } - ); - const comment = result.updateDiscussionComment.comment; - const successMessage = label - ? `Successfully updated discussion comment with ${label} link` - : "Successfully updated discussion comment"; - core.info(successMessage); - core.info(`Comment ID: ${comment.id}`); - core.info(`Comment URL: ${comment.url}`); - } else { - const currentComment = await github.request("GET /repos/{owner}/{repo}/issues/comments/{comment_id}", { - owner: repoOwner, - repo: repoName, - comment_id: parseInt(commentId, 10), - headers: { - Accept: "application/vnd.github+json", - }, - }); - if (!currentComment?.data?.body) { - core.warning("Unable to fetch current comment body, comment may have been deleted"); - return; - } - const currentBody = currentComment.data.body; - const updatedBody = currentBody + message; - const response = await github.request("PATCH /repos/{owner}/{repo}/issues/comments/{comment_id}", { - owner: repoOwner, - repo: repoName, - comment_id: parseInt(commentId, 10), - body: updatedBody, - headers: { - Accept: "application/vnd.github+json", - }, - }); - const successMessage = label ? `Successfully updated comment with ${label} link` : "Successfully updated comment"; - core.info(successMessage); - core.info(`Comment ID: ${response.data.id}`); - core.info(`Comment URL: ${response.data.html_url}`); - } - } catch (error) { - core.warning(`Failed to update activation comment: ${error instanceof Error ? error.message : String(error)}`); - } - } - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } + const { updateActivationComment } = require("./update_activation_comment.cjs"); + const { getTrackerID } = require("./get_tracker_id.cjs"); + const { addExpirationComment } = require("./expiration_helpers.cjs"); + const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); function generatePatchPreview(patchContent) { if (!patchContent || !patchContent.trim()) { return ""; @@ -7942,7 +7454,9 @@ jobs: return; } let title = pullRequestItem.title.trim(); - let bodyLines = pullRequestItem.body.split("\n"); + let processedBody = pullRequestItem.body; + processedBody = removeDuplicateTitleFromDescription(title, processedBody); + let bodyLines = processedBody.split("\n"); let branchName = pullRequestItem.branch ? pullRequestItem.branch.trim() : null; if (!title) { title = "Agent Output"; diff --git a/.github/workflows/dictation-prompt.lock.yml b/.github/workflows/dictation-prompt.lock.yml index b159fe89602..8853cae3005 100644 --- a/.github/workflows/dictation-prompt.lock.yml +++ b/.github/workflows/dictation-prompt.lock.yml @@ -6147,132 +6147,10 @@ jobs: script: | const fs = require("fs"); const crypto = require("crypto"); - async function updateActivationComment(github, context, core, itemUrl, itemNumber, itemType = "pull_request") { - const itemLabel = itemType === "issue" ? "issue" : "pull request"; - const linkMessage = - itemType === "issue" - ? `\n\n✅ Issue created: [#${itemNumber}](${itemUrl})` - : `\n\n✅ Pull request created: [#${itemNumber}](${itemUrl})`; - await updateActivationCommentWithMessage(github, context, core, linkMessage, itemLabel); - } - async function updateActivationCommentWithCommit(github, context, core, commitSha, commitUrl) { - const shortSha = commitSha.substring(0, 7); - const message = `\n\n✅ Commit pushed: [\`${shortSha}\`](${commitUrl})`; - await updateActivationCommentWithMessage(github, context, core, message, "commit"); - } - async function updateActivationCommentWithMessage(github, context, core, message, label = "") { - const commentId = process.env.GH_AW_COMMENT_ID; - const commentRepo = process.env.GH_AW_COMMENT_REPO; - if (!commentId) { - core.info("No activation comment to update (GH_AW_COMMENT_ID not set)"); - return; - } - core.info(`Updating activation comment ${commentId}`); - let repoOwner = context.repo.owner; - let repoName = context.repo.repo; - if (commentRepo) { - const parts = commentRepo.split("/"); - if (parts.length === 2) { - repoOwner = parts[0]; - repoName = parts[1]; - } else { - core.warning(`Invalid comment repo format: ${commentRepo}, expected "owner/repo". Falling back to context.repo.`); - } - } - core.info(`Updating comment in ${repoOwner}/${repoName}`); - const isDiscussionComment = commentId.startsWith("DC_"); - try { - if (isDiscussionComment) { - const currentComment = await github.graphql( - ` - query($commentId: ID!) { - node(id: $commentId) { - ... on DiscussionComment { - body - } - } - }`, - { commentId: commentId } - ); - if (!currentComment?.node?.body) { - core.warning("Unable to fetch current comment body, comment may have been deleted or is inaccessible"); - return; - } - const currentBody = currentComment.node.body; - const updatedBody = currentBody + message; - const result = await github.graphql( - ` - mutation($commentId: ID!, $body: String!) { - updateDiscussionComment(input: { commentId: $commentId, body: $body }) { - comment { - id - url - } - } - }`, - { commentId: commentId, body: updatedBody } - ); - const comment = result.updateDiscussionComment.comment; - const successMessage = label - ? `Successfully updated discussion comment with ${label} link` - : "Successfully updated discussion comment"; - core.info(successMessage); - core.info(`Comment ID: ${comment.id}`); - core.info(`Comment URL: ${comment.url}`); - } else { - const currentComment = await github.request("GET /repos/{owner}/{repo}/issues/comments/{comment_id}", { - owner: repoOwner, - repo: repoName, - comment_id: parseInt(commentId, 10), - headers: { - Accept: "application/vnd.github+json", - }, - }); - if (!currentComment?.data?.body) { - core.warning("Unable to fetch current comment body, comment may have been deleted"); - return; - } - const currentBody = currentComment.data.body; - const updatedBody = currentBody + message; - const response = await github.request("PATCH /repos/{owner}/{repo}/issues/comments/{comment_id}", { - owner: repoOwner, - repo: repoName, - comment_id: parseInt(commentId, 10), - body: updatedBody, - headers: { - Accept: "application/vnd.github+json", - }, - }); - const successMessage = label ? `Successfully updated comment with ${label} link` : "Successfully updated comment"; - core.info(successMessage); - core.info(`Comment ID: ${response.data.id}`); - core.info(`Comment URL: ${response.data.html_url}`); - } - } catch (error) { - core.warning(`Failed to update activation comment: ${error instanceof Error ? error.message : String(error)}`); - } - } - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } + const { updateActivationComment } = require("./update_activation_comment.cjs"); + const { getTrackerID } = require("./get_tracker_id.cjs"); + const { addExpirationComment } = require("./expiration_helpers.cjs"); + const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); function generatePatchPreview(patchContent) { if (!patchContent || !patchContent.trim()) { return ""; @@ -6464,7 +6342,9 @@ jobs: return; } let title = pullRequestItem.title.trim(); - let bodyLines = pullRequestItem.body.split("\n"); + let processedBody = pullRequestItem.body; + processedBody = removeDuplicateTitleFromDescription(title, processedBody); + let bodyLines = processedBody.split("\n"); let branchName = pullRequestItem.branch ? pullRequestItem.branch.trim() : null; if (!title) { title = "Agent Output"; diff --git a/.github/workflows/docs-noob-tester.lock.yml b/.github/workflows/docs-noob-tester.lock.yml index f119c047511..455cebc87d5 100644 --- a/.github/workflows/docs-noob-tester.lock.yml +++ b/.github/workflows/docs-noob-tester.lock.yml @@ -6251,381 +6251,13 @@ jobs: with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - const fs = require("fs"); - const crypto = require("crypto"); - const MAX_LOG_CONTENT_LENGTH = 10000; - function truncateForLogging(content) { - if (content.length <= MAX_LOG_CONTENT_LENGTH) { - return content; - } - return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; - } - function loadAgentOutput() { - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; - if (!agentOutputFile) { - core.info("No GH_AW_AGENT_OUTPUT environment variable found"); - return { success: false }; - } - let outputContent; - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - return { success: false, error: errorMessage }; - } - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return { success: false }; - } - core.info(`Agent output content length: ${outputContent.length}`); - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); - return { success: false, error: errorMessage }; - } - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); - return { success: false }; - } - return { success: true, items: validatedOutput.items }; - } - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - function getMessages() { - const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES; - if (!messagesEnv) { - return null; - } - try { - return JSON.parse(messagesEnv); - } catch (error) { - core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`); - return null; - } - } - function renderTemplate(template, context) { - return template.replace(/\{(\w+)\}/g, (match, key) => { - const value = context[key]; - return value !== undefined && value !== null ? String(value) : match; - }); - } - function toSnakeCase(obj) { - const result = {}; - for (const [key, value] of Object.entries(obj)) { - const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase(); - result[snakeKey] = value; - result[key] = value; - } - return result; - } - function getCloseOlderDiscussionMessage(ctx) { - const messages = getMessages(); - const templateContext = toSnakeCase(ctx); - const defaultMessage = `⚓ Avast! This discussion be marked as **outdated** by [{workflow_name}]({run_url}). - 🗺️ A newer treasure map awaits ye at **[Discussion #{new_discussion_number}]({new_discussion_url})**. - Fair winds, matey! 🏴‍☠️`; - return messages?.closeOlderDiscussion - ? renderTemplate(messages.closeOlderDiscussion, templateContext) - : renderTemplate(defaultMessage, templateContext); - } - const MAX_CLOSE_COUNT = 10; - const GRAPHQL_DELAY_MS = 500; - function delay(ms) { - return new Promise(resolve => setTimeout(resolve, ms)); - } - async function searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, excludeNumber) { - let searchQuery = `repo:${owner}/${repo} is:open`; - if (titlePrefix) { - const escapedPrefix = titlePrefix.replace(/"/g, '\\"'); - searchQuery += ` in:title "${escapedPrefix}"`; - } - if (labels && labels.length > 0) { - for (const label of labels) { - const escapedLabel = label.replace(/"/g, '\\"'); - searchQuery += ` label:"${escapedLabel}"`; - } - } - const result = await github.graphql( - ` - query($searchTerms: String!, $first: Int!) { - search(query: $searchTerms, type: DISCUSSION, first: $first) { - nodes { - ... on Discussion { - id - number - title - url - category { - id - } - labels(first: 100) { - nodes { - name - } - } - closed - } - } - } - }`, - { searchTerms: searchQuery, first: 50 } - ); - if (!result || !result.search || !result.search.nodes) { - return []; - } - return result.search.nodes - .filter( - d => { - if (!d || d.number === excludeNumber || d.closed) { - return false; - } - if (titlePrefix && d.title && !d.title.startsWith(titlePrefix)) { - return false; - } - if (labels && labels.length > 0) { - const discussionLabels = d.labels?.nodes?.map(( l) => l.name) || []; - const hasAllLabels = labels.every(label => discussionLabels.includes(label)); - if (!hasAllLabels) { - return false; - } - } - if (categoryId && (!d.category || d.category.id !== categoryId)) { - return false; - } - return true; - } - ) - .map( - d => ({ - id: d.id, - number: d.number, - title: d.title, - url: d.url, - }) - ); - } - async function addDiscussionComment(github, discussionId, message) { - const result = await github.graphql( - ` - mutation($dId: ID!, $body: String!) { - addDiscussionComment(input: { discussionId: $dId, body: $body }) { - comment { - id - url - } - } - }`, - { dId: discussionId, body: message } - ); - return result.addDiscussionComment.comment; - } - async function closeDiscussionAsOutdated(github, discussionId) { - const result = await github.graphql( - ` - mutation($dId: ID!) { - closeDiscussion(input: { discussionId: $dId, reason: OUTDATED }) { - discussion { - id - url - } - } - }`, - { dId: discussionId } - ); - return result.closeDiscussion.discussion; - } - async function closeOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion, workflowName, runUrl) { - const searchCriteria = []; - if (titlePrefix) searchCriteria.push(`title prefix: "${titlePrefix}"`); - if (labels && labels.length > 0) searchCriteria.push(`labels: [${labels.join(", ")}]`); - core.info(`Searching for older discussions with ${searchCriteria.join(" and ")}`); - const olderDiscussions = await searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion.number); - if (olderDiscussions.length === 0) { - core.info("No older discussions found to close"); - return []; - } - core.info(`Found ${olderDiscussions.length} older discussion(s) to close`); - const discussionsToClose = olderDiscussions.slice(0, MAX_CLOSE_COUNT); - if (olderDiscussions.length > MAX_CLOSE_COUNT) { - core.warning(`Found ${olderDiscussions.length} older discussions, but only closing the first ${MAX_CLOSE_COUNT}`); - } - const closedDiscussions = []; - for (let i = 0; i < discussionsToClose.length; i++) { - const discussion = discussionsToClose[i]; - try { - const closingMessage = getCloseOlderDiscussionMessage({ - newDiscussionUrl: newDiscussion.url, - newDiscussionNumber: newDiscussion.number, - workflowName, - runUrl, - }); - core.info(`Adding closing comment to discussion #${discussion.number}`); - await addDiscussionComment(github, discussion.id, closingMessage); - core.info(`Closing discussion #${discussion.number} as outdated`); - await closeDiscussionAsOutdated(github, discussion.id); - closedDiscussions.push({ - number: discussion.number, - url: discussion.url, - }); - core.info(`✓ Closed discussion #${discussion.number}: ${discussion.url}`); - } catch (error) { - core.error(`✗ Failed to close discussion #${discussion.number}: ${error instanceof Error ? error.message : String(error)}`); - } - if (i < discussionsToClose.length - 1) { - await delay(GRAPHQL_DELAY_MS); - } - } - return closedDiscussions; - } - const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; - function generateTemporaryId() { - return "aw_" + crypto.randomBytes(6).toString("hex"); - } - function isTemporaryId(value) { - if (typeof value === "string") { - return /^aw_[0-9a-f]{12}$/i.test(value); - } - return false; - } - function normalizeTemporaryId(tempId) { - return String(tempId).toLowerCase(); - } - function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); - if (resolved !== undefined) { - if (currentRepo && resolved.repo === currentRepo) { - return `#${resolved.number}`; - } - return `${resolved.repo}#${resolved.number}`; - } - return match; - }); - } - function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); - if (issueNumber !== undefined) { - return `#${issueNumber}`; - } - return match; - }); - } - function loadTemporaryIdMap() { - const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; - if (!mapJson || mapJson === "{}") { - return new Map(); - } - try { - const mapObject = JSON.parse(mapJson); - const result = new Map(); - for (const [key, value] of Object.entries(mapObject)) { - const normalizedKey = normalizeTemporaryId(key); - if (typeof value === "number") { - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - result.set(normalizedKey, { repo: contextRepo, number: value }); - } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { - result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); - } - } - return result; - } catch (error) { - if (typeof core !== "undefined") { - core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); - } - return new Map(); - } - } - function resolveIssueNumber(value, temporaryIdMap) { - if (value === undefined || value === null) { - return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; - } - const valueStr = String(value); - if (isTemporaryId(valueStr)) { - const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); - if (resolvedPair !== undefined) { - return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; - } - return { - resolved: null, - wasTemporaryId: true, - errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, - }; - } - const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); - if (isNaN(issueNumber) || issueNumber <= 0) { - return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; - } - const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; - return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; - } - function serializeTemporaryIdMap(tempIdMap) { - const obj = Object.fromEntries(tempIdMap); - return JSON.stringify(obj); - } - function parseAllowedRepos() { - const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; - const set = new Set(); - if (allowedReposEnv) { - allowedReposEnv - .split(",") - .map(repo => repo.trim()) - .filter(repo => repo) - .forEach(repo => set.add(repo)); - } - return set; - } - function getDefaultTargetRepo() { - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - if (targetRepoSlug) { - return targetRepoSlug; - } - return `${context.repo.owner}/${context.repo.repo}`; - } - function validateRepo(repo, defaultRepo, allowedRepos) { - if (repo === defaultRepo) { - return { valid: true, error: null }; - } - if (allowedRepos.has(repo)) { - return { valid: true, error: null }; - } - return { - valid: false, - error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, - }; - } - function parseRepoSlug(repoSlug) { - const parts = repoSlug.split("/"); - if (parts.length !== 2 || !parts[0] || !parts[1]) { - return null; - } - return { owner: parts[0], repo: parts[1] }; - } - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { getTrackerID } = require("./get_tracker_id.cjs"); + const { closeOlderDiscussions } = require("./close_older_discussions.cjs"); + const { replaceTemporaryIdReferences, loadTemporaryIdMap } = require("./temporary_id.cjs"); + const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require("./repo_helpers.cjs"); + const { addExpirationComment } = require("./expiration_helpers.cjs"); + const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); async function fetchRepoDiscussionInfo(owner, repo) { const repositoryQuery = ` query($owner: String!, $repo: String!) { @@ -6810,7 +6442,9 @@ jobs: ); let title = createDiscussionItem.title ? replaceTemporaryIdReferences(createDiscussionItem.title.trim(), temporaryIdMap, itemRepo) : ""; const bodyText = createDiscussionItem.body || ""; - let bodyLines = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo).split("\n"); + let processedBody = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo); + processedBody = removeDuplicateTitleFromDescription(title, processedBody); + let bodyLines = processedBody.split("\n"); if (!title) { title = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo) || "Agent Output"; } diff --git a/.github/workflows/duplicate-code-detector.lock.yml b/.github/workflows/duplicate-code-detector.lock.yml index f274b35458a..9070a4927e9 100644 --- a/.github/workflows/duplicate-code-detector.lock.yml +++ b/.github/workflows/duplicate-code-detector.lock.yml @@ -5822,272 +5822,21 @@ jobs: with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - function sanitizeLabelContent(content) { - if (!content || typeof content !== "string") { - return ""; - } - let sanitized = content.trim(); - sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); - sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); - sanitized = sanitized.replace( - /(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, - (_m, p1, p2) => `${p1}\`@${p2}\`` - ); - sanitized = sanitized.replace(/[<>&'"]/g, ""); - return sanitized.trim(); - } - const fs = require("fs"); - const crypto = require("crypto"); - const MAX_LOG_CONTENT_LENGTH = 10000; - function truncateForLogging(content) { - if (content.length <= MAX_LOG_CONTENT_LENGTH) { - return content; - } - return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; - } - function loadAgentOutput() { - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; - if (!agentOutputFile) { - core.info("No GH_AW_AGENT_OUTPUT environment variable found"); - return { success: false }; - } - let outputContent; - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - return { success: false, error: errorMessage }; - } - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return { success: false }; - } - core.info(`Agent output content length: ${outputContent.length}`); - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); - return { success: false, error: errorMessage }; - } - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); - return { success: false }; - } - return { success: true, items: validatedOutput.items }; - } - async function generateStagedPreview(options) { - const { title, description, items, renderItem } = options; - let summaryContent = `## 🎭 Staged Mode: ${title} Preview\n\n`; - summaryContent += `${description}\n\n`; - for (let i = 0; i < items.length; i++) { - const item = items[i]; - summaryContent += renderItem(item, i); - summaryContent += "---\n\n"; - } - try { - await core.summary.addRaw(summaryContent).write(); - core.info(summaryContent); - core.info(`📝 ${title} preview written to step summary`); - } catch (error) { - core.setFailed(error instanceof Error ? error : String(error)); - } - } - function generateXMLMarker(workflowName, runUrl) { - const engineId = process.env.GH_AW_ENGINE_ID || ""; - const engineVersion = process.env.GH_AW_ENGINE_VERSION || ""; - const engineModel = process.env.GH_AW_ENGINE_MODEL || ""; - const trackerId = process.env.GH_AW_TRACKER_ID || ""; - const parts = []; - parts.push(`agentic-workflow: ${workflowName}`); - if (trackerId) { - parts.push(`tracker-id: ${trackerId}`); - } - if (engineId) { - parts.push(`engine: ${engineId}`); - } - if (engineVersion) { - parts.push(`version: ${engineVersion}`); - } - if (engineModel) { - parts.push(`model: ${engineModel}`); - } - parts.push(`run: ${runUrl}`); - return ``; - } - function generateFooter( - workflowName, - runUrl, - workflowSource, - workflowSourceURL, - triggeringIssueNumber, - triggeringPRNumber, - triggeringDiscussionNumber - ) { - let footer = `\n\n> AI generated by [${workflowName}](${runUrl})`; - if (triggeringIssueNumber) { - footer += ` for #${triggeringIssueNumber}`; - } else if (triggeringPRNumber) { - footer += ` for #${triggeringPRNumber}`; - } else if (triggeringDiscussionNumber) { - footer += ` for discussion #${triggeringDiscussionNumber}`; - } - if (workflowSource && workflowSourceURL) { - footer += `\n>\n> To add this workflow in your repository, run \`gh aw add ${workflowSource}\`. See [usage guide](https://githubnext.github.io/gh-aw/tools/cli/).`; - } - footer += "\n\n" + generateXMLMarker(workflowName, runUrl); - footer += "\n"; - return footer; - } - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; - function generateTemporaryId() { - return "aw_" + crypto.randomBytes(6).toString("hex"); - } - function isTemporaryId(value) { - if (typeof value === "string") { - return /^aw_[0-9a-f]{12}$/i.test(value); - } - return false; - } - function normalizeTemporaryId(tempId) { - return String(tempId).toLowerCase(); - } - function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); - if (resolved !== undefined) { - if (currentRepo && resolved.repo === currentRepo) { - return `#${resolved.number}`; - } - return `${resolved.repo}#${resolved.number}`; - } - return match; - }); - } - function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); - if (issueNumber !== undefined) { - return `#${issueNumber}`; - } - return match; - }); - } - function loadTemporaryIdMap() { - const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; - if (!mapJson || mapJson === "{}") { - return new Map(); - } - try { - const mapObject = JSON.parse(mapJson); - const result = new Map(); - for (const [key, value] of Object.entries(mapObject)) { - const normalizedKey = normalizeTemporaryId(key); - if (typeof value === "number") { - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - result.set(normalizedKey, { repo: contextRepo, number: value }); - } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { - result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); - } - } - return result; - } catch (error) { - if (typeof core !== "undefined") { - core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); - } - return new Map(); - } - } - function resolveIssueNumber(value, temporaryIdMap) { - if (value === undefined || value === null) { - return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; - } - const valueStr = String(value); - if (isTemporaryId(valueStr)) { - const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); - if (resolvedPair !== undefined) { - return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; - } - return { - resolved: null, - wasTemporaryId: true, - errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, - }; - } - const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); - if (isNaN(issueNumber) || issueNumber <= 0) { - return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; - } - const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; - return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; - } - function serializeTemporaryIdMap(tempIdMap) { - const obj = Object.fromEntries(tempIdMap); - return JSON.stringify(obj); - } - function parseAllowedRepos() { - const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; - const set = new Set(); - if (allowedReposEnv) { - allowedReposEnv - .split(",") - .map(repo => repo.trim()) - .filter(repo => repo) - .forEach(repo => set.add(repo)); - } - return set; - } - function getDefaultTargetRepo() { - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - if (targetRepoSlug) { - return targetRepoSlug; - } - return `${context.repo.owner}/${context.repo.repo}`; - } - function validateRepo(repo, defaultRepo, allowedRepos) { - if (repo === defaultRepo) { - return { valid: true, error: null }; - } - if (allowedRepos.has(repo)) { - return { valid: true, error: null }; - } - return { - valid: false, - error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, - }; - } - function parseRepoSlug(repoSlug) { - const parts = repoSlug.split("/"); - if (parts.length !== 2 || !parts[0] || !parts[1]) { - return null; - } - return { owner: parts[0], repo: parts[1] }; - } - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } + const { sanitizeLabelContent } = require("./sanitize_label_content.cjs"); + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { generateStagedPreview } = require("./staged_preview.cjs"); + const { generateFooter } = require("./generate_footer.cjs"); + const { getTrackerID } = require("./get_tracker_id.cjs"); + const { + generateTemporaryId, + isTemporaryId, + normalizeTemporaryId, + replaceTemporaryIdReferences, + serializeTemporaryIdMap, + } = require("./temporary_id.cjs"); + const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require("./repo_helpers.cjs"); + const { addExpirationComment } = require("./expiration_helpers.cjs"); + const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); async function main() { core.setOutput("issue_number", ""); core.setOutput("issue_url", ""); @@ -6220,6 +5969,7 @@ jobs: .filter((label, index, arr) => arr.indexOf(label) === index); let title = createIssueItem.title ? createIssueItem.title.trim() : ""; let processedBody = replaceTemporaryIdReferences(createIssueItem.body, temporaryIdMap, itemRepo); + processedBody = removeDuplicateTitleFromDescription(title, processedBody); let bodyLines = processedBody.split("\n"); if (!title) { title = createIssueItem.body || "Agent Output"; diff --git a/.github/workflows/example-workflow-analyzer.lock.yml b/.github/workflows/example-workflow-analyzer.lock.yml index 563ec3d04c1..0bfe02d2a2a 100644 --- a/.github/workflows/example-workflow-analyzer.lock.yml +++ b/.github/workflows/example-workflow-analyzer.lock.yml @@ -5437,381 +5437,13 @@ jobs: with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - const fs = require("fs"); - const crypto = require("crypto"); - const MAX_LOG_CONTENT_LENGTH = 10000; - function truncateForLogging(content) { - if (content.length <= MAX_LOG_CONTENT_LENGTH) { - return content; - } - return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; - } - function loadAgentOutput() { - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; - if (!agentOutputFile) { - core.info("No GH_AW_AGENT_OUTPUT environment variable found"); - return { success: false }; - } - let outputContent; - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - return { success: false, error: errorMessage }; - } - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return { success: false }; - } - core.info(`Agent output content length: ${outputContent.length}`); - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); - return { success: false, error: errorMessage }; - } - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); - return { success: false }; - } - return { success: true, items: validatedOutput.items }; - } - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - function getMessages() { - const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES; - if (!messagesEnv) { - return null; - } - try { - return JSON.parse(messagesEnv); - } catch (error) { - core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`); - return null; - } - } - function renderTemplate(template, context) { - return template.replace(/\{(\w+)\}/g, (match, key) => { - const value = context[key]; - return value !== undefined && value !== null ? String(value) : match; - }); - } - function toSnakeCase(obj) { - const result = {}; - for (const [key, value] of Object.entries(obj)) { - const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase(); - result[snakeKey] = value; - result[key] = value; - } - return result; - } - function getCloseOlderDiscussionMessage(ctx) { - const messages = getMessages(); - const templateContext = toSnakeCase(ctx); - const defaultMessage = `⚓ Avast! This discussion be marked as **outdated** by [{workflow_name}]({run_url}). - 🗺️ A newer treasure map awaits ye at **[Discussion #{new_discussion_number}]({new_discussion_url})**. - Fair winds, matey! 🏴‍☠️`; - return messages?.closeOlderDiscussion - ? renderTemplate(messages.closeOlderDiscussion, templateContext) - : renderTemplate(defaultMessage, templateContext); - } - const MAX_CLOSE_COUNT = 10; - const GRAPHQL_DELAY_MS = 500; - function delay(ms) { - return new Promise(resolve => setTimeout(resolve, ms)); - } - async function searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, excludeNumber) { - let searchQuery = `repo:${owner}/${repo} is:open`; - if (titlePrefix) { - const escapedPrefix = titlePrefix.replace(/"/g, '\\"'); - searchQuery += ` in:title "${escapedPrefix}"`; - } - if (labels && labels.length > 0) { - for (const label of labels) { - const escapedLabel = label.replace(/"/g, '\\"'); - searchQuery += ` label:"${escapedLabel}"`; - } - } - const result = await github.graphql( - ` - query($searchTerms: String!, $first: Int!) { - search(query: $searchTerms, type: DISCUSSION, first: $first) { - nodes { - ... on Discussion { - id - number - title - url - category { - id - } - labels(first: 100) { - nodes { - name - } - } - closed - } - } - } - }`, - { searchTerms: searchQuery, first: 50 } - ); - if (!result || !result.search || !result.search.nodes) { - return []; - } - return result.search.nodes - .filter( - d => { - if (!d || d.number === excludeNumber || d.closed) { - return false; - } - if (titlePrefix && d.title && !d.title.startsWith(titlePrefix)) { - return false; - } - if (labels && labels.length > 0) { - const discussionLabels = d.labels?.nodes?.map(( l) => l.name) || []; - const hasAllLabels = labels.every(label => discussionLabels.includes(label)); - if (!hasAllLabels) { - return false; - } - } - if (categoryId && (!d.category || d.category.id !== categoryId)) { - return false; - } - return true; - } - ) - .map( - d => ({ - id: d.id, - number: d.number, - title: d.title, - url: d.url, - }) - ); - } - async function addDiscussionComment(github, discussionId, message) { - const result = await github.graphql( - ` - mutation($dId: ID!, $body: String!) { - addDiscussionComment(input: { discussionId: $dId, body: $body }) { - comment { - id - url - } - } - }`, - { dId: discussionId, body: message } - ); - return result.addDiscussionComment.comment; - } - async function closeDiscussionAsOutdated(github, discussionId) { - const result = await github.graphql( - ` - mutation($dId: ID!) { - closeDiscussion(input: { discussionId: $dId, reason: OUTDATED }) { - discussion { - id - url - } - } - }`, - { dId: discussionId } - ); - return result.closeDiscussion.discussion; - } - async function closeOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion, workflowName, runUrl) { - const searchCriteria = []; - if (titlePrefix) searchCriteria.push(`title prefix: "${titlePrefix}"`); - if (labels && labels.length > 0) searchCriteria.push(`labels: [${labels.join(", ")}]`); - core.info(`Searching for older discussions with ${searchCriteria.join(" and ")}`); - const olderDiscussions = await searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion.number); - if (olderDiscussions.length === 0) { - core.info("No older discussions found to close"); - return []; - } - core.info(`Found ${olderDiscussions.length} older discussion(s) to close`); - const discussionsToClose = olderDiscussions.slice(0, MAX_CLOSE_COUNT); - if (olderDiscussions.length > MAX_CLOSE_COUNT) { - core.warning(`Found ${olderDiscussions.length} older discussions, but only closing the first ${MAX_CLOSE_COUNT}`); - } - const closedDiscussions = []; - for (let i = 0; i < discussionsToClose.length; i++) { - const discussion = discussionsToClose[i]; - try { - const closingMessage = getCloseOlderDiscussionMessage({ - newDiscussionUrl: newDiscussion.url, - newDiscussionNumber: newDiscussion.number, - workflowName, - runUrl, - }); - core.info(`Adding closing comment to discussion #${discussion.number}`); - await addDiscussionComment(github, discussion.id, closingMessage); - core.info(`Closing discussion #${discussion.number} as outdated`); - await closeDiscussionAsOutdated(github, discussion.id); - closedDiscussions.push({ - number: discussion.number, - url: discussion.url, - }); - core.info(`✓ Closed discussion #${discussion.number}: ${discussion.url}`); - } catch (error) { - core.error(`✗ Failed to close discussion #${discussion.number}: ${error instanceof Error ? error.message : String(error)}`); - } - if (i < discussionsToClose.length - 1) { - await delay(GRAPHQL_DELAY_MS); - } - } - return closedDiscussions; - } - const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; - function generateTemporaryId() { - return "aw_" + crypto.randomBytes(6).toString("hex"); - } - function isTemporaryId(value) { - if (typeof value === "string") { - return /^aw_[0-9a-f]{12}$/i.test(value); - } - return false; - } - function normalizeTemporaryId(tempId) { - return String(tempId).toLowerCase(); - } - function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); - if (resolved !== undefined) { - if (currentRepo && resolved.repo === currentRepo) { - return `#${resolved.number}`; - } - return `${resolved.repo}#${resolved.number}`; - } - return match; - }); - } - function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); - if (issueNumber !== undefined) { - return `#${issueNumber}`; - } - return match; - }); - } - function loadTemporaryIdMap() { - const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; - if (!mapJson || mapJson === "{}") { - return new Map(); - } - try { - const mapObject = JSON.parse(mapJson); - const result = new Map(); - for (const [key, value] of Object.entries(mapObject)) { - const normalizedKey = normalizeTemporaryId(key); - if (typeof value === "number") { - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - result.set(normalizedKey, { repo: contextRepo, number: value }); - } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { - result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); - } - } - return result; - } catch (error) { - if (typeof core !== "undefined") { - core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); - } - return new Map(); - } - } - function resolveIssueNumber(value, temporaryIdMap) { - if (value === undefined || value === null) { - return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; - } - const valueStr = String(value); - if (isTemporaryId(valueStr)) { - const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); - if (resolvedPair !== undefined) { - return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; - } - return { - resolved: null, - wasTemporaryId: true, - errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, - }; - } - const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); - if (isNaN(issueNumber) || issueNumber <= 0) { - return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; - } - const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; - return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; - } - function serializeTemporaryIdMap(tempIdMap) { - const obj = Object.fromEntries(tempIdMap); - return JSON.stringify(obj); - } - function parseAllowedRepos() { - const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; - const set = new Set(); - if (allowedReposEnv) { - allowedReposEnv - .split(",") - .map(repo => repo.trim()) - .filter(repo => repo) - .forEach(repo => set.add(repo)); - } - return set; - } - function getDefaultTargetRepo() { - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - if (targetRepoSlug) { - return targetRepoSlug; - } - return `${context.repo.owner}/${context.repo.repo}`; - } - function validateRepo(repo, defaultRepo, allowedRepos) { - if (repo === defaultRepo) { - return { valid: true, error: null }; - } - if (allowedRepos.has(repo)) { - return { valid: true, error: null }; - } - return { - valid: false, - error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, - }; - } - function parseRepoSlug(repoSlug) { - const parts = repoSlug.split("/"); - if (parts.length !== 2 || !parts[0] || !parts[1]) { - return null; - } - return { owner: parts[0], repo: parts[1] }; - } - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { getTrackerID } = require("./get_tracker_id.cjs"); + const { closeOlderDiscussions } = require("./close_older_discussions.cjs"); + const { replaceTemporaryIdReferences, loadTemporaryIdMap } = require("./temporary_id.cjs"); + const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require("./repo_helpers.cjs"); + const { addExpirationComment } = require("./expiration_helpers.cjs"); + const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); async function fetchRepoDiscussionInfo(owner, repo) { const repositoryQuery = ` query($owner: String!, $repo: String!) { @@ -5996,7 +5628,9 @@ jobs: ); let title = createDiscussionItem.title ? replaceTemporaryIdReferences(createDiscussionItem.title.trim(), temporaryIdMap, itemRepo) : ""; const bodyText = createDiscussionItem.body || ""; - let bodyLines = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo).split("\n"); + let processedBody = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo); + processedBody = removeDuplicateTitleFromDescription(title, processedBody); + let bodyLines = processedBody.split("\n"); if (!title) { title = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo) || "Agent Output"; } diff --git a/.github/workflows/github-mcp-structural-analysis.lock.yml b/.github/workflows/github-mcp-structural-analysis.lock.yml index 596b2bd7640..083bef5d61d 100644 --- a/.github/workflows/github-mcp-structural-analysis.lock.yml +++ b/.github/workflows/github-mcp-structural-analysis.lock.yml @@ -6817,381 +6817,13 @@ jobs: with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - const fs = require("fs"); - const crypto = require("crypto"); - const MAX_LOG_CONTENT_LENGTH = 10000; - function truncateForLogging(content) { - if (content.length <= MAX_LOG_CONTENT_LENGTH) { - return content; - } - return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; - } - function loadAgentOutput() { - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; - if (!agentOutputFile) { - core.info("No GH_AW_AGENT_OUTPUT environment variable found"); - return { success: false }; - } - let outputContent; - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - return { success: false, error: errorMessage }; - } - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return { success: false }; - } - core.info(`Agent output content length: ${outputContent.length}`); - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); - return { success: false, error: errorMessage }; - } - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); - return { success: false }; - } - return { success: true, items: validatedOutput.items }; - } - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - function getMessages() { - const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES; - if (!messagesEnv) { - return null; - } - try { - return JSON.parse(messagesEnv); - } catch (error) { - core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`); - return null; - } - } - function renderTemplate(template, context) { - return template.replace(/\{(\w+)\}/g, (match, key) => { - const value = context[key]; - return value !== undefined && value !== null ? String(value) : match; - }); - } - function toSnakeCase(obj) { - const result = {}; - for (const [key, value] of Object.entries(obj)) { - const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase(); - result[snakeKey] = value; - result[key] = value; - } - return result; - } - function getCloseOlderDiscussionMessage(ctx) { - const messages = getMessages(); - const templateContext = toSnakeCase(ctx); - const defaultMessage = `⚓ Avast! This discussion be marked as **outdated** by [{workflow_name}]({run_url}). - 🗺️ A newer treasure map awaits ye at **[Discussion #{new_discussion_number}]({new_discussion_url})**. - Fair winds, matey! 🏴‍☠️`; - return messages?.closeOlderDiscussion - ? renderTemplate(messages.closeOlderDiscussion, templateContext) - : renderTemplate(defaultMessage, templateContext); - } - const MAX_CLOSE_COUNT = 10; - const GRAPHQL_DELAY_MS = 500; - function delay(ms) { - return new Promise(resolve => setTimeout(resolve, ms)); - } - async function searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, excludeNumber) { - let searchQuery = `repo:${owner}/${repo} is:open`; - if (titlePrefix) { - const escapedPrefix = titlePrefix.replace(/"/g, '\\"'); - searchQuery += ` in:title "${escapedPrefix}"`; - } - if (labels && labels.length > 0) { - for (const label of labels) { - const escapedLabel = label.replace(/"/g, '\\"'); - searchQuery += ` label:"${escapedLabel}"`; - } - } - const result = await github.graphql( - ` - query($searchTerms: String!, $first: Int!) { - search(query: $searchTerms, type: DISCUSSION, first: $first) { - nodes { - ... on Discussion { - id - number - title - url - category { - id - } - labels(first: 100) { - nodes { - name - } - } - closed - } - } - } - }`, - { searchTerms: searchQuery, first: 50 } - ); - if (!result || !result.search || !result.search.nodes) { - return []; - } - return result.search.nodes - .filter( - d => { - if (!d || d.number === excludeNumber || d.closed) { - return false; - } - if (titlePrefix && d.title && !d.title.startsWith(titlePrefix)) { - return false; - } - if (labels && labels.length > 0) { - const discussionLabels = d.labels?.nodes?.map(( l) => l.name) || []; - const hasAllLabels = labels.every(label => discussionLabels.includes(label)); - if (!hasAllLabels) { - return false; - } - } - if (categoryId && (!d.category || d.category.id !== categoryId)) { - return false; - } - return true; - } - ) - .map( - d => ({ - id: d.id, - number: d.number, - title: d.title, - url: d.url, - }) - ); - } - async function addDiscussionComment(github, discussionId, message) { - const result = await github.graphql( - ` - mutation($dId: ID!, $body: String!) { - addDiscussionComment(input: { discussionId: $dId, body: $body }) { - comment { - id - url - } - } - }`, - { dId: discussionId, body: message } - ); - return result.addDiscussionComment.comment; - } - async function closeDiscussionAsOutdated(github, discussionId) { - const result = await github.graphql( - ` - mutation($dId: ID!) { - closeDiscussion(input: { discussionId: $dId, reason: OUTDATED }) { - discussion { - id - url - } - } - }`, - { dId: discussionId } - ); - return result.closeDiscussion.discussion; - } - async function closeOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion, workflowName, runUrl) { - const searchCriteria = []; - if (titlePrefix) searchCriteria.push(`title prefix: "${titlePrefix}"`); - if (labels && labels.length > 0) searchCriteria.push(`labels: [${labels.join(", ")}]`); - core.info(`Searching for older discussions with ${searchCriteria.join(" and ")}`); - const olderDiscussions = await searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion.number); - if (olderDiscussions.length === 0) { - core.info("No older discussions found to close"); - return []; - } - core.info(`Found ${olderDiscussions.length} older discussion(s) to close`); - const discussionsToClose = olderDiscussions.slice(0, MAX_CLOSE_COUNT); - if (olderDiscussions.length > MAX_CLOSE_COUNT) { - core.warning(`Found ${olderDiscussions.length} older discussions, but only closing the first ${MAX_CLOSE_COUNT}`); - } - const closedDiscussions = []; - for (let i = 0; i < discussionsToClose.length; i++) { - const discussion = discussionsToClose[i]; - try { - const closingMessage = getCloseOlderDiscussionMessage({ - newDiscussionUrl: newDiscussion.url, - newDiscussionNumber: newDiscussion.number, - workflowName, - runUrl, - }); - core.info(`Adding closing comment to discussion #${discussion.number}`); - await addDiscussionComment(github, discussion.id, closingMessage); - core.info(`Closing discussion #${discussion.number} as outdated`); - await closeDiscussionAsOutdated(github, discussion.id); - closedDiscussions.push({ - number: discussion.number, - url: discussion.url, - }); - core.info(`✓ Closed discussion #${discussion.number}: ${discussion.url}`); - } catch (error) { - core.error(`✗ Failed to close discussion #${discussion.number}: ${error instanceof Error ? error.message : String(error)}`); - } - if (i < discussionsToClose.length - 1) { - await delay(GRAPHQL_DELAY_MS); - } - } - return closedDiscussions; - } - const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; - function generateTemporaryId() { - return "aw_" + crypto.randomBytes(6).toString("hex"); - } - function isTemporaryId(value) { - if (typeof value === "string") { - return /^aw_[0-9a-f]{12}$/i.test(value); - } - return false; - } - function normalizeTemporaryId(tempId) { - return String(tempId).toLowerCase(); - } - function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); - if (resolved !== undefined) { - if (currentRepo && resolved.repo === currentRepo) { - return `#${resolved.number}`; - } - return `${resolved.repo}#${resolved.number}`; - } - return match; - }); - } - function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); - if (issueNumber !== undefined) { - return `#${issueNumber}`; - } - return match; - }); - } - function loadTemporaryIdMap() { - const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; - if (!mapJson || mapJson === "{}") { - return new Map(); - } - try { - const mapObject = JSON.parse(mapJson); - const result = new Map(); - for (const [key, value] of Object.entries(mapObject)) { - const normalizedKey = normalizeTemporaryId(key); - if (typeof value === "number") { - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - result.set(normalizedKey, { repo: contextRepo, number: value }); - } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { - result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); - } - } - return result; - } catch (error) { - if (typeof core !== "undefined") { - core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); - } - return new Map(); - } - } - function resolveIssueNumber(value, temporaryIdMap) { - if (value === undefined || value === null) { - return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; - } - const valueStr = String(value); - if (isTemporaryId(valueStr)) { - const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); - if (resolvedPair !== undefined) { - return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; - } - return { - resolved: null, - wasTemporaryId: true, - errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, - }; - } - const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); - if (isNaN(issueNumber) || issueNumber <= 0) { - return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; - } - const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; - return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; - } - function serializeTemporaryIdMap(tempIdMap) { - const obj = Object.fromEntries(tempIdMap); - return JSON.stringify(obj); - } - function parseAllowedRepos() { - const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; - const set = new Set(); - if (allowedReposEnv) { - allowedReposEnv - .split(",") - .map(repo => repo.trim()) - .filter(repo => repo) - .forEach(repo => set.add(repo)); - } - return set; - } - function getDefaultTargetRepo() { - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - if (targetRepoSlug) { - return targetRepoSlug; - } - return `${context.repo.owner}/${context.repo.repo}`; - } - function validateRepo(repo, defaultRepo, allowedRepos) { - if (repo === defaultRepo) { - return { valid: true, error: null }; - } - if (allowedRepos.has(repo)) { - return { valid: true, error: null }; - } - return { - valid: false, - error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, - }; - } - function parseRepoSlug(repoSlug) { - const parts = repoSlug.split("/"); - if (parts.length !== 2 || !parts[0] || !parts[1]) { - return null; - } - return { owner: parts[0], repo: parts[1] }; - } - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { getTrackerID } = require("./get_tracker_id.cjs"); + const { closeOlderDiscussions } = require("./close_older_discussions.cjs"); + const { replaceTemporaryIdReferences, loadTemporaryIdMap } = require("./temporary_id.cjs"); + const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require("./repo_helpers.cjs"); + const { addExpirationComment } = require("./expiration_helpers.cjs"); + const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); async function fetchRepoDiscussionInfo(owner, repo) { const repositoryQuery = ` query($owner: String!, $repo: String!) { @@ -7376,7 +7008,9 @@ jobs: ); let title = createDiscussionItem.title ? replaceTemporaryIdReferences(createDiscussionItem.title.trim(), temporaryIdMap, itemRepo) : ""; const bodyText = createDiscussionItem.body || ""; - let bodyLines = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo).split("\n"); + let processedBody = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo); + processedBody = removeDuplicateTitleFromDescription(title, processedBody); + let bodyLines = processedBody.split("\n"); if (!title) { title = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo) || "Agent Output"; } diff --git a/.github/workflows/github-mcp-tools-report.lock.yml b/.github/workflows/github-mcp-tools-report.lock.yml index 1b044554f97..036afba235a 100644 --- a/.github/workflows/github-mcp-tools-report.lock.yml +++ b/.github/workflows/github-mcp-tools-report.lock.yml @@ -6594,381 +6594,13 @@ jobs: with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - const fs = require("fs"); - const crypto = require("crypto"); - const MAX_LOG_CONTENT_LENGTH = 10000; - function truncateForLogging(content) { - if (content.length <= MAX_LOG_CONTENT_LENGTH) { - return content; - } - return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; - } - function loadAgentOutput() { - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; - if (!agentOutputFile) { - core.info("No GH_AW_AGENT_OUTPUT environment variable found"); - return { success: false }; - } - let outputContent; - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - return { success: false, error: errorMessage }; - } - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return { success: false }; - } - core.info(`Agent output content length: ${outputContent.length}`); - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); - return { success: false, error: errorMessage }; - } - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); - return { success: false }; - } - return { success: true, items: validatedOutput.items }; - } - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - function getMessages() { - const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES; - if (!messagesEnv) { - return null; - } - try { - return JSON.parse(messagesEnv); - } catch (error) { - core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`); - return null; - } - } - function renderTemplate(template, context) { - return template.replace(/\{(\w+)\}/g, (match, key) => { - const value = context[key]; - return value !== undefined && value !== null ? String(value) : match; - }); - } - function toSnakeCase(obj) { - const result = {}; - for (const [key, value] of Object.entries(obj)) { - const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase(); - result[snakeKey] = value; - result[key] = value; - } - return result; - } - function getCloseOlderDiscussionMessage(ctx) { - const messages = getMessages(); - const templateContext = toSnakeCase(ctx); - const defaultMessage = `⚓ Avast! This discussion be marked as **outdated** by [{workflow_name}]({run_url}). - 🗺️ A newer treasure map awaits ye at **[Discussion #{new_discussion_number}]({new_discussion_url})**. - Fair winds, matey! 🏴‍☠️`; - return messages?.closeOlderDiscussion - ? renderTemplate(messages.closeOlderDiscussion, templateContext) - : renderTemplate(defaultMessage, templateContext); - } - const MAX_CLOSE_COUNT = 10; - const GRAPHQL_DELAY_MS = 500; - function delay(ms) { - return new Promise(resolve => setTimeout(resolve, ms)); - } - async function searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, excludeNumber) { - let searchQuery = `repo:${owner}/${repo} is:open`; - if (titlePrefix) { - const escapedPrefix = titlePrefix.replace(/"/g, '\\"'); - searchQuery += ` in:title "${escapedPrefix}"`; - } - if (labels && labels.length > 0) { - for (const label of labels) { - const escapedLabel = label.replace(/"/g, '\\"'); - searchQuery += ` label:"${escapedLabel}"`; - } - } - const result = await github.graphql( - ` - query($searchTerms: String!, $first: Int!) { - search(query: $searchTerms, type: DISCUSSION, first: $first) { - nodes { - ... on Discussion { - id - number - title - url - category { - id - } - labels(first: 100) { - nodes { - name - } - } - closed - } - } - } - }`, - { searchTerms: searchQuery, first: 50 } - ); - if (!result || !result.search || !result.search.nodes) { - return []; - } - return result.search.nodes - .filter( - d => { - if (!d || d.number === excludeNumber || d.closed) { - return false; - } - if (titlePrefix && d.title && !d.title.startsWith(titlePrefix)) { - return false; - } - if (labels && labels.length > 0) { - const discussionLabels = d.labels?.nodes?.map(( l) => l.name) || []; - const hasAllLabels = labels.every(label => discussionLabels.includes(label)); - if (!hasAllLabels) { - return false; - } - } - if (categoryId && (!d.category || d.category.id !== categoryId)) { - return false; - } - return true; - } - ) - .map( - d => ({ - id: d.id, - number: d.number, - title: d.title, - url: d.url, - }) - ); - } - async function addDiscussionComment(github, discussionId, message) { - const result = await github.graphql( - ` - mutation($dId: ID!, $body: String!) { - addDiscussionComment(input: { discussionId: $dId, body: $body }) { - comment { - id - url - } - } - }`, - { dId: discussionId, body: message } - ); - return result.addDiscussionComment.comment; - } - async function closeDiscussionAsOutdated(github, discussionId) { - const result = await github.graphql( - ` - mutation($dId: ID!) { - closeDiscussion(input: { discussionId: $dId, reason: OUTDATED }) { - discussion { - id - url - } - } - }`, - { dId: discussionId } - ); - return result.closeDiscussion.discussion; - } - async function closeOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion, workflowName, runUrl) { - const searchCriteria = []; - if (titlePrefix) searchCriteria.push(`title prefix: "${titlePrefix}"`); - if (labels && labels.length > 0) searchCriteria.push(`labels: [${labels.join(", ")}]`); - core.info(`Searching for older discussions with ${searchCriteria.join(" and ")}`); - const olderDiscussions = await searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion.number); - if (olderDiscussions.length === 0) { - core.info("No older discussions found to close"); - return []; - } - core.info(`Found ${olderDiscussions.length} older discussion(s) to close`); - const discussionsToClose = olderDiscussions.slice(0, MAX_CLOSE_COUNT); - if (olderDiscussions.length > MAX_CLOSE_COUNT) { - core.warning(`Found ${olderDiscussions.length} older discussions, but only closing the first ${MAX_CLOSE_COUNT}`); - } - const closedDiscussions = []; - for (let i = 0; i < discussionsToClose.length; i++) { - const discussion = discussionsToClose[i]; - try { - const closingMessage = getCloseOlderDiscussionMessage({ - newDiscussionUrl: newDiscussion.url, - newDiscussionNumber: newDiscussion.number, - workflowName, - runUrl, - }); - core.info(`Adding closing comment to discussion #${discussion.number}`); - await addDiscussionComment(github, discussion.id, closingMessage); - core.info(`Closing discussion #${discussion.number} as outdated`); - await closeDiscussionAsOutdated(github, discussion.id); - closedDiscussions.push({ - number: discussion.number, - url: discussion.url, - }); - core.info(`✓ Closed discussion #${discussion.number}: ${discussion.url}`); - } catch (error) { - core.error(`✗ Failed to close discussion #${discussion.number}: ${error instanceof Error ? error.message : String(error)}`); - } - if (i < discussionsToClose.length - 1) { - await delay(GRAPHQL_DELAY_MS); - } - } - return closedDiscussions; - } - const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; - function generateTemporaryId() { - return "aw_" + crypto.randomBytes(6).toString("hex"); - } - function isTemporaryId(value) { - if (typeof value === "string") { - return /^aw_[0-9a-f]{12}$/i.test(value); - } - return false; - } - function normalizeTemporaryId(tempId) { - return String(tempId).toLowerCase(); - } - function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); - if (resolved !== undefined) { - if (currentRepo && resolved.repo === currentRepo) { - return `#${resolved.number}`; - } - return `${resolved.repo}#${resolved.number}`; - } - return match; - }); - } - function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); - if (issueNumber !== undefined) { - return `#${issueNumber}`; - } - return match; - }); - } - function loadTemporaryIdMap() { - const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; - if (!mapJson || mapJson === "{}") { - return new Map(); - } - try { - const mapObject = JSON.parse(mapJson); - const result = new Map(); - for (const [key, value] of Object.entries(mapObject)) { - const normalizedKey = normalizeTemporaryId(key); - if (typeof value === "number") { - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - result.set(normalizedKey, { repo: contextRepo, number: value }); - } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { - result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); - } - } - return result; - } catch (error) { - if (typeof core !== "undefined") { - core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); - } - return new Map(); - } - } - function resolveIssueNumber(value, temporaryIdMap) { - if (value === undefined || value === null) { - return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; - } - const valueStr = String(value); - if (isTemporaryId(valueStr)) { - const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); - if (resolvedPair !== undefined) { - return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; - } - return { - resolved: null, - wasTemporaryId: true, - errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, - }; - } - const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); - if (isNaN(issueNumber) || issueNumber <= 0) { - return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; - } - const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; - return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; - } - function serializeTemporaryIdMap(tempIdMap) { - const obj = Object.fromEntries(tempIdMap); - return JSON.stringify(obj); - } - function parseAllowedRepos() { - const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; - const set = new Set(); - if (allowedReposEnv) { - allowedReposEnv - .split(",") - .map(repo => repo.trim()) - .filter(repo => repo) - .forEach(repo => set.add(repo)); - } - return set; - } - function getDefaultTargetRepo() { - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - if (targetRepoSlug) { - return targetRepoSlug; - } - return `${context.repo.owner}/${context.repo.repo}`; - } - function validateRepo(repo, defaultRepo, allowedRepos) { - if (repo === defaultRepo) { - return { valid: true, error: null }; - } - if (allowedRepos.has(repo)) { - return { valid: true, error: null }; - } - return { - valid: false, - error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, - }; - } - function parseRepoSlug(repoSlug) { - const parts = repoSlug.split("/"); - if (parts.length !== 2 || !parts[0] || !parts[1]) { - return null; - } - return { owner: parts[0], repo: parts[1] }; - } - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { getTrackerID } = require("./get_tracker_id.cjs"); + const { closeOlderDiscussions } = require("./close_older_discussions.cjs"); + const { replaceTemporaryIdReferences, loadTemporaryIdMap } = require("./temporary_id.cjs"); + const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require("./repo_helpers.cjs"); + const { addExpirationComment } = require("./expiration_helpers.cjs"); + const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); async function fetchRepoDiscussionInfo(owner, repo) { const repositoryQuery = ` query($owner: String!, $repo: String!) { @@ -7153,7 +6785,9 @@ jobs: ); let title = createDiscussionItem.title ? replaceTemporaryIdReferences(createDiscussionItem.title.trim(), temporaryIdMap, itemRepo) : ""; const bodyText = createDiscussionItem.body || ""; - let bodyLines = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo).split("\n"); + let processedBody = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo); + processedBody = removeDuplicateTitleFromDescription(title, processedBody); + let bodyLines = processedBody.split("\n"); if (!title) { title = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo) || "Agent Output"; } @@ -7328,132 +6962,10 @@ jobs: script: | const fs = require("fs"); const crypto = require("crypto"); - async function updateActivationComment(github, context, core, itemUrl, itemNumber, itemType = "pull_request") { - const itemLabel = itemType === "issue" ? "issue" : "pull request"; - const linkMessage = - itemType === "issue" - ? `\n\n✅ Issue created: [#${itemNumber}](${itemUrl})` - : `\n\n✅ Pull request created: [#${itemNumber}](${itemUrl})`; - await updateActivationCommentWithMessage(github, context, core, linkMessage, itemLabel); - } - async function updateActivationCommentWithCommit(github, context, core, commitSha, commitUrl) { - const shortSha = commitSha.substring(0, 7); - const message = `\n\n✅ Commit pushed: [\`${shortSha}\`](${commitUrl})`; - await updateActivationCommentWithMessage(github, context, core, message, "commit"); - } - async function updateActivationCommentWithMessage(github, context, core, message, label = "") { - const commentId = process.env.GH_AW_COMMENT_ID; - const commentRepo = process.env.GH_AW_COMMENT_REPO; - if (!commentId) { - core.info("No activation comment to update (GH_AW_COMMENT_ID not set)"); - return; - } - core.info(`Updating activation comment ${commentId}`); - let repoOwner = context.repo.owner; - let repoName = context.repo.repo; - if (commentRepo) { - const parts = commentRepo.split("/"); - if (parts.length === 2) { - repoOwner = parts[0]; - repoName = parts[1]; - } else { - core.warning(`Invalid comment repo format: ${commentRepo}, expected "owner/repo". Falling back to context.repo.`); - } - } - core.info(`Updating comment in ${repoOwner}/${repoName}`); - const isDiscussionComment = commentId.startsWith("DC_"); - try { - if (isDiscussionComment) { - const currentComment = await github.graphql( - ` - query($commentId: ID!) { - node(id: $commentId) { - ... on DiscussionComment { - body - } - } - }`, - { commentId: commentId } - ); - if (!currentComment?.node?.body) { - core.warning("Unable to fetch current comment body, comment may have been deleted or is inaccessible"); - return; - } - const currentBody = currentComment.node.body; - const updatedBody = currentBody + message; - const result = await github.graphql( - ` - mutation($commentId: ID!, $body: String!) { - updateDiscussionComment(input: { commentId: $commentId, body: $body }) { - comment { - id - url - } - } - }`, - { commentId: commentId, body: updatedBody } - ); - const comment = result.updateDiscussionComment.comment; - const successMessage = label - ? `Successfully updated discussion comment with ${label} link` - : "Successfully updated discussion comment"; - core.info(successMessage); - core.info(`Comment ID: ${comment.id}`); - core.info(`Comment URL: ${comment.url}`); - } else { - const currentComment = await github.request("GET /repos/{owner}/{repo}/issues/comments/{comment_id}", { - owner: repoOwner, - repo: repoName, - comment_id: parseInt(commentId, 10), - headers: { - Accept: "application/vnd.github+json", - }, - }); - if (!currentComment?.data?.body) { - core.warning("Unable to fetch current comment body, comment may have been deleted"); - return; - } - const currentBody = currentComment.data.body; - const updatedBody = currentBody + message; - const response = await github.request("PATCH /repos/{owner}/{repo}/issues/comments/{comment_id}", { - owner: repoOwner, - repo: repoName, - comment_id: parseInt(commentId, 10), - body: updatedBody, - headers: { - Accept: "application/vnd.github+json", - }, - }); - const successMessage = label ? `Successfully updated comment with ${label} link` : "Successfully updated comment"; - core.info(successMessage); - core.info(`Comment ID: ${response.data.id}`); - core.info(`Comment URL: ${response.data.html_url}`); - } - } catch (error) { - core.warning(`Failed to update activation comment: ${error instanceof Error ? error.message : String(error)}`); - } - } - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } + const { updateActivationComment } = require("./update_activation_comment.cjs"); + const { getTrackerID } = require("./get_tracker_id.cjs"); + const { addExpirationComment } = require("./expiration_helpers.cjs"); + const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); function generatePatchPreview(patchContent) { if (!patchContent || !patchContent.trim()) { return ""; @@ -7645,7 +7157,9 @@ jobs: return; } let title = pullRequestItem.title.trim(); - let bodyLines = pullRequestItem.body.split("\n"); + let processedBody = pullRequestItem.body; + processedBody = removeDuplicateTitleFromDescription(title, processedBody); + let bodyLines = processedBody.split("\n"); let branchName = pullRequestItem.branch ? pullRequestItem.branch.trim() : null; if (!title) { title = "Agent Output"; diff --git a/.github/workflows/glossary-maintainer.lock.yml b/.github/workflows/glossary-maintainer.lock.yml index dbb5ebcb054..799ca5e091d 100644 --- a/.github/workflows/glossary-maintainer.lock.yml +++ b/.github/workflows/glossary-maintainer.lock.yml @@ -7312,132 +7312,10 @@ jobs: script: | const fs = require("fs"); const crypto = require("crypto"); - async function updateActivationComment(github, context, core, itemUrl, itemNumber, itemType = "pull_request") { - const itemLabel = itemType === "issue" ? "issue" : "pull request"; - const linkMessage = - itemType === "issue" - ? `\n\n✅ Issue created: [#${itemNumber}](${itemUrl})` - : `\n\n✅ Pull request created: [#${itemNumber}](${itemUrl})`; - await updateActivationCommentWithMessage(github, context, core, linkMessage, itemLabel); - } - async function updateActivationCommentWithCommit(github, context, core, commitSha, commitUrl) { - const shortSha = commitSha.substring(0, 7); - const message = `\n\n✅ Commit pushed: [\`${shortSha}\`](${commitUrl})`; - await updateActivationCommentWithMessage(github, context, core, message, "commit"); - } - async function updateActivationCommentWithMessage(github, context, core, message, label = "") { - const commentId = process.env.GH_AW_COMMENT_ID; - const commentRepo = process.env.GH_AW_COMMENT_REPO; - if (!commentId) { - core.info("No activation comment to update (GH_AW_COMMENT_ID not set)"); - return; - } - core.info(`Updating activation comment ${commentId}`); - let repoOwner = context.repo.owner; - let repoName = context.repo.repo; - if (commentRepo) { - const parts = commentRepo.split("/"); - if (parts.length === 2) { - repoOwner = parts[0]; - repoName = parts[1]; - } else { - core.warning(`Invalid comment repo format: ${commentRepo}, expected "owner/repo". Falling back to context.repo.`); - } - } - core.info(`Updating comment in ${repoOwner}/${repoName}`); - const isDiscussionComment = commentId.startsWith("DC_"); - try { - if (isDiscussionComment) { - const currentComment = await github.graphql( - ` - query($commentId: ID!) { - node(id: $commentId) { - ... on DiscussionComment { - body - } - } - }`, - { commentId: commentId } - ); - if (!currentComment?.node?.body) { - core.warning("Unable to fetch current comment body, comment may have been deleted or is inaccessible"); - return; - } - const currentBody = currentComment.node.body; - const updatedBody = currentBody + message; - const result = await github.graphql( - ` - mutation($commentId: ID!, $body: String!) { - updateDiscussionComment(input: { commentId: $commentId, body: $body }) { - comment { - id - url - } - } - }`, - { commentId: commentId, body: updatedBody } - ); - const comment = result.updateDiscussionComment.comment; - const successMessage = label - ? `Successfully updated discussion comment with ${label} link` - : "Successfully updated discussion comment"; - core.info(successMessage); - core.info(`Comment ID: ${comment.id}`); - core.info(`Comment URL: ${comment.url}`); - } else { - const currentComment = await github.request("GET /repos/{owner}/{repo}/issues/comments/{comment_id}", { - owner: repoOwner, - repo: repoName, - comment_id: parseInt(commentId, 10), - headers: { - Accept: "application/vnd.github+json", - }, - }); - if (!currentComment?.data?.body) { - core.warning("Unable to fetch current comment body, comment may have been deleted"); - return; - } - const currentBody = currentComment.data.body; - const updatedBody = currentBody + message; - const response = await github.request("PATCH /repos/{owner}/{repo}/issues/comments/{comment_id}", { - owner: repoOwner, - repo: repoName, - comment_id: parseInt(commentId, 10), - body: updatedBody, - headers: { - Accept: "application/vnd.github+json", - }, - }); - const successMessage = label ? `Successfully updated comment with ${label} link` : "Successfully updated comment"; - core.info(successMessage); - core.info(`Comment ID: ${response.data.id}`); - core.info(`Comment URL: ${response.data.html_url}`); - } - } catch (error) { - core.warning(`Failed to update activation comment: ${error instanceof Error ? error.message : String(error)}`); - } - } - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } + const { updateActivationComment } = require("./update_activation_comment.cjs"); + const { getTrackerID } = require("./get_tracker_id.cjs"); + const { addExpirationComment } = require("./expiration_helpers.cjs"); + const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); function generatePatchPreview(patchContent) { if (!patchContent || !patchContent.trim()) { return ""; @@ -7629,7 +7507,9 @@ jobs: return; } let title = pullRequestItem.title.trim(); - let bodyLines = pullRequestItem.body.split("\n"); + let processedBody = pullRequestItem.body; + processedBody = removeDuplicateTitleFromDescription(title, processedBody); + let bodyLines = processedBody.split("\n"); let branchName = pullRequestItem.branch ? pullRequestItem.branch.trim() : null; if (!title) { title = "Agent Output"; diff --git a/.github/workflows/go-fan.lock.yml b/.github/workflows/go-fan.lock.yml index 058332bb320..622d270d3a2 100644 --- a/.github/workflows/go-fan.lock.yml +++ b/.github/workflows/go-fan.lock.yml @@ -6162,381 +6162,13 @@ jobs: with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - const fs = require("fs"); - const crypto = require("crypto"); - const MAX_LOG_CONTENT_LENGTH = 10000; - function truncateForLogging(content) { - if (content.length <= MAX_LOG_CONTENT_LENGTH) { - return content; - } - return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; - } - function loadAgentOutput() { - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; - if (!agentOutputFile) { - core.info("No GH_AW_AGENT_OUTPUT environment variable found"); - return { success: false }; - } - let outputContent; - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - return { success: false, error: errorMessage }; - } - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return { success: false }; - } - core.info(`Agent output content length: ${outputContent.length}`); - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); - return { success: false, error: errorMessage }; - } - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); - return { success: false }; - } - return { success: true, items: validatedOutput.items }; - } - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - function getMessages() { - const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES; - if (!messagesEnv) { - return null; - } - try { - return JSON.parse(messagesEnv); - } catch (error) { - core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`); - return null; - } - } - function renderTemplate(template, context) { - return template.replace(/\{(\w+)\}/g, (match, key) => { - const value = context[key]; - return value !== undefined && value !== null ? String(value) : match; - }); - } - function toSnakeCase(obj) { - const result = {}; - for (const [key, value] of Object.entries(obj)) { - const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase(); - result[snakeKey] = value; - result[key] = value; - } - return result; - } - function getCloseOlderDiscussionMessage(ctx) { - const messages = getMessages(); - const templateContext = toSnakeCase(ctx); - const defaultMessage = `⚓ Avast! This discussion be marked as **outdated** by [{workflow_name}]({run_url}). - 🗺️ A newer treasure map awaits ye at **[Discussion #{new_discussion_number}]({new_discussion_url})**. - Fair winds, matey! 🏴‍☠️`; - return messages?.closeOlderDiscussion - ? renderTemplate(messages.closeOlderDiscussion, templateContext) - : renderTemplate(defaultMessage, templateContext); - } - const MAX_CLOSE_COUNT = 10; - const GRAPHQL_DELAY_MS = 500; - function delay(ms) { - return new Promise(resolve => setTimeout(resolve, ms)); - } - async function searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, excludeNumber) { - let searchQuery = `repo:${owner}/${repo} is:open`; - if (titlePrefix) { - const escapedPrefix = titlePrefix.replace(/"/g, '\\"'); - searchQuery += ` in:title "${escapedPrefix}"`; - } - if (labels && labels.length > 0) { - for (const label of labels) { - const escapedLabel = label.replace(/"/g, '\\"'); - searchQuery += ` label:"${escapedLabel}"`; - } - } - const result = await github.graphql( - ` - query($searchTerms: String!, $first: Int!) { - search(query: $searchTerms, type: DISCUSSION, first: $first) { - nodes { - ... on Discussion { - id - number - title - url - category { - id - } - labels(first: 100) { - nodes { - name - } - } - closed - } - } - } - }`, - { searchTerms: searchQuery, first: 50 } - ); - if (!result || !result.search || !result.search.nodes) { - return []; - } - return result.search.nodes - .filter( - d => { - if (!d || d.number === excludeNumber || d.closed) { - return false; - } - if (titlePrefix && d.title && !d.title.startsWith(titlePrefix)) { - return false; - } - if (labels && labels.length > 0) { - const discussionLabels = d.labels?.nodes?.map(( l) => l.name) || []; - const hasAllLabels = labels.every(label => discussionLabels.includes(label)); - if (!hasAllLabels) { - return false; - } - } - if (categoryId && (!d.category || d.category.id !== categoryId)) { - return false; - } - return true; - } - ) - .map( - d => ({ - id: d.id, - number: d.number, - title: d.title, - url: d.url, - }) - ); - } - async function addDiscussionComment(github, discussionId, message) { - const result = await github.graphql( - ` - mutation($dId: ID!, $body: String!) { - addDiscussionComment(input: { discussionId: $dId, body: $body }) { - comment { - id - url - } - } - }`, - { dId: discussionId, body: message } - ); - return result.addDiscussionComment.comment; - } - async function closeDiscussionAsOutdated(github, discussionId) { - const result = await github.graphql( - ` - mutation($dId: ID!) { - closeDiscussion(input: { discussionId: $dId, reason: OUTDATED }) { - discussion { - id - url - } - } - }`, - { dId: discussionId } - ); - return result.closeDiscussion.discussion; - } - async function closeOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion, workflowName, runUrl) { - const searchCriteria = []; - if (titlePrefix) searchCriteria.push(`title prefix: "${titlePrefix}"`); - if (labels && labels.length > 0) searchCriteria.push(`labels: [${labels.join(", ")}]`); - core.info(`Searching for older discussions with ${searchCriteria.join(" and ")}`); - const olderDiscussions = await searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion.number); - if (olderDiscussions.length === 0) { - core.info("No older discussions found to close"); - return []; - } - core.info(`Found ${olderDiscussions.length} older discussion(s) to close`); - const discussionsToClose = olderDiscussions.slice(0, MAX_CLOSE_COUNT); - if (olderDiscussions.length > MAX_CLOSE_COUNT) { - core.warning(`Found ${olderDiscussions.length} older discussions, but only closing the first ${MAX_CLOSE_COUNT}`); - } - const closedDiscussions = []; - for (let i = 0; i < discussionsToClose.length; i++) { - const discussion = discussionsToClose[i]; - try { - const closingMessage = getCloseOlderDiscussionMessage({ - newDiscussionUrl: newDiscussion.url, - newDiscussionNumber: newDiscussion.number, - workflowName, - runUrl, - }); - core.info(`Adding closing comment to discussion #${discussion.number}`); - await addDiscussionComment(github, discussion.id, closingMessage); - core.info(`Closing discussion #${discussion.number} as outdated`); - await closeDiscussionAsOutdated(github, discussion.id); - closedDiscussions.push({ - number: discussion.number, - url: discussion.url, - }); - core.info(`✓ Closed discussion #${discussion.number}: ${discussion.url}`); - } catch (error) { - core.error(`✗ Failed to close discussion #${discussion.number}: ${error instanceof Error ? error.message : String(error)}`); - } - if (i < discussionsToClose.length - 1) { - await delay(GRAPHQL_DELAY_MS); - } - } - return closedDiscussions; - } - const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; - function generateTemporaryId() { - return "aw_" + crypto.randomBytes(6).toString("hex"); - } - function isTemporaryId(value) { - if (typeof value === "string") { - return /^aw_[0-9a-f]{12}$/i.test(value); - } - return false; - } - function normalizeTemporaryId(tempId) { - return String(tempId).toLowerCase(); - } - function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); - if (resolved !== undefined) { - if (currentRepo && resolved.repo === currentRepo) { - return `#${resolved.number}`; - } - return `${resolved.repo}#${resolved.number}`; - } - return match; - }); - } - function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); - if (issueNumber !== undefined) { - return `#${issueNumber}`; - } - return match; - }); - } - function loadTemporaryIdMap() { - const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; - if (!mapJson || mapJson === "{}") { - return new Map(); - } - try { - const mapObject = JSON.parse(mapJson); - const result = new Map(); - for (const [key, value] of Object.entries(mapObject)) { - const normalizedKey = normalizeTemporaryId(key); - if (typeof value === "number") { - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - result.set(normalizedKey, { repo: contextRepo, number: value }); - } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { - result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); - } - } - return result; - } catch (error) { - if (typeof core !== "undefined") { - core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); - } - return new Map(); - } - } - function resolveIssueNumber(value, temporaryIdMap) { - if (value === undefined || value === null) { - return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; - } - const valueStr = String(value); - if (isTemporaryId(valueStr)) { - const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); - if (resolvedPair !== undefined) { - return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; - } - return { - resolved: null, - wasTemporaryId: true, - errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, - }; - } - const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); - if (isNaN(issueNumber) || issueNumber <= 0) { - return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; - } - const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; - return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; - } - function serializeTemporaryIdMap(tempIdMap) { - const obj = Object.fromEntries(tempIdMap); - return JSON.stringify(obj); - } - function parseAllowedRepos() { - const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; - const set = new Set(); - if (allowedReposEnv) { - allowedReposEnv - .split(",") - .map(repo => repo.trim()) - .filter(repo => repo) - .forEach(repo => set.add(repo)); - } - return set; - } - function getDefaultTargetRepo() { - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - if (targetRepoSlug) { - return targetRepoSlug; - } - return `${context.repo.owner}/${context.repo.repo}`; - } - function validateRepo(repo, defaultRepo, allowedRepos) { - if (repo === defaultRepo) { - return { valid: true, error: null }; - } - if (allowedRepos.has(repo)) { - return { valid: true, error: null }; - } - return { - valid: false, - error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, - }; - } - function parseRepoSlug(repoSlug) { - const parts = repoSlug.split("/"); - if (parts.length !== 2 || !parts[0] || !parts[1]) { - return null; - } - return { owner: parts[0], repo: parts[1] }; - } - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { getTrackerID } = require("./get_tracker_id.cjs"); + const { closeOlderDiscussions } = require("./close_older_discussions.cjs"); + const { replaceTemporaryIdReferences, loadTemporaryIdMap } = require("./temporary_id.cjs"); + const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require("./repo_helpers.cjs"); + const { addExpirationComment } = require("./expiration_helpers.cjs"); + const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); async function fetchRepoDiscussionInfo(owner, repo) { const repositoryQuery = ` query($owner: String!, $repo: String!) { @@ -6721,7 +6353,9 @@ jobs: ); let title = createDiscussionItem.title ? replaceTemporaryIdReferences(createDiscussionItem.title.trim(), temporaryIdMap, itemRepo) : ""; const bodyText = createDiscussionItem.body || ""; - let bodyLines = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo).split("\n"); + let processedBody = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo); + processedBody = removeDuplicateTitleFromDescription(title, processedBody); + let bodyLines = processedBody.split("\n"); if (!title) { title = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo) || "Agent Output"; } diff --git a/.github/workflows/go-logger.lock.yml b/.github/workflows/go-logger.lock.yml index 1716e6d6a20..01971d44e3f 100644 --- a/.github/workflows/go-logger.lock.yml +++ b/.github/workflows/go-logger.lock.yml @@ -5933,132 +5933,10 @@ jobs: script: | const fs = require("fs"); const crypto = require("crypto"); - async function updateActivationComment(github, context, core, itemUrl, itemNumber, itemType = "pull_request") { - const itemLabel = itemType === "issue" ? "issue" : "pull request"; - const linkMessage = - itemType === "issue" - ? `\n\n✅ Issue created: [#${itemNumber}](${itemUrl})` - : `\n\n✅ Pull request created: [#${itemNumber}](${itemUrl})`; - await updateActivationCommentWithMessage(github, context, core, linkMessage, itemLabel); - } - async function updateActivationCommentWithCommit(github, context, core, commitSha, commitUrl) { - const shortSha = commitSha.substring(0, 7); - const message = `\n\n✅ Commit pushed: [\`${shortSha}\`](${commitUrl})`; - await updateActivationCommentWithMessage(github, context, core, message, "commit"); - } - async function updateActivationCommentWithMessage(github, context, core, message, label = "") { - const commentId = process.env.GH_AW_COMMENT_ID; - const commentRepo = process.env.GH_AW_COMMENT_REPO; - if (!commentId) { - core.info("No activation comment to update (GH_AW_COMMENT_ID not set)"); - return; - } - core.info(`Updating activation comment ${commentId}`); - let repoOwner = context.repo.owner; - let repoName = context.repo.repo; - if (commentRepo) { - const parts = commentRepo.split("/"); - if (parts.length === 2) { - repoOwner = parts[0]; - repoName = parts[1]; - } else { - core.warning(`Invalid comment repo format: ${commentRepo}, expected "owner/repo". Falling back to context.repo.`); - } - } - core.info(`Updating comment in ${repoOwner}/${repoName}`); - const isDiscussionComment = commentId.startsWith("DC_"); - try { - if (isDiscussionComment) { - const currentComment = await github.graphql( - ` - query($commentId: ID!) { - node(id: $commentId) { - ... on DiscussionComment { - body - } - } - }`, - { commentId: commentId } - ); - if (!currentComment?.node?.body) { - core.warning("Unable to fetch current comment body, comment may have been deleted or is inaccessible"); - return; - } - const currentBody = currentComment.node.body; - const updatedBody = currentBody + message; - const result = await github.graphql( - ` - mutation($commentId: ID!, $body: String!) { - updateDiscussionComment(input: { commentId: $commentId, body: $body }) { - comment { - id - url - } - } - }`, - { commentId: commentId, body: updatedBody } - ); - const comment = result.updateDiscussionComment.comment; - const successMessage = label - ? `Successfully updated discussion comment with ${label} link` - : "Successfully updated discussion comment"; - core.info(successMessage); - core.info(`Comment ID: ${comment.id}`); - core.info(`Comment URL: ${comment.url}`); - } else { - const currentComment = await github.request("GET /repos/{owner}/{repo}/issues/comments/{comment_id}", { - owner: repoOwner, - repo: repoName, - comment_id: parseInt(commentId, 10), - headers: { - Accept: "application/vnd.github+json", - }, - }); - if (!currentComment?.data?.body) { - core.warning("Unable to fetch current comment body, comment may have been deleted"); - return; - } - const currentBody = currentComment.data.body; - const updatedBody = currentBody + message; - const response = await github.request("PATCH /repos/{owner}/{repo}/issues/comments/{comment_id}", { - owner: repoOwner, - repo: repoName, - comment_id: parseInt(commentId, 10), - body: updatedBody, - headers: { - Accept: "application/vnd.github+json", - }, - }); - const successMessage = label ? `Successfully updated comment with ${label} link` : "Successfully updated comment"; - core.info(successMessage); - core.info(`Comment ID: ${response.data.id}`); - core.info(`Comment URL: ${response.data.html_url}`); - } - } catch (error) { - core.warning(`Failed to update activation comment: ${error instanceof Error ? error.message : String(error)}`); - } - } - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } + const { updateActivationComment } = require("./update_activation_comment.cjs"); + const { getTrackerID } = require("./get_tracker_id.cjs"); + const { addExpirationComment } = require("./expiration_helpers.cjs"); + const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); function generatePatchPreview(patchContent) { if (!patchContent || !patchContent.trim()) { return ""; @@ -6250,7 +6128,9 @@ jobs: return; } let title = pullRequestItem.title.trim(); - let bodyLines = pullRequestItem.body.split("\n"); + let processedBody = pullRequestItem.body; + processedBody = removeDuplicateTitleFromDescription(title, processedBody); + let bodyLines = processedBody.split("\n"); let branchName = pullRequestItem.branch ? pullRequestItem.branch.trim() : null; if (!title) { title = "Agent Output"; diff --git a/.github/workflows/go-pattern-detector.lock.yml b/.github/workflows/go-pattern-detector.lock.yml index 8c1e2715c74..43448681713 100644 --- a/.github/workflows/go-pattern-detector.lock.yml +++ b/.github/workflows/go-pattern-detector.lock.yml @@ -5669,272 +5669,21 @@ jobs: with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - function sanitizeLabelContent(content) { - if (!content || typeof content !== "string") { - return ""; - } - let sanitized = content.trim(); - sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); - sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); - sanitized = sanitized.replace( - /(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, - (_m, p1, p2) => `${p1}\`@${p2}\`` - ); - sanitized = sanitized.replace(/[<>&'"]/g, ""); - return sanitized.trim(); - } - const fs = require("fs"); - const crypto = require("crypto"); - const MAX_LOG_CONTENT_LENGTH = 10000; - function truncateForLogging(content) { - if (content.length <= MAX_LOG_CONTENT_LENGTH) { - return content; - } - return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; - } - function loadAgentOutput() { - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; - if (!agentOutputFile) { - core.info("No GH_AW_AGENT_OUTPUT environment variable found"); - return { success: false }; - } - let outputContent; - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - return { success: false, error: errorMessage }; - } - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return { success: false }; - } - core.info(`Agent output content length: ${outputContent.length}`); - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); - return { success: false, error: errorMessage }; - } - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); - return { success: false }; - } - return { success: true, items: validatedOutput.items }; - } - async function generateStagedPreview(options) { - const { title, description, items, renderItem } = options; - let summaryContent = `## 🎭 Staged Mode: ${title} Preview\n\n`; - summaryContent += `${description}\n\n`; - for (let i = 0; i < items.length; i++) { - const item = items[i]; - summaryContent += renderItem(item, i); - summaryContent += "---\n\n"; - } - try { - await core.summary.addRaw(summaryContent).write(); - core.info(summaryContent); - core.info(`📝 ${title} preview written to step summary`); - } catch (error) { - core.setFailed(error instanceof Error ? error : String(error)); - } - } - function generateXMLMarker(workflowName, runUrl) { - const engineId = process.env.GH_AW_ENGINE_ID || ""; - const engineVersion = process.env.GH_AW_ENGINE_VERSION || ""; - const engineModel = process.env.GH_AW_ENGINE_MODEL || ""; - const trackerId = process.env.GH_AW_TRACKER_ID || ""; - const parts = []; - parts.push(`agentic-workflow: ${workflowName}`); - if (trackerId) { - parts.push(`tracker-id: ${trackerId}`); - } - if (engineId) { - parts.push(`engine: ${engineId}`); - } - if (engineVersion) { - parts.push(`version: ${engineVersion}`); - } - if (engineModel) { - parts.push(`model: ${engineModel}`); - } - parts.push(`run: ${runUrl}`); - return ``; - } - function generateFooter( - workflowName, - runUrl, - workflowSource, - workflowSourceURL, - triggeringIssueNumber, - triggeringPRNumber, - triggeringDiscussionNumber - ) { - let footer = `\n\n> AI generated by [${workflowName}](${runUrl})`; - if (triggeringIssueNumber) { - footer += ` for #${triggeringIssueNumber}`; - } else if (triggeringPRNumber) { - footer += ` for #${triggeringPRNumber}`; - } else if (triggeringDiscussionNumber) { - footer += ` for discussion #${triggeringDiscussionNumber}`; - } - if (workflowSource && workflowSourceURL) { - footer += `\n>\n> To add this workflow in your repository, run \`gh aw add ${workflowSource}\`. See [usage guide](https://githubnext.github.io/gh-aw/tools/cli/).`; - } - footer += "\n\n" + generateXMLMarker(workflowName, runUrl); - footer += "\n"; - return footer; - } - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; - function generateTemporaryId() { - return "aw_" + crypto.randomBytes(6).toString("hex"); - } - function isTemporaryId(value) { - if (typeof value === "string") { - return /^aw_[0-9a-f]{12}$/i.test(value); - } - return false; - } - function normalizeTemporaryId(tempId) { - return String(tempId).toLowerCase(); - } - function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); - if (resolved !== undefined) { - if (currentRepo && resolved.repo === currentRepo) { - return `#${resolved.number}`; - } - return `${resolved.repo}#${resolved.number}`; - } - return match; - }); - } - function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); - if (issueNumber !== undefined) { - return `#${issueNumber}`; - } - return match; - }); - } - function loadTemporaryIdMap() { - const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; - if (!mapJson || mapJson === "{}") { - return new Map(); - } - try { - const mapObject = JSON.parse(mapJson); - const result = new Map(); - for (const [key, value] of Object.entries(mapObject)) { - const normalizedKey = normalizeTemporaryId(key); - if (typeof value === "number") { - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - result.set(normalizedKey, { repo: contextRepo, number: value }); - } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { - result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); - } - } - return result; - } catch (error) { - if (typeof core !== "undefined") { - core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); - } - return new Map(); - } - } - function resolveIssueNumber(value, temporaryIdMap) { - if (value === undefined || value === null) { - return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; - } - const valueStr = String(value); - if (isTemporaryId(valueStr)) { - const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); - if (resolvedPair !== undefined) { - return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; - } - return { - resolved: null, - wasTemporaryId: true, - errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, - }; - } - const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); - if (isNaN(issueNumber) || issueNumber <= 0) { - return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; - } - const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; - return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; - } - function serializeTemporaryIdMap(tempIdMap) { - const obj = Object.fromEntries(tempIdMap); - return JSON.stringify(obj); - } - function parseAllowedRepos() { - const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; - const set = new Set(); - if (allowedReposEnv) { - allowedReposEnv - .split(",") - .map(repo => repo.trim()) - .filter(repo => repo) - .forEach(repo => set.add(repo)); - } - return set; - } - function getDefaultTargetRepo() { - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - if (targetRepoSlug) { - return targetRepoSlug; - } - return `${context.repo.owner}/${context.repo.repo}`; - } - function validateRepo(repo, defaultRepo, allowedRepos) { - if (repo === defaultRepo) { - return { valid: true, error: null }; - } - if (allowedRepos.has(repo)) { - return { valid: true, error: null }; - } - return { - valid: false, - error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, - }; - } - function parseRepoSlug(repoSlug) { - const parts = repoSlug.split("/"); - if (parts.length !== 2 || !parts[0] || !parts[1]) { - return null; - } - return { owner: parts[0], repo: parts[1] }; - } - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } + const { sanitizeLabelContent } = require("./sanitize_label_content.cjs"); + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { generateStagedPreview } = require("./staged_preview.cjs"); + const { generateFooter } = require("./generate_footer.cjs"); + const { getTrackerID } = require("./get_tracker_id.cjs"); + const { + generateTemporaryId, + isTemporaryId, + normalizeTemporaryId, + replaceTemporaryIdReferences, + serializeTemporaryIdMap, + } = require("./temporary_id.cjs"); + const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require("./repo_helpers.cjs"); + const { addExpirationComment } = require("./expiration_helpers.cjs"); + const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); async function main() { core.setOutput("issue_number", ""); core.setOutput("issue_url", ""); @@ -6067,6 +5816,7 @@ jobs: .filter((label, index, arr) => arr.indexOf(label) === index); let title = createIssueItem.title ? createIssueItem.title.trim() : ""; let processedBody = replaceTemporaryIdReferences(createIssueItem.body, temporaryIdMap, itemRepo); + processedBody = removeDuplicateTitleFromDescription(title, processedBody); let bodyLines = processedBody.split("\n"); if (!title) { title = createIssueItem.body || "Agent Output"; diff --git a/.github/workflows/instructions-janitor.lock.yml b/.github/workflows/instructions-janitor.lock.yml index 7bb68e97428..7a7fbf0f93b 100644 --- a/.github/workflows/instructions-janitor.lock.yml +++ b/.github/workflows/instructions-janitor.lock.yml @@ -5698,132 +5698,10 @@ jobs: script: | const fs = require("fs"); const crypto = require("crypto"); - async function updateActivationComment(github, context, core, itemUrl, itemNumber, itemType = "pull_request") { - const itemLabel = itemType === "issue" ? "issue" : "pull request"; - const linkMessage = - itemType === "issue" - ? `\n\n✅ Issue created: [#${itemNumber}](${itemUrl})` - : `\n\n✅ Pull request created: [#${itemNumber}](${itemUrl})`; - await updateActivationCommentWithMessage(github, context, core, linkMessage, itemLabel); - } - async function updateActivationCommentWithCommit(github, context, core, commitSha, commitUrl) { - const shortSha = commitSha.substring(0, 7); - const message = `\n\n✅ Commit pushed: [\`${shortSha}\`](${commitUrl})`; - await updateActivationCommentWithMessage(github, context, core, message, "commit"); - } - async function updateActivationCommentWithMessage(github, context, core, message, label = "") { - const commentId = process.env.GH_AW_COMMENT_ID; - const commentRepo = process.env.GH_AW_COMMENT_REPO; - if (!commentId) { - core.info("No activation comment to update (GH_AW_COMMENT_ID not set)"); - return; - } - core.info(`Updating activation comment ${commentId}`); - let repoOwner = context.repo.owner; - let repoName = context.repo.repo; - if (commentRepo) { - const parts = commentRepo.split("/"); - if (parts.length === 2) { - repoOwner = parts[0]; - repoName = parts[1]; - } else { - core.warning(`Invalid comment repo format: ${commentRepo}, expected "owner/repo". Falling back to context.repo.`); - } - } - core.info(`Updating comment in ${repoOwner}/${repoName}`); - const isDiscussionComment = commentId.startsWith("DC_"); - try { - if (isDiscussionComment) { - const currentComment = await github.graphql( - ` - query($commentId: ID!) { - node(id: $commentId) { - ... on DiscussionComment { - body - } - } - }`, - { commentId: commentId } - ); - if (!currentComment?.node?.body) { - core.warning("Unable to fetch current comment body, comment may have been deleted or is inaccessible"); - return; - } - const currentBody = currentComment.node.body; - const updatedBody = currentBody + message; - const result = await github.graphql( - ` - mutation($commentId: ID!, $body: String!) { - updateDiscussionComment(input: { commentId: $commentId, body: $body }) { - comment { - id - url - } - } - }`, - { commentId: commentId, body: updatedBody } - ); - const comment = result.updateDiscussionComment.comment; - const successMessage = label - ? `Successfully updated discussion comment with ${label} link` - : "Successfully updated discussion comment"; - core.info(successMessage); - core.info(`Comment ID: ${comment.id}`); - core.info(`Comment URL: ${comment.url}`); - } else { - const currentComment = await github.request("GET /repos/{owner}/{repo}/issues/comments/{comment_id}", { - owner: repoOwner, - repo: repoName, - comment_id: parseInt(commentId, 10), - headers: { - Accept: "application/vnd.github+json", - }, - }); - if (!currentComment?.data?.body) { - core.warning("Unable to fetch current comment body, comment may have been deleted"); - return; - } - const currentBody = currentComment.data.body; - const updatedBody = currentBody + message; - const response = await github.request("PATCH /repos/{owner}/{repo}/issues/comments/{comment_id}", { - owner: repoOwner, - repo: repoName, - comment_id: parseInt(commentId, 10), - body: updatedBody, - headers: { - Accept: "application/vnd.github+json", - }, - }); - const successMessage = label ? `Successfully updated comment with ${label} link` : "Successfully updated comment"; - core.info(successMessage); - core.info(`Comment ID: ${response.data.id}`); - core.info(`Comment URL: ${response.data.html_url}`); - } - } catch (error) { - core.warning(`Failed to update activation comment: ${error instanceof Error ? error.message : String(error)}`); - } - } - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } + const { updateActivationComment } = require("./update_activation_comment.cjs"); + const { getTrackerID } = require("./get_tracker_id.cjs"); + const { addExpirationComment } = require("./expiration_helpers.cjs"); + const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); function generatePatchPreview(patchContent) { if (!patchContent || !patchContent.trim()) { return ""; @@ -6015,7 +5893,9 @@ jobs: return; } let title = pullRequestItem.title.trim(); - let bodyLines = pullRequestItem.body.split("\n"); + let processedBody = pullRequestItem.body; + processedBody = removeDuplicateTitleFromDescription(title, processedBody); + let bodyLines = processedBody.split("\n"); let branchName = pullRequestItem.branch ? pullRequestItem.branch.trim() : null; if (!title) { title = "Agent Output"; diff --git a/.github/workflows/issue-arborist.lock.yml b/.github/workflows/issue-arborist.lock.yml index 61ac22d859a..c62e355a504 100644 --- a/.github/workflows/issue-arborist.lock.yml +++ b/.github/workflows/issue-arborist.lock.yml @@ -5830,381 +5830,13 @@ jobs: with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - const fs = require("fs"); - const crypto = require("crypto"); - const MAX_LOG_CONTENT_LENGTH = 10000; - function truncateForLogging(content) { - if (content.length <= MAX_LOG_CONTENT_LENGTH) { - return content; - } - return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; - } - function loadAgentOutput() { - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; - if (!agentOutputFile) { - core.info("No GH_AW_AGENT_OUTPUT environment variable found"); - return { success: false }; - } - let outputContent; - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - return { success: false, error: errorMessage }; - } - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return { success: false }; - } - core.info(`Agent output content length: ${outputContent.length}`); - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); - return { success: false, error: errorMessage }; - } - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); - return { success: false }; - } - return { success: true, items: validatedOutput.items }; - } - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - function getMessages() { - const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES; - if (!messagesEnv) { - return null; - } - try { - return JSON.parse(messagesEnv); - } catch (error) { - core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`); - return null; - } - } - function renderTemplate(template, context) { - return template.replace(/\{(\w+)\}/g, (match, key) => { - const value = context[key]; - return value !== undefined && value !== null ? String(value) : match; - }); - } - function toSnakeCase(obj) { - const result = {}; - for (const [key, value] of Object.entries(obj)) { - const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase(); - result[snakeKey] = value; - result[key] = value; - } - return result; - } - function getCloseOlderDiscussionMessage(ctx) { - const messages = getMessages(); - const templateContext = toSnakeCase(ctx); - const defaultMessage = `⚓ Avast! This discussion be marked as **outdated** by [{workflow_name}]({run_url}). - 🗺️ A newer treasure map awaits ye at **[Discussion #{new_discussion_number}]({new_discussion_url})**. - Fair winds, matey! 🏴‍☠️`; - return messages?.closeOlderDiscussion - ? renderTemplate(messages.closeOlderDiscussion, templateContext) - : renderTemplate(defaultMessage, templateContext); - } - const MAX_CLOSE_COUNT = 10; - const GRAPHQL_DELAY_MS = 500; - function delay(ms) { - return new Promise(resolve => setTimeout(resolve, ms)); - } - async function searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, excludeNumber) { - let searchQuery = `repo:${owner}/${repo} is:open`; - if (titlePrefix) { - const escapedPrefix = titlePrefix.replace(/"/g, '\\"'); - searchQuery += ` in:title "${escapedPrefix}"`; - } - if (labels && labels.length > 0) { - for (const label of labels) { - const escapedLabel = label.replace(/"/g, '\\"'); - searchQuery += ` label:"${escapedLabel}"`; - } - } - const result = await github.graphql( - ` - query($searchTerms: String!, $first: Int!) { - search(query: $searchTerms, type: DISCUSSION, first: $first) { - nodes { - ... on Discussion { - id - number - title - url - category { - id - } - labels(first: 100) { - nodes { - name - } - } - closed - } - } - } - }`, - { searchTerms: searchQuery, first: 50 } - ); - if (!result || !result.search || !result.search.nodes) { - return []; - } - return result.search.nodes - .filter( - d => { - if (!d || d.number === excludeNumber || d.closed) { - return false; - } - if (titlePrefix && d.title && !d.title.startsWith(titlePrefix)) { - return false; - } - if (labels && labels.length > 0) { - const discussionLabels = d.labels?.nodes?.map(( l) => l.name) || []; - const hasAllLabels = labels.every(label => discussionLabels.includes(label)); - if (!hasAllLabels) { - return false; - } - } - if (categoryId && (!d.category || d.category.id !== categoryId)) { - return false; - } - return true; - } - ) - .map( - d => ({ - id: d.id, - number: d.number, - title: d.title, - url: d.url, - }) - ); - } - async function addDiscussionComment(github, discussionId, message) { - const result = await github.graphql( - ` - mutation($dId: ID!, $body: String!) { - addDiscussionComment(input: { discussionId: $dId, body: $body }) { - comment { - id - url - } - } - }`, - { dId: discussionId, body: message } - ); - return result.addDiscussionComment.comment; - } - async function closeDiscussionAsOutdated(github, discussionId) { - const result = await github.graphql( - ` - mutation($dId: ID!) { - closeDiscussion(input: { discussionId: $dId, reason: OUTDATED }) { - discussion { - id - url - } - } - }`, - { dId: discussionId } - ); - return result.closeDiscussion.discussion; - } - async function closeOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion, workflowName, runUrl) { - const searchCriteria = []; - if (titlePrefix) searchCriteria.push(`title prefix: "${titlePrefix}"`); - if (labels && labels.length > 0) searchCriteria.push(`labels: [${labels.join(", ")}]`); - core.info(`Searching for older discussions with ${searchCriteria.join(" and ")}`); - const olderDiscussions = await searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion.number); - if (olderDiscussions.length === 0) { - core.info("No older discussions found to close"); - return []; - } - core.info(`Found ${olderDiscussions.length} older discussion(s) to close`); - const discussionsToClose = olderDiscussions.slice(0, MAX_CLOSE_COUNT); - if (olderDiscussions.length > MAX_CLOSE_COUNT) { - core.warning(`Found ${olderDiscussions.length} older discussions, but only closing the first ${MAX_CLOSE_COUNT}`); - } - const closedDiscussions = []; - for (let i = 0; i < discussionsToClose.length; i++) { - const discussion = discussionsToClose[i]; - try { - const closingMessage = getCloseOlderDiscussionMessage({ - newDiscussionUrl: newDiscussion.url, - newDiscussionNumber: newDiscussion.number, - workflowName, - runUrl, - }); - core.info(`Adding closing comment to discussion #${discussion.number}`); - await addDiscussionComment(github, discussion.id, closingMessage); - core.info(`Closing discussion #${discussion.number} as outdated`); - await closeDiscussionAsOutdated(github, discussion.id); - closedDiscussions.push({ - number: discussion.number, - url: discussion.url, - }); - core.info(`✓ Closed discussion #${discussion.number}: ${discussion.url}`); - } catch (error) { - core.error(`✗ Failed to close discussion #${discussion.number}: ${error instanceof Error ? error.message : String(error)}`); - } - if (i < discussionsToClose.length - 1) { - await delay(GRAPHQL_DELAY_MS); - } - } - return closedDiscussions; - } - const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; - function generateTemporaryId() { - return "aw_" + crypto.randomBytes(6).toString("hex"); - } - function isTemporaryId(value) { - if (typeof value === "string") { - return /^aw_[0-9a-f]{12}$/i.test(value); - } - return false; - } - function normalizeTemporaryId(tempId) { - return String(tempId).toLowerCase(); - } - function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); - if (resolved !== undefined) { - if (currentRepo && resolved.repo === currentRepo) { - return `#${resolved.number}`; - } - return `${resolved.repo}#${resolved.number}`; - } - return match; - }); - } - function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); - if (issueNumber !== undefined) { - return `#${issueNumber}`; - } - return match; - }); - } - function loadTemporaryIdMap() { - const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; - if (!mapJson || mapJson === "{}") { - return new Map(); - } - try { - const mapObject = JSON.parse(mapJson); - const result = new Map(); - for (const [key, value] of Object.entries(mapObject)) { - const normalizedKey = normalizeTemporaryId(key); - if (typeof value === "number") { - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - result.set(normalizedKey, { repo: contextRepo, number: value }); - } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { - result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); - } - } - return result; - } catch (error) { - if (typeof core !== "undefined") { - core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); - } - return new Map(); - } - } - function resolveIssueNumber(value, temporaryIdMap) { - if (value === undefined || value === null) { - return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; - } - const valueStr = String(value); - if (isTemporaryId(valueStr)) { - const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); - if (resolvedPair !== undefined) { - return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; - } - return { - resolved: null, - wasTemporaryId: true, - errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, - }; - } - const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); - if (isNaN(issueNumber) || issueNumber <= 0) { - return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; - } - const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; - return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; - } - function serializeTemporaryIdMap(tempIdMap) { - const obj = Object.fromEntries(tempIdMap); - return JSON.stringify(obj); - } - function parseAllowedRepos() { - const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; - const set = new Set(); - if (allowedReposEnv) { - allowedReposEnv - .split(",") - .map(repo => repo.trim()) - .filter(repo => repo) - .forEach(repo => set.add(repo)); - } - return set; - } - function getDefaultTargetRepo() { - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - if (targetRepoSlug) { - return targetRepoSlug; - } - return `${context.repo.owner}/${context.repo.repo}`; - } - function validateRepo(repo, defaultRepo, allowedRepos) { - if (repo === defaultRepo) { - return { valid: true, error: null }; - } - if (allowedRepos.has(repo)) { - return { valid: true, error: null }; - } - return { - valid: false, - error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, - }; - } - function parseRepoSlug(repoSlug) { - const parts = repoSlug.split("/"); - if (parts.length !== 2 || !parts[0] || !parts[1]) { - return null; - } - return { owner: parts[0], repo: parts[1] }; - } - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { getTrackerID } = require("./get_tracker_id.cjs"); + const { closeOlderDiscussions } = require("./close_older_discussions.cjs"); + const { replaceTemporaryIdReferences, loadTemporaryIdMap } = require("./temporary_id.cjs"); + const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require("./repo_helpers.cjs"); + const { addExpirationComment } = require("./expiration_helpers.cjs"); + const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); async function fetchRepoDiscussionInfo(owner, repo) { const repositoryQuery = ` query($owner: String!, $repo: String!) { @@ -6389,7 +6021,9 @@ jobs: ); let title = createDiscussionItem.title ? replaceTemporaryIdReferences(createDiscussionItem.title.trim(), temporaryIdMap, itemRepo) : ""; const bodyText = createDiscussionItem.body || ""; - let bodyLines = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo).split("\n"); + let processedBody = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo); + processedBody = removeDuplicateTitleFromDescription(title, processedBody); + let bodyLines = processedBody.split("\n"); if (!title) { title = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo) || "Agent Output"; } diff --git a/.github/workflows/layout-spec-maintainer.lock.yml b/.github/workflows/layout-spec-maintainer.lock.yml index 6a320d172c0..bb02017b46d 100644 --- a/.github/workflows/layout-spec-maintainer.lock.yml +++ b/.github/workflows/layout-spec-maintainer.lock.yml @@ -6442,132 +6442,10 @@ jobs: script: | const fs = require("fs"); const crypto = require("crypto"); - async function updateActivationComment(github, context, core, itemUrl, itemNumber, itemType = "pull_request") { - const itemLabel = itemType === "issue" ? "issue" : "pull request"; - const linkMessage = - itemType === "issue" - ? `\n\n✅ Issue created: [#${itemNumber}](${itemUrl})` - : `\n\n✅ Pull request created: [#${itemNumber}](${itemUrl})`; - await updateActivationCommentWithMessage(github, context, core, linkMessage, itemLabel); - } - async function updateActivationCommentWithCommit(github, context, core, commitSha, commitUrl) { - const shortSha = commitSha.substring(0, 7); - const message = `\n\n✅ Commit pushed: [\`${shortSha}\`](${commitUrl})`; - await updateActivationCommentWithMessage(github, context, core, message, "commit"); - } - async function updateActivationCommentWithMessage(github, context, core, message, label = "") { - const commentId = process.env.GH_AW_COMMENT_ID; - const commentRepo = process.env.GH_AW_COMMENT_REPO; - if (!commentId) { - core.info("No activation comment to update (GH_AW_COMMENT_ID not set)"); - return; - } - core.info(`Updating activation comment ${commentId}`); - let repoOwner = context.repo.owner; - let repoName = context.repo.repo; - if (commentRepo) { - const parts = commentRepo.split("/"); - if (parts.length === 2) { - repoOwner = parts[0]; - repoName = parts[1]; - } else { - core.warning(`Invalid comment repo format: ${commentRepo}, expected "owner/repo". Falling back to context.repo.`); - } - } - core.info(`Updating comment in ${repoOwner}/${repoName}`); - const isDiscussionComment = commentId.startsWith("DC_"); - try { - if (isDiscussionComment) { - const currentComment = await github.graphql( - ` - query($commentId: ID!) { - node(id: $commentId) { - ... on DiscussionComment { - body - } - } - }`, - { commentId: commentId } - ); - if (!currentComment?.node?.body) { - core.warning("Unable to fetch current comment body, comment may have been deleted or is inaccessible"); - return; - } - const currentBody = currentComment.node.body; - const updatedBody = currentBody + message; - const result = await github.graphql( - ` - mutation($commentId: ID!, $body: String!) { - updateDiscussionComment(input: { commentId: $commentId, body: $body }) { - comment { - id - url - } - } - }`, - { commentId: commentId, body: updatedBody } - ); - const comment = result.updateDiscussionComment.comment; - const successMessage = label - ? `Successfully updated discussion comment with ${label} link` - : "Successfully updated discussion comment"; - core.info(successMessage); - core.info(`Comment ID: ${comment.id}`); - core.info(`Comment URL: ${comment.url}`); - } else { - const currentComment = await github.request("GET /repos/{owner}/{repo}/issues/comments/{comment_id}", { - owner: repoOwner, - repo: repoName, - comment_id: parseInt(commentId, 10), - headers: { - Accept: "application/vnd.github+json", - }, - }); - if (!currentComment?.data?.body) { - core.warning("Unable to fetch current comment body, comment may have been deleted"); - return; - } - const currentBody = currentComment.data.body; - const updatedBody = currentBody + message; - const response = await github.request("PATCH /repos/{owner}/{repo}/issues/comments/{comment_id}", { - owner: repoOwner, - repo: repoName, - comment_id: parseInt(commentId, 10), - body: updatedBody, - headers: { - Accept: "application/vnd.github+json", - }, - }); - const successMessage = label ? `Successfully updated comment with ${label} link` : "Successfully updated comment"; - core.info(successMessage); - core.info(`Comment ID: ${response.data.id}`); - core.info(`Comment URL: ${response.data.html_url}`); - } - } catch (error) { - core.warning(`Failed to update activation comment: ${error instanceof Error ? error.message : String(error)}`); - } - } - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } + const { updateActivationComment } = require("./update_activation_comment.cjs"); + const { getTrackerID } = require("./get_tracker_id.cjs"); + const { addExpirationComment } = require("./expiration_helpers.cjs"); + const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); function generatePatchPreview(patchContent) { if (!patchContent || !patchContent.trim()) { return ""; @@ -6759,7 +6637,9 @@ jobs: return; } let title = pullRequestItem.title.trim(); - let bodyLines = pullRequestItem.body.split("\n"); + let processedBody = pullRequestItem.body; + processedBody = removeDuplicateTitleFromDescription(title, processedBody); + let bodyLines = processedBody.split("\n"); let branchName = pullRequestItem.branch ? pullRequestItem.branch.trim() : null; if (!title) { title = "Agent Output"; diff --git a/.github/workflows/lockfile-stats.lock.yml b/.github/workflows/lockfile-stats.lock.yml index 9b6aa4f79a6..846c163b15c 100644 --- a/.github/workflows/lockfile-stats.lock.yml +++ b/.github/workflows/lockfile-stats.lock.yml @@ -6168,381 +6168,13 @@ jobs: with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - const fs = require("fs"); - const crypto = require("crypto"); - const MAX_LOG_CONTENT_LENGTH = 10000; - function truncateForLogging(content) { - if (content.length <= MAX_LOG_CONTENT_LENGTH) { - return content; - } - return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; - } - function loadAgentOutput() { - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; - if (!agentOutputFile) { - core.info("No GH_AW_AGENT_OUTPUT environment variable found"); - return { success: false }; - } - let outputContent; - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - return { success: false, error: errorMessage }; - } - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return { success: false }; - } - core.info(`Agent output content length: ${outputContent.length}`); - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); - return { success: false, error: errorMessage }; - } - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); - return { success: false }; - } - return { success: true, items: validatedOutput.items }; - } - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - function getMessages() { - const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES; - if (!messagesEnv) { - return null; - } - try { - return JSON.parse(messagesEnv); - } catch (error) { - core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`); - return null; - } - } - function renderTemplate(template, context) { - return template.replace(/\{(\w+)\}/g, (match, key) => { - const value = context[key]; - return value !== undefined && value !== null ? String(value) : match; - }); - } - function toSnakeCase(obj) { - const result = {}; - for (const [key, value] of Object.entries(obj)) { - const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase(); - result[snakeKey] = value; - result[key] = value; - } - return result; - } - function getCloseOlderDiscussionMessage(ctx) { - const messages = getMessages(); - const templateContext = toSnakeCase(ctx); - const defaultMessage = `⚓ Avast! This discussion be marked as **outdated** by [{workflow_name}]({run_url}). - 🗺️ A newer treasure map awaits ye at **[Discussion #{new_discussion_number}]({new_discussion_url})**. - Fair winds, matey! 🏴‍☠️`; - return messages?.closeOlderDiscussion - ? renderTemplate(messages.closeOlderDiscussion, templateContext) - : renderTemplate(defaultMessage, templateContext); - } - const MAX_CLOSE_COUNT = 10; - const GRAPHQL_DELAY_MS = 500; - function delay(ms) { - return new Promise(resolve => setTimeout(resolve, ms)); - } - async function searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, excludeNumber) { - let searchQuery = `repo:${owner}/${repo} is:open`; - if (titlePrefix) { - const escapedPrefix = titlePrefix.replace(/"/g, '\\"'); - searchQuery += ` in:title "${escapedPrefix}"`; - } - if (labels && labels.length > 0) { - for (const label of labels) { - const escapedLabel = label.replace(/"/g, '\\"'); - searchQuery += ` label:"${escapedLabel}"`; - } - } - const result = await github.graphql( - ` - query($searchTerms: String!, $first: Int!) { - search(query: $searchTerms, type: DISCUSSION, first: $first) { - nodes { - ... on Discussion { - id - number - title - url - category { - id - } - labels(first: 100) { - nodes { - name - } - } - closed - } - } - } - }`, - { searchTerms: searchQuery, first: 50 } - ); - if (!result || !result.search || !result.search.nodes) { - return []; - } - return result.search.nodes - .filter( - d => { - if (!d || d.number === excludeNumber || d.closed) { - return false; - } - if (titlePrefix && d.title && !d.title.startsWith(titlePrefix)) { - return false; - } - if (labels && labels.length > 0) { - const discussionLabels = d.labels?.nodes?.map(( l) => l.name) || []; - const hasAllLabels = labels.every(label => discussionLabels.includes(label)); - if (!hasAllLabels) { - return false; - } - } - if (categoryId && (!d.category || d.category.id !== categoryId)) { - return false; - } - return true; - } - ) - .map( - d => ({ - id: d.id, - number: d.number, - title: d.title, - url: d.url, - }) - ); - } - async function addDiscussionComment(github, discussionId, message) { - const result = await github.graphql( - ` - mutation($dId: ID!, $body: String!) { - addDiscussionComment(input: { discussionId: $dId, body: $body }) { - comment { - id - url - } - } - }`, - { dId: discussionId, body: message } - ); - return result.addDiscussionComment.comment; - } - async function closeDiscussionAsOutdated(github, discussionId) { - const result = await github.graphql( - ` - mutation($dId: ID!) { - closeDiscussion(input: { discussionId: $dId, reason: OUTDATED }) { - discussion { - id - url - } - } - }`, - { dId: discussionId } - ); - return result.closeDiscussion.discussion; - } - async function closeOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion, workflowName, runUrl) { - const searchCriteria = []; - if (titlePrefix) searchCriteria.push(`title prefix: "${titlePrefix}"`); - if (labels && labels.length > 0) searchCriteria.push(`labels: [${labels.join(", ")}]`); - core.info(`Searching for older discussions with ${searchCriteria.join(" and ")}`); - const olderDiscussions = await searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion.number); - if (olderDiscussions.length === 0) { - core.info("No older discussions found to close"); - return []; - } - core.info(`Found ${olderDiscussions.length} older discussion(s) to close`); - const discussionsToClose = olderDiscussions.slice(0, MAX_CLOSE_COUNT); - if (olderDiscussions.length > MAX_CLOSE_COUNT) { - core.warning(`Found ${olderDiscussions.length} older discussions, but only closing the first ${MAX_CLOSE_COUNT}`); - } - const closedDiscussions = []; - for (let i = 0; i < discussionsToClose.length; i++) { - const discussion = discussionsToClose[i]; - try { - const closingMessage = getCloseOlderDiscussionMessage({ - newDiscussionUrl: newDiscussion.url, - newDiscussionNumber: newDiscussion.number, - workflowName, - runUrl, - }); - core.info(`Adding closing comment to discussion #${discussion.number}`); - await addDiscussionComment(github, discussion.id, closingMessage); - core.info(`Closing discussion #${discussion.number} as outdated`); - await closeDiscussionAsOutdated(github, discussion.id); - closedDiscussions.push({ - number: discussion.number, - url: discussion.url, - }); - core.info(`✓ Closed discussion #${discussion.number}: ${discussion.url}`); - } catch (error) { - core.error(`✗ Failed to close discussion #${discussion.number}: ${error instanceof Error ? error.message : String(error)}`); - } - if (i < discussionsToClose.length - 1) { - await delay(GRAPHQL_DELAY_MS); - } - } - return closedDiscussions; - } - const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; - function generateTemporaryId() { - return "aw_" + crypto.randomBytes(6).toString("hex"); - } - function isTemporaryId(value) { - if (typeof value === "string") { - return /^aw_[0-9a-f]{12}$/i.test(value); - } - return false; - } - function normalizeTemporaryId(tempId) { - return String(tempId).toLowerCase(); - } - function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); - if (resolved !== undefined) { - if (currentRepo && resolved.repo === currentRepo) { - return `#${resolved.number}`; - } - return `${resolved.repo}#${resolved.number}`; - } - return match; - }); - } - function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); - if (issueNumber !== undefined) { - return `#${issueNumber}`; - } - return match; - }); - } - function loadTemporaryIdMap() { - const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; - if (!mapJson || mapJson === "{}") { - return new Map(); - } - try { - const mapObject = JSON.parse(mapJson); - const result = new Map(); - for (const [key, value] of Object.entries(mapObject)) { - const normalizedKey = normalizeTemporaryId(key); - if (typeof value === "number") { - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - result.set(normalizedKey, { repo: contextRepo, number: value }); - } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { - result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); - } - } - return result; - } catch (error) { - if (typeof core !== "undefined") { - core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); - } - return new Map(); - } - } - function resolveIssueNumber(value, temporaryIdMap) { - if (value === undefined || value === null) { - return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; - } - const valueStr = String(value); - if (isTemporaryId(valueStr)) { - const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); - if (resolvedPair !== undefined) { - return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; - } - return { - resolved: null, - wasTemporaryId: true, - errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, - }; - } - const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); - if (isNaN(issueNumber) || issueNumber <= 0) { - return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; - } - const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; - return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; - } - function serializeTemporaryIdMap(tempIdMap) { - const obj = Object.fromEntries(tempIdMap); - return JSON.stringify(obj); - } - function parseAllowedRepos() { - const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; - const set = new Set(); - if (allowedReposEnv) { - allowedReposEnv - .split(",") - .map(repo => repo.trim()) - .filter(repo => repo) - .forEach(repo => set.add(repo)); - } - return set; - } - function getDefaultTargetRepo() { - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - if (targetRepoSlug) { - return targetRepoSlug; - } - return `${context.repo.owner}/${context.repo.repo}`; - } - function validateRepo(repo, defaultRepo, allowedRepos) { - if (repo === defaultRepo) { - return { valid: true, error: null }; - } - if (allowedRepos.has(repo)) { - return { valid: true, error: null }; - } - return { - valid: false, - error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, - }; - } - function parseRepoSlug(repoSlug) { - const parts = repoSlug.split("/"); - if (parts.length !== 2 || !parts[0] || !parts[1]) { - return null; - } - return { owner: parts[0], repo: parts[1] }; - } - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { getTrackerID } = require("./get_tracker_id.cjs"); + const { closeOlderDiscussions } = require("./close_older_discussions.cjs"); + const { replaceTemporaryIdReferences, loadTemporaryIdMap } = require("./temporary_id.cjs"); + const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require("./repo_helpers.cjs"); + const { addExpirationComment } = require("./expiration_helpers.cjs"); + const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); async function fetchRepoDiscussionInfo(owner, repo) { const repositoryQuery = ` query($owner: String!, $repo: String!) { @@ -6727,7 +6359,9 @@ jobs: ); let title = createDiscussionItem.title ? replaceTemporaryIdReferences(createDiscussionItem.title.trim(), temporaryIdMap, itemRepo) : ""; const bodyText = createDiscussionItem.body || ""; - let bodyLines = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo).split("\n"); + let processedBody = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo); + processedBody = removeDuplicateTitleFromDescription(title, processedBody); + let bodyLines = processedBody.split("\n"); if (!title) { title = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo) || "Agent Output"; } diff --git a/.github/workflows/mcp-inspector.lock.yml b/.github/workflows/mcp-inspector.lock.yml index 14ddab76a75..33cbeeba0c0 100644 --- a/.github/workflows/mcp-inspector.lock.yml +++ b/.github/workflows/mcp-inspector.lock.yml @@ -6784,381 +6784,13 @@ jobs: with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - const fs = require("fs"); - const crypto = require("crypto"); - const MAX_LOG_CONTENT_LENGTH = 10000; - function truncateForLogging(content) { - if (content.length <= MAX_LOG_CONTENT_LENGTH) { - return content; - } - return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; - } - function loadAgentOutput() { - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; - if (!agentOutputFile) { - core.info("No GH_AW_AGENT_OUTPUT environment variable found"); - return { success: false }; - } - let outputContent; - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - return { success: false, error: errorMessage }; - } - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return { success: false }; - } - core.info(`Agent output content length: ${outputContent.length}`); - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); - return { success: false, error: errorMessage }; - } - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); - return { success: false }; - } - return { success: true, items: validatedOutput.items }; - } - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - function getMessages() { - const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES; - if (!messagesEnv) { - return null; - } - try { - return JSON.parse(messagesEnv); - } catch (error) { - core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`); - return null; - } - } - function renderTemplate(template, context) { - return template.replace(/\{(\w+)\}/g, (match, key) => { - const value = context[key]; - return value !== undefined && value !== null ? String(value) : match; - }); - } - function toSnakeCase(obj) { - const result = {}; - for (const [key, value] of Object.entries(obj)) { - const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase(); - result[snakeKey] = value; - result[key] = value; - } - return result; - } - function getCloseOlderDiscussionMessage(ctx) { - const messages = getMessages(); - const templateContext = toSnakeCase(ctx); - const defaultMessage = `⚓ Avast! This discussion be marked as **outdated** by [{workflow_name}]({run_url}). - 🗺️ A newer treasure map awaits ye at **[Discussion #{new_discussion_number}]({new_discussion_url})**. - Fair winds, matey! 🏴‍☠️`; - return messages?.closeOlderDiscussion - ? renderTemplate(messages.closeOlderDiscussion, templateContext) - : renderTemplate(defaultMessage, templateContext); - } - const MAX_CLOSE_COUNT = 10; - const GRAPHQL_DELAY_MS = 500; - function delay(ms) { - return new Promise(resolve => setTimeout(resolve, ms)); - } - async function searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, excludeNumber) { - let searchQuery = `repo:${owner}/${repo} is:open`; - if (titlePrefix) { - const escapedPrefix = titlePrefix.replace(/"/g, '\\"'); - searchQuery += ` in:title "${escapedPrefix}"`; - } - if (labels && labels.length > 0) { - for (const label of labels) { - const escapedLabel = label.replace(/"/g, '\\"'); - searchQuery += ` label:"${escapedLabel}"`; - } - } - const result = await github.graphql( - ` - query($searchTerms: String!, $first: Int!) { - search(query: $searchTerms, type: DISCUSSION, first: $first) { - nodes { - ... on Discussion { - id - number - title - url - category { - id - } - labels(first: 100) { - nodes { - name - } - } - closed - } - } - } - }`, - { searchTerms: searchQuery, first: 50 } - ); - if (!result || !result.search || !result.search.nodes) { - return []; - } - return result.search.nodes - .filter( - d => { - if (!d || d.number === excludeNumber || d.closed) { - return false; - } - if (titlePrefix && d.title && !d.title.startsWith(titlePrefix)) { - return false; - } - if (labels && labels.length > 0) { - const discussionLabels = d.labels?.nodes?.map(( l) => l.name) || []; - const hasAllLabels = labels.every(label => discussionLabels.includes(label)); - if (!hasAllLabels) { - return false; - } - } - if (categoryId && (!d.category || d.category.id !== categoryId)) { - return false; - } - return true; - } - ) - .map( - d => ({ - id: d.id, - number: d.number, - title: d.title, - url: d.url, - }) - ); - } - async function addDiscussionComment(github, discussionId, message) { - const result = await github.graphql( - ` - mutation($dId: ID!, $body: String!) { - addDiscussionComment(input: { discussionId: $dId, body: $body }) { - comment { - id - url - } - } - }`, - { dId: discussionId, body: message } - ); - return result.addDiscussionComment.comment; - } - async function closeDiscussionAsOutdated(github, discussionId) { - const result = await github.graphql( - ` - mutation($dId: ID!) { - closeDiscussion(input: { discussionId: $dId, reason: OUTDATED }) { - discussion { - id - url - } - } - }`, - { dId: discussionId } - ); - return result.closeDiscussion.discussion; - } - async function closeOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion, workflowName, runUrl) { - const searchCriteria = []; - if (titlePrefix) searchCriteria.push(`title prefix: "${titlePrefix}"`); - if (labels && labels.length > 0) searchCriteria.push(`labels: [${labels.join(", ")}]`); - core.info(`Searching for older discussions with ${searchCriteria.join(" and ")}`); - const olderDiscussions = await searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion.number); - if (olderDiscussions.length === 0) { - core.info("No older discussions found to close"); - return []; - } - core.info(`Found ${olderDiscussions.length} older discussion(s) to close`); - const discussionsToClose = olderDiscussions.slice(0, MAX_CLOSE_COUNT); - if (olderDiscussions.length > MAX_CLOSE_COUNT) { - core.warning(`Found ${olderDiscussions.length} older discussions, but only closing the first ${MAX_CLOSE_COUNT}`); - } - const closedDiscussions = []; - for (let i = 0; i < discussionsToClose.length; i++) { - const discussion = discussionsToClose[i]; - try { - const closingMessage = getCloseOlderDiscussionMessage({ - newDiscussionUrl: newDiscussion.url, - newDiscussionNumber: newDiscussion.number, - workflowName, - runUrl, - }); - core.info(`Adding closing comment to discussion #${discussion.number}`); - await addDiscussionComment(github, discussion.id, closingMessage); - core.info(`Closing discussion #${discussion.number} as outdated`); - await closeDiscussionAsOutdated(github, discussion.id); - closedDiscussions.push({ - number: discussion.number, - url: discussion.url, - }); - core.info(`✓ Closed discussion #${discussion.number}: ${discussion.url}`); - } catch (error) { - core.error(`✗ Failed to close discussion #${discussion.number}: ${error instanceof Error ? error.message : String(error)}`); - } - if (i < discussionsToClose.length - 1) { - await delay(GRAPHQL_DELAY_MS); - } - } - return closedDiscussions; - } - const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; - function generateTemporaryId() { - return "aw_" + crypto.randomBytes(6).toString("hex"); - } - function isTemporaryId(value) { - if (typeof value === "string") { - return /^aw_[0-9a-f]{12}$/i.test(value); - } - return false; - } - function normalizeTemporaryId(tempId) { - return String(tempId).toLowerCase(); - } - function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); - if (resolved !== undefined) { - if (currentRepo && resolved.repo === currentRepo) { - return `#${resolved.number}`; - } - return `${resolved.repo}#${resolved.number}`; - } - return match; - }); - } - function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); - if (issueNumber !== undefined) { - return `#${issueNumber}`; - } - return match; - }); - } - function loadTemporaryIdMap() { - const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; - if (!mapJson || mapJson === "{}") { - return new Map(); - } - try { - const mapObject = JSON.parse(mapJson); - const result = new Map(); - for (const [key, value] of Object.entries(mapObject)) { - const normalizedKey = normalizeTemporaryId(key); - if (typeof value === "number") { - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - result.set(normalizedKey, { repo: contextRepo, number: value }); - } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { - result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); - } - } - return result; - } catch (error) { - if (typeof core !== "undefined") { - core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); - } - return new Map(); - } - } - function resolveIssueNumber(value, temporaryIdMap) { - if (value === undefined || value === null) { - return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; - } - const valueStr = String(value); - if (isTemporaryId(valueStr)) { - const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); - if (resolvedPair !== undefined) { - return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; - } - return { - resolved: null, - wasTemporaryId: true, - errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, - }; - } - const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); - if (isNaN(issueNumber) || issueNumber <= 0) { - return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; - } - const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; - return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; - } - function serializeTemporaryIdMap(tempIdMap) { - const obj = Object.fromEntries(tempIdMap); - return JSON.stringify(obj); - } - function parseAllowedRepos() { - const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; - const set = new Set(); - if (allowedReposEnv) { - allowedReposEnv - .split(",") - .map(repo => repo.trim()) - .filter(repo => repo) - .forEach(repo => set.add(repo)); - } - return set; - } - function getDefaultTargetRepo() { - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - if (targetRepoSlug) { - return targetRepoSlug; - } - return `${context.repo.owner}/${context.repo.repo}`; - } - function validateRepo(repo, defaultRepo, allowedRepos) { - if (repo === defaultRepo) { - return { valid: true, error: null }; - } - if (allowedRepos.has(repo)) { - return { valid: true, error: null }; - } - return { - valid: false, - error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, - }; - } - function parseRepoSlug(repoSlug) { - const parts = repoSlug.split("/"); - if (parts.length !== 2 || !parts[0] || !parts[1]) { - return null; - } - return { owner: parts[0], repo: parts[1] }; - } - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { getTrackerID } = require("./get_tracker_id.cjs"); + const { closeOlderDiscussions } = require("./close_older_discussions.cjs"); + const { replaceTemporaryIdReferences, loadTemporaryIdMap } = require("./temporary_id.cjs"); + const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require("./repo_helpers.cjs"); + const { addExpirationComment } = require("./expiration_helpers.cjs"); + const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); async function fetchRepoDiscussionInfo(owner, repo) { const repositoryQuery = ` query($owner: String!, $repo: String!) { @@ -7343,7 +6975,9 @@ jobs: ); let title = createDiscussionItem.title ? replaceTemporaryIdReferences(createDiscussionItem.title.trim(), temporaryIdMap, itemRepo) : ""; const bodyText = createDiscussionItem.body || ""; - let bodyLines = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo).split("\n"); + let processedBody = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo); + processedBody = removeDuplicateTitleFromDescription(title, processedBody); + let bodyLines = processedBody.split("\n"); if (!title) { title = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo) || "Agent Output"; } diff --git a/.github/workflows/org-health-report.lock.yml b/.github/workflows/org-health-report.lock.yml index 1af6e6b9a0b..266da6b8fa5 100644 --- a/.github/workflows/org-health-report.lock.yml +++ b/.github/workflows/org-health-report.lock.yml @@ -7649,381 +7649,13 @@ jobs: with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - const fs = require("fs"); - const crypto = require("crypto"); - const MAX_LOG_CONTENT_LENGTH = 10000; - function truncateForLogging(content) { - if (content.length <= MAX_LOG_CONTENT_LENGTH) { - return content; - } - return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; - } - function loadAgentOutput() { - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; - if (!agentOutputFile) { - core.info("No GH_AW_AGENT_OUTPUT environment variable found"); - return { success: false }; - } - let outputContent; - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - return { success: false, error: errorMessage }; - } - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return { success: false }; - } - core.info(`Agent output content length: ${outputContent.length}`); - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); - return { success: false, error: errorMessage }; - } - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); - return { success: false }; - } - return { success: true, items: validatedOutput.items }; - } - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - function getMessages() { - const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES; - if (!messagesEnv) { - return null; - } - try { - return JSON.parse(messagesEnv); - } catch (error) { - core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`); - return null; - } - } - function renderTemplate(template, context) { - return template.replace(/\{(\w+)\}/g, (match, key) => { - const value = context[key]; - return value !== undefined && value !== null ? String(value) : match; - }); - } - function toSnakeCase(obj) { - const result = {}; - for (const [key, value] of Object.entries(obj)) { - const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase(); - result[snakeKey] = value; - result[key] = value; - } - return result; - } - function getCloseOlderDiscussionMessage(ctx) { - const messages = getMessages(); - const templateContext = toSnakeCase(ctx); - const defaultMessage = `⚓ Avast! This discussion be marked as **outdated** by [{workflow_name}]({run_url}). - 🗺️ A newer treasure map awaits ye at **[Discussion #{new_discussion_number}]({new_discussion_url})**. - Fair winds, matey! 🏴‍☠️`; - return messages?.closeOlderDiscussion - ? renderTemplate(messages.closeOlderDiscussion, templateContext) - : renderTemplate(defaultMessage, templateContext); - } - const MAX_CLOSE_COUNT = 10; - const GRAPHQL_DELAY_MS = 500; - function delay(ms) { - return new Promise(resolve => setTimeout(resolve, ms)); - } - async function searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, excludeNumber) { - let searchQuery = `repo:${owner}/${repo} is:open`; - if (titlePrefix) { - const escapedPrefix = titlePrefix.replace(/"/g, '\\"'); - searchQuery += ` in:title "${escapedPrefix}"`; - } - if (labels && labels.length > 0) { - for (const label of labels) { - const escapedLabel = label.replace(/"/g, '\\"'); - searchQuery += ` label:"${escapedLabel}"`; - } - } - const result = await github.graphql( - ` - query($searchTerms: String!, $first: Int!) { - search(query: $searchTerms, type: DISCUSSION, first: $first) { - nodes { - ... on Discussion { - id - number - title - url - category { - id - } - labels(first: 100) { - nodes { - name - } - } - closed - } - } - } - }`, - { searchTerms: searchQuery, first: 50 } - ); - if (!result || !result.search || !result.search.nodes) { - return []; - } - return result.search.nodes - .filter( - d => { - if (!d || d.number === excludeNumber || d.closed) { - return false; - } - if (titlePrefix && d.title && !d.title.startsWith(titlePrefix)) { - return false; - } - if (labels && labels.length > 0) { - const discussionLabels = d.labels?.nodes?.map(( l) => l.name) || []; - const hasAllLabels = labels.every(label => discussionLabels.includes(label)); - if (!hasAllLabels) { - return false; - } - } - if (categoryId && (!d.category || d.category.id !== categoryId)) { - return false; - } - return true; - } - ) - .map( - d => ({ - id: d.id, - number: d.number, - title: d.title, - url: d.url, - }) - ); - } - async function addDiscussionComment(github, discussionId, message) { - const result = await github.graphql( - ` - mutation($dId: ID!, $body: String!) { - addDiscussionComment(input: { discussionId: $dId, body: $body }) { - comment { - id - url - } - } - }`, - { dId: discussionId, body: message } - ); - return result.addDiscussionComment.comment; - } - async function closeDiscussionAsOutdated(github, discussionId) { - const result = await github.graphql( - ` - mutation($dId: ID!) { - closeDiscussion(input: { discussionId: $dId, reason: OUTDATED }) { - discussion { - id - url - } - } - }`, - { dId: discussionId } - ); - return result.closeDiscussion.discussion; - } - async function closeOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion, workflowName, runUrl) { - const searchCriteria = []; - if (titlePrefix) searchCriteria.push(`title prefix: "${titlePrefix}"`); - if (labels && labels.length > 0) searchCriteria.push(`labels: [${labels.join(", ")}]`); - core.info(`Searching for older discussions with ${searchCriteria.join(" and ")}`); - const olderDiscussions = await searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion.number); - if (olderDiscussions.length === 0) { - core.info("No older discussions found to close"); - return []; - } - core.info(`Found ${olderDiscussions.length} older discussion(s) to close`); - const discussionsToClose = olderDiscussions.slice(0, MAX_CLOSE_COUNT); - if (olderDiscussions.length > MAX_CLOSE_COUNT) { - core.warning(`Found ${olderDiscussions.length} older discussions, but only closing the first ${MAX_CLOSE_COUNT}`); - } - const closedDiscussions = []; - for (let i = 0; i < discussionsToClose.length; i++) { - const discussion = discussionsToClose[i]; - try { - const closingMessage = getCloseOlderDiscussionMessage({ - newDiscussionUrl: newDiscussion.url, - newDiscussionNumber: newDiscussion.number, - workflowName, - runUrl, - }); - core.info(`Adding closing comment to discussion #${discussion.number}`); - await addDiscussionComment(github, discussion.id, closingMessage); - core.info(`Closing discussion #${discussion.number} as outdated`); - await closeDiscussionAsOutdated(github, discussion.id); - closedDiscussions.push({ - number: discussion.number, - url: discussion.url, - }); - core.info(`✓ Closed discussion #${discussion.number}: ${discussion.url}`); - } catch (error) { - core.error(`✗ Failed to close discussion #${discussion.number}: ${error instanceof Error ? error.message : String(error)}`); - } - if (i < discussionsToClose.length - 1) { - await delay(GRAPHQL_DELAY_MS); - } - } - return closedDiscussions; - } - const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; - function generateTemporaryId() { - return "aw_" + crypto.randomBytes(6).toString("hex"); - } - function isTemporaryId(value) { - if (typeof value === "string") { - return /^aw_[0-9a-f]{12}$/i.test(value); - } - return false; - } - function normalizeTemporaryId(tempId) { - return String(tempId).toLowerCase(); - } - function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); - if (resolved !== undefined) { - if (currentRepo && resolved.repo === currentRepo) { - return `#${resolved.number}`; - } - return `${resolved.repo}#${resolved.number}`; - } - return match; - }); - } - function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); - if (issueNumber !== undefined) { - return `#${issueNumber}`; - } - return match; - }); - } - function loadTemporaryIdMap() { - const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; - if (!mapJson || mapJson === "{}") { - return new Map(); - } - try { - const mapObject = JSON.parse(mapJson); - const result = new Map(); - for (const [key, value] of Object.entries(mapObject)) { - const normalizedKey = normalizeTemporaryId(key); - if (typeof value === "number") { - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - result.set(normalizedKey, { repo: contextRepo, number: value }); - } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { - result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); - } - } - return result; - } catch (error) { - if (typeof core !== "undefined") { - core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); - } - return new Map(); - } - } - function resolveIssueNumber(value, temporaryIdMap) { - if (value === undefined || value === null) { - return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; - } - const valueStr = String(value); - if (isTemporaryId(valueStr)) { - const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); - if (resolvedPair !== undefined) { - return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; - } - return { - resolved: null, - wasTemporaryId: true, - errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, - }; - } - const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); - if (isNaN(issueNumber) || issueNumber <= 0) { - return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; - } - const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; - return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; - } - function serializeTemporaryIdMap(tempIdMap) { - const obj = Object.fromEntries(tempIdMap); - return JSON.stringify(obj); - } - function parseAllowedRepos() { - const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; - const set = new Set(); - if (allowedReposEnv) { - allowedReposEnv - .split(",") - .map(repo => repo.trim()) - .filter(repo => repo) - .forEach(repo => set.add(repo)); - } - return set; - } - function getDefaultTargetRepo() { - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - if (targetRepoSlug) { - return targetRepoSlug; - } - return `${context.repo.owner}/${context.repo.repo}`; - } - function validateRepo(repo, defaultRepo, allowedRepos) { - if (repo === defaultRepo) { - return { valid: true, error: null }; - } - if (allowedRepos.has(repo)) { - return { valid: true, error: null }; - } - return { - valid: false, - error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, - }; - } - function parseRepoSlug(repoSlug) { - const parts = repoSlug.split("/"); - if (parts.length !== 2 || !parts[0] || !parts[1]) { - return null; - } - return { owner: parts[0], repo: parts[1] }; - } - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { getTrackerID } = require("./get_tracker_id.cjs"); + const { closeOlderDiscussions } = require("./close_older_discussions.cjs"); + const { replaceTemporaryIdReferences, loadTemporaryIdMap } = require("./temporary_id.cjs"); + const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require("./repo_helpers.cjs"); + const { addExpirationComment } = require("./expiration_helpers.cjs"); + const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); async function fetchRepoDiscussionInfo(owner, repo) { const repositoryQuery = ` query($owner: String!, $repo: String!) { @@ -8208,7 +7840,9 @@ jobs: ); let title = createDiscussionItem.title ? replaceTemporaryIdReferences(createDiscussionItem.title.trim(), temporaryIdMap, itemRepo) : ""; const bodyText = createDiscussionItem.body || ""; - let bodyLines = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo).split("\n"); + let processedBody = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo); + processedBody = removeDuplicateTitleFromDescription(title, processedBody); + let bodyLines = processedBody.split("\n"); if (!title) { title = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo) || "Agent Output"; } diff --git a/.github/workflows/plan.lock.yml b/.github/workflows/plan.lock.yml index e97f09d8140..36688fcd3ae 100644 --- a/.github/workflows/plan.lock.yml +++ b/.github/workflows/plan.lock.yml @@ -7449,272 +7449,21 @@ jobs: with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - function sanitizeLabelContent(content) { - if (!content || typeof content !== "string") { - return ""; - } - let sanitized = content.trim(); - sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); - sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); - sanitized = sanitized.replace( - /(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, - (_m, p1, p2) => `${p1}\`@${p2}\`` - ); - sanitized = sanitized.replace(/[<>&'"]/g, ""); - return sanitized.trim(); - } - const fs = require("fs"); - const crypto = require("crypto"); - const MAX_LOG_CONTENT_LENGTH = 10000; - function truncateForLogging(content) { - if (content.length <= MAX_LOG_CONTENT_LENGTH) { - return content; - } - return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; - } - function loadAgentOutput() { - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; - if (!agentOutputFile) { - core.info("No GH_AW_AGENT_OUTPUT environment variable found"); - return { success: false }; - } - let outputContent; - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - return { success: false, error: errorMessage }; - } - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return { success: false }; - } - core.info(`Agent output content length: ${outputContent.length}`); - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); - return { success: false, error: errorMessage }; - } - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); - return { success: false }; - } - return { success: true, items: validatedOutput.items }; - } - async function generateStagedPreview(options) { - const { title, description, items, renderItem } = options; - let summaryContent = `## 🎭 Staged Mode: ${title} Preview\n\n`; - summaryContent += `${description}\n\n`; - for (let i = 0; i < items.length; i++) { - const item = items[i]; - summaryContent += renderItem(item, i); - summaryContent += "---\n\n"; - } - try { - await core.summary.addRaw(summaryContent).write(); - core.info(summaryContent); - core.info(`📝 ${title} preview written to step summary`); - } catch (error) { - core.setFailed(error instanceof Error ? error : String(error)); - } - } - function generateXMLMarker(workflowName, runUrl) { - const engineId = process.env.GH_AW_ENGINE_ID || ""; - const engineVersion = process.env.GH_AW_ENGINE_VERSION || ""; - const engineModel = process.env.GH_AW_ENGINE_MODEL || ""; - const trackerId = process.env.GH_AW_TRACKER_ID || ""; - const parts = []; - parts.push(`agentic-workflow: ${workflowName}`); - if (trackerId) { - parts.push(`tracker-id: ${trackerId}`); - } - if (engineId) { - parts.push(`engine: ${engineId}`); - } - if (engineVersion) { - parts.push(`version: ${engineVersion}`); - } - if (engineModel) { - parts.push(`model: ${engineModel}`); - } - parts.push(`run: ${runUrl}`); - return ``; - } - function generateFooter( - workflowName, - runUrl, - workflowSource, - workflowSourceURL, - triggeringIssueNumber, - triggeringPRNumber, - triggeringDiscussionNumber - ) { - let footer = `\n\n> AI generated by [${workflowName}](${runUrl})`; - if (triggeringIssueNumber) { - footer += ` for #${triggeringIssueNumber}`; - } else if (triggeringPRNumber) { - footer += ` for #${triggeringPRNumber}`; - } else if (triggeringDiscussionNumber) { - footer += ` for discussion #${triggeringDiscussionNumber}`; - } - if (workflowSource && workflowSourceURL) { - footer += `\n>\n> To add this workflow in your repository, run \`gh aw add ${workflowSource}\`. See [usage guide](https://githubnext.github.io/gh-aw/tools/cli/).`; - } - footer += "\n\n" + generateXMLMarker(workflowName, runUrl); - footer += "\n"; - return footer; - } - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; - function generateTemporaryId() { - return "aw_" + crypto.randomBytes(6).toString("hex"); - } - function isTemporaryId(value) { - if (typeof value === "string") { - return /^aw_[0-9a-f]{12}$/i.test(value); - } - return false; - } - function normalizeTemporaryId(tempId) { - return String(tempId).toLowerCase(); - } - function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); - if (resolved !== undefined) { - if (currentRepo && resolved.repo === currentRepo) { - return `#${resolved.number}`; - } - return `${resolved.repo}#${resolved.number}`; - } - return match; - }); - } - function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); - if (issueNumber !== undefined) { - return `#${issueNumber}`; - } - return match; - }); - } - function loadTemporaryIdMap() { - const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; - if (!mapJson || mapJson === "{}") { - return new Map(); - } - try { - const mapObject = JSON.parse(mapJson); - const result = new Map(); - for (const [key, value] of Object.entries(mapObject)) { - const normalizedKey = normalizeTemporaryId(key); - if (typeof value === "number") { - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - result.set(normalizedKey, { repo: contextRepo, number: value }); - } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { - result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); - } - } - return result; - } catch (error) { - if (typeof core !== "undefined") { - core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); - } - return new Map(); - } - } - function resolveIssueNumber(value, temporaryIdMap) { - if (value === undefined || value === null) { - return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; - } - const valueStr = String(value); - if (isTemporaryId(valueStr)) { - const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); - if (resolvedPair !== undefined) { - return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; - } - return { - resolved: null, - wasTemporaryId: true, - errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, - }; - } - const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); - if (isNaN(issueNumber) || issueNumber <= 0) { - return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; - } - const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; - return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; - } - function serializeTemporaryIdMap(tempIdMap) { - const obj = Object.fromEntries(tempIdMap); - return JSON.stringify(obj); - } - function parseAllowedRepos() { - const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; - const set = new Set(); - if (allowedReposEnv) { - allowedReposEnv - .split(",") - .map(repo => repo.trim()) - .filter(repo => repo) - .forEach(repo => set.add(repo)); - } - return set; - } - function getDefaultTargetRepo() { - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - if (targetRepoSlug) { - return targetRepoSlug; - } - return `${context.repo.owner}/${context.repo.repo}`; - } - function validateRepo(repo, defaultRepo, allowedRepos) { - if (repo === defaultRepo) { - return { valid: true, error: null }; - } - if (allowedRepos.has(repo)) { - return { valid: true, error: null }; - } - return { - valid: false, - error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, - }; - } - function parseRepoSlug(repoSlug) { - const parts = repoSlug.split("/"); - if (parts.length !== 2 || !parts[0] || !parts[1]) { - return null; - } - return { owner: parts[0], repo: parts[1] }; - } - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } + const { sanitizeLabelContent } = require("./sanitize_label_content.cjs"); + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { generateStagedPreview } = require("./staged_preview.cjs"); + const { generateFooter } = require("./generate_footer.cjs"); + const { getTrackerID } = require("./get_tracker_id.cjs"); + const { + generateTemporaryId, + isTemporaryId, + normalizeTemporaryId, + replaceTemporaryIdReferences, + serializeTemporaryIdMap, + } = require("./temporary_id.cjs"); + const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require("./repo_helpers.cjs"); + const { addExpirationComment } = require("./expiration_helpers.cjs"); + const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); async function main() { core.setOutput("issue_number", ""); core.setOutput("issue_url", ""); @@ -7847,6 +7596,7 @@ jobs: .filter((label, index, arr) => arr.indexOf(label) === index); let title = createIssueItem.title ? createIssueItem.title.trim() : ""; let processedBody = replaceTemporaryIdReferences(createIssueItem.body, temporaryIdMap, itemRepo); + processedBody = removeDuplicateTitleFromDescription(title, processedBody); let bodyLines = processedBody.split("\n"); if (!title) { title = createIssueItem.body || "Agent Output"; diff --git a/.github/workflows/poem-bot.lock.yml b/.github/workflows/poem-bot.lock.yml index f03a17ad34c..75f30aae256 100644 --- a/.github/workflows/poem-bot.lock.yml +++ b/.github/workflows/poem-bot.lock.yml @@ -8984,381 +8984,13 @@ jobs: with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - const fs = require("fs"); - const crypto = require("crypto"); - const MAX_LOG_CONTENT_LENGTH = 10000; - function truncateForLogging(content) { - if (content.length <= MAX_LOG_CONTENT_LENGTH) { - return content; - } - return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; - } - function loadAgentOutput() { - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; - if (!agentOutputFile) { - core.info("No GH_AW_AGENT_OUTPUT environment variable found"); - return { success: false }; - } - let outputContent; - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - return { success: false, error: errorMessage }; - } - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return { success: false }; - } - core.info(`Agent output content length: ${outputContent.length}`); - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); - return { success: false, error: errorMessage }; - } - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); - return { success: false }; - } - return { success: true, items: validatedOutput.items }; - } - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - function getMessages() { - const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES; - if (!messagesEnv) { - return null; - } - try { - return JSON.parse(messagesEnv); - } catch (error) { - core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`); - return null; - } - } - function renderTemplate(template, context) { - return template.replace(/\{(\w+)\}/g, (match, key) => { - const value = context[key]; - return value !== undefined && value !== null ? String(value) : match; - }); - } - function toSnakeCase(obj) { - const result = {}; - for (const [key, value] of Object.entries(obj)) { - const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase(); - result[snakeKey] = value; - result[key] = value; - } - return result; - } - function getCloseOlderDiscussionMessage(ctx) { - const messages = getMessages(); - const templateContext = toSnakeCase(ctx); - const defaultMessage = `⚓ Avast! This discussion be marked as **outdated** by [{workflow_name}]({run_url}). - 🗺️ A newer treasure map awaits ye at **[Discussion #{new_discussion_number}]({new_discussion_url})**. - Fair winds, matey! 🏴‍☠️`; - return messages?.closeOlderDiscussion - ? renderTemplate(messages.closeOlderDiscussion, templateContext) - : renderTemplate(defaultMessage, templateContext); - } - const MAX_CLOSE_COUNT = 10; - const GRAPHQL_DELAY_MS = 500; - function delay(ms) { - return new Promise(resolve => setTimeout(resolve, ms)); - } - async function searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, excludeNumber) { - let searchQuery = `repo:${owner}/${repo} is:open`; - if (titlePrefix) { - const escapedPrefix = titlePrefix.replace(/"/g, '\\"'); - searchQuery += ` in:title "${escapedPrefix}"`; - } - if (labels && labels.length > 0) { - for (const label of labels) { - const escapedLabel = label.replace(/"/g, '\\"'); - searchQuery += ` label:"${escapedLabel}"`; - } - } - const result = await github.graphql( - ` - query($searchTerms: String!, $first: Int!) { - search(query: $searchTerms, type: DISCUSSION, first: $first) { - nodes { - ... on Discussion { - id - number - title - url - category { - id - } - labels(first: 100) { - nodes { - name - } - } - closed - } - } - } - }`, - { searchTerms: searchQuery, first: 50 } - ); - if (!result || !result.search || !result.search.nodes) { - return []; - } - return result.search.nodes - .filter( - d => { - if (!d || d.number === excludeNumber || d.closed) { - return false; - } - if (titlePrefix && d.title && !d.title.startsWith(titlePrefix)) { - return false; - } - if (labels && labels.length > 0) { - const discussionLabels = d.labels?.nodes?.map(( l) => l.name) || []; - const hasAllLabels = labels.every(label => discussionLabels.includes(label)); - if (!hasAllLabels) { - return false; - } - } - if (categoryId && (!d.category || d.category.id !== categoryId)) { - return false; - } - return true; - } - ) - .map( - d => ({ - id: d.id, - number: d.number, - title: d.title, - url: d.url, - }) - ); - } - async function addDiscussionComment(github, discussionId, message) { - const result = await github.graphql( - ` - mutation($dId: ID!, $body: String!) { - addDiscussionComment(input: { discussionId: $dId, body: $body }) { - comment { - id - url - } - } - }`, - { dId: discussionId, body: message } - ); - return result.addDiscussionComment.comment; - } - async function closeDiscussionAsOutdated(github, discussionId) { - const result = await github.graphql( - ` - mutation($dId: ID!) { - closeDiscussion(input: { discussionId: $dId, reason: OUTDATED }) { - discussion { - id - url - } - } - }`, - { dId: discussionId } - ); - return result.closeDiscussion.discussion; - } - async function closeOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion, workflowName, runUrl) { - const searchCriteria = []; - if (titlePrefix) searchCriteria.push(`title prefix: "${titlePrefix}"`); - if (labels && labels.length > 0) searchCriteria.push(`labels: [${labels.join(", ")}]`); - core.info(`Searching for older discussions with ${searchCriteria.join(" and ")}`); - const olderDiscussions = await searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion.number); - if (olderDiscussions.length === 0) { - core.info("No older discussions found to close"); - return []; - } - core.info(`Found ${olderDiscussions.length} older discussion(s) to close`); - const discussionsToClose = olderDiscussions.slice(0, MAX_CLOSE_COUNT); - if (olderDiscussions.length > MAX_CLOSE_COUNT) { - core.warning(`Found ${olderDiscussions.length} older discussions, but only closing the first ${MAX_CLOSE_COUNT}`); - } - const closedDiscussions = []; - for (let i = 0; i < discussionsToClose.length; i++) { - const discussion = discussionsToClose[i]; - try { - const closingMessage = getCloseOlderDiscussionMessage({ - newDiscussionUrl: newDiscussion.url, - newDiscussionNumber: newDiscussion.number, - workflowName, - runUrl, - }); - core.info(`Adding closing comment to discussion #${discussion.number}`); - await addDiscussionComment(github, discussion.id, closingMessage); - core.info(`Closing discussion #${discussion.number} as outdated`); - await closeDiscussionAsOutdated(github, discussion.id); - closedDiscussions.push({ - number: discussion.number, - url: discussion.url, - }); - core.info(`✓ Closed discussion #${discussion.number}: ${discussion.url}`); - } catch (error) { - core.error(`✗ Failed to close discussion #${discussion.number}: ${error instanceof Error ? error.message : String(error)}`); - } - if (i < discussionsToClose.length - 1) { - await delay(GRAPHQL_DELAY_MS); - } - } - return closedDiscussions; - } - const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; - function generateTemporaryId() { - return "aw_" + crypto.randomBytes(6).toString("hex"); - } - function isTemporaryId(value) { - if (typeof value === "string") { - return /^aw_[0-9a-f]{12}$/i.test(value); - } - return false; - } - function normalizeTemporaryId(tempId) { - return String(tempId).toLowerCase(); - } - function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); - if (resolved !== undefined) { - if (currentRepo && resolved.repo === currentRepo) { - return `#${resolved.number}`; - } - return `${resolved.repo}#${resolved.number}`; - } - return match; - }); - } - function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); - if (issueNumber !== undefined) { - return `#${issueNumber}`; - } - return match; - }); - } - function loadTemporaryIdMap() { - const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; - if (!mapJson || mapJson === "{}") { - return new Map(); - } - try { - const mapObject = JSON.parse(mapJson); - const result = new Map(); - for (const [key, value] of Object.entries(mapObject)) { - const normalizedKey = normalizeTemporaryId(key); - if (typeof value === "number") { - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - result.set(normalizedKey, { repo: contextRepo, number: value }); - } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { - result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); - } - } - return result; - } catch (error) { - if (typeof core !== "undefined") { - core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); - } - return new Map(); - } - } - function resolveIssueNumber(value, temporaryIdMap) { - if (value === undefined || value === null) { - return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; - } - const valueStr = String(value); - if (isTemporaryId(valueStr)) { - const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); - if (resolvedPair !== undefined) { - return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; - } - return { - resolved: null, - wasTemporaryId: true, - errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, - }; - } - const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); - if (isNaN(issueNumber) || issueNumber <= 0) { - return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; - } - const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; - return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; - } - function serializeTemporaryIdMap(tempIdMap) { - const obj = Object.fromEntries(tempIdMap); - return JSON.stringify(obj); - } - function parseAllowedRepos() { - const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; - const set = new Set(); - if (allowedReposEnv) { - allowedReposEnv - .split(",") - .map(repo => repo.trim()) - .filter(repo => repo) - .forEach(repo => set.add(repo)); - } - return set; - } - function getDefaultTargetRepo() { - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - if (targetRepoSlug) { - return targetRepoSlug; - } - return `${context.repo.owner}/${context.repo.repo}`; - } - function validateRepo(repo, defaultRepo, allowedRepos) { - if (repo === defaultRepo) { - return { valid: true, error: null }; - } - if (allowedRepos.has(repo)) { - return { valid: true, error: null }; - } - return { - valid: false, - error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, - }; - } - function parseRepoSlug(repoSlug) { - const parts = repoSlug.split("/"); - if (parts.length !== 2 || !parts[0] || !parts[1]) { - return null; - } - return { owner: parts[0], repo: parts[1] }; - } - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { getTrackerID } = require("./get_tracker_id.cjs"); + const { closeOlderDiscussions } = require("./close_older_discussions.cjs"); + const { replaceTemporaryIdReferences, loadTemporaryIdMap } = require("./temporary_id.cjs"); + const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require("./repo_helpers.cjs"); + const { addExpirationComment } = require("./expiration_helpers.cjs"); + const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); async function fetchRepoDiscussionInfo(owner, repo) { const repositoryQuery = ` query($owner: String!, $repo: String!) { @@ -9543,7 +9175,9 @@ jobs: ); let title = createDiscussionItem.title ? replaceTemporaryIdReferences(createDiscussionItem.title.trim(), temporaryIdMap, itemRepo) : ""; const bodyText = createDiscussionItem.body || ""; - let bodyLines = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo).split("\n"); + let processedBody = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo); + processedBody = removeDuplicateTitleFromDescription(title, processedBody); + let bodyLines = processedBody.split("\n"); if (!title) { title = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo) || "Agent Output"; } @@ -9674,284 +9308,33 @@ jobs: - name: Create Output Issue id: create_issue uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_ISSUE_TITLE_PREFIX: "[🎭 POEM-BOT] " - GH_AW_ISSUE_LABELS: "poetry,automation,ai-generated" - GH_AW_WORKFLOW_NAME: "Poem Bot - A Creative Agentic Workflow" - GH_AW_ENGINE_ID: "copilot" - GH_AW_ENGINE_MODEL: "gpt-5" - GH_AW_SAFE_OUTPUTS_STAGED: "true" - GH_AW_SAFE_OUTPUT_MESSAGES: "{\"footer\":\"\\u003e 🪶 *Verses penned by [{workflow_name}]({run_url})*\",\"runStarted\":\"🎭 Hear ye! The muse stirs! [{workflow_name}]({run_url}) takes quill in hand for this {event_type}...\",\"runSuccess\":\"🪶 The poem is writ! [{workflow_name}]({run_url}) has composed verses most fair. Applause! 👏\",\"runFailure\":\"🎭 Alas! [{workflow_name}]({run_url}) {status}. The muse has fled, leaving verses unsung...\"}" - with: - github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - script: | - function sanitizeLabelContent(content) { - if (!content || typeof content !== "string") { - return ""; - } - let sanitized = content.trim(); - sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); - sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); - sanitized = sanitized.replace( - /(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, - (_m, p1, p2) => `${p1}\`@${p2}\`` - ); - sanitized = sanitized.replace(/[<>&'"]/g, ""); - return sanitized.trim(); - } - const fs = require("fs"); - const crypto = require("crypto"); - const MAX_LOG_CONTENT_LENGTH = 10000; - function truncateForLogging(content) { - if (content.length <= MAX_LOG_CONTENT_LENGTH) { - return content; - } - return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; - } - function loadAgentOutput() { - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; - if (!agentOutputFile) { - core.info("No GH_AW_AGENT_OUTPUT environment variable found"); - return { success: false }; - } - let outputContent; - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - return { success: false, error: errorMessage }; - } - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return { success: false }; - } - core.info(`Agent output content length: ${outputContent.length}`); - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); - return { success: false, error: errorMessage }; - } - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); - return { success: false }; - } - return { success: true, items: validatedOutput.items }; - } - async function generateStagedPreview(options) { - const { title, description, items, renderItem } = options; - let summaryContent = `## 🎭 Staged Mode: ${title} Preview\n\n`; - summaryContent += `${description}\n\n`; - for (let i = 0; i < items.length; i++) { - const item = items[i]; - summaryContent += renderItem(item, i); - summaryContent += "---\n\n"; - } - try { - await core.summary.addRaw(summaryContent).write(); - core.info(summaryContent); - core.info(`📝 ${title} preview written to step summary`); - } catch (error) { - core.setFailed(error instanceof Error ? error : String(error)); - } - } - function generateXMLMarker(workflowName, runUrl) { - const engineId = process.env.GH_AW_ENGINE_ID || ""; - const engineVersion = process.env.GH_AW_ENGINE_VERSION || ""; - const engineModel = process.env.GH_AW_ENGINE_MODEL || ""; - const trackerId = process.env.GH_AW_TRACKER_ID || ""; - const parts = []; - parts.push(`agentic-workflow: ${workflowName}`); - if (trackerId) { - parts.push(`tracker-id: ${trackerId}`); - } - if (engineId) { - parts.push(`engine: ${engineId}`); - } - if (engineVersion) { - parts.push(`version: ${engineVersion}`); - } - if (engineModel) { - parts.push(`model: ${engineModel}`); - } - parts.push(`run: ${runUrl}`); - return ``; - } - function generateFooter( - workflowName, - runUrl, - workflowSource, - workflowSourceURL, - triggeringIssueNumber, - triggeringPRNumber, - triggeringDiscussionNumber - ) { - let footer = `\n\n> AI generated by [${workflowName}](${runUrl})`; - if (triggeringIssueNumber) { - footer += ` for #${triggeringIssueNumber}`; - } else if (triggeringPRNumber) { - footer += ` for #${triggeringPRNumber}`; - } else if (triggeringDiscussionNumber) { - footer += ` for discussion #${triggeringDiscussionNumber}`; - } - if (workflowSource && workflowSourceURL) { - footer += `\n>\n> To add this workflow in your repository, run \`gh aw add ${workflowSource}\`. See [usage guide](https://githubnext.github.io/gh-aw/tools/cli/).`; - } - footer += "\n\n" + generateXMLMarker(workflowName, runUrl); - footer += "\n"; - return footer; - } - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; - function generateTemporaryId() { - return "aw_" + crypto.randomBytes(6).toString("hex"); - } - function isTemporaryId(value) { - if (typeof value === "string") { - return /^aw_[0-9a-f]{12}$/i.test(value); - } - return false; - } - function normalizeTemporaryId(tempId) { - return String(tempId).toLowerCase(); - } - function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); - if (resolved !== undefined) { - if (currentRepo && resolved.repo === currentRepo) { - return `#${resolved.number}`; - } - return `${resolved.repo}#${resolved.number}`; - } - return match; - }); - } - function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); - if (issueNumber !== undefined) { - return `#${issueNumber}`; - } - return match; - }); - } - function loadTemporaryIdMap() { - const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; - if (!mapJson || mapJson === "{}") { - return new Map(); - } - try { - const mapObject = JSON.parse(mapJson); - const result = new Map(); - for (const [key, value] of Object.entries(mapObject)) { - const normalizedKey = normalizeTemporaryId(key); - if (typeof value === "number") { - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - result.set(normalizedKey, { repo: contextRepo, number: value }); - } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { - result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); - } - } - return result; - } catch (error) { - if (typeof core !== "undefined") { - core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); - } - return new Map(); - } - } - function resolveIssueNumber(value, temporaryIdMap) { - if (value === undefined || value === null) { - return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; - } - const valueStr = String(value); - if (isTemporaryId(valueStr)) { - const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); - if (resolvedPair !== undefined) { - return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; - } - return { - resolved: null, - wasTemporaryId: true, - errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, - }; - } - const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); - if (isNaN(issueNumber) || issueNumber <= 0) { - return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; - } - const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; - return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; - } - function serializeTemporaryIdMap(tempIdMap) { - const obj = Object.fromEntries(tempIdMap); - return JSON.stringify(obj); - } - function parseAllowedRepos() { - const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; - const set = new Set(); - if (allowedReposEnv) { - allowedReposEnv - .split(",") - .map(repo => repo.trim()) - .filter(repo => repo) - .forEach(repo => set.add(repo)); - } - return set; - } - function getDefaultTargetRepo() { - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - if (targetRepoSlug) { - return targetRepoSlug; - } - return `${context.repo.owner}/${context.repo.repo}`; - } - function validateRepo(repo, defaultRepo, allowedRepos) { - if (repo === defaultRepo) { - return { valid: true, error: null }; - } - if (allowedRepos.has(repo)) { - return { valid: true, error: null }; - } - return { - valid: false, - error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, - }; - } - function parseRepoSlug(repoSlug) { - const parts = repoSlug.split("/"); - if (parts.length !== 2 || !parts[0] || !parts[1]) { - return null; - } - return { owner: parts[0], repo: parts[1] }; - } - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } + env: + GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} + GH_AW_ISSUE_TITLE_PREFIX: "[🎭 POEM-BOT] " + GH_AW_ISSUE_LABELS: "poetry,automation,ai-generated" + GH_AW_WORKFLOW_NAME: "Poem Bot - A Creative Agentic Workflow" + GH_AW_ENGINE_ID: "copilot" + GH_AW_ENGINE_MODEL: "gpt-5" + GH_AW_SAFE_OUTPUTS_STAGED: "true" + GH_AW_SAFE_OUTPUT_MESSAGES: "{\"footer\":\"\\u003e 🪶 *Verses penned by [{workflow_name}]({run_url})*\",\"runStarted\":\"🎭 Hear ye! The muse stirs! [{workflow_name}]({run_url}) takes quill in hand for this {event_type}...\",\"runSuccess\":\"🪶 The poem is writ! [{workflow_name}]({run_url}) has composed verses most fair. Applause! 👏\",\"runFailure\":\"🎭 Alas! [{workflow_name}]({run_url}) {status}. The muse has fled, leaving verses unsung...\"}" + with: + github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + script: | + const { sanitizeLabelContent } = require("./sanitize_label_content.cjs"); + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { generateStagedPreview } = require("./staged_preview.cjs"); + const { generateFooter } = require("./generate_footer.cjs"); + const { getTrackerID } = require("./get_tracker_id.cjs"); + const { + generateTemporaryId, + isTemporaryId, + normalizeTemporaryId, + replaceTemporaryIdReferences, + serializeTemporaryIdMap, + } = require("./temporary_id.cjs"); + const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require("./repo_helpers.cjs"); + const { addExpirationComment } = require("./expiration_helpers.cjs"); + const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); async function main() { core.setOutput("issue_number", ""); core.setOutput("issue_url", ""); @@ -10084,6 +9467,7 @@ jobs: .filter((label, index, arr) => arr.indexOf(label) === index); let title = createIssueItem.title ? createIssueItem.title.trim() : ""; let processedBody = replaceTemporaryIdReferences(createIssueItem.body, temporaryIdMap, itemRepo); + processedBody = removeDuplicateTitleFromDescription(title, processedBody); let bodyLines = processedBody.split("\n"); if (!title) { title = createIssueItem.body || "Agent Output"; @@ -10700,132 +10084,10 @@ jobs: script: | const fs = require("fs"); const crypto = require("crypto"); - async function updateActivationComment(github, context, core, itemUrl, itemNumber, itemType = "pull_request") { - const itemLabel = itemType === "issue" ? "issue" : "pull request"; - const linkMessage = - itemType === "issue" - ? `\n\n✅ Issue created: [#${itemNumber}](${itemUrl})` - : `\n\n✅ Pull request created: [#${itemNumber}](${itemUrl})`; - await updateActivationCommentWithMessage(github, context, core, linkMessage, itemLabel); - } - async function updateActivationCommentWithCommit(github, context, core, commitSha, commitUrl) { - const shortSha = commitSha.substring(0, 7); - const message = `\n\n✅ Commit pushed: [\`${shortSha}\`](${commitUrl})`; - await updateActivationCommentWithMessage(github, context, core, message, "commit"); - } - async function updateActivationCommentWithMessage(github, context, core, message, label = "") { - const commentId = process.env.GH_AW_COMMENT_ID; - const commentRepo = process.env.GH_AW_COMMENT_REPO; - if (!commentId) { - core.info("No activation comment to update (GH_AW_COMMENT_ID not set)"); - return; - } - core.info(`Updating activation comment ${commentId}`); - let repoOwner = context.repo.owner; - let repoName = context.repo.repo; - if (commentRepo) { - const parts = commentRepo.split("/"); - if (parts.length === 2) { - repoOwner = parts[0]; - repoName = parts[1]; - } else { - core.warning(`Invalid comment repo format: ${commentRepo}, expected "owner/repo". Falling back to context.repo.`); - } - } - core.info(`Updating comment in ${repoOwner}/${repoName}`); - const isDiscussionComment = commentId.startsWith("DC_"); - try { - if (isDiscussionComment) { - const currentComment = await github.graphql( - ` - query($commentId: ID!) { - node(id: $commentId) { - ... on DiscussionComment { - body - } - } - }`, - { commentId: commentId } - ); - if (!currentComment?.node?.body) { - core.warning("Unable to fetch current comment body, comment may have been deleted or is inaccessible"); - return; - } - const currentBody = currentComment.node.body; - const updatedBody = currentBody + message; - const result = await github.graphql( - ` - mutation($commentId: ID!, $body: String!) { - updateDiscussionComment(input: { commentId: $commentId, body: $body }) { - comment { - id - url - } - } - }`, - { commentId: commentId, body: updatedBody } - ); - const comment = result.updateDiscussionComment.comment; - const successMessage = label - ? `Successfully updated discussion comment with ${label} link` - : "Successfully updated discussion comment"; - core.info(successMessage); - core.info(`Comment ID: ${comment.id}`); - core.info(`Comment URL: ${comment.url}`); - } else { - const currentComment = await github.request("GET /repos/{owner}/{repo}/issues/comments/{comment_id}", { - owner: repoOwner, - repo: repoName, - comment_id: parseInt(commentId, 10), - headers: { - Accept: "application/vnd.github+json", - }, - }); - if (!currentComment?.data?.body) { - core.warning("Unable to fetch current comment body, comment may have been deleted"); - return; - } - const currentBody = currentComment.data.body; - const updatedBody = currentBody + message; - const response = await github.request("PATCH /repos/{owner}/{repo}/issues/comments/{comment_id}", { - owner: repoOwner, - repo: repoName, - comment_id: parseInt(commentId, 10), - body: updatedBody, - headers: { - Accept: "application/vnd.github+json", - }, - }); - const successMessage = label ? `Successfully updated comment with ${label} link` : "Successfully updated comment"; - core.info(successMessage); - core.info(`Comment ID: ${response.data.id}`); - core.info(`Comment URL: ${response.data.html_url}`); - } - } catch (error) { - core.warning(`Failed to update activation comment: ${error instanceof Error ? error.message : String(error)}`); - } - } - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } + const { updateActivationComment } = require("./update_activation_comment.cjs"); + const { getTrackerID } = require("./get_tracker_id.cjs"); + const { addExpirationComment } = require("./expiration_helpers.cjs"); + const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); function generatePatchPreview(patchContent) { if (!patchContent || !patchContent.trim()) { return ""; @@ -11017,7 +10279,9 @@ jobs: return; } let title = pullRequestItem.title.trim(); - let bodyLines = pullRequestItem.body.split("\n"); + let processedBody = pullRequestItem.body; + processedBody = removeDuplicateTitleFromDescription(title, processedBody); + let bodyLines = processedBody.split("\n"); let branchName = pullRequestItem.branch ? pullRequestItem.branch.trim() : null; if (!title) { title = "Agent Output"; @@ -12882,271 +12146,7 @@ jobs: with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - const fs = require("fs"); - const MAX_LOG_CONTENT_LENGTH = 10000; - function truncateForLogging(content) { - if (content.length <= MAX_LOG_CONTENT_LENGTH) { - return content; - } - return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; - } - function loadAgentOutput() { - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; - if (!agentOutputFile) { - core.info("No GH_AW_AGENT_OUTPUT environment variable found"); - return { success: false }; - } - let outputContent; - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - return { success: false, error: errorMessage }; - } - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return { success: false }; - } - core.info(`Agent output content length: ${outputContent.length}`); - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); - return { success: false, error: errorMessage }; - } - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); - return { success: false }; - } - return { success: true, items: validatedOutput.items }; - } - async function generateStagedPreview(options) { - const { title, description, items, renderItem } = options; - let summaryContent = `## 🎭 Staged Mode: ${title} Preview\n\n`; - summaryContent += `${description}\n\n`; - for (let i = 0; i < items.length; i++) { - const item = items[i]; - summaryContent += renderItem(item, i); - summaryContent += "---\n\n"; - } - try { - await core.summary.addRaw(summaryContent).write(); - core.info(summaryContent); - core.info(`📝 ${title} preview written to step summary`); - } catch (error) { - core.setFailed(error instanceof Error ? error : String(error)); - } - } - function resolveTargetNumber(params) { - const { updateTarget, item, numberField, isValidContext, contextNumber, displayName } = params; - if (updateTarget === "*") { - const explicitNumber = item[numberField]; - if (explicitNumber) { - const parsed = parseInt(explicitNumber, 10); - if (isNaN(parsed) || parsed <= 0) { - return { success: false, error: `Invalid ${numberField} specified: ${explicitNumber}` }; - } - return { success: true, number: parsed }; - } else { - return { success: false, error: `Target is "*" but no ${numberField} specified in update item` }; - } - } else if (updateTarget && updateTarget !== "triggering") { - const parsed = parseInt(updateTarget, 10); - if (isNaN(parsed) || parsed <= 0) { - return { success: false, error: `Invalid ${displayName} number in target configuration: ${updateTarget}` }; - } - return { success: true, number: parsed }; - } else { - if (isValidContext && contextNumber) { - return { success: true, number: contextNumber }; - } - return { success: false, error: `Could not determine ${displayName} number` }; - } - } - function buildUpdateData(params) { - const { item, canUpdateStatus, canUpdateTitle, canUpdateBody, supportsStatus } = params; - const updateData = {}; - let hasUpdates = false; - const logMessages = []; - if (supportsStatus && canUpdateStatus && item.status !== undefined) { - if (item.status === "open" || item.status === "closed") { - updateData.state = item.status; - hasUpdates = true; - logMessages.push(`Will update status to: ${item.status}`); - } else { - logMessages.push(`Invalid status value: ${item.status}. Must be 'open' or 'closed'`); - } - } - if (canUpdateTitle && item.title !== undefined) { - const trimmedTitle = typeof item.title === "string" ? item.title.trim() : ""; - if (trimmedTitle.length > 0) { - updateData.title = trimmedTitle; - hasUpdates = true; - logMessages.push(`Will update title to: ${trimmedTitle}`); - } else { - logMessages.push("Invalid title value: must be a non-empty string"); - } - } - if (canUpdateBody && item.body !== undefined) { - if (typeof item.body === "string") { - updateData.body = item.body; - hasUpdates = true; - logMessages.push(`Will update body (length: ${item.body.length})`); - } else { - logMessages.push("Invalid body value: must be a string"); - } - } - return { hasUpdates, updateData, logMessages }; - } - async function runUpdateWorkflow(config) { - const { - itemType, - displayName, - displayNamePlural, - numberField, - outputNumberKey, - outputUrlKey, - isValidContext, - getContextNumber, - supportsStatus, - supportsOperation, - renderStagedItem, - executeUpdate, - getSummaryLine, - } = config; - const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; - const result = loadAgentOutput(); - if (!result.success) { - return; - } - const updateItems = result.items.filter( item => item.type === itemType); - if (updateItems.length === 0) { - core.info(`No ${itemType} items found in agent output`); - return; - } - core.info(`Found ${updateItems.length} ${itemType} item(s)`); - if (isStaged) { - await generateStagedPreview({ - title: `Update ${displayNamePlural.charAt(0).toUpperCase() + displayNamePlural.slice(1)}`, - description: `The following ${displayName} updates would be applied if staged mode was disabled:`, - items: updateItems, - renderItem: renderStagedItem, - }); - return; - } - const updateTarget = process.env.GH_AW_UPDATE_TARGET || "triggering"; - const canUpdateStatus = process.env.GH_AW_UPDATE_STATUS === "true"; - const canUpdateTitle = process.env.GH_AW_UPDATE_TITLE === "true"; - const canUpdateBody = process.env.GH_AW_UPDATE_BODY === "true"; - core.info(`Update target configuration: ${updateTarget}`); - if (supportsStatus) { - core.info(`Can update status: ${canUpdateStatus}, title: ${canUpdateTitle}, body: ${canUpdateBody}`); - } else { - core.info(`Can update title: ${canUpdateTitle}, body: ${canUpdateBody}`); - } - const contextIsValid = isValidContext(context.eventName, context.payload); - const contextNumber = getContextNumber(context.payload); - if (updateTarget === "triggering" && !contextIsValid) { - core.info(`Target is "triggering" but not running in ${displayName} context, skipping ${displayName} update`); - return; - } - const updatedItems = []; - for (let i = 0; i < updateItems.length; i++) { - const updateItem = updateItems[i]; - core.info(`Processing ${itemType} item ${i + 1}/${updateItems.length}`); - const targetResult = resolveTargetNumber({ - updateTarget, - item: updateItem, - numberField, - isValidContext: contextIsValid, - contextNumber, - displayName, - }); - if (!targetResult.success) { - core.info(targetResult.error); - continue; - } - const targetNumber = targetResult.number; - core.info(`Updating ${displayName} #${targetNumber}`); - const { hasUpdates, updateData, logMessages } = buildUpdateData({ - item: updateItem, - canUpdateStatus, - canUpdateTitle, - canUpdateBody, - supportsStatus, - }); - for (const msg of logMessages) { - core.info(msg); - } - if (supportsOperation && canUpdateBody && updateItem.body !== undefined && typeof updateItem.body === "string") { - updateData._operation = updateItem.operation || "append"; - updateData._rawBody = updateItem.body; - } - if (!hasUpdates) { - core.info("No valid updates to apply for this item"); - continue; - } - try { - const updatedItem = await executeUpdate(github, context, targetNumber, updateData); - core.info(`Updated ${displayName} #${updatedItem.number}: ${updatedItem.html_url}`); - updatedItems.push(updatedItem); - if (i === updateItems.length - 1) { - core.setOutput(outputNumberKey, updatedItem.number); - core.setOutput(outputUrlKey, updatedItem.html_url); - } - } catch (error) { - core.error(`✗ Failed to update ${displayName} #${targetNumber}: ${error instanceof Error ? error.message : String(error)}`); - throw error; - } - } - if (updatedItems.length > 0) { - let summaryContent = `\n\n## Updated ${displayNamePlural.charAt(0).toUpperCase() + displayNamePlural.slice(1)}\n`; - for (const item of updatedItems) { - summaryContent += getSummaryLine(item); - } - await core.summary.addRaw(summaryContent).write(); - } - core.info(`Successfully updated ${updatedItems.length} ${displayName}(s)`); - return updatedItems; - } - function createRenderStagedItem(config) { - const { entityName, numberField, targetLabel, currentTargetText, includeOperation = false } = config; - return function renderStagedItem(item, index) { - let content = `### ${entityName} Update ${index + 1}\n`; - if (item[numberField]) { - content += `**${targetLabel}** #${item[numberField]}\n\n`; - } else { - content += `**Target:** ${currentTargetText}\n\n`; - } - if (item.title !== undefined) { - content += `**New Title:** ${item.title}\n\n`; - } - if (item.body !== undefined) { - if (includeOperation) { - const operation = item.operation || "append"; - content += `**Operation:** ${operation}\n`; - content += `**Body Content:**\n${item.body}\n\n`; - } else { - content += `**New Body:**\n${item.body}\n\n`; - } - } - if (item.status !== undefined) { - content += `**New Status:** ${item.status}\n\n`; - } - return content; - }; - } - function createGetSummaryLine(config) { - const { entityPrefix } = config; - return function getSummaryLine(item) { - return `- ${entityPrefix} #${item.number}: [${item.title}](${item.html_url})\n`; - }; - } + const { runUpdateWorkflow, createRenderStagedItem, createGetSummaryLine } = require("./update_runner.cjs"); function isIssueContext(eventName, _payload) { return eventName === "issues" || eventName === "issue_comment"; } diff --git a/.github/workflows/portfolio-analyst.lock.yml b/.github/workflows/portfolio-analyst.lock.yml index d1bdf0d6fde..85c1cc68e69 100644 --- a/.github/workflows/portfolio-analyst.lock.yml +++ b/.github/workflows/portfolio-analyst.lock.yml @@ -7022,381 +7022,13 @@ jobs: with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - const fs = require("fs"); - const crypto = require("crypto"); - const MAX_LOG_CONTENT_LENGTH = 10000; - function truncateForLogging(content) { - if (content.length <= MAX_LOG_CONTENT_LENGTH) { - return content; - } - return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; - } - function loadAgentOutput() { - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; - if (!agentOutputFile) { - core.info("No GH_AW_AGENT_OUTPUT environment variable found"); - return { success: false }; - } - let outputContent; - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - return { success: false, error: errorMessage }; - } - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return { success: false }; - } - core.info(`Agent output content length: ${outputContent.length}`); - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); - return { success: false, error: errorMessage }; - } - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); - return { success: false }; - } - return { success: true, items: validatedOutput.items }; - } - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - function getMessages() { - const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES; - if (!messagesEnv) { - return null; - } - try { - return JSON.parse(messagesEnv); - } catch (error) { - core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`); - return null; - } - } - function renderTemplate(template, context) { - return template.replace(/\{(\w+)\}/g, (match, key) => { - const value = context[key]; - return value !== undefined && value !== null ? String(value) : match; - }); - } - function toSnakeCase(obj) { - const result = {}; - for (const [key, value] of Object.entries(obj)) { - const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase(); - result[snakeKey] = value; - result[key] = value; - } - return result; - } - function getCloseOlderDiscussionMessage(ctx) { - const messages = getMessages(); - const templateContext = toSnakeCase(ctx); - const defaultMessage = `⚓ Avast! This discussion be marked as **outdated** by [{workflow_name}]({run_url}). - 🗺️ A newer treasure map awaits ye at **[Discussion #{new_discussion_number}]({new_discussion_url})**. - Fair winds, matey! 🏴‍☠️`; - return messages?.closeOlderDiscussion - ? renderTemplate(messages.closeOlderDiscussion, templateContext) - : renderTemplate(defaultMessage, templateContext); - } - const MAX_CLOSE_COUNT = 10; - const GRAPHQL_DELAY_MS = 500; - function delay(ms) { - return new Promise(resolve => setTimeout(resolve, ms)); - } - async function searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, excludeNumber) { - let searchQuery = `repo:${owner}/${repo} is:open`; - if (titlePrefix) { - const escapedPrefix = titlePrefix.replace(/"/g, '\\"'); - searchQuery += ` in:title "${escapedPrefix}"`; - } - if (labels && labels.length > 0) { - for (const label of labels) { - const escapedLabel = label.replace(/"/g, '\\"'); - searchQuery += ` label:"${escapedLabel}"`; - } - } - const result = await github.graphql( - ` - query($searchTerms: String!, $first: Int!) { - search(query: $searchTerms, type: DISCUSSION, first: $first) { - nodes { - ... on Discussion { - id - number - title - url - category { - id - } - labels(first: 100) { - nodes { - name - } - } - closed - } - } - } - }`, - { searchTerms: searchQuery, first: 50 } - ); - if (!result || !result.search || !result.search.nodes) { - return []; - } - return result.search.nodes - .filter( - d => { - if (!d || d.number === excludeNumber || d.closed) { - return false; - } - if (titlePrefix && d.title && !d.title.startsWith(titlePrefix)) { - return false; - } - if (labels && labels.length > 0) { - const discussionLabels = d.labels?.nodes?.map(( l) => l.name) || []; - const hasAllLabels = labels.every(label => discussionLabels.includes(label)); - if (!hasAllLabels) { - return false; - } - } - if (categoryId && (!d.category || d.category.id !== categoryId)) { - return false; - } - return true; - } - ) - .map( - d => ({ - id: d.id, - number: d.number, - title: d.title, - url: d.url, - }) - ); - } - async function addDiscussionComment(github, discussionId, message) { - const result = await github.graphql( - ` - mutation($dId: ID!, $body: String!) { - addDiscussionComment(input: { discussionId: $dId, body: $body }) { - comment { - id - url - } - } - }`, - { dId: discussionId, body: message } - ); - return result.addDiscussionComment.comment; - } - async function closeDiscussionAsOutdated(github, discussionId) { - const result = await github.graphql( - ` - mutation($dId: ID!) { - closeDiscussion(input: { discussionId: $dId, reason: OUTDATED }) { - discussion { - id - url - } - } - }`, - { dId: discussionId } - ); - return result.closeDiscussion.discussion; - } - async function closeOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion, workflowName, runUrl) { - const searchCriteria = []; - if (titlePrefix) searchCriteria.push(`title prefix: "${titlePrefix}"`); - if (labels && labels.length > 0) searchCriteria.push(`labels: [${labels.join(", ")}]`); - core.info(`Searching for older discussions with ${searchCriteria.join(" and ")}`); - const olderDiscussions = await searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion.number); - if (olderDiscussions.length === 0) { - core.info("No older discussions found to close"); - return []; - } - core.info(`Found ${olderDiscussions.length} older discussion(s) to close`); - const discussionsToClose = olderDiscussions.slice(0, MAX_CLOSE_COUNT); - if (olderDiscussions.length > MAX_CLOSE_COUNT) { - core.warning(`Found ${olderDiscussions.length} older discussions, but only closing the first ${MAX_CLOSE_COUNT}`); - } - const closedDiscussions = []; - for (let i = 0; i < discussionsToClose.length; i++) { - const discussion = discussionsToClose[i]; - try { - const closingMessage = getCloseOlderDiscussionMessage({ - newDiscussionUrl: newDiscussion.url, - newDiscussionNumber: newDiscussion.number, - workflowName, - runUrl, - }); - core.info(`Adding closing comment to discussion #${discussion.number}`); - await addDiscussionComment(github, discussion.id, closingMessage); - core.info(`Closing discussion #${discussion.number} as outdated`); - await closeDiscussionAsOutdated(github, discussion.id); - closedDiscussions.push({ - number: discussion.number, - url: discussion.url, - }); - core.info(`✓ Closed discussion #${discussion.number}: ${discussion.url}`); - } catch (error) { - core.error(`✗ Failed to close discussion #${discussion.number}: ${error instanceof Error ? error.message : String(error)}`); - } - if (i < discussionsToClose.length - 1) { - await delay(GRAPHQL_DELAY_MS); - } - } - return closedDiscussions; - } - const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; - function generateTemporaryId() { - return "aw_" + crypto.randomBytes(6).toString("hex"); - } - function isTemporaryId(value) { - if (typeof value === "string") { - return /^aw_[0-9a-f]{12}$/i.test(value); - } - return false; - } - function normalizeTemporaryId(tempId) { - return String(tempId).toLowerCase(); - } - function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); - if (resolved !== undefined) { - if (currentRepo && resolved.repo === currentRepo) { - return `#${resolved.number}`; - } - return `${resolved.repo}#${resolved.number}`; - } - return match; - }); - } - function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); - if (issueNumber !== undefined) { - return `#${issueNumber}`; - } - return match; - }); - } - function loadTemporaryIdMap() { - const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; - if (!mapJson || mapJson === "{}") { - return new Map(); - } - try { - const mapObject = JSON.parse(mapJson); - const result = new Map(); - for (const [key, value] of Object.entries(mapObject)) { - const normalizedKey = normalizeTemporaryId(key); - if (typeof value === "number") { - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - result.set(normalizedKey, { repo: contextRepo, number: value }); - } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { - result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); - } - } - return result; - } catch (error) { - if (typeof core !== "undefined") { - core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); - } - return new Map(); - } - } - function resolveIssueNumber(value, temporaryIdMap) { - if (value === undefined || value === null) { - return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; - } - const valueStr = String(value); - if (isTemporaryId(valueStr)) { - const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); - if (resolvedPair !== undefined) { - return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; - } - return { - resolved: null, - wasTemporaryId: true, - errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, - }; - } - const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); - if (isNaN(issueNumber) || issueNumber <= 0) { - return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; - } - const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; - return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; - } - function serializeTemporaryIdMap(tempIdMap) { - const obj = Object.fromEntries(tempIdMap); - return JSON.stringify(obj); - } - function parseAllowedRepos() { - const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; - const set = new Set(); - if (allowedReposEnv) { - allowedReposEnv - .split(",") - .map(repo => repo.trim()) - .filter(repo => repo) - .forEach(repo => set.add(repo)); - } - return set; - } - function getDefaultTargetRepo() { - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - if (targetRepoSlug) { - return targetRepoSlug; - } - return `${context.repo.owner}/${context.repo.repo}`; - } - function validateRepo(repo, defaultRepo, allowedRepos) { - if (repo === defaultRepo) { - return { valid: true, error: null }; - } - if (allowedRepos.has(repo)) { - return { valid: true, error: null }; - } - return { - valid: false, - error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, - }; - } - function parseRepoSlug(repoSlug) { - const parts = repoSlug.split("/"); - if (parts.length !== 2 || !parts[0] || !parts[1]) { - return null; - } - return { owner: parts[0], repo: parts[1] }; - } - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { getTrackerID } = require("./get_tracker_id.cjs"); + const { closeOlderDiscussions } = require("./close_older_discussions.cjs"); + const { replaceTemporaryIdReferences, loadTemporaryIdMap } = require("./temporary_id.cjs"); + const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require("./repo_helpers.cjs"); + const { addExpirationComment } = require("./expiration_helpers.cjs"); + const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); async function fetchRepoDiscussionInfo(owner, repo) { const repositoryQuery = ` query($owner: String!, $repo: String!) { @@ -7581,7 +7213,9 @@ jobs: ); let title = createDiscussionItem.title ? replaceTemporaryIdReferences(createDiscussionItem.title.trim(), temporaryIdMap, itemRepo) : ""; const bodyText = createDiscussionItem.body || ""; - let bodyLines = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo).split("\n"); + let processedBody = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo); + processedBody = removeDuplicateTitleFromDescription(title, processedBody); + let bodyLines = processedBody.split("\n"); if (!title) { title = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo) || "Agent Output"; } diff --git a/.github/workflows/pr-nitpick-reviewer.lock.yml b/.github/workflows/pr-nitpick-reviewer.lock.yml index 737da27e9d7..5ae3161935a 100644 --- a/.github/workflows/pr-nitpick-reviewer.lock.yml +++ b/.github/workflows/pr-nitpick-reviewer.lock.yml @@ -7950,381 +7950,13 @@ jobs: with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - const fs = require("fs"); - const crypto = require("crypto"); - const MAX_LOG_CONTENT_LENGTH = 10000; - function truncateForLogging(content) { - if (content.length <= MAX_LOG_CONTENT_LENGTH) { - return content; - } - return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; - } - function loadAgentOutput() { - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; - if (!agentOutputFile) { - core.info("No GH_AW_AGENT_OUTPUT environment variable found"); - return { success: false }; - } - let outputContent; - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - return { success: false, error: errorMessage }; - } - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return { success: false }; - } - core.info(`Agent output content length: ${outputContent.length}`); - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); - return { success: false, error: errorMessage }; - } - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); - return { success: false }; - } - return { success: true, items: validatedOutput.items }; - } - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - function getMessages() { - const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES; - if (!messagesEnv) { - return null; - } - try { - return JSON.parse(messagesEnv); - } catch (error) { - core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`); - return null; - } - } - function renderTemplate(template, context) { - return template.replace(/\{(\w+)\}/g, (match, key) => { - const value = context[key]; - return value !== undefined && value !== null ? String(value) : match; - }); - } - function toSnakeCase(obj) { - const result = {}; - for (const [key, value] of Object.entries(obj)) { - const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase(); - result[snakeKey] = value; - result[key] = value; - } - return result; - } - function getCloseOlderDiscussionMessage(ctx) { - const messages = getMessages(); - const templateContext = toSnakeCase(ctx); - const defaultMessage = `⚓ Avast! This discussion be marked as **outdated** by [{workflow_name}]({run_url}). - 🗺️ A newer treasure map awaits ye at **[Discussion #{new_discussion_number}]({new_discussion_url})**. - Fair winds, matey! 🏴‍☠️`; - return messages?.closeOlderDiscussion - ? renderTemplate(messages.closeOlderDiscussion, templateContext) - : renderTemplate(defaultMessage, templateContext); - } - const MAX_CLOSE_COUNT = 10; - const GRAPHQL_DELAY_MS = 500; - function delay(ms) { - return new Promise(resolve => setTimeout(resolve, ms)); - } - async function searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, excludeNumber) { - let searchQuery = `repo:${owner}/${repo} is:open`; - if (titlePrefix) { - const escapedPrefix = titlePrefix.replace(/"/g, '\\"'); - searchQuery += ` in:title "${escapedPrefix}"`; - } - if (labels && labels.length > 0) { - for (const label of labels) { - const escapedLabel = label.replace(/"/g, '\\"'); - searchQuery += ` label:"${escapedLabel}"`; - } - } - const result = await github.graphql( - ` - query($searchTerms: String!, $first: Int!) { - search(query: $searchTerms, type: DISCUSSION, first: $first) { - nodes { - ... on Discussion { - id - number - title - url - category { - id - } - labels(first: 100) { - nodes { - name - } - } - closed - } - } - } - }`, - { searchTerms: searchQuery, first: 50 } - ); - if (!result || !result.search || !result.search.nodes) { - return []; - } - return result.search.nodes - .filter( - d => { - if (!d || d.number === excludeNumber || d.closed) { - return false; - } - if (titlePrefix && d.title && !d.title.startsWith(titlePrefix)) { - return false; - } - if (labels && labels.length > 0) { - const discussionLabels = d.labels?.nodes?.map(( l) => l.name) || []; - const hasAllLabels = labels.every(label => discussionLabels.includes(label)); - if (!hasAllLabels) { - return false; - } - } - if (categoryId && (!d.category || d.category.id !== categoryId)) { - return false; - } - return true; - } - ) - .map( - d => ({ - id: d.id, - number: d.number, - title: d.title, - url: d.url, - }) - ); - } - async function addDiscussionComment(github, discussionId, message) { - const result = await github.graphql( - ` - mutation($dId: ID!, $body: String!) { - addDiscussionComment(input: { discussionId: $dId, body: $body }) { - comment { - id - url - } - } - }`, - { dId: discussionId, body: message } - ); - return result.addDiscussionComment.comment; - } - async function closeDiscussionAsOutdated(github, discussionId) { - const result = await github.graphql( - ` - mutation($dId: ID!) { - closeDiscussion(input: { discussionId: $dId, reason: OUTDATED }) { - discussion { - id - url - } - } - }`, - { dId: discussionId } - ); - return result.closeDiscussion.discussion; - } - async function closeOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion, workflowName, runUrl) { - const searchCriteria = []; - if (titlePrefix) searchCriteria.push(`title prefix: "${titlePrefix}"`); - if (labels && labels.length > 0) searchCriteria.push(`labels: [${labels.join(", ")}]`); - core.info(`Searching for older discussions with ${searchCriteria.join(" and ")}`); - const olderDiscussions = await searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion.number); - if (olderDiscussions.length === 0) { - core.info("No older discussions found to close"); - return []; - } - core.info(`Found ${olderDiscussions.length} older discussion(s) to close`); - const discussionsToClose = olderDiscussions.slice(0, MAX_CLOSE_COUNT); - if (olderDiscussions.length > MAX_CLOSE_COUNT) { - core.warning(`Found ${olderDiscussions.length} older discussions, but only closing the first ${MAX_CLOSE_COUNT}`); - } - const closedDiscussions = []; - for (let i = 0; i < discussionsToClose.length; i++) { - const discussion = discussionsToClose[i]; - try { - const closingMessage = getCloseOlderDiscussionMessage({ - newDiscussionUrl: newDiscussion.url, - newDiscussionNumber: newDiscussion.number, - workflowName, - runUrl, - }); - core.info(`Adding closing comment to discussion #${discussion.number}`); - await addDiscussionComment(github, discussion.id, closingMessage); - core.info(`Closing discussion #${discussion.number} as outdated`); - await closeDiscussionAsOutdated(github, discussion.id); - closedDiscussions.push({ - number: discussion.number, - url: discussion.url, - }); - core.info(`✓ Closed discussion #${discussion.number}: ${discussion.url}`); - } catch (error) { - core.error(`✗ Failed to close discussion #${discussion.number}: ${error instanceof Error ? error.message : String(error)}`); - } - if (i < discussionsToClose.length - 1) { - await delay(GRAPHQL_DELAY_MS); - } - } - return closedDiscussions; - } - const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; - function generateTemporaryId() { - return "aw_" + crypto.randomBytes(6).toString("hex"); - } - function isTemporaryId(value) { - if (typeof value === "string") { - return /^aw_[0-9a-f]{12}$/i.test(value); - } - return false; - } - function normalizeTemporaryId(tempId) { - return String(tempId).toLowerCase(); - } - function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); - if (resolved !== undefined) { - if (currentRepo && resolved.repo === currentRepo) { - return `#${resolved.number}`; - } - return `${resolved.repo}#${resolved.number}`; - } - return match; - }); - } - function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); - if (issueNumber !== undefined) { - return `#${issueNumber}`; - } - return match; - }); - } - function loadTemporaryIdMap() { - const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; - if (!mapJson || mapJson === "{}") { - return new Map(); - } - try { - const mapObject = JSON.parse(mapJson); - const result = new Map(); - for (const [key, value] of Object.entries(mapObject)) { - const normalizedKey = normalizeTemporaryId(key); - if (typeof value === "number") { - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - result.set(normalizedKey, { repo: contextRepo, number: value }); - } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { - result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); - } - } - return result; - } catch (error) { - if (typeof core !== "undefined") { - core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); - } - return new Map(); - } - } - function resolveIssueNumber(value, temporaryIdMap) { - if (value === undefined || value === null) { - return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; - } - const valueStr = String(value); - if (isTemporaryId(valueStr)) { - const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); - if (resolvedPair !== undefined) { - return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; - } - return { - resolved: null, - wasTemporaryId: true, - errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, - }; - } - const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); - if (isNaN(issueNumber) || issueNumber <= 0) { - return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; - } - const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; - return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; - } - function serializeTemporaryIdMap(tempIdMap) { - const obj = Object.fromEntries(tempIdMap); - return JSON.stringify(obj); - } - function parseAllowedRepos() { - const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; - const set = new Set(); - if (allowedReposEnv) { - allowedReposEnv - .split(",") - .map(repo => repo.trim()) - .filter(repo => repo) - .forEach(repo => set.add(repo)); - } - return set; - } - function getDefaultTargetRepo() { - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - if (targetRepoSlug) { - return targetRepoSlug; - } - return `${context.repo.owner}/${context.repo.repo}`; - } - function validateRepo(repo, defaultRepo, allowedRepos) { - if (repo === defaultRepo) { - return { valid: true, error: null }; - } - if (allowedRepos.has(repo)) { - return { valid: true, error: null }; - } - return { - valid: false, - error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, - }; - } - function parseRepoSlug(repoSlug) { - const parts = repoSlug.split("/"); - if (parts.length !== 2 || !parts[0] || !parts[1]) { - return null; - } - return { owner: parts[0], repo: parts[1] }; - } - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { getTrackerID } = require("./get_tracker_id.cjs"); + const { closeOlderDiscussions } = require("./close_older_discussions.cjs"); + const { replaceTemporaryIdReferences, loadTemporaryIdMap } = require("./temporary_id.cjs"); + const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require("./repo_helpers.cjs"); + const { addExpirationComment } = require("./expiration_helpers.cjs"); + const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); async function fetchRepoDiscussionInfo(owner, repo) { const repositoryQuery = ` query($owner: String!, $repo: String!) { @@ -8509,7 +8141,9 @@ jobs: ); let title = createDiscussionItem.title ? replaceTemporaryIdReferences(createDiscussionItem.title.trim(), temporaryIdMap, itemRepo) : ""; const bodyText = createDiscussionItem.body || ""; - let bodyLines = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo).split("\n"); + let processedBody = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo); + processedBody = removeDuplicateTitleFromDescription(title, processedBody); + let bodyLines = processedBody.split("\n"); if (!title) { title = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo) || "Agent Output"; } diff --git a/.github/workflows/prompt-clustering-analysis.lock.yml b/.github/workflows/prompt-clustering-analysis.lock.yml index 117660a7b52..b2c4d9da3c9 100644 --- a/.github/workflows/prompt-clustering-analysis.lock.yml +++ b/.github/workflows/prompt-clustering-analysis.lock.yml @@ -7446,381 +7446,13 @@ jobs: with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - const fs = require("fs"); - const crypto = require("crypto"); - const MAX_LOG_CONTENT_LENGTH = 10000; - function truncateForLogging(content) { - if (content.length <= MAX_LOG_CONTENT_LENGTH) { - return content; - } - return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; - } - function loadAgentOutput() { - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; - if (!agentOutputFile) { - core.info("No GH_AW_AGENT_OUTPUT environment variable found"); - return { success: false }; - } - let outputContent; - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - return { success: false, error: errorMessage }; - } - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return { success: false }; - } - core.info(`Agent output content length: ${outputContent.length}`); - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); - return { success: false, error: errorMessage }; - } - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); - return { success: false }; - } - return { success: true, items: validatedOutput.items }; - } - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - function getMessages() { - const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES; - if (!messagesEnv) { - return null; - } - try { - return JSON.parse(messagesEnv); - } catch (error) { - core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`); - return null; - } - } - function renderTemplate(template, context) { - return template.replace(/\{(\w+)\}/g, (match, key) => { - const value = context[key]; - return value !== undefined && value !== null ? String(value) : match; - }); - } - function toSnakeCase(obj) { - const result = {}; - for (const [key, value] of Object.entries(obj)) { - const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase(); - result[snakeKey] = value; - result[key] = value; - } - return result; - } - function getCloseOlderDiscussionMessage(ctx) { - const messages = getMessages(); - const templateContext = toSnakeCase(ctx); - const defaultMessage = `⚓ Avast! This discussion be marked as **outdated** by [{workflow_name}]({run_url}). - 🗺️ A newer treasure map awaits ye at **[Discussion #{new_discussion_number}]({new_discussion_url})**. - Fair winds, matey! 🏴‍☠️`; - return messages?.closeOlderDiscussion - ? renderTemplate(messages.closeOlderDiscussion, templateContext) - : renderTemplate(defaultMessage, templateContext); - } - const MAX_CLOSE_COUNT = 10; - const GRAPHQL_DELAY_MS = 500; - function delay(ms) { - return new Promise(resolve => setTimeout(resolve, ms)); - } - async function searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, excludeNumber) { - let searchQuery = `repo:${owner}/${repo} is:open`; - if (titlePrefix) { - const escapedPrefix = titlePrefix.replace(/"/g, '\\"'); - searchQuery += ` in:title "${escapedPrefix}"`; - } - if (labels && labels.length > 0) { - for (const label of labels) { - const escapedLabel = label.replace(/"/g, '\\"'); - searchQuery += ` label:"${escapedLabel}"`; - } - } - const result = await github.graphql( - ` - query($searchTerms: String!, $first: Int!) { - search(query: $searchTerms, type: DISCUSSION, first: $first) { - nodes { - ... on Discussion { - id - number - title - url - category { - id - } - labels(first: 100) { - nodes { - name - } - } - closed - } - } - } - }`, - { searchTerms: searchQuery, first: 50 } - ); - if (!result || !result.search || !result.search.nodes) { - return []; - } - return result.search.nodes - .filter( - d => { - if (!d || d.number === excludeNumber || d.closed) { - return false; - } - if (titlePrefix && d.title && !d.title.startsWith(titlePrefix)) { - return false; - } - if (labels && labels.length > 0) { - const discussionLabels = d.labels?.nodes?.map(( l) => l.name) || []; - const hasAllLabels = labels.every(label => discussionLabels.includes(label)); - if (!hasAllLabels) { - return false; - } - } - if (categoryId && (!d.category || d.category.id !== categoryId)) { - return false; - } - return true; - } - ) - .map( - d => ({ - id: d.id, - number: d.number, - title: d.title, - url: d.url, - }) - ); - } - async function addDiscussionComment(github, discussionId, message) { - const result = await github.graphql( - ` - mutation($dId: ID!, $body: String!) { - addDiscussionComment(input: { discussionId: $dId, body: $body }) { - comment { - id - url - } - } - }`, - { dId: discussionId, body: message } - ); - return result.addDiscussionComment.comment; - } - async function closeDiscussionAsOutdated(github, discussionId) { - const result = await github.graphql( - ` - mutation($dId: ID!) { - closeDiscussion(input: { discussionId: $dId, reason: OUTDATED }) { - discussion { - id - url - } - } - }`, - { dId: discussionId } - ); - return result.closeDiscussion.discussion; - } - async function closeOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion, workflowName, runUrl) { - const searchCriteria = []; - if (titlePrefix) searchCriteria.push(`title prefix: "${titlePrefix}"`); - if (labels && labels.length > 0) searchCriteria.push(`labels: [${labels.join(", ")}]`); - core.info(`Searching for older discussions with ${searchCriteria.join(" and ")}`); - const olderDiscussions = await searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion.number); - if (olderDiscussions.length === 0) { - core.info("No older discussions found to close"); - return []; - } - core.info(`Found ${olderDiscussions.length} older discussion(s) to close`); - const discussionsToClose = olderDiscussions.slice(0, MAX_CLOSE_COUNT); - if (olderDiscussions.length > MAX_CLOSE_COUNT) { - core.warning(`Found ${olderDiscussions.length} older discussions, but only closing the first ${MAX_CLOSE_COUNT}`); - } - const closedDiscussions = []; - for (let i = 0; i < discussionsToClose.length; i++) { - const discussion = discussionsToClose[i]; - try { - const closingMessage = getCloseOlderDiscussionMessage({ - newDiscussionUrl: newDiscussion.url, - newDiscussionNumber: newDiscussion.number, - workflowName, - runUrl, - }); - core.info(`Adding closing comment to discussion #${discussion.number}`); - await addDiscussionComment(github, discussion.id, closingMessage); - core.info(`Closing discussion #${discussion.number} as outdated`); - await closeDiscussionAsOutdated(github, discussion.id); - closedDiscussions.push({ - number: discussion.number, - url: discussion.url, - }); - core.info(`✓ Closed discussion #${discussion.number}: ${discussion.url}`); - } catch (error) { - core.error(`✗ Failed to close discussion #${discussion.number}: ${error instanceof Error ? error.message : String(error)}`); - } - if (i < discussionsToClose.length - 1) { - await delay(GRAPHQL_DELAY_MS); - } - } - return closedDiscussions; - } - const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; - function generateTemporaryId() { - return "aw_" + crypto.randomBytes(6).toString("hex"); - } - function isTemporaryId(value) { - if (typeof value === "string") { - return /^aw_[0-9a-f]{12}$/i.test(value); - } - return false; - } - function normalizeTemporaryId(tempId) { - return String(tempId).toLowerCase(); - } - function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); - if (resolved !== undefined) { - if (currentRepo && resolved.repo === currentRepo) { - return `#${resolved.number}`; - } - return `${resolved.repo}#${resolved.number}`; - } - return match; - }); - } - function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); - if (issueNumber !== undefined) { - return `#${issueNumber}`; - } - return match; - }); - } - function loadTemporaryIdMap() { - const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; - if (!mapJson || mapJson === "{}") { - return new Map(); - } - try { - const mapObject = JSON.parse(mapJson); - const result = new Map(); - for (const [key, value] of Object.entries(mapObject)) { - const normalizedKey = normalizeTemporaryId(key); - if (typeof value === "number") { - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - result.set(normalizedKey, { repo: contextRepo, number: value }); - } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { - result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); - } - } - return result; - } catch (error) { - if (typeof core !== "undefined") { - core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); - } - return new Map(); - } - } - function resolveIssueNumber(value, temporaryIdMap) { - if (value === undefined || value === null) { - return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; - } - const valueStr = String(value); - if (isTemporaryId(valueStr)) { - const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); - if (resolvedPair !== undefined) { - return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; - } - return { - resolved: null, - wasTemporaryId: true, - errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, - }; - } - const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); - if (isNaN(issueNumber) || issueNumber <= 0) { - return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; - } - const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; - return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; - } - function serializeTemporaryIdMap(tempIdMap) { - const obj = Object.fromEntries(tempIdMap); - return JSON.stringify(obj); - } - function parseAllowedRepos() { - const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; - const set = new Set(); - if (allowedReposEnv) { - allowedReposEnv - .split(",") - .map(repo => repo.trim()) - .filter(repo => repo) - .forEach(repo => set.add(repo)); - } - return set; - } - function getDefaultTargetRepo() { - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - if (targetRepoSlug) { - return targetRepoSlug; - } - return `${context.repo.owner}/${context.repo.repo}`; - } - function validateRepo(repo, defaultRepo, allowedRepos) { - if (repo === defaultRepo) { - return { valid: true, error: null }; - } - if (allowedRepos.has(repo)) { - return { valid: true, error: null }; - } - return { - valid: false, - error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, - }; - } - function parseRepoSlug(repoSlug) { - const parts = repoSlug.split("/"); - if (parts.length !== 2 || !parts[0] || !parts[1]) { - return null; - } - return { owner: parts[0], repo: parts[1] }; - } - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { getTrackerID } = require("./get_tracker_id.cjs"); + const { closeOlderDiscussions } = require("./close_older_discussions.cjs"); + const { replaceTemporaryIdReferences, loadTemporaryIdMap } = require("./temporary_id.cjs"); + const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require("./repo_helpers.cjs"); + const { addExpirationComment } = require("./expiration_helpers.cjs"); + const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); async function fetchRepoDiscussionInfo(owner, repo) { const repositoryQuery = ` query($owner: String!, $repo: String!) { @@ -8005,7 +7637,9 @@ jobs: ); let title = createDiscussionItem.title ? replaceTemporaryIdReferences(createDiscussionItem.title.trim(), temporaryIdMap, itemRepo) : ""; const bodyText = createDiscussionItem.body || ""; - let bodyLines = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo).split("\n"); + let processedBody = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo); + processedBody = removeDuplicateTitleFromDescription(title, processedBody); + let bodyLines = processedBody.split("\n"); if (!title) { title = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo) || "Agent Output"; } diff --git a/.github/workflows/python-data-charts.lock.yml b/.github/workflows/python-data-charts.lock.yml index e385a5920a9..5137043f577 100644 --- a/.github/workflows/python-data-charts.lock.yml +++ b/.github/workflows/python-data-charts.lock.yml @@ -8015,381 +8015,13 @@ jobs: with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - const fs = require("fs"); - const crypto = require("crypto"); - const MAX_LOG_CONTENT_LENGTH = 10000; - function truncateForLogging(content) { - if (content.length <= MAX_LOG_CONTENT_LENGTH) { - return content; - } - return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; - } - function loadAgentOutput() { - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; - if (!agentOutputFile) { - core.info("No GH_AW_AGENT_OUTPUT environment variable found"); - return { success: false }; - } - let outputContent; - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - return { success: false, error: errorMessage }; - } - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return { success: false }; - } - core.info(`Agent output content length: ${outputContent.length}`); - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); - return { success: false, error: errorMessage }; - } - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); - return { success: false }; - } - return { success: true, items: validatedOutput.items }; - } - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - function getMessages() { - const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES; - if (!messagesEnv) { - return null; - } - try { - return JSON.parse(messagesEnv); - } catch (error) { - core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`); - return null; - } - } - function renderTemplate(template, context) { - return template.replace(/\{(\w+)\}/g, (match, key) => { - const value = context[key]; - return value !== undefined && value !== null ? String(value) : match; - }); - } - function toSnakeCase(obj) { - const result = {}; - for (const [key, value] of Object.entries(obj)) { - const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase(); - result[snakeKey] = value; - result[key] = value; - } - return result; - } - function getCloseOlderDiscussionMessage(ctx) { - const messages = getMessages(); - const templateContext = toSnakeCase(ctx); - const defaultMessage = `⚓ Avast! This discussion be marked as **outdated** by [{workflow_name}]({run_url}). - 🗺️ A newer treasure map awaits ye at **[Discussion #{new_discussion_number}]({new_discussion_url})**. - Fair winds, matey! 🏴‍☠️`; - return messages?.closeOlderDiscussion - ? renderTemplate(messages.closeOlderDiscussion, templateContext) - : renderTemplate(defaultMessage, templateContext); - } - const MAX_CLOSE_COUNT = 10; - const GRAPHQL_DELAY_MS = 500; - function delay(ms) { - return new Promise(resolve => setTimeout(resolve, ms)); - } - async function searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, excludeNumber) { - let searchQuery = `repo:${owner}/${repo} is:open`; - if (titlePrefix) { - const escapedPrefix = titlePrefix.replace(/"/g, '\\"'); - searchQuery += ` in:title "${escapedPrefix}"`; - } - if (labels && labels.length > 0) { - for (const label of labels) { - const escapedLabel = label.replace(/"/g, '\\"'); - searchQuery += ` label:"${escapedLabel}"`; - } - } - const result = await github.graphql( - ` - query($searchTerms: String!, $first: Int!) { - search(query: $searchTerms, type: DISCUSSION, first: $first) { - nodes { - ... on Discussion { - id - number - title - url - category { - id - } - labels(first: 100) { - nodes { - name - } - } - closed - } - } - } - }`, - { searchTerms: searchQuery, first: 50 } - ); - if (!result || !result.search || !result.search.nodes) { - return []; - } - return result.search.nodes - .filter( - d => { - if (!d || d.number === excludeNumber || d.closed) { - return false; - } - if (titlePrefix && d.title && !d.title.startsWith(titlePrefix)) { - return false; - } - if (labels && labels.length > 0) { - const discussionLabels = d.labels?.nodes?.map(( l) => l.name) || []; - const hasAllLabels = labels.every(label => discussionLabels.includes(label)); - if (!hasAllLabels) { - return false; - } - } - if (categoryId && (!d.category || d.category.id !== categoryId)) { - return false; - } - return true; - } - ) - .map( - d => ({ - id: d.id, - number: d.number, - title: d.title, - url: d.url, - }) - ); - } - async function addDiscussionComment(github, discussionId, message) { - const result = await github.graphql( - ` - mutation($dId: ID!, $body: String!) { - addDiscussionComment(input: { discussionId: $dId, body: $body }) { - comment { - id - url - } - } - }`, - { dId: discussionId, body: message } - ); - return result.addDiscussionComment.comment; - } - async function closeDiscussionAsOutdated(github, discussionId) { - const result = await github.graphql( - ` - mutation($dId: ID!) { - closeDiscussion(input: { discussionId: $dId, reason: OUTDATED }) { - discussion { - id - url - } - } - }`, - { dId: discussionId } - ); - return result.closeDiscussion.discussion; - } - async function closeOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion, workflowName, runUrl) { - const searchCriteria = []; - if (titlePrefix) searchCriteria.push(`title prefix: "${titlePrefix}"`); - if (labels && labels.length > 0) searchCriteria.push(`labels: [${labels.join(", ")}]`); - core.info(`Searching for older discussions with ${searchCriteria.join(" and ")}`); - const olderDiscussions = await searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion.number); - if (olderDiscussions.length === 0) { - core.info("No older discussions found to close"); - return []; - } - core.info(`Found ${olderDiscussions.length} older discussion(s) to close`); - const discussionsToClose = olderDiscussions.slice(0, MAX_CLOSE_COUNT); - if (olderDiscussions.length > MAX_CLOSE_COUNT) { - core.warning(`Found ${olderDiscussions.length} older discussions, but only closing the first ${MAX_CLOSE_COUNT}`); - } - const closedDiscussions = []; - for (let i = 0; i < discussionsToClose.length; i++) { - const discussion = discussionsToClose[i]; - try { - const closingMessage = getCloseOlderDiscussionMessage({ - newDiscussionUrl: newDiscussion.url, - newDiscussionNumber: newDiscussion.number, - workflowName, - runUrl, - }); - core.info(`Adding closing comment to discussion #${discussion.number}`); - await addDiscussionComment(github, discussion.id, closingMessage); - core.info(`Closing discussion #${discussion.number} as outdated`); - await closeDiscussionAsOutdated(github, discussion.id); - closedDiscussions.push({ - number: discussion.number, - url: discussion.url, - }); - core.info(`✓ Closed discussion #${discussion.number}: ${discussion.url}`); - } catch (error) { - core.error(`✗ Failed to close discussion #${discussion.number}: ${error instanceof Error ? error.message : String(error)}`); - } - if (i < discussionsToClose.length - 1) { - await delay(GRAPHQL_DELAY_MS); - } - } - return closedDiscussions; - } - const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; - function generateTemporaryId() { - return "aw_" + crypto.randomBytes(6).toString("hex"); - } - function isTemporaryId(value) { - if (typeof value === "string") { - return /^aw_[0-9a-f]{12}$/i.test(value); - } - return false; - } - function normalizeTemporaryId(tempId) { - return String(tempId).toLowerCase(); - } - function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); - if (resolved !== undefined) { - if (currentRepo && resolved.repo === currentRepo) { - return `#${resolved.number}`; - } - return `${resolved.repo}#${resolved.number}`; - } - return match; - }); - } - function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); - if (issueNumber !== undefined) { - return `#${issueNumber}`; - } - return match; - }); - } - function loadTemporaryIdMap() { - const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; - if (!mapJson || mapJson === "{}") { - return new Map(); - } - try { - const mapObject = JSON.parse(mapJson); - const result = new Map(); - for (const [key, value] of Object.entries(mapObject)) { - const normalizedKey = normalizeTemporaryId(key); - if (typeof value === "number") { - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - result.set(normalizedKey, { repo: contextRepo, number: value }); - } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { - result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); - } - } - return result; - } catch (error) { - if (typeof core !== "undefined") { - core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); - } - return new Map(); - } - } - function resolveIssueNumber(value, temporaryIdMap) { - if (value === undefined || value === null) { - return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; - } - const valueStr = String(value); - if (isTemporaryId(valueStr)) { - const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); - if (resolvedPair !== undefined) { - return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; - } - return { - resolved: null, - wasTemporaryId: true, - errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, - }; - } - const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); - if (isNaN(issueNumber) || issueNumber <= 0) { - return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; - } - const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; - return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; - } - function serializeTemporaryIdMap(tempIdMap) { - const obj = Object.fromEntries(tempIdMap); - return JSON.stringify(obj); - } - function parseAllowedRepos() { - const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; - const set = new Set(); - if (allowedReposEnv) { - allowedReposEnv - .split(",") - .map(repo => repo.trim()) - .filter(repo => repo) - .forEach(repo => set.add(repo)); - } - return set; - } - function getDefaultTargetRepo() { - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - if (targetRepoSlug) { - return targetRepoSlug; - } - return `${context.repo.owner}/${context.repo.repo}`; - } - function validateRepo(repo, defaultRepo, allowedRepos) { - if (repo === defaultRepo) { - return { valid: true, error: null }; - } - if (allowedRepos.has(repo)) { - return { valid: true, error: null }; - } - return { - valid: false, - error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, - }; - } - function parseRepoSlug(repoSlug) { - const parts = repoSlug.split("/"); - if (parts.length !== 2 || !parts[0] || !parts[1]) { - return null; - } - return { owner: parts[0], repo: parts[1] }; - } - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { getTrackerID } = require("./get_tracker_id.cjs"); + const { closeOlderDiscussions } = require("./close_older_discussions.cjs"); + const { replaceTemporaryIdReferences, loadTemporaryIdMap } = require("./temporary_id.cjs"); + const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require("./repo_helpers.cjs"); + const { addExpirationComment } = require("./expiration_helpers.cjs"); + const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); async function fetchRepoDiscussionInfo(owner, repo) { const repositoryQuery = ` query($owner: String!, $repo: String!) { @@ -8574,7 +8206,9 @@ jobs: ); let title = createDiscussionItem.title ? replaceTemporaryIdReferences(createDiscussionItem.title.trim(), temporaryIdMap, itemRepo) : ""; const bodyText = createDiscussionItem.body || ""; - let bodyLines = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo).split("\n"); + let processedBody = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo); + processedBody = removeDuplicateTitleFromDescription(title, processedBody); + let bodyLines = processedBody.split("\n"); if (!title) { title = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo) || "Agent Output"; } diff --git a/.github/workflows/q.lock.yml b/.github/workflows/q.lock.yml index ff65d7df3d4..cf6a8c0e4bb 100644 --- a/.github/workflows/q.lock.yml +++ b/.github/workflows/q.lock.yml @@ -8257,132 +8257,10 @@ jobs: script: | const fs = require("fs"); const crypto = require("crypto"); - async function updateActivationComment(github, context, core, itemUrl, itemNumber, itemType = "pull_request") { - const itemLabel = itemType === "issue" ? "issue" : "pull request"; - const linkMessage = - itemType === "issue" - ? `\n\n✅ Issue created: [#${itemNumber}](${itemUrl})` - : `\n\n✅ Pull request created: [#${itemNumber}](${itemUrl})`; - await updateActivationCommentWithMessage(github, context, core, linkMessage, itemLabel); - } - async function updateActivationCommentWithCommit(github, context, core, commitSha, commitUrl) { - const shortSha = commitSha.substring(0, 7); - const message = `\n\n✅ Commit pushed: [\`${shortSha}\`](${commitUrl})`; - await updateActivationCommentWithMessage(github, context, core, message, "commit"); - } - async function updateActivationCommentWithMessage(github, context, core, message, label = "") { - const commentId = process.env.GH_AW_COMMENT_ID; - const commentRepo = process.env.GH_AW_COMMENT_REPO; - if (!commentId) { - core.info("No activation comment to update (GH_AW_COMMENT_ID not set)"); - return; - } - core.info(`Updating activation comment ${commentId}`); - let repoOwner = context.repo.owner; - let repoName = context.repo.repo; - if (commentRepo) { - const parts = commentRepo.split("/"); - if (parts.length === 2) { - repoOwner = parts[0]; - repoName = parts[1]; - } else { - core.warning(`Invalid comment repo format: ${commentRepo}, expected "owner/repo". Falling back to context.repo.`); - } - } - core.info(`Updating comment in ${repoOwner}/${repoName}`); - const isDiscussionComment = commentId.startsWith("DC_"); - try { - if (isDiscussionComment) { - const currentComment = await github.graphql( - ` - query($commentId: ID!) { - node(id: $commentId) { - ... on DiscussionComment { - body - } - } - }`, - { commentId: commentId } - ); - if (!currentComment?.node?.body) { - core.warning("Unable to fetch current comment body, comment may have been deleted or is inaccessible"); - return; - } - const currentBody = currentComment.node.body; - const updatedBody = currentBody + message; - const result = await github.graphql( - ` - mutation($commentId: ID!, $body: String!) { - updateDiscussionComment(input: { commentId: $commentId, body: $body }) { - comment { - id - url - } - } - }`, - { commentId: commentId, body: updatedBody } - ); - const comment = result.updateDiscussionComment.comment; - const successMessage = label - ? `Successfully updated discussion comment with ${label} link` - : "Successfully updated discussion comment"; - core.info(successMessage); - core.info(`Comment ID: ${comment.id}`); - core.info(`Comment URL: ${comment.url}`); - } else { - const currentComment = await github.request("GET /repos/{owner}/{repo}/issues/comments/{comment_id}", { - owner: repoOwner, - repo: repoName, - comment_id: parseInt(commentId, 10), - headers: { - Accept: "application/vnd.github+json", - }, - }); - if (!currentComment?.data?.body) { - core.warning("Unable to fetch current comment body, comment may have been deleted"); - return; - } - const currentBody = currentComment.data.body; - const updatedBody = currentBody + message; - const response = await github.request("PATCH /repos/{owner}/{repo}/issues/comments/{comment_id}", { - owner: repoOwner, - repo: repoName, - comment_id: parseInt(commentId, 10), - body: updatedBody, - headers: { - Accept: "application/vnd.github+json", - }, - }); - const successMessage = label ? `Successfully updated comment with ${label} link` : "Successfully updated comment"; - core.info(successMessage); - core.info(`Comment ID: ${response.data.id}`); - core.info(`Comment URL: ${response.data.html_url}`); - } - } catch (error) { - core.warning(`Failed to update activation comment: ${error instanceof Error ? error.message : String(error)}`); - } - } - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } + const { updateActivationComment } = require("./update_activation_comment.cjs"); + const { getTrackerID } = require("./get_tracker_id.cjs"); + const { addExpirationComment } = require("./expiration_helpers.cjs"); + const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); function generatePatchPreview(patchContent) { if (!patchContent || !patchContent.trim()) { return ""; @@ -8574,7 +8452,9 @@ jobs: return; } let title = pullRequestItem.title.trim(); - let bodyLines = pullRequestItem.body.split("\n"); + let processedBody = pullRequestItem.body; + processedBody = removeDuplicateTitleFromDescription(title, processedBody); + let bodyLines = processedBody.split("\n"); let branchName = pullRequestItem.branch ? pullRequestItem.branch.trim() : null; if (!title) { title = "Agent Output"; diff --git a/.github/workflows/repo-tree-map.lock.yml b/.github/workflows/repo-tree-map.lock.yml index 0abbb82d122..8662585e3e6 100644 --- a/.github/workflows/repo-tree-map.lock.yml +++ b/.github/workflows/repo-tree-map.lock.yml @@ -6178,381 +6178,13 @@ jobs: with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - const fs = require("fs"); - const crypto = require("crypto"); - const MAX_LOG_CONTENT_LENGTH = 10000; - function truncateForLogging(content) { - if (content.length <= MAX_LOG_CONTENT_LENGTH) { - return content; - } - return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; - } - function loadAgentOutput() { - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; - if (!agentOutputFile) { - core.info("No GH_AW_AGENT_OUTPUT environment variable found"); - return { success: false }; - } - let outputContent; - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - return { success: false, error: errorMessage }; - } - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return { success: false }; - } - core.info(`Agent output content length: ${outputContent.length}`); - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); - return { success: false, error: errorMessage }; - } - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); - return { success: false }; - } - return { success: true, items: validatedOutput.items }; - } - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - function getMessages() { - const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES; - if (!messagesEnv) { - return null; - } - try { - return JSON.parse(messagesEnv); - } catch (error) { - core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`); - return null; - } - } - function renderTemplate(template, context) { - return template.replace(/\{(\w+)\}/g, (match, key) => { - const value = context[key]; - return value !== undefined && value !== null ? String(value) : match; - }); - } - function toSnakeCase(obj) { - const result = {}; - for (const [key, value] of Object.entries(obj)) { - const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase(); - result[snakeKey] = value; - result[key] = value; - } - return result; - } - function getCloseOlderDiscussionMessage(ctx) { - const messages = getMessages(); - const templateContext = toSnakeCase(ctx); - const defaultMessage = `⚓ Avast! This discussion be marked as **outdated** by [{workflow_name}]({run_url}). - 🗺️ A newer treasure map awaits ye at **[Discussion #{new_discussion_number}]({new_discussion_url})**. - Fair winds, matey! 🏴‍☠️`; - return messages?.closeOlderDiscussion - ? renderTemplate(messages.closeOlderDiscussion, templateContext) - : renderTemplate(defaultMessage, templateContext); - } - const MAX_CLOSE_COUNT = 10; - const GRAPHQL_DELAY_MS = 500; - function delay(ms) { - return new Promise(resolve => setTimeout(resolve, ms)); - } - async function searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, excludeNumber) { - let searchQuery = `repo:${owner}/${repo} is:open`; - if (titlePrefix) { - const escapedPrefix = titlePrefix.replace(/"/g, '\\"'); - searchQuery += ` in:title "${escapedPrefix}"`; - } - if (labels && labels.length > 0) { - for (const label of labels) { - const escapedLabel = label.replace(/"/g, '\\"'); - searchQuery += ` label:"${escapedLabel}"`; - } - } - const result = await github.graphql( - ` - query($searchTerms: String!, $first: Int!) { - search(query: $searchTerms, type: DISCUSSION, first: $first) { - nodes { - ... on Discussion { - id - number - title - url - category { - id - } - labels(first: 100) { - nodes { - name - } - } - closed - } - } - } - }`, - { searchTerms: searchQuery, first: 50 } - ); - if (!result || !result.search || !result.search.nodes) { - return []; - } - return result.search.nodes - .filter( - d => { - if (!d || d.number === excludeNumber || d.closed) { - return false; - } - if (titlePrefix && d.title && !d.title.startsWith(titlePrefix)) { - return false; - } - if (labels && labels.length > 0) { - const discussionLabels = d.labels?.nodes?.map(( l) => l.name) || []; - const hasAllLabels = labels.every(label => discussionLabels.includes(label)); - if (!hasAllLabels) { - return false; - } - } - if (categoryId && (!d.category || d.category.id !== categoryId)) { - return false; - } - return true; - } - ) - .map( - d => ({ - id: d.id, - number: d.number, - title: d.title, - url: d.url, - }) - ); - } - async function addDiscussionComment(github, discussionId, message) { - const result = await github.graphql( - ` - mutation($dId: ID!, $body: String!) { - addDiscussionComment(input: { discussionId: $dId, body: $body }) { - comment { - id - url - } - } - }`, - { dId: discussionId, body: message } - ); - return result.addDiscussionComment.comment; - } - async function closeDiscussionAsOutdated(github, discussionId) { - const result = await github.graphql( - ` - mutation($dId: ID!) { - closeDiscussion(input: { discussionId: $dId, reason: OUTDATED }) { - discussion { - id - url - } - } - }`, - { dId: discussionId } - ); - return result.closeDiscussion.discussion; - } - async function closeOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion, workflowName, runUrl) { - const searchCriteria = []; - if (titlePrefix) searchCriteria.push(`title prefix: "${titlePrefix}"`); - if (labels && labels.length > 0) searchCriteria.push(`labels: [${labels.join(", ")}]`); - core.info(`Searching for older discussions with ${searchCriteria.join(" and ")}`); - const olderDiscussions = await searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion.number); - if (olderDiscussions.length === 0) { - core.info("No older discussions found to close"); - return []; - } - core.info(`Found ${olderDiscussions.length} older discussion(s) to close`); - const discussionsToClose = olderDiscussions.slice(0, MAX_CLOSE_COUNT); - if (olderDiscussions.length > MAX_CLOSE_COUNT) { - core.warning(`Found ${olderDiscussions.length} older discussions, but only closing the first ${MAX_CLOSE_COUNT}`); - } - const closedDiscussions = []; - for (let i = 0; i < discussionsToClose.length; i++) { - const discussion = discussionsToClose[i]; - try { - const closingMessage = getCloseOlderDiscussionMessage({ - newDiscussionUrl: newDiscussion.url, - newDiscussionNumber: newDiscussion.number, - workflowName, - runUrl, - }); - core.info(`Adding closing comment to discussion #${discussion.number}`); - await addDiscussionComment(github, discussion.id, closingMessage); - core.info(`Closing discussion #${discussion.number} as outdated`); - await closeDiscussionAsOutdated(github, discussion.id); - closedDiscussions.push({ - number: discussion.number, - url: discussion.url, - }); - core.info(`✓ Closed discussion #${discussion.number}: ${discussion.url}`); - } catch (error) { - core.error(`✗ Failed to close discussion #${discussion.number}: ${error instanceof Error ? error.message : String(error)}`); - } - if (i < discussionsToClose.length - 1) { - await delay(GRAPHQL_DELAY_MS); - } - } - return closedDiscussions; - } - const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; - function generateTemporaryId() { - return "aw_" + crypto.randomBytes(6).toString("hex"); - } - function isTemporaryId(value) { - if (typeof value === "string") { - return /^aw_[0-9a-f]{12}$/i.test(value); - } - return false; - } - function normalizeTemporaryId(tempId) { - return String(tempId).toLowerCase(); - } - function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); - if (resolved !== undefined) { - if (currentRepo && resolved.repo === currentRepo) { - return `#${resolved.number}`; - } - return `${resolved.repo}#${resolved.number}`; - } - return match; - }); - } - function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); - if (issueNumber !== undefined) { - return `#${issueNumber}`; - } - return match; - }); - } - function loadTemporaryIdMap() { - const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; - if (!mapJson || mapJson === "{}") { - return new Map(); - } - try { - const mapObject = JSON.parse(mapJson); - const result = new Map(); - for (const [key, value] of Object.entries(mapObject)) { - const normalizedKey = normalizeTemporaryId(key); - if (typeof value === "number") { - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - result.set(normalizedKey, { repo: contextRepo, number: value }); - } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { - result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); - } - } - return result; - } catch (error) { - if (typeof core !== "undefined") { - core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); - } - return new Map(); - } - } - function resolveIssueNumber(value, temporaryIdMap) { - if (value === undefined || value === null) { - return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; - } - const valueStr = String(value); - if (isTemporaryId(valueStr)) { - const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); - if (resolvedPair !== undefined) { - return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; - } - return { - resolved: null, - wasTemporaryId: true, - errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, - }; - } - const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); - if (isNaN(issueNumber) || issueNumber <= 0) { - return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; - } - const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; - return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; - } - function serializeTemporaryIdMap(tempIdMap) { - const obj = Object.fromEntries(tempIdMap); - return JSON.stringify(obj); - } - function parseAllowedRepos() { - const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; - const set = new Set(); - if (allowedReposEnv) { - allowedReposEnv - .split(",") - .map(repo => repo.trim()) - .filter(repo => repo) - .forEach(repo => set.add(repo)); - } - return set; - } - function getDefaultTargetRepo() { - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - if (targetRepoSlug) { - return targetRepoSlug; - } - return `${context.repo.owner}/${context.repo.repo}`; - } - function validateRepo(repo, defaultRepo, allowedRepos) { - if (repo === defaultRepo) { - return { valid: true, error: null }; - } - if (allowedRepos.has(repo)) { - return { valid: true, error: null }; - } - return { - valid: false, - error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, - }; - } - function parseRepoSlug(repoSlug) { - const parts = repoSlug.split("/"); - if (parts.length !== 2 || !parts[0] || !parts[1]) { - return null; - } - return { owner: parts[0], repo: parts[1] }; - } - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { getTrackerID } = require("./get_tracker_id.cjs"); + const { closeOlderDiscussions } = require("./close_older_discussions.cjs"); + const { replaceTemporaryIdReferences, loadTemporaryIdMap } = require("./temporary_id.cjs"); + const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require("./repo_helpers.cjs"); + const { addExpirationComment } = require("./expiration_helpers.cjs"); + const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); async function fetchRepoDiscussionInfo(owner, repo) { const repositoryQuery = ` query($owner: String!, $repo: String!) { @@ -6737,7 +6369,9 @@ jobs: ); let title = createDiscussionItem.title ? replaceTemporaryIdReferences(createDiscussionItem.title.trim(), temporaryIdMap, itemRepo) : ""; const bodyText = createDiscussionItem.body || ""; - let bodyLines = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo).split("\n"); + let processedBody = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo); + processedBody = removeDuplicateTitleFromDescription(title, processedBody); + let bodyLines = processedBody.split("\n"); if (!title) { title = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo) || "Agent Output"; } diff --git a/.github/workflows/repository-quality-improver.lock.yml b/.github/workflows/repository-quality-improver.lock.yml index bd0bfac8946..4c9ddf7d96c 100644 --- a/.github/workflows/repository-quality-improver.lock.yml +++ b/.github/workflows/repository-quality-improver.lock.yml @@ -7222,381 +7222,13 @@ jobs: with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - const fs = require("fs"); - const crypto = require("crypto"); - const MAX_LOG_CONTENT_LENGTH = 10000; - function truncateForLogging(content) { - if (content.length <= MAX_LOG_CONTENT_LENGTH) { - return content; - } - return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; - } - function loadAgentOutput() { - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; - if (!agentOutputFile) { - core.info("No GH_AW_AGENT_OUTPUT environment variable found"); - return { success: false }; - } - let outputContent; - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - return { success: false, error: errorMessage }; - } - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return { success: false }; - } - core.info(`Agent output content length: ${outputContent.length}`); - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); - return { success: false, error: errorMessage }; - } - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); - return { success: false }; - } - return { success: true, items: validatedOutput.items }; - } - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - function getMessages() { - const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES; - if (!messagesEnv) { - return null; - } - try { - return JSON.parse(messagesEnv); - } catch (error) { - core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`); - return null; - } - } - function renderTemplate(template, context) { - return template.replace(/\{(\w+)\}/g, (match, key) => { - const value = context[key]; - return value !== undefined && value !== null ? String(value) : match; - }); - } - function toSnakeCase(obj) { - const result = {}; - for (const [key, value] of Object.entries(obj)) { - const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase(); - result[snakeKey] = value; - result[key] = value; - } - return result; - } - function getCloseOlderDiscussionMessage(ctx) { - const messages = getMessages(); - const templateContext = toSnakeCase(ctx); - const defaultMessage = `⚓ Avast! This discussion be marked as **outdated** by [{workflow_name}]({run_url}). - 🗺️ A newer treasure map awaits ye at **[Discussion #{new_discussion_number}]({new_discussion_url})**. - Fair winds, matey! 🏴‍☠️`; - return messages?.closeOlderDiscussion - ? renderTemplate(messages.closeOlderDiscussion, templateContext) - : renderTemplate(defaultMessage, templateContext); - } - const MAX_CLOSE_COUNT = 10; - const GRAPHQL_DELAY_MS = 500; - function delay(ms) { - return new Promise(resolve => setTimeout(resolve, ms)); - } - async function searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, excludeNumber) { - let searchQuery = `repo:${owner}/${repo} is:open`; - if (titlePrefix) { - const escapedPrefix = titlePrefix.replace(/"/g, '\\"'); - searchQuery += ` in:title "${escapedPrefix}"`; - } - if (labels && labels.length > 0) { - for (const label of labels) { - const escapedLabel = label.replace(/"/g, '\\"'); - searchQuery += ` label:"${escapedLabel}"`; - } - } - const result = await github.graphql( - ` - query($searchTerms: String!, $first: Int!) { - search(query: $searchTerms, type: DISCUSSION, first: $first) { - nodes { - ... on Discussion { - id - number - title - url - category { - id - } - labels(first: 100) { - nodes { - name - } - } - closed - } - } - } - }`, - { searchTerms: searchQuery, first: 50 } - ); - if (!result || !result.search || !result.search.nodes) { - return []; - } - return result.search.nodes - .filter( - d => { - if (!d || d.number === excludeNumber || d.closed) { - return false; - } - if (titlePrefix && d.title && !d.title.startsWith(titlePrefix)) { - return false; - } - if (labels && labels.length > 0) { - const discussionLabels = d.labels?.nodes?.map(( l) => l.name) || []; - const hasAllLabels = labels.every(label => discussionLabels.includes(label)); - if (!hasAllLabels) { - return false; - } - } - if (categoryId && (!d.category || d.category.id !== categoryId)) { - return false; - } - return true; - } - ) - .map( - d => ({ - id: d.id, - number: d.number, - title: d.title, - url: d.url, - }) - ); - } - async function addDiscussionComment(github, discussionId, message) { - const result = await github.graphql( - ` - mutation($dId: ID!, $body: String!) { - addDiscussionComment(input: { discussionId: $dId, body: $body }) { - comment { - id - url - } - } - }`, - { dId: discussionId, body: message } - ); - return result.addDiscussionComment.comment; - } - async function closeDiscussionAsOutdated(github, discussionId) { - const result = await github.graphql( - ` - mutation($dId: ID!) { - closeDiscussion(input: { discussionId: $dId, reason: OUTDATED }) { - discussion { - id - url - } - } - }`, - { dId: discussionId } - ); - return result.closeDiscussion.discussion; - } - async function closeOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion, workflowName, runUrl) { - const searchCriteria = []; - if (titlePrefix) searchCriteria.push(`title prefix: "${titlePrefix}"`); - if (labels && labels.length > 0) searchCriteria.push(`labels: [${labels.join(", ")}]`); - core.info(`Searching for older discussions with ${searchCriteria.join(" and ")}`); - const olderDiscussions = await searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion.number); - if (olderDiscussions.length === 0) { - core.info("No older discussions found to close"); - return []; - } - core.info(`Found ${olderDiscussions.length} older discussion(s) to close`); - const discussionsToClose = olderDiscussions.slice(0, MAX_CLOSE_COUNT); - if (olderDiscussions.length > MAX_CLOSE_COUNT) { - core.warning(`Found ${olderDiscussions.length} older discussions, but only closing the first ${MAX_CLOSE_COUNT}`); - } - const closedDiscussions = []; - for (let i = 0; i < discussionsToClose.length; i++) { - const discussion = discussionsToClose[i]; - try { - const closingMessage = getCloseOlderDiscussionMessage({ - newDiscussionUrl: newDiscussion.url, - newDiscussionNumber: newDiscussion.number, - workflowName, - runUrl, - }); - core.info(`Adding closing comment to discussion #${discussion.number}`); - await addDiscussionComment(github, discussion.id, closingMessage); - core.info(`Closing discussion #${discussion.number} as outdated`); - await closeDiscussionAsOutdated(github, discussion.id); - closedDiscussions.push({ - number: discussion.number, - url: discussion.url, - }); - core.info(`✓ Closed discussion #${discussion.number}: ${discussion.url}`); - } catch (error) { - core.error(`✗ Failed to close discussion #${discussion.number}: ${error instanceof Error ? error.message : String(error)}`); - } - if (i < discussionsToClose.length - 1) { - await delay(GRAPHQL_DELAY_MS); - } - } - return closedDiscussions; - } - const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; - function generateTemporaryId() { - return "aw_" + crypto.randomBytes(6).toString("hex"); - } - function isTemporaryId(value) { - if (typeof value === "string") { - return /^aw_[0-9a-f]{12}$/i.test(value); - } - return false; - } - function normalizeTemporaryId(tempId) { - return String(tempId).toLowerCase(); - } - function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); - if (resolved !== undefined) { - if (currentRepo && resolved.repo === currentRepo) { - return `#${resolved.number}`; - } - return `${resolved.repo}#${resolved.number}`; - } - return match; - }); - } - function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); - if (issueNumber !== undefined) { - return `#${issueNumber}`; - } - return match; - }); - } - function loadTemporaryIdMap() { - const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; - if (!mapJson || mapJson === "{}") { - return new Map(); - } - try { - const mapObject = JSON.parse(mapJson); - const result = new Map(); - for (const [key, value] of Object.entries(mapObject)) { - const normalizedKey = normalizeTemporaryId(key); - if (typeof value === "number") { - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - result.set(normalizedKey, { repo: contextRepo, number: value }); - } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { - result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); - } - } - return result; - } catch (error) { - if (typeof core !== "undefined") { - core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); - } - return new Map(); - } - } - function resolveIssueNumber(value, temporaryIdMap) { - if (value === undefined || value === null) { - return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; - } - const valueStr = String(value); - if (isTemporaryId(valueStr)) { - const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); - if (resolvedPair !== undefined) { - return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; - } - return { - resolved: null, - wasTemporaryId: true, - errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, - }; - } - const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); - if (isNaN(issueNumber) || issueNumber <= 0) { - return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; - } - const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; - return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; - } - function serializeTemporaryIdMap(tempIdMap) { - const obj = Object.fromEntries(tempIdMap); - return JSON.stringify(obj); - } - function parseAllowedRepos() { - const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; - const set = new Set(); - if (allowedReposEnv) { - allowedReposEnv - .split(",") - .map(repo => repo.trim()) - .filter(repo => repo) - .forEach(repo => set.add(repo)); - } - return set; - } - function getDefaultTargetRepo() { - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - if (targetRepoSlug) { - return targetRepoSlug; - } - return `${context.repo.owner}/${context.repo.repo}`; - } - function validateRepo(repo, defaultRepo, allowedRepos) { - if (repo === defaultRepo) { - return { valid: true, error: null }; - } - if (allowedRepos.has(repo)) { - return { valid: true, error: null }; - } - return { - valid: false, - error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, - }; - } - function parseRepoSlug(repoSlug) { - const parts = repoSlug.split("/"); - if (parts.length !== 2 || !parts[0] || !parts[1]) { - return null; - } - return { owner: parts[0], repo: parts[1] }; - } - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { getTrackerID } = require("./get_tracker_id.cjs"); + const { closeOlderDiscussions } = require("./close_older_discussions.cjs"); + const { replaceTemporaryIdReferences, loadTemporaryIdMap } = require("./temporary_id.cjs"); + const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require("./repo_helpers.cjs"); + const { addExpirationComment } = require("./expiration_helpers.cjs"); + const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); async function fetchRepoDiscussionInfo(owner, repo) { const repositoryQuery = ` query($owner: String!, $repo: String!) { @@ -7781,7 +7413,9 @@ jobs: ); let title = createDiscussionItem.title ? replaceTemporaryIdReferences(createDiscussionItem.title.trim(), temporaryIdMap, itemRepo) : ""; const bodyText = createDiscussionItem.body || ""; - let bodyLines = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo).split("\n"); + let processedBody = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo); + processedBody = removeDuplicateTitleFromDescription(title, processedBody); + let bodyLines = processedBody.split("\n"); if (!title) { title = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo) || "Agent Output"; } diff --git a/.github/workflows/research.lock.yml b/.github/workflows/research.lock.yml index 82410b9779f..57089e0f6e9 100644 --- a/.github/workflows/research.lock.yml +++ b/.github/workflows/research.lock.yml @@ -6091,381 +6091,13 @@ jobs: with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - const fs = require("fs"); - const crypto = require("crypto"); - const MAX_LOG_CONTENT_LENGTH = 10000; - function truncateForLogging(content) { - if (content.length <= MAX_LOG_CONTENT_LENGTH) { - return content; - } - return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; - } - function loadAgentOutput() { - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; - if (!agentOutputFile) { - core.info("No GH_AW_AGENT_OUTPUT environment variable found"); - return { success: false }; - } - let outputContent; - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - return { success: false, error: errorMessage }; - } - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return { success: false }; - } - core.info(`Agent output content length: ${outputContent.length}`); - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); - return { success: false, error: errorMessage }; - } - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); - return { success: false }; - } - return { success: true, items: validatedOutput.items }; - } - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - function getMessages() { - const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES; - if (!messagesEnv) { - return null; - } - try { - return JSON.parse(messagesEnv); - } catch (error) { - core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`); - return null; - } - } - function renderTemplate(template, context) { - return template.replace(/\{(\w+)\}/g, (match, key) => { - const value = context[key]; - return value !== undefined && value !== null ? String(value) : match; - }); - } - function toSnakeCase(obj) { - const result = {}; - for (const [key, value] of Object.entries(obj)) { - const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase(); - result[snakeKey] = value; - result[key] = value; - } - return result; - } - function getCloseOlderDiscussionMessage(ctx) { - const messages = getMessages(); - const templateContext = toSnakeCase(ctx); - const defaultMessage = `⚓ Avast! This discussion be marked as **outdated** by [{workflow_name}]({run_url}). - 🗺️ A newer treasure map awaits ye at **[Discussion #{new_discussion_number}]({new_discussion_url})**. - Fair winds, matey! 🏴‍☠️`; - return messages?.closeOlderDiscussion - ? renderTemplate(messages.closeOlderDiscussion, templateContext) - : renderTemplate(defaultMessage, templateContext); - } - const MAX_CLOSE_COUNT = 10; - const GRAPHQL_DELAY_MS = 500; - function delay(ms) { - return new Promise(resolve => setTimeout(resolve, ms)); - } - async function searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, excludeNumber) { - let searchQuery = `repo:${owner}/${repo} is:open`; - if (titlePrefix) { - const escapedPrefix = titlePrefix.replace(/"/g, '\\"'); - searchQuery += ` in:title "${escapedPrefix}"`; - } - if (labels && labels.length > 0) { - for (const label of labels) { - const escapedLabel = label.replace(/"/g, '\\"'); - searchQuery += ` label:"${escapedLabel}"`; - } - } - const result = await github.graphql( - ` - query($searchTerms: String!, $first: Int!) { - search(query: $searchTerms, type: DISCUSSION, first: $first) { - nodes { - ... on Discussion { - id - number - title - url - category { - id - } - labels(first: 100) { - nodes { - name - } - } - closed - } - } - } - }`, - { searchTerms: searchQuery, first: 50 } - ); - if (!result || !result.search || !result.search.nodes) { - return []; - } - return result.search.nodes - .filter( - d => { - if (!d || d.number === excludeNumber || d.closed) { - return false; - } - if (titlePrefix && d.title && !d.title.startsWith(titlePrefix)) { - return false; - } - if (labels && labels.length > 0) { - const discussionLabels = d.labels?.nodes?.map(( l) => l.name) || []; - const hasAllLabels = labels.every(label => discussionLabels.includes(label)); - if (!hasAllLabels) { - return false; - } - } - if (categoryId && (!d.category || d.category.id !== categoryId)) { - return false; - } - return true; - } - ) - .map( - d => ({ - id: d.id, - number: d.number, - title: d.title, - url: d.url, - }) - ); - } - async function addDiscussionComment(github, discussionId, message) { - const result = await github.graphql( - ` - mutation($dId: ID!, $body: String!) { - addDiscussionComment(input: { discussionId: $dId, body: $body }) { - comment { - id - url - } - } - }`, - { dId: discussionId, body: message } - ); - return result.addDiscussionComment.comment; - } - async function closeDiscussionAsOutdated(github, discussionId) { - const result = await github.graphql( - ` - mutation($dId: ID!) { - closeDiscussion(input: { discussionId: $dId, reason: OUTDATED }) { - discussion { - id - url - } - } - }`, - { dId: discussionId } - ); - return result.closeDiscussion.discussion; - } - async function closeOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion, workflowName, runUrl) { - const searchCriteria = []; - if (titlePrefix) searchCriteria.push(`title prefix: "${titlePrefix}"`); - if (labels && labels.length > 0) searchCriteria.push(`labels: [${labels.join(", ")}]`); - core.info(`Searching for older discussions with ${searchCriteria.join(" and ")}`); - const olderDiscussions = await searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion.number); - if (olderDiscussions.length === 0) { - core.info("No older discussions found to close"); - return []; - } - core.info(`Found ${olderDiscussions.length} older discussion(s) to close`); - const discussionsToClose = olderDiscussions.slice(0, MAX_CLOSE_COUNT); - if (olderDiscussions.length > MAX_CLOSE_COUNT) { - core.warning(`Found ${olderDiscussions.length} older discussions, but only closing the first ${MAX_CLOSE_COUNT}`); - } - const closedDiscussions = []; - for (let i = 0; i < discussionsToClose.length; i++) { - const discussion = discussionsToClose[i]; - try { - const closingMessage = getCloseOlderDiscussionMessage({ - newDiscussionUrl: newDiscussion.url, - newDiscussionNumber: newDiscussion.number, - workflowName, - runUrl, - }); - core.info(`Adding closing comment to discussion #${discussion.number}`); - await addDiscussionComment(github, discussion.id, closingMessage); - core.info(`Closing discussion #${discussion.number} as outdated`); - await closeDiscussionAsOutdated(github, discussion.id); - closedDiscussions.push({ - number: discussion.number, - url: discussion.url, - }); - core.info(`✓ Closed discussion #${discussion.number}: ${discussion.url}`); - } catch (error) { - core.error(`✗ Failed to close discussion #${discussion.number}: ${error instanceof Error ? error.message : String(error)}`); - } - if (i < discussionsToClose.length - 1) { - await delay(GRAPHQL_DELAY_MS); - } - } - return closedDiscussions; - } - const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; - function generateTemporaryId() { - return "aw_" + crypto.randomBytes(6).toString("hex"); - } - function isTemporaryId(value) { - if (typeof value === "string") { - return /^aw_[0-9a-f]{12}$/i.test(value); - } - return false; - } - function normalizeTemporaryId(tempId) { - return String(tempId).toLowerCase(); - } - function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); - if (resolved !== undefined) { - if (currentRepo && resolved.repo === currentRepo) { - return `#${resolved.number}`; - } - return `${resolved.repo}#${resolved.number}`; - } - return match; - }); - } - function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); - if (issueNumber !== undefined) { - return `#${issueNumber}`; - } - return match; - }); - } - function loadTemporaryIdMap() { - const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; - if (!mapJson || mapJson === "{}") { - return new Map(); - } - try { - const mapObject = JSON.parse(mapJson); - const result = new Map(); - for (const [key, value] of Object.entries(mapObject)) { - const normalizedKey = normalizeTemporaryId(key); - if (typeof value === "number") { - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - result.set(normalizedKey, { repo: contextRepo, number: value }); - } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { - result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); - } - } - return result; - } catch (error) { - if (typeof core !== "undefined") { - core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); - } - return new Map(); - } - } - function resolveIssueNumber(value, temporaryIdMap) { - if (value === undefined || value === null) { - return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; - } - const valueStr = String(value); - if (isTemporaryId(valueStr)) { - const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); - if (resolvedPair !== undefined) { - return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; - } - return { - resolved: null, - wasTemporaryId: true, - errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, - }; - } - const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); - if (isNaN(issueNumber) || issueNumber <= 0) { - return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; - } - const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; - return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; - } - function serializeTemporaryIdMap(tempIdMap) { - const obj = Object.fromEntries(tempIdMap); - return JSON.stringify(obj); - } - function parseAllowedRepos() { - const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; - const set = new Set(); - if (allowedReposEnv) { - allowedReposEnv - .split(",") - .map(repo => repo.trim()) - .filter(repo => repo) - .forEach(repo => set.add(repo)); - } - return set; - } - function getDefaultTargetRepo() { - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - if (targetRepoSlug) { - return targetRepoSlug; - } - return `${context.repo.owner}/${context.repo.repo}`; - } - function validateRepo(repo, defaultRepo, allowedRepos) { - if (repo === defaultRepo) { - return { valid: true, error: null }; - } - if (allowedRepos.has(repo)) { - return { valid: true, error: null }; - } - return { - valid: false, - error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, - }; - } - function parseRepoSlug(repoSlug) { - const parts = repoSlug.split("/"); - if (parts.length !== 2 || !parts[0] || !parts[1]) { - return null; - } - return { owner: parts[0], repo: parts[1] }; - } - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { getTrackerID } = require("./get_tracker_id.cjs"); + const { closeOlderDiscussions } = require("./close_older_discussions.cjs"); + const { replaceTemporaryIdReferences, loadTemporaryIdMap } = require("./temporary_id.cjs"); + const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require("./repo_helpers.cjs"); + const { addExpirationComment } = require("./expiration_helpers.cjs"); + const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); async function fetchRepoDiscussionInfo(owner, repo) { const repositoryQuery = ` query($owner: String!, $repo: String!) { @@ -6650,7 +6282,9 @@ jobs: ); let title = createDiscussionItem.title ? replaceTemporaryIdReferences(createDiscussionItem.title.trim(), temporaryIdMap, itemRepo) : ""; const bodyText = createDiscussionItem.body || ""; - let bodyLines = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo).split("\n"); + let processedBody = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo); + processedBody = removeDuplicateTitleFromDescription(title, processedBody); + let bodyLines = processedBody.split("\n"); if (!title) { title = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo) || "Agent Output"; } diff --git a/.github/workflows/safe-output-health.lock.yml b/.github/workflows/safe-output-health.lock.yml index 498e3a50d04..c6ab427ba41 100644 --- a/.github/workflows/safe-output-health.lock.yml +++ b/.github/workflows/safe-output-health.lock.yml @@ -6467,381 +6467,13 @@ jobs: with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - const fs = require("fs"); - const crypto = require("crypto"); - const MAX_LOG_CONTENT_LENGTH = 10000; - function truncateForLogging(content) { - if (content.length <= MAX_LOG_CONTENT_LENGTH) { - return content; - } - return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; - } - function loadAgentOutput() { - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; - if (!agentOutputFile) { - core.info("No GH_AW_AGENT_OUTPUT environment variable found"); - return { success: false }; - } - let outputContent; - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - return { success: false, error: errorMessage }; - } - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return { success: false }; - } - core.info(`Agent output content length: ${outputContent.length}`); - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); - return { success: false, error: errorMessage }; - } - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); - return { success: false }; - } - return { success: true, items: validatedOutput.items }; - } - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - function getMessages() { - const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES; - if (!messagesEnv) { - return null; - } - try { - return JSON.parse(messagesEnv); - } catch (error) { - core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`); - return null; - } - } - function renderTemplate(template, context) { - return template.replace(/\{(\w+)\}/g, (match, key) => { - const value = context[key]; - return value !== undefined && value !== null ? String(value) : match; - }); - } - function toSnakeCase(obj) { - const result = {}; - for (const [key, value] of Object.entries(obj)) { - const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase(); - result[snakeKey] = value; - result[key] = value; - } - return result; - } - function getCloseOlderDiscussionMessage(ctx) { - const messages = getMessages(); - const templateContext = toSnakeCase(ctx); - const defaultMessage = `⚓ Avast! This discussion be marked as **outdated** by [{workflow_name}]({run_url}). - 🗺️ A newer treasure map awaits ye at **[Discussion #{new_discussion_number}]({new_discussion_url})**. - Fair winds, matey! 🏴‍☠️`; - return messages?.closeOlderDiscussion - ? renderTemplate(messages.closeOlderDiscussion, templateContext) - : renderTemplate(defaultMessage, templateContext); - } - const MAX_CLOSE_COUNT = 10; - const GRAPHQL_DELAY_MS = 500; - function delay(ms) { - return new Promise(resolve => setTimeout(resolve, ms)); - } - async function searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, excludeNumber) { - let searchQuery = `repo:${owner}/${repo} is:open`; - if (titlePrefix) { - const escapedPrefix = titlePrefix.replace(/"/g, '\\"'); - searchQuery += ` in:title "${escapedPrefix}"`; - } - if (labels && labels.length > 0) { - for (const label of labels) { - const escapedLabel = label.replace(/"/g, '\\"'); - searchQuery += ` label:"${escapedLabel}"`; - } - } - const result = await github.graphql( - ` - query($searchTerms: String!, $first: Int!) { - search(query: $searchTerms, type: DISCUSSION, first: $first) { - nodes { - ... on Discussion { - id - number - title - url - category { - id - } - labels(first: 100) { - nodes { - name - } - } - closed - } - } - } - }`, - { searchTerms: searchQuery, first: 50 } - ); - if (!result || !result.search || !result.search.nodes) { - return []; - } - return result.search.nodes - .filter( - d => { - if (!d || d.number === excludeNumber || d.closed) { - return false; - } - if (titlePrefix && d.title && !d.title.startsWith(titlePrefix)) { - return false; - } - if (labels && labels.length > 0) { - const discussionLabels = d.labels?.nodes?.map(( l) => l.name) || []; - const hasAllLabels = labels.every(label => discussionLabels.includes(label)); - if (!hasAllLabels) { - return false; - } - } - if (categoryId && (!d.category || d.category.id !== categoryId)) { - return false; - } - return true; - } - ) - .map( - d => ({ - id: d.id, - number: d.number, - title: d.title, - url: d.url, - }) - ); - } - async function addDiscussionComment(github, discussionId, message) { - const result = await github.graphql( - ` - mutation($dId: ID!, $body: String!) { - addDiscussionComment(input: { discussionId: $dId, body: $body }) { - comment { - id - url - } - } - }`, - { dId: discussionId, body: message } - ); - return result.addDiscussionComment.comment; - } - async function closeDiscussionAsOutdated(github, discussionId) { - const result = await github.graphql( - ` - mutation($dId: ID!) { - closeDiscussion(input: { discussionId: $dId, reason: OUTDATED }) { - discussion { - id - url - } - } - }`, - { dId: discussionId } - ); - return result.closeDiscussion.discussion; - } - async function closeOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion, workflowName, runUrl) { - const searchCriteria = []; - if (titlePrefix) searchCriteria.push(`title prefix: "${titlePrefix}"`); - if (labels && labels.length > 0) searchCriteria.push(`labels: [${labels.join(", ")}]`); - core.info(`Searching for older discussions with ${searchCriteria.join(" and ")}`); - const olderDiscussions = await searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion.number); - if (olderDiscussions.length === 0) { - core.info("No older discussions found to close"); - return []; - } - core.info(`Found ${olderDiscussions.length} older discussion(s) to close`); - const discussionsToClose = olderDiscussions.slice(0, MAX_CLOSE_COUNT); - if (olderDiscussions.length > MAX_CLOSE_COUNT) { - core.warning(`Found ${olderDiscussions.length} older discussions, but only closing the first ${MAX_CLOSE_COUNT}`); - } - const closedDiscussions = []; - for (let i = 0; i < discussionsToClose.length; i++) { - const discussion = discussionsToClose[i]; - try { - const closingMessage = getCloseOlderDiscussionMessage({ - newDiscussionUrl: newDiscussion.url, - newDiscussionNumber: newDiscussion.number, - workflowName, - runUrl, - }); - core.info(`Adding closing comment to discussion #${discussion.number}`); - await addDiscussionComment(github, discussion.id, closingMessage); - core.info(`Closing discussion #${discussion.number} as outdated`); - await closeDiscussionAsOutdated(github, discussion.id); - closedDiscussions.push({ - number: discussion.number, - url: discussion.url, - }); - core.info(`✓ Closed discussion #${discussion.number}: ${discussion.url}`); - } catch (error) { - core.error(`✗ Failed to close discussion #${discussion.number}: ${error instanceof Error ? error.message : String(error)}`); - } - if (i < discussionsToClose.length - 1) { - await delay(GRAPHQL_DELAY_MS); - } - } - return closedDiscussions; - } - const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; - function generateTemporaryId() { - return "aw_" + crypto.randomBytes(6).toString("hex"); - } - function isTemporaryId(value) { - if (typeof value === "string") { - return /^aw_[0-9a-f]{12}$/i.test(value); - } - return false; - } - function normalizeTemporaryId(tempId) { - return String(tempId).toLowerCase(); - } - function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); - if (resolved !== undefined) { - if (currentRepo && resolved.repo === currentRepo) { - return `#${resolved.number}`; - } - return `${resolved.repo}#${resolved.number}`; - } - return match; - }); - } - function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); - if (issueNumber !== undefined) { - return `#${issueNumber}`; - } - return match; - }); - } - function loadTemporaryIdMap() { - const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; - if (!mapJson || mapJson === "{}") { - return new Map(); - } - try { - const mapObject = JSON.parse(mapJson); - const result = new Map(); - for (const [key, value] of Object.entries(mapObject)) { - const normalizedKey = normalizeTemporaryId(key); - if (typeof value === "number") { - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - result.set(normalizedKey, { repo: contextRepo, number: value }); - } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { - result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); - } - } - return result; - } catch (error) { - if (typeof core !== "undefined") { - core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); - } - return new Map(); - } - } - function resolveIssueNumber(value, temporaryIdMap) { - if (value === undefined || value === null) { - return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; - } - const valueStr = String(value); - if (isTemporaryId(valueStr)) { - const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); - if (resolvedPair !== undefined) { - return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; - } - return { - resolved: null, - wasTemporaryId: true, - errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, - }; - } - const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); - if (isNaN(issueNumber) || issueNumber <= 0) { - return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; - } - const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; - return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; - } - function serializeTemporaryIdMap(tempIdMap) { - const obj = Object.fromEntries(tempIdMap); - return JSON.stringify(obj); - } - function parseAllowedRepos() { - const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; - const set = new Set(); - if (allowedReposEnv) { - allowedReposEnv - .split(",") - .map(repo => repo.trim()) - .filter(repo => repo) - .forEach(repo => set.add(repo)); - } - return set; - } - function getDefaultTargetRepo() { - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - if (targetRepoSlug) { - return targetRepoSlug; - } - return `${context.repo.owner}/${context.repo.repo}`; - } - function validateRepo(repo, defaultRepo, allowedRepos) { - if (repo === defaultRepo) { - return { valid: true, error: null }; - } - if (allowedRepos.has(repo)) { - return { valid: true, error: null }; - } - return { - valid: false, - error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, - }; - } - function parseRepoSlug(repoSlug) { - const parts = repoSlug.split("/"); - if (parts.length !== 2 || !parts[0] || !parts[1]) { - return null; - } - return { owner: parts[0], repo: parts[1] }; - } - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { getTrackerID } = require("./get_tracker_id.cjs"); + const { closeOlderDiscussions } = require("./close_older_discussions.cjs"); + const { replaceTemporaryIdReferences, loadTemporaryIdMap } = require("./temporary_id.cjs"); + const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require("./repo_helpers.cjs"); + const { addExpirationComment } = require("./expiration_helpers.cjs"); + const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); async function fetchRepoDiscussionInfo(owner, repo) { const repositoryQuery = ` query($owner: String!, $repo: String!) { @@ -7026,7 +6658,9 @@ jobs: ); let title = createDiscussionItem.title ? replaceTemporaryIdReferences(createDiscussionItem.title.trim(), temporaryIdMap, itemRepo) : ""; const bodyText = createDiscussionItem.body || ""; - let bodyLines = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo).split("\n"); + let processedBody = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo); + processedBody = removeDuplicateTitleFromDescription(title, processedBody); + let bodyLines = processedBody.split("\n"); if (!title) { title = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo) || "Agent Output"; } diff --git a/.github/workflows/schema-consistency-checker.lock.yml b/.github/workflows/schema-consistency-checker.lock.yml index cec7e76392d..ad9c690af4a 100644 --- a/.github/workflows/schema-consistency-checker.lock.yml +++ b/.github/workflows/schema-consistency-checker.lock.yml @@ -6114,381 +6114,13 @@ jobs: with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - const fs = require("fs"); - const crypto = require("crypto"); - const MAX_LOG_CONTENT_LENGTH = 10000; - function truncateForLogging(content) { - if (content.length <= MAX_LOG_CONTENT_LENGTH) { - return content; - } - return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; - } - function loadAgentOutput() { - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; - if (!agentOutputFile) { - core.info("No GH_AW_AGENT_OUTPUT environment variable found"); - return { success: false }; - } - let outputContent; - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - return { success: false, error: errorMessage }; - } - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return { success: false }; - } - core.info(`Agent output content length: ${outputContent.length}`); - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); - return { success: false, error: errorMessage }; - } - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); - return { success: false }; - } - return { success: true, items: validatedOutput.items }; - } - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - function getMessages() { - const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES; - if (!messagesEnv) { - return null; - } - try { - return JSON.parse(messagesEnv); - } catch (error) { - core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`); - return null; - } - } - function renderTemplate(template, context) { - return template.replace(/\{(\w+)\}/g, (match, key) => { - const value = context[key]; - return value !== undefined && value !== null ? String(value) : match; - }); - } - function toSnakeCase(obj) { - const result = {}; - for (const [key, value] of Object.entries(obj)) { - const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase(); - result[snakeKey] = value; - result[key] = value; - } - return result; - } - function getCloseOlderDiscussionMessage(ctx) { - const messages = getMessages(); - const templateContext = toSnakeCase(ctx); - const defaultMessage = `⚓ Avast! This discussion be marked as **outdated** by [{workflow_name}]({run_url}). - 🗺️ A newer treasure map awaits ye at **[Discussion #{new_discussion_number}]({new_discussion_url})**. - Fair winds, matey! 🏴‍☠️`; - return messages?.closeOlderDiscussion - ? renderTemplate(messages.closeOlderDiscussion, templateContext) - : renderTemplate(defaultMessage, templateContext); - } - const MAX_CLOSE_COUNT = 10; - const GRAPHQL_DELAY_MS = 500; - function delay(ms) { - return new Promise(resolve => setTimeout(resolve, ms)); - } - async function searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, excludeNumber) { - let searchQuery = `repo:${owner}/${repo} is:open`; - if (titlePrefix) { - const escapedPrefix = titlePrefix.replace(/"/g, '\\"'); - searchQuery += ` in:title "${escapedPrefix}"`; - } - if (labels && labels.length > 0) { - for (const label of labels) { - const escapedLabel = label.replace(/"/g, '\\"'); - searchQuery += ` label:"${escapedLabel}"`; - } - } - const result = await github.graphql( - ` - query($searchTerms: String!, $first: Int!) { - search(query: $searchTerms, type: DISCUSSION, first: $first) { - nodes { - ... on Discussion { - id - number - title - url - category { - id - } - labels(first: 100) { - nodes { - name - } - } - closed - } - } - } - }`, - { searchTerms: searchQuery, first: 50 } - ); - if (!result || !result.search || !result.search.nodes) { - return []; - } - return result.search.nodes - .filter( - d => { - if (!d || d.number === excludeNumber || d.closed) { - return false; - } - if (titlePrefix && d.title && !d.title.startsWith(titlePrefix)) { - return false; - } - if (labels && labels.length > 0) { - const discussionLabels = d.labels?.nodes?.map(( l) => l.name) || []; - const hasAllLabels = labels.every(label => discussionLabels.includes(label)); - if (!hasAllLabels) { - return false; - } - } - if (categoryId && (!d.category || d.category.id !== categoryId)) { - return false; - } - return true; - } - ) - .map( - d => ({ - id: d.id, - number: d.number, - title: d.title, - url: d.url, - }) - ); - } - async function addDiscussionComment(github, discussionId, message) { - const result = await github.graphql( - ` - mutation($dId: ID!, $body: String!) { - addDiscussionComment(input: { discussionId: $dId, body: $body }) { - comment { - id - url - } - } - }`, - { dId: discussionId, body: message } - ); - return result.addDiscussionComment.comment; - } - async function closeDiscussionAsOutdated(github, discussionId) { - const result = await github.graphql( - ` - mutation($dId: ID!) { - closeDiscussion(input: { discussionId: $dId, reason: OUTDATED }) { - discussion { - id - url - } - } - }`, - { dId: discussionId } - ); - return result.closeDiscussion.discussion; - } - async function closeOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion, workflowName, runUrl) { - const searchCriteria = []; - if (titlePrefix) searchCriteria.push(`title prefix: "${titlePrefix}"`); - if (labels && labels.length > 0) searchCriteria.push(`labels: [${labels.join(", ")}]`); - core.info(`Searching for older discussions with ${searchCriteria.join(" and ")}`); - const olderDiscussions = await searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion.number); - if (olderDiscussions.length === 0) { - core.info("No older discussions found to close"); - return []; - } - core.info(`Found ${olderDiscussions.length} older discussion(s) to close`); - const discussionsToClose = olderDiscussions.slice(0, MAX_CLOSE_COUNT); - if (olderDiscussions.length > MAX_CLOSE_COUNT) { - core.warning(`Found ${olderDiscussions.length} older discussions, but only closing the first ${MAX_CLOSE_COUNT}`); - } - const closedDiscussions = []; - for (let i = 0; i < discussionsToClose.length; i++) { - const discussion = discussionsToClose[i]; - try { - const closingMessage = getCloseOlderDiscussionMessage({ - newDiscussionUrl: newDiscussion.url, - newDiscussionNumber: newDiscussion.number, - workflowName, - runUrl, - }); - core.info(`Adding closing comment to discussion #${discussion.number}`); - await addDiscussionComment(github, discussion.id, closingMessage); - core.info(`Closing discussion #${discussion.number} as outdated`); - await closeDiscussionAsOutdated(github, discussion.id); - closedDiscussions.push({ - number: discussion.number, - url: discussion.url, - }); - core.info(`✓ Closed discussion #${discussion.number}: ${discussion.url}`); - } catch (error) { - core.error(`✗ Failed to close discussion #${discussion.number}: ${error instanceof Error ? error.message : String(error)}`); - } - if (i < discussionsToClose.length - 1) { - await delay(GRAPHQL_DELAY_MS); - } - } - return closedDiscussions; - } - const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; - function generateTemporaryId() { - return "aw_" + crypto.randomBytes(6).toString("hex"); - } - function isTemporaryId(value) { - if (typeof value === "string") { - return /^aw_[0-9a-f]{12}$/i.test(value); - } - return false; - } - function normalizeTemporaryId(tempId) { - return String(tempId).toLowerCase(); - } - function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); - if (resolved !== undefined) { - if (currentRepo && resolved.repo === currentRepo) { - return `#${resolved.number}`; - } - return `${resolved.repo}#${resolved.number}`; - } - return match; - }); - } - function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); - if (issueNumber !== undefined) { - return `#${issueNumber}`; - } - return match; - }); - } - function loadTemporaryIdMap() { - const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; - if (!mapJson || mapJson === "{}") { - return new Map(); - } - try { - const mapObject = JSON.parse(mapJson); - const result = new Map(); - for (const [key, value] of Object.entries(mapObject)) { - const normalizedKey = normalizeTemporaryId(key); - if (typeof value === "number") { - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - result.set(normalizedKey, { repo: contextRepo, number: value }); - } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { - result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); - } - } - return result; - } catch (error) { - if (typeof core !== "undefined") { - core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); - } - return new Map(); - } - } - function resolveIssueNumber(value, temporaryIdMap) { - if (value === undefined || value === null) { - return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; - } - const valueStr = String(value); - if (isTemporaryId(valueStr)) { - const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); - if (resolvedPair !== undefined) { - return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; - } - return { - resolved: null, - wasTemporaryId: true, - errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, - }; - } - const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); - if (isNaN(issueNumber) || issueNumber <= 0) { - return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; - } - const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; - return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; - } - function serializeTemporaryIdMap(tempIdMap) { - const obj = Object.fromEntries(tempIdMap); - return JSON.stringify(obj); - } - function parseAllowedRepos() { - const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; - const set = new Set(); - if (allowedReposEnv) { - allowedReposEnv - .split(",") - .map(repo => repo.trim()) - .filter(repo => repo) - .forEach(repo => set.add(repo)); - } - return set; - } - function getDefaultTargetRepo() { - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - if (targetRepoSlug) { - return targetRepoSlug; - } - return `${context.repo.owner}/${context.repo.repo}`; - } - function validateRepo(repo, defaultRepo, allowedRepos) { - if (repo === defaultRepo) { - return { valid: true, error: null }; - } - if (allowedRepos.has(repo)) { - return { valid: true, error: null }; - } - return { - valid: false, - error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, - }; - } - function parseRepoSlug(repoSlug) { - const parts = repoSlug.split("/"); - if (parts.length !== 2 || !parts[0] || !parts[1]) { - return null; - } - return { owner: parts[0], repo: parts[1] }; - } - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { getTrackerID } = require("./get_tracker_id.cjs"); + const { closeOlderDiscussions } = require("./close_older_discussions.cjs"); + const { replaceTemporaryIdReferences, loadTemporaryIdMap } = require("./temporary_id.cjs"); + const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require("./repo_helpers.cjs"); + const { addExpirationComment } = require("./expiration_helpers.cjs"); + const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); async function fetchRepoDiscussionInfo(owner, repo) { const repositoryQuery = ` query($owner: String!, $repo: String!) { @@ -6673,7 +6305,9 @@ jobs: ); let title = createDiscussionItem.title ? replaceTemporaryIdReferences(createDiscussionItem.title.trim(), temporaryIdMap, itemRepo) : ""; const bodyText = createDiscussionItem.body || ""; - let bodyLines = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo).split("\n"); + let processedBody = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo); + processedBody = removeDuplicateTitleFromDescription(title, processedBody); + let bodyLines = processedBody.split("\n"); if (!title) { title = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo) || "Agent Output"; } diff --git a/.github/workflows/security-fix-pr.lock.yml b/.github/workflows/security-fix-pr.lock.yml index 268031e6f92..100575c4a78 100644 --- a/.github/workflows/security-fix-pr.lock.yml +++ b/.github/workflows/security-fix-pr.lock.yml @@ -5706,132 +5706,10 @@ jobs: script: | const fs = require("fs"); const crypto = require("crypto"); - async function updateActivationComment(github, context, core, itemUrl, itemNumber, itemType = "pull_request") { - const itemLabel = itemType === "issue" ? "issue" : "pull request"; - const linkMessage = - itemType === "issue" - ? `\n\n✅ Issue created: [#${itemNumber}](${itemUrl})` - : `\n\n✅ Pull request created: [#${itemNumber}](${itemUrl})`; - await updateActivationCommentWithMessage(github, context, core, linkMessage, itemLabel); - } - async function updateActivationCommentWithCommit(github, context, core, commitSha, commitUrl) { - const shortSha = commitSha.substring(0, 7); - const message = `\n\n✅ Commit pushed: [\`${shortSha}\`](${commitUrl})`; - await updateActivationCommentWithMessage(github, context, core, message, "commit"); - } - async function updateActivationCommentWithMessage(github, context, core, message, label = "") { - const commentId = process.env.GH_AW_COMMENT_ID; - const commentRepo = process.env.GH_AW_COMMENT_REPO; - if (!commentId) { - core.info("No activation comment to update (GH_AW_COMMENT_ID not set)"); - return; - } - core.info(`Updating activation comment ${commentId}`); - let repoOwner = context.repo.owner; - let repoName = context.repo.repo; - if (commentRepo) { - const parts = commentRepo.split("/"); - if (parts.length === 2) { - repoOwner = parts[0]; - repoName = parts[1]; - } else { - core.warning(`Invalid comment repo format: ${commentRepo}, expected "owner/repo". Falling back to context.repo.`); - } - } - core.info(`Updating comment in ${repoOwner}/${repoName}`); - const isDiscussionComment = commentId.startsWith("DC_"); - try { - if (isDiscussionComment) { - const currentComment = await github.graphql( - ` - query($commentId: ID!) { - node(id: $commentId) { - ... on DiscussionComment { - body - } - } - }`, - { commentId: commentId } - ); - if (!currentComment?.node?.body) { - core.warning("Unable to fetch current comment body, comment may have been deleted or is inaccessible"); - return; - } - const currentBody = currentComment.node.body; - const updatedBody = currentBody + message; - const result = await github.graphql( - ` - mutation($commentId: ID!, $body: String!) { - updateDiscussionComment(input: { commentId: $commentId, body: $body }) { - comment { - id - url - } - } - }`, - { commentId: commentId, body: updatedBody } - ); - const comment = result.updateDiscussionComment.comment; - const successMessage = label - ? `Successfully updated discussion comment with ${label} link` - : "Successfully updated discussion comment"; - core.info(successMessage); - core.info(`Comment ID: ${comment.id}`); - core.info(`Comment URL: ${comment.url}`); - } else { - const currentComment = await github.request("GET /repos/{owner}/{repo}/issues/comments/{comment_id}", { - owner: repoOwner, - repo: repoName, - comment_id: parseInt(commentId, 10), - headers: { - Accept: "application/vnd.github+json", - }, - }); - if (!currentComment?.data?.body) { - core.warning("Unable to fetch current comment body, comment may have been deleted"); - return; - } - const currentBody = currentComment.data.body; - const updatedBody = currentBody + message; - const response = await github.request("PATCH /repos/{owner}/{repo}/issues/comments/{comment_id}", { - owner: repoOwner, - repo: repoName, - comment_id: parseInt(commentId, 10), - body: updatedBody, - headers: { - Accept: "application/vnd.github+json", - }, - }); - const successMessage = label ? `Successfully updated comment with ${label} link` : "Successfully updated comment"; - core.info(successMessage); - core.info(`Comment ID: ${response.data.id}`); - core.info(`Comment URL: ${response.data.html_url}`); - } - } catch (error) { - core.warning(`Failed to update activation comment: ${error instanceof Error ? error.message : String(error)}`); - } - } - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } + const { updateActivationComment } = require("./update_activation_comment.cjs"); + const { getTrackerID } = require("./get_tracker_id.cjs"); + const { addExpirationComment } = require("./expiration_helpers.cjs"); + const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); function generatePatchPreview(patchContent) { if (!patchContent || !patchContent.trim()) { return ""; @@ -6023,7 +5901,9 @@ jobs: return; } let title = pullRequestItem.title.trim(); - let bodyLines = pullRequestItem.body.split("\n"); + let processedBody = pullRequestItem.body; + processedBody = removeDuplicateTitleFromDescription(title, processedBody); + let bodyLines = processedBody.split("\n"); let branchName = pullRequestItem.branch ? pullRequestItem.branch.trim() : null; if (!title) { title = "Agent Output"; diff --git a/.github/workflows/semantic-function-refactor.lock.yml b/.github/workflows/semantic-function-refactor.lock.yml index cdc5efdfdaf..1de8be94405 100644 --- a/.github/workflows/semantic-function-refactor.lock.yml +++ b/.github/workflows/semantic-function-refactor.lock.yml @@ -6576,272 +6576,21 @@ jobs: with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - function sanitizeLabelContent(content) { - if (!content || typeof content !== "string") { - return ""; - } - let sanitized = content.trim(); - sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); - sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); - sanitized = sanitized.replace( - /(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, - (_m, p1, p2) => `${p1}\`@${p2}\`` - ); - sanitized = sanitized.replace(/[<>&'"]/g, ""); - return sanitized.trim(); - } - const fs = require("fs"); - const crypto = require("crypto"); - const MAX_LOG_CONTENT_LENGTH = 10000; - function truncateForLogging(content) { - if (content.length <= MAX_LOG_CONTENT_LENGTH) { - return content; - } - return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; - } - function loadAgentOutput() { - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; - if (!agentOutputFile) { - core.info("No GH_AW_AGENT_OUTPUT environment variable found"); - return { success: false }; - } - let outputContent; - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - return { success: false, error: errorMessage }; - } - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return { success: false }; - } - core.info(`Agent output content length: ${outputContent.length}`); - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); - return { success: false, error: errorMessage }; - } - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); - return { success: false }; - } - return { success: true, items: validatedOutput.items }; - } - async function generateStagedPreview(options) { - const { title, description, items, renderItem } = options; - let summaryContent = `## 🎭 Staged Mode: ${title} Preview\n\n`; - summaryContent += `${description}\n\n`; - for (let i = 0; i < items.length; i++) { - const item = items[i]; - summaryContent += renderItem(item, i); - summaryContent += "---\n\n"; - } - try { - await core.summary.addRaw(summaryContent).write(); - core.info(summaryContent); - core.info(`📝 ${title} preview written to step summary`); - } catch (error) { - core.setFailed(error instanceof Error ? error : String(error)); - } - } - function generateXMLMarker(workflowName, runUrl) { - const engineId = process.env.GH_AW_ENGINE_ID || ""; - const engineVersion = process.env.GH_AW_ENGINE_VERSION || ""; - const engineModel = process.env.GH_AW_ENGINE_MODEL || ""; - const trackerId = process.env.GH_AW_TRACKER_ID || ""; - const parts = []; - parts.push(`agentic-workflow: ${workflowName}`); - if (trackerId) { - parts.push(`tracker-id: ${trackerId}`); - } - if (engineId) { - parts.push(`engine: ${engineId}`); - } - if (engineVersion) { - parts.push(`version: ${engineVersion}`); - } - if (engineModel) { - parts.push(`model: ${engineModel}`); - } - parts.push(`run: ${runUrl}`); - return ``; - } - function generateFooter( - workflowName, - runUrl, - workflowSource, - workflowSourceURL, - triggeringIssueNumber, - triggeringPRNumber, - triggeringDiscussionNumber - ) { - let footer = `\n\n> AI generated by [${workflowName}](${runUrl})`; - if (triggeringIssueNumber) { - footer += ` for #${triggeringIssueNumber}`; - } else if (triggeringPRNumber) { - footer += ` for #${triggeringPRNumber}`; - } else if (triggeringDiscussionNumber) { - footer += ` for discussion #${triggeringDiscussionNumber}`; - } - if (workflowSource && workflowSourceURL) { - footer += `\n>\n> To add this workflow in your repository, run \`gh aw add ${workflowSource}\`. See [usage guide](https://githubnext.github.io/gh-aw/tools/cli/).`; - } - footer += "\n\n" + generateXMLMarker(workflowName, runUrl); - footer += "\n"; - return footer; - } - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; - function generateTemporaryId() { - return "aw_" + crypto.randomBytes(6).toString("hex"); - } - function isTemporaryId(value) { - if (typeof value === "string") { - return /^aw_[0-9a-f]{12}$/i.test(value); - } - return false; - } - function normalizeTemporaryId(tempId) { - return String(tempId).toLowerCase(); - } - function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); - if (resolved !== undefined) { - if (currentRepo && resolved.repo === currentRepo) { - return `#${resolved.number}`; - } - return `${resolved.repo}#${resolved.number}`; - } - return match; - }); - } - function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); - if (issueNumber !== undefined) { - return `#${issueNumber}`; - } - return match; - }); - } - function loadTemporaryIdMap() { - const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; - if (!mapJson || mapJson === "{}") { - return new Map(); - } - try { - const mapObject = JSON.parse(mapJson); - const result = new Map(); - for (const [key, value] of Object.entries(mapObject)) { - const normalizedKey = normalizeTemporaryId(key); - if (typeof value === "number") { - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - result.set(normalizedKey, { repo: contextRepo, number: value }); - } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { - result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); - } - } - return result; - } catch (error) { - if (typeof core !== "undefined") { - core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); - } - return new Map(); - } - } - function resolveIssueNumber(value, temporaryIdMap) { - if (value === undefined || value === null) { - return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; - } - const valueStr = String(value); - if (isTemporaryId(valueStr)) { - const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); - if (resolvedPair !== undefined) { - return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; - } - return { - resolved: null, - wasTemporaryId: true, - errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, - }; - } - const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); - if (isNaN(issueNumber) || issueNumber <= 0) { - return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; - } - const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; - return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; - } - function serializeTemporaryIdMap(tempIdMap) { - const obj = Object.fromEntries(tempIdMap); - return JSON.stringify(obj); - } - function parseAllowedRepos() { - const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; - const set = new Set(); - if (allowedReposEnv) { - allowedReposEnv - .split(",") - .map(repo => repo.trim()) - .filter(repo => repo) - .forEach(repo => set.add(repo)); - } - return set; - } - function getDefaultTargetRepo() { - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - if (targetRepoSlug) { - return targetRepoSlug; - } - return `${context.repo.owner}/${context.repo.repo}`; - } - function validateRepo(repo, defaultRepo, allowedRepos) { - if (repo === defaultRepo) { - return { valid: true, error: null }; - } - if (allowedRepos.has(repo)) { - return { valid: true, error: null }; - } - return { - valid: false, - error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, - }; - } - function parseRepoSlug(repoSlug) { - const parts = repoSlug.split("/"); - if (parts.length !== 2 || !parts[0] || !parts[1]) { - return null; - } - return { owner: parts[0], repo: parts[1] }; - } - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } + const { sanitizeLabelContent } = require("./sanitize_label_content.cjs"); + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { generateStagedPreview } = require("./staged_preview.cjs"); + const { generateFooter } = require("./generate_footer.cjs"); + const { getTrackerID } = require("./get_tracker_id.cjs"); + const { + generateTemporaryId, + isTemporaryId, + normalizeTemporaryId, + replaceTemporaryIdReferences, + serializeTemporaryIdMap, + } = require("./temporary_id.cjs"); + const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require("./repo_helpers.cjs"); + const { addExpirationComment } = require("./expiration_helpers.cjs"); + const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); async function main() { core.setOutput("issue_number", ""); core.setOutput("issue_url", ""); @@ -6974,6 +6723,7 @@ jobs: .filter((label, index, arr) => arr.indexOf(label) === index); let title = createIssueItem.title ? createIssueItem.title.trim() : ""; let processedBody = replaceTemporaryIdReferences(createIssueItem.body, temporaryIdMap, itemRepo); + processedBody = removeDuplicateTitleFromDescription(title, processedBody); let bodyLines = processedBody.split("\n"); if (!title) { title = createIssueItem.body || "Agent Output"; diff --git a/.github/workflows/smoke-claude.lock.yml b/.github/workflows/smoke-claude.lock.yml index b30ab12be33..05b523f1aed 100644 --- a/.github/workflows/smoke-claude.lock.yml +++ b/.github/workflows/smoke-claude.lock.yml @@ -7403,272 +7403,21 @@ jobs: with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - function sanitizeLabelContent(content) { - if (!content || typeof content !== "string") { - return ""; - } - let sanitized = content.trim(); - sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); - sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); - sanitized = sanitized.replace( - /(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, - (_m, p1, p2) => `${p1}\`@${p2}\`` - ); - sanitized = sanitized.replace(/[<>&'"]/g, ""); - return sanitized.trim(); - } - const fs = require("fs"); - const crypto = require("crypto"); - const MAX_LOG_CONTENT_LENGTH = 10000; - function truncateForLogging(content) { - if (content.length <= MAX_LOG_CONTENT_LENGTH) { - return content; - } - return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; - } - function loadAgentOutput() { - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; - if (!agentOutputFile) { - core.info("No GH_AW_AGENT_OUTPUT environment variable found"); - return { success: false }; - } - let outputContent; - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - return { success: false, error: errorMessage }; - } - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return { success: false }; - } - core.info(`Agent output content length: ${outputContent.length}`); - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); - return { success: false, error: errorMessage }; - } - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); - return { success: false }; - } - return { success: true, items: validatedOutput.items }; - } - async function generateStagedPreview(options) { - const { title, description, items, renderItem } = options; - let summaryContent = `## 🎭 Staged Mode: ${title} Preview\n\n`; - summaryContent += `${description}\n\n`; - for (let i = 0; i < items.length; i++) { - const item = items[i]; - summaryContent += renderItem(item, i); - summaryContent += "---\n\n"; - } - try { - await core.summary.addRaw(summaryContent).write(); - core.info(summaryContent); - core.info(`📝 ${title} preview written to step summary`); - } catch (error) { - core.setFailed(error instanceof Error ? error : String(error)); - } - } - function generateXMLMarker(workflowName, runUrl) { - const engineId = process.env.GH_AW_ENGINE_ID || ""; - const engineVersion = process.env.GH_AW_ENGINE_VERSION || ""; - const engineModel = process.env.GH_AW_ENGINE_MODEL || ""; - const trackerId = process.env.GH_AW_TRACKER_ID || ""; - const parts = []; - parts.push(`agentic-workflow: ${workflowName}`); - if (trackerId) { - parts.push(`tracker-id: ${trackerId}`); - } - if (engineId) { - parts.push(`engine: ${engineId}`); - } - if (engineVersion) { - parts.push(`version: ${engineVersion}`); - } - if (engineModel) { - parts.push(`model: ${engineModel}`); - } - parts.push(`run: ${runUrl}`); - return ``; - } - function generateFooter( - workflowName, - runUrl, - workflowSource, - workflowSourceURL, - triggeringIssueNumber, - triggeringPRNumber, - triggeringDiscussionNumber - ) { - let footer = `\n\n> AI generated by [${workflowName}](${runUrl})`; - if (triggeringIssueNumber) { - footer += ` for #${triggeringIssueNumber}`; - } else if (triggeringPRNumber) { - footer += ` for #${triggeringPRNumber}`; - } else if (triggeringDiscussionNumber) { - footer += ` for discussion #${triggeringDiscussionNumber}`; - } - if (workflowSource && workflowSourceURL) { - footer += `\n>\n> To add this workflow in your repository, run \`gh aw add ${workflowSource}\`. See [usage guide](https://githubnext.github.io/gh-aw/tools/cli/).`; - } - footer += "\n\n" + generateXMLMarker(workflowName, runUrl); - footer += "\n"; - return footer; - } - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; - function generateTemporaryId() { - return "aw_" + crypto.randomBytes(6).toString("hex"); - } - function isTemporaryId(value) { - if (typeof value === "string") { - return /^aw_[0-9a-f]{12}$/i.test(value); - } - return false; - } - function normalizeTemporaryId(tempId) { - return String(tempId).toLowerCase(); - } - function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); - if (resolved !== undefined) { - if (currentRepo && resolved.repo === currentRepo) { - return `#${resolved.number}`; - } - return `${resolved.repo}#${resolved.number}`; - } - return match; - }); - } - function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); - if (issueNumber !== undefined) { - return `#${issueNumber}`; - } - return match; - }); - } - function loadTemporaryIdMap() { - const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; - if (!mapJson || mapJson === "{}") { - return new Map(); - } - try { - const mapObject = JSON.parse(mapJson); - const result = new Map(); - for (const [key, value] of Object.entries(mapObject)) { - const normalizedKey = normalizeTemporaryId(key); - if (typeof value === "number") { - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - result.set(normalizedKey, { repo: contextRepo, number: value }); - } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { - result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); - } - } - return result; - } catch (error) { - if (typeof core !== "undefined") { - core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); - } - return new Map(); - } - } - function resolveIssueNumber(value, temporaryIdMap) { - if (value === undefined || value === null) { - return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; - } - const valueStr = String(value); - if (isTemporaryId(valueStr)) { - const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); - if (resolvedPair !== undefined) { - return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; - } - return { - resolved: null, - wasTemporaryId: true, - errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, - }; - } - const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); - if (isNaN(issueNumber) || issueNumber <= 0) { - return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; - } - const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; - return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; - } - function serializeTemporaryIdMap(tempIdMap) { - const obj = Object.fromEntries(tempIdMap); - return JSON.stringify(obj); - } - function parseAllowedRepos() { - const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; - const set = new Set(); - if (allowedReposEnv) { - allowedReposEnv - .split(",") - .map(repo => repo.trim()) - .filter(repo => repo) - .forEach(repo => set.add(repo)); - } - return set; - } - function getDefaultTargetRepo() { - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - if (targetRepoSlug) { - return targetRepoSlug; - } - return `${context.repo.owner}/${context.repo.repo}`; - } - function validateRepo(repo, defaultRepo, allowedRepos) { - if (repo === defaultRepo) { - return { valid: true, error: null }; - } - if (allowedRepos.has(repo)) { - return { valid: true, error: null }; - } - return { - valid: false, - error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, - }; - } - function parseRepoSlug(repoSlug) { - const parts = repoSlug.split("/"); - if (parts.length !== 2 || !parts[0] || !parts[1]) { - return null; - } - return { owner: parts[0], repo: parts[1] }; - } - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } + const { sanitizeLabelContent } = require("./sanitize_label_content.cjs"); + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { generateStagedPreview } = require("./staged_preview.cjs"); + const { generateFooter } = require("./generate_footer.cjs"); + const { getTrackerID } = require("./get_tracker_id.cjs"); + const { + generateTemporaryId, + isTemporaryId, + normalizeTemporaryId, + replaceTemporaryIdReferences, + serializeTemporaryIdMap, + } = require("./temporary_id.cjs"); + const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require("./repo_helpers.cjs"); + const { addExpirationComment } = require("./expiration_helpers.cjs"); + const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); async function main() { core.setOutput("issue_number", ""); core.setOutput("issue_url", ""); @@ -7801,6 +7550,7 @@ jobs: .filter((label, index, arr) => arr.indexOf(label) === index); let title = createIssueItem.title ? createIssueItem.title.trim() : ""; let processedBody = replaceTemporaryIdReferences(createIssueItem.body, temporaryIdMap, itemRepo); + processedBody = removeDuplicateTitleFromDescription(title, processedBody); let bodyLines = processedBody.split("\n"); if (!title) { title = createIssueItem.body || "Agent Output"; diff --git a/.github/workflows/smoke-codex.lock.yml b/.github/workflows/smoke-codex.lock.yml index 6bb1d5c53e3..5b4d9be73b0 100644 --- a/.github/workflows/smoke-codex.lock.yml +++ b/.github/workflows/smoke-codex.lock.yml @@ -7216,272 +7216,21 @@ jobs: with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - function sanitizeLabelContent(content) { - if (!content || typeof content !== "string") { - return ""; - } - let sanitized = content.trim(); - sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); - sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); - sanitized = sanitized.replace( - /(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, - (_m, p1, p2) => `${p1}\`@${p2}\`` - ); - sanitized = sanitized.replace(/[<>&'"]/g, ""); - return sanitized.trim(); - } - const fs = require("fs"); - const crypto = require("crypto"); - const MAX_LOG_CONTENT_LENGTH = 10000; - function truncateForLogging(content) { - if (content.length <= MAX_LOG_CONTENT_LENGTH) { - return content; - } - return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; - } - function loadAgentOutput() { - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; - if (!agentOutputFile) { - core.info("No GH_AW_AGENT_OUTPUT environment variable found"); - return { success: false }; - } - let outputContent; - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - return { success: false, error: errorMessage }; - } - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return { success: false }; - } - core.info(`Agent output content length: ${outputContent.length}`); - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); - return { success: false, error: errorMessage }; - } - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); - return { success: false }; - } - return { success: true, items: validatedOutput.items }; - } - async function generateStagedPreview(options) { - const { title, description, items, renderItem } = options; - let summaryContent = `## 🎭 Staged Mode: ${title} Preview\n\n`; - summaryContent += `${description}\n\n`; - for (let i = 0; i < items.length; i++) { - const item = items[i]; - summaryContent += renderItem(item, i); - summaryContent += "---\n\n"; - } - try { - await core.summary.addRaw(summaryContent).write(); - core.info(summaryContent); - core.info(`📝 ${title} preview written to step summary`); - } catch (error) { - core.setFailed(error instanceof Error ? error : String(error)); - } - } - function generateXMLMarker(workflowName, runUrl) { - const engineId = process.env.GH_AW_ENGINE_ID || ""; - const engineVersion = process.env.GH_AW_ENGINE_VERSION || ""; - const engineModel = process.env.GH_AW_ENGINE_MODEL || ""; - const trackerId = process.env.GH_AW_TRACKER_ID || ""; - const parts = []; - parts.push(`agentic-workflow: ${workflowName}`); - if (trackerId) { - parts.push(`tracker-id: ${trackerId}`); - } - if (engineId) { - parts.push(`engine: ${engineId}`); - } - if (engineVersion) { - parts.push(`version: ${engineVersion}`); - } - if (engineModel) { - parts.push(`model: ${engineModel}`); - } - parts.push(`run: ${runUrl}`); - return ``; - } - function generateFooter( - workflowName, - runUrl, - workflowSource, - workflowSourceURL, - triggeringIssueNumber, - triggeringPRNumber, - triggeringDiscussionNumber - ) { - let footer = `\n\n> AI generated by [${workflowName}](${runUrl})`; - if (triggeringIssueNumber) { - footer += ` for #${triggeringIssueNumber}`; - } else if (triggeringPRNumber) { - footer += ` for #${triggeringPRNumber}`; - } else if (triggeringDiscussionNumber) { - footer += ` for discussion #${triggeringDiscussionNumber}`; - } - if (workflowSource && workflowSourceURL) { - footer += `\n>\n> To add this workflow in your repository, run \`gh aw add ${workflowSource}\`. See [usage guide](https://githubnext.github.io/gh-aw/tools/cli/).`; - } - footer += "\n\n" + generateXMLMarker(workflowName, runUrl); - footer += "\n"; - return footer; - } - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; - function generateTemporaryId() { - return "aw_" + crypto.randomBytes(6).toString("hex"); - } - function isTemporaryId(value) { - if (typeof value === "string") { - return /^aw_[0-9a-f]{12}$/i.test(value); - } - return false; - } - function normalizeTemporaryId(tempId) { - return String(tempId).toLowerCase(); - } - function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); - if (resolved !== undefined) { - if (currentRepo && resolved.repo === currentRepo) { - return `#${resolved.number}`; - } - return `${resolved.repo}#${resolved.number}`; - } - return match; - }); - } - function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); - if (issueNumber !== undefined) { - return `#${issueNumber}`; - } - return match; - }); - } - function loadTemporaryIdMap() { - const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; - if (!mapJson || mapJson === "{}") { - return new Map(); - } - try { - const mapObject = JSON.parse(mapJson); - const result = new Map(); - for (const [key, value] of Object.entries(mapObject)) { - const normalizedKey = normalizeTemporaryId(key); - if (typeof value === "number") { - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - result.set(normalizedKey, { repo: contextRepo, number: value }); - } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { - result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); - } - } - return result; - } catch (error) { - if (typeof core !== "undefined") { - core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); - } - return new Map(); - } - } - function resolveIssueNumber(value, temporaryIdMap) { - if (value === undefined || value === null) { - return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; - } - const valueStr = String(value); - if (isTemporaryId(valueStr)) { - const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); - if (resolvedPair !== undefined) { - return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; - } - return { - resolved: null, - wasTemporaryId: true, - errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, - }; - } - const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); - if (isNaN(issueNumber) || issueNumber <= 0) { - return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; - } - const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; - return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; - } - function serializeTemporaryIdMap(tempIdMap) { - const obj = Object.fromEntries(tempIdMap); - return JSON.stringify(obj); - } - function parseAllowedRepos() { - const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; - const set = new Set(); - if (allowedReposEnv) { - allowedReposEnv - .split(",") - .map(repo => repo.trim()) - .filter(repo => repo) - .forEach(repo => set.add(repo)); - } - return set; - } - function getDefaultTargetRepo() { - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - if (targetRepoSlug) { - return targetRepoSlug; - } - return `${context.repo.owner}/${context.repo.repo}`; - } - function validateRepo(repo, defaultRepo, allowedRepos) { - if (repo === defaultRepo) { - return { valid: true, error: null }; - } - if (allowedRepos.has(repo)) { - return { valid: true, error: null }; - } - return { - valid: false, - error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, - }; - } - function parseRepoSlug(repoSlug) { - const parts = repoSlug.split("/"); - if (parts.length !== 2 || !parts[0] || !parts[1]) { - return null; - } - return { owner: parts[0], repo: parts[1] }; - } - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } + const { sanitizeLabelContent } = require("./sanitize_label_content.cjs"); + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { generateStagedPreview } = require("./staged_preview.cjs"); + const { generateFooter } = require("./generate_footer.cjs"); + const { getTrackerID } = require("./get_tracker_id.cjs"); + const { + generateTemporaryId, + isTemporaryId, + normalizeTemporaryId, + replaceTemporaryIdReferences, + serializeTemporaryIdMap, + } = require("./temporary_id.cjs"); + const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require("./repo_helpers.cjs"); + const { addExpirationComment } = require("./expiration_helpers.cjs"); + const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); async function main() { core.setOutput("issue_number", ""); core.setOutput("issue_url", ""); @@ -7614,6 +7363,7 @@ jobs: .filter((label, index, arr) => arr.indexOf(label) === index); let title = createIssueItem.title ? createIssueItem.title.trim() : ""; let processedBody = replaceTemporaryIdReferences(createIssueItem.body, temporaryIdMap, itemRepo); + processedBody = removeDuplicateTitleFromDescription(title, processedBody); let bodyLines = processedBody.split("\n"); if (!title) { title = createIssueItem.body || "Agent Output"; diff --git a/.github/workflows/smoke-copilot-no-firewall.lock.yml b/.github/workflows/smoke-copilot-no-firewall.lock.yml index 02788a96ed1..dfde83ff030 100644 --- a/.github/workflows/smoke-copilot-no-firewall.lock.yml +++ b/.github/workflows/smoke-copilot-no-firewall.lock.yml @@ -9124,272 +9124,21 @@ jobs: with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - function sanitizeLabelContent(content) { - if (!content || typeof content !== "string") { - return ""; - } - let sanitized = content.trim(); - sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); - sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); - sanitized = sanitized.replace( - /(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, - (_m, p1, p2) => `${p1}\`@${p2}\`` - ); - sanitized = sanitized.replace(/[<>&'"]/g, ""); - return sanitized.trim(); - } - const fs = require("fs"); - const crypto = require("crypto"); - const MAX_LOG_CONTENT_LENGTH = 10000; - function truncateForLogging(content) { - if (content.length <= MAX_LOG_CONTENT_LENGTH) { - return content; - } - return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; - } - function loadAgentOutput() { - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; - if (!agentOutputFile) { - core.info("No GH_AW_AGENT_OUTPUT environment variable found"); - return { success: false }; - } - let outputContent; - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - return { success: false, error: errorMessage }; - } - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return { success: false }; - } - core.info(`Agent output content length: ${outputContent.length}`); - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); - return { success: false, error: errorMessage }; - } - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); - return { success: false }; - } - return { success: true, items: validatedOutput.items }; - } - async function generateStagedPreview(options) { - const { title, description, items, renderItem } = options; - let summaryContent = `## 🎭 Staged Mode: ${title} Preview\n\n`; - summaryContent += `${description}\n\n`; - for (let i = 0; i < items.length; i++) { - const item = items[i]; - summaryContent += renderItem(item, i); - summaryContent += "---\n\n"; - } - try { - await core.summary.addRaw(summaryContent).write(); - core.info(summaryContent); - core.info(`📝 ${title} preview written to step summary`); - } catch (error) { - core.setFailed(error instanceof Error ? error : String(error)); - } - } - function generateXMLMarker(workflowName, runUrl) { - const engineId = process.env.GH_AW_ENGINE_ID || ""; - const engineVersion = process.env.GH_AW_ENGINE_VERSION || ""; - const engineModel = process.env.GH_AW_ENGINE_MODEL || ""; - const trackerId = process.env.GH_AW_TRACKER_ID || ""; - const parts = []; - parts.push(`agentic-workflow: ${workflowName}`); - if (trackerId) { - parts.push(`tracker-id: ${trackerId}`); - } - if (engineId) { - parts.push(`engine: ${engineId}`); - } - if (engineVersion) { - parts.push(`version: ${engineVersion}`); - } - if (engineModel) { - parts.push(`model: ${engineModel}`); - } - parts.push(`run: ${runUrl}`); - return ``; - } - function generateFooter( - workflowName, - runUrl, - workflowSource, - workflowSourceURL, - triggeringIssueNumber, - triggeringPRNumber, - triggeringDiscussionNumber - ) { - let footer = `\n\n> AI generated by [${workflowName}](${runUrl})`; - if (triggeringIssueNumber) { - footer += ` for #${triggeringIssueNumber}`; - } else if (triggeringPRNumber) { - footer += ` for #${triggeringPRNumber}`; - } else if (triggeringDiscussionNumber) { - footer += ` for discussion #${triggeringDiscussionNumber}`; - } - if (workflowSource && workflowSourceURL) { - footer += `\n>\n> To add this workflow in your repository, run \`gh aw add ${workflowSource}\`. See [usage guide](https://githubnext.github.io/gh-aw/tools/cli/).`; - } - footer += "\n\n" + generateXMLMarker(workflowName, runUrl); - footer += "\n"; - return footer; - } - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; - function generateTemporaryId() { - return "aw_" + crypto.randomBytes(6).toString("hex"); - } - function isTemporaryId(value) { - if (typeof value === "string") { - return /^aw_[0-9a-f]{12}$/i.test(value); - } - return false; - } - function normalizeTemporaryId(tempId) { - return String(tempId).toLowerCase(); - } - function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); - if (resolved !== undefined) { - if (currentRepo && resolved.repo === currentRepo) { - return `#${resolved.number}`; - } - return `${resolved.repo}#${resolved.number}`; - } - return match; - }); - } - function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); - if (issueNumber !== undefined) { - return `#${issueNumber}`; - } - return match; - }); - } - function loadTemporaryIdMap() { - const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; - if (!mapJson || mapJson === "{}") { - return new Map(); - } - try { - const mapObject = JSON.parse(mapJson); - const result = new Map(); - for (const [key, value] of Object.entries(mapObject)) { - const normalizedKey = normalizeTemporaryId(key); - if (typeof value === "number") { - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - result.set(normalizedKey, { repo: contextRepo, number: value }); - } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { - result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); - } - } - return result; - } catch (error) { - if (typeof core !== "undefined") { - core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); - } - return new Map(); - } - } - function resolveIssueNumber(value, temporaryIdMap) { - if (value === undefined || value === null) { - return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; - } - const valueStr = String(value); - if (isTemporaryId(valueStr)) { - const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); - if (resolvedPair !== undefined) { - return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; - } - return { - resolved: null, - wasTemporaryId: true, - errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, - }; - } - const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); - if (isNaN(issueNumber) || issueNumber <= 0) { - return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; - } - const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; - return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; - } - function serializeTemporaryIdMap(tempIdMap) { - const obj = Object.fromEntries(tempIdMap); - return JSON.stringify(obj); - } - function parseAllowedRepos() { - const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; - const set = new Set(); - if (allowedReposEnv) { - allowedReposEnv - .split(",") - .map(repo => repo.trim()) - .filter(repo => repo) - .forEach(repo => set.add(repo)); - } - return set; - } - function getDefaultTargetRepo() { - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - if (targetRepoSlug) { - return targetRepoSlug; - } - return `${context.repo.owner}/${context.repo.repo}`; - } - function validateRepo(repo, defaultRepo, allowedRepos) { - if (repo === defaultRepo) { - return { valid: true, error: null }; - } - if (allowedRepos.has(repo)) { - return { valid: true, error: null }; - } - return { - valid: false, - error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, - }; - } - function parseRepoSlug(repoSlug) { - const parts = repoSlug.split("/"); - if (parts.length !== 2 || !parts[0] || !parts[1]) { - return null; - } - return { owner: parts[0], repo: parts[1] }; - } - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } + const { sanitizeLabelContent } = require("./sanitize_label_content.cjs"); + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { generateStagedPreview } = require("./staged_preview.cjs"); + const { generateFooter } = require("./generate_footer.cjs"); + const { getTrackerID } = require("./get_tracker_id.cjs"); + const { + generateTemporaryId, + isTemporaryId, + normalizeTemporaryId, + replaceTemporaryIdReferences, + serializeTemporaryIdMap, + } = require("./temporary_id.cjs"); + const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require("./repo_helpers.cjs"); + const { addExpirationComment } = require("./expiration_helpers.cjs"); + const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); async function main() { core.setOutput("issue_number", ""); core.setOutput("issue_url", ""); @@ -9522,6 +9271,7 @@ jobs: .filter((label, index, arr) => arr.indexOf(label) === index); let title = createIssueItem.title ? createIssueItem.title.trim() : ""; let processedBody = replaceTemporaryIdReferences(createIssueItem.body, temporaryIdMap, itemRepo); + processedBody = removeDuplicateTitleFromDescription(title, processedBody); let bodyLines = processedBody.split("\n"); if (!title) { title = createIssueItem.body || "Agent Output"; @@ -10151,432 +9901,8 @@ jobs: with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - const fs = require("fs"); - const MAX_LOG_CONTENT_LENGTH = 10000; - function truncateForLogging(content) { - if (content.length <= MAX_LOG_CONTENT_LENGTH) { - return content; - } - return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; - } - function loadAgentOutput() { - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; - if (!agentOutputFile) { - core.info("No GH_AW_AGENT_OUTPUT environment variable found"); - return { success: false }; - } - let outputContent; - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - return { success: false, error: errorMessage }; - } - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return { success: false }; - } - core.info(`Agent output content length: ${outputContent.length}`); - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); - return { success: false, error: errorMessage }; - } - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); - return { success: false }; - } - return { success: true, items: validatedOutput.items }; - } - async function generateStagedPreview(options) { - const { title, description, items, renderItem } = options; - let summaryContent = `## 🎭 Staged Mode: ${title} Preview\n\n`; - summaryContent += `${description}\n\n`; - for (let i = 0; i < items.length; i++) { - const item = items[i]; - summaryContent += renderItem(item, i); - summaryContent += "---\n\n"; - } - try { - await core.summary.addRaw(summaryContent).write(); - core.info(summaryContent); - core.info(`📝 ${title} preview written to step summary`); - } catch (error) { - core.setFailed(error instanceof Error ? error : String(error)); - } - } - function resolveTargetNumber(params) { - const { updateTarget, item, numberField, isValidContext, contextNumber, displayName } = params; - if (updateTarget === "*") { - const explicitNumber = item[numberField]; - if (explicitNumber) { - const parsed = parseInt(explicitNumber, 10); - if (isNaN(parsed) || parsed <= 0) { - return { success: false, error: `Invalid ${numberField} specified: ${explicitNumber}` }; - } - return { success: true, number: parsed }; - } else { - return { success: false, error: `Target is "*" but no ${numberField} specified in update item` }; - } - } else if (updateTarget && updateTarget !== "triggering") { - const parsed = parseInt(updateTarget, 10); - if (isNaN(parsed) || parsed <= 0) { - return { success: false, error: `Invalid ${displayName} number in target configuration: ${updateTarget}` }; - } - return { success: true, number: parsed }; - } else { - if (isValidContext && contextNumber) { - return { success: true, number: contextNumber }; - } - return { success: false, error: `Could not determine ${displayName} number` }; - } - } - function buildUpdateData(params) { - const { item, canUpdateStatus, canUpdateTitle, canUpdateBody, supportsStatus } = params; - const updateData = {}; - let hasUpdates = false; - const logMessages = []; - if (supportsStatus && canUpdateStatus && item.status !== undefined) { - if (item.status === "open" || item.status === "closed") { - updateData.state = item.status; - hasUpdates = true; - logMessages.push(`Will update status to: ${item.status}`); - } else { - logMessages.push(`Invalid status value: ${item.status}. Must be 'open' or 'closed'`); - } - } - if (canUpdateTitle && item.title !== undefined) { - const trimmedTitle = typeof item.title === "string" ? item.title.trim() : ""; - if (trimmedTitle.length > 0) { - updateData.title = trimmedTitle; - hasUpdates = true; - logMessages.push(`Will update title to: ${trimmedTitle}`); - } else { - logMessages.push("Invalid title value: must be a non-empty string"); - } - } - if (canUpdateBody && item.body !== undefined) { - if (typeof item.body === "string") { - updateData.body = item.body; - hasUpdates = true; - logMessages.push(`Will update body (length: ${item.body.length})`); - } else { - logMessages.push("Invalid body value: must be a string"); - } - } - return { hasUpdates, updateData, logMessages }; - } - async function runUpdateWorkflow(config) { - const { - itemType, - displayName, - displayNamePlural, - numberField, - outputNumberKey, - outputUrlKey, - isValidContext, - getContextNumber, - supportsStatus, - supportsOperation, - renderStagedItem, - executeUpdate, - getSummaryLine, - } = config; - const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; - const result = loadAgentOutput(); - if (!result.success) { - return; - } - const updateItems = result.items.filter( item => item.type === itemType); - if (updateItems.length === 0) { - core.info(`No ${itemType} items found in agent output`); - return; - } - core.info(`Found ${updateItems.length} ${itemType} item(s)`); - if (isStaged) { - await generateStagedPreview({ - title: `Update ${displayNamePlural.charAt(0).toUpperCase() + displayNamePlural.slice(1)}`, - description: `The following ${displayName} updates would be applied if staged mode was disabled:`, - items: updateItems, - renderItem: renderStagedItem, - }); - return; - } - const updateTarget = process.env.GH_AW_UPDATE_TARGET || "triggering"; - const canUpdateStatus = process.env.GH_AW_UPDATE_STATUS === "true"; - const canUpdateTitle = process.env.GH_AW_UPDATE_TITLE === "true"; - const canUpdateBody = process.env.GH_AW_UPDATE_BODY === "true"; - core.info(`Update target configuration: ${updateTarget}`); - if (supportsStatus) { - core.info(`Can update status: ${canUpdateStatus}, title: ${canUpdateTitle}, body: ${canUpdateBody}`); - } else { - core.info(`Can update title: ${canUpdateTitle}, body: ${canUpdateBody}`); - } - const contextIsValid = isValidContext(context.eventName, context.payload); - const contextNumber = getContextNumber(context.payload); - if (updateTarget === "triggering" && !contextIsValid) { - core.info(`Target is "triggering" but not running in ${displayName} context, skipping ${displayName} update`); - return; - } - const updatedItems = []; - for (let i = 0; i < updateItems.length; i++) { - const updateItem = updateItems[i]; - core.info(`Processing ${itemType} item ${i + 1}/${updateItems.length}`); - const targetResult = resolveTargetNumber({ - updateTarget, - item: updateItem, - numberField, - isValidContext: contextIsValid, - contextNumber, - displayName, - }); - if (!targetResult.success) { - core.info(targetResult.error); - continue; - } - const targetNumber = targetResult.number; - core.info(`Updating ${displayName} #${targetNumber}`); - const { hasUpdates, updateData, logMessages } = buildUpdateData({ - item: updateItem, - canUpdateStatus, - canUpdateTitle, - canUpdateBody, - supportsStatus, - }); - for (const msg of logMessages) { - core.info(msg); - } - if (supportsOperation && canUpdateBody && updateItem.body !== undefined && typeof updateItem.body === "string") { - updateData._operation = updateItem.operation || "append"; - updateData._rawBody = updateItem.body; - } - if (!hasUpdates) { - core.info("No valid updates to apply for this item"); - continue; - } - try { - const updatedItem = await executeUpdate(github, context, targetNumber, updateData); - core.info(`Updated ${displayName} #${updatedItem.number}: ${updatedItem.html_url}`); - updatedItems.push(updatedItem); - if (i === updateItems.length - 1) { - core.setOutput(outputNumberKey, updatedItem.number); - core.setOutput(outputUrlKey, updatedItem.html_url); - } - } catch (error) { - core.error(`✗ Failed to update ${displayName} #${targetNumber}: ${error instanceof Error ? error.message : String(error)}`); - throw error; - } - } - if (updatedItems.length > 0) { - let summaryContent = `\n\n## Updated ${displayNamePlural.charAt(0).toUpperCase() + displayNamePlural.slice(1)}\n`; - for (const item of updatedItems) { - summaryContent += getSummaryLine(item); - } - await core.summary.addRaw(summaryContent).write(); - } - core.info(`Successfully updated ${updatedItems.length} ${displayName}(s)`); - return updatedItems; - } - function createRenderStagedItem(config) { - const { entityName, numberField, targetLabel, currentTargetText, includeOperation = false } = config; - return function renderStagedItem(item, index) { - let content = `### ${entityName} Update ${index + 1}\n`; - if (item[numberField]) { - content += `**${targetLabel}** #${item[numberField]}\n\n`; - } else { - content += `**Target:** ${currentTargetText}\n\n`; - } - if (item.title !== undefined) { - content += `**New Title:** ${item.title}\n\n`; - } - if (item.body !== undefined) { - if (includeOperation) { - const operation = item.operation || "append"; - content += `**Operation:** ${operation}\n`; - content += `**Body Content:**\n${item.body}\n\n`; - } else { - content += `**New Body:**\n${item.body}\n\n`; - } - } - if (item.status !== undefined) { - content += `**New Status:** ${item.status}\n\n`; - } - return content; - }; - } - function createGetSummaryLine(config) { - const { entityPrefix } = config; - return function getSummaryLine(item) { - return `- ${entityPrefix} #${item.number}: [${item.title}](${item.html_url})\n`; - }; - } - function getMessages() { - const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES; - if (!messagesEnv) { - return null; - } - try { - return JSON.parse(messagesEnv); - } catch (error) { - core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`); - return null; - } - } - function renderTemplate(template, context) { - return template.replace(/\{(\w+)\}/g, (match, key) => { - const value = context[key]; - return value !== undefined && value !== null ? String(value) : match; - }); - } - function toSnakeCase(obj) { - const result = {}; - for (const [key, value] of Object.entries(obj)) { - const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase(); - result[snakeKey] = value; - result[key] = value; - } - return result; - } - function getFooterMessage(ctx) { - const messages = getMessages(); - const templateContext = toSnakeCase(ctx); - const defaultFooter = "> Ahoy! This treasure was crafted by [🏴‍☠️ {workflow_name}]({run_url})"; - let footer = messages?.footer ? renderTemplate(messages.footer, templateContext) : renderTemplate(defaultFooter, templateContext); - if (ctx.triggeringNumber) { - footer += ` fer issue #{triggering_number} 🗺️`.replace("{triggering_number}", String(ctx.triggeringNumber)); - } - return footer; - } - function getFooterInstallMessage(ctx) { - if (!ctx.workflowSource || !ctx.workflowSourceUrl) { - return ""; - } - const messages = getMessages(); - const templateContext = toSnakeCase(ctx); - const defaultInstall = - "> Arr! To plunder this workflow fer yer own ship, run `gh aw add {workflow_source}`. Chart yer course at [🦜 {workflow_source_url}]({workflow_source_url})!"; - return messages?.footerInstall - ? renderTemplate(messages.footerInstall, templateContext) - : renderTemplate(defaultInstall, templateContext); - } - function generateXMLMarker(workflowName, runUrl) { - const engineId = process.env.GH_AW_ENGINE_ID || ""; - const engineVersion = process.env.GH_AW_ENGINE_VERSION || ""; - const engineModel = process.env.GH_AW_ENGINE_MODEL || ""; - const trackerId = process.env.GH_AW_TRACKER_ID || ""; - const parts = []; - parts.push(`agentic-workflow: ${workflowName}`); - if (trackerId) { - parts.push(`tracker-id: ${trackerId}`); - } - if (engineId) { - parts.push(`engine: ${engineId}`); - } - if (engineVersion) { - parts.push(`version: ${engineVersion}`); - } - if (engineModel) { - parts.push(`model: ${engineModel}`); - } - parts.push(`run: ${runUrl}`); - return ``; - } - function generateFooterWithMessages( - workflowName, - runUrl, - workflowSource, - workflowSourceURL, - triggeringIssueNumber, - triggeringPRNumber, - triggeringDiscussionNumber - ) { - let triggeringNumber; - if (triggeringIssueNumber) { - triggeringNumber = triggeringIssueNumber; - } else if (triggeringPRNumber) { - triggeringNumber = triggeringPRNumber; - } else if (triggeringDiscussionNumber) { - triggeringNumber = `discussion #${triggeringDiscussionNumber}`; - } - const ctx = { - workflowName, - runUrl, - workflowSource, - workflowSourceUrl: workflowSourceURL, - triggeringNumber, - }; - let footer = "\n\n" + getFooterMessage(ctx); - const installMessage = getFooterInstallMessage(ctx); - if (installMessage) { - footer += "\n>\n" + installMessage; - } - footer += "\n\n" + generateXMLMarker(workflowName, runUrl); - footer += "\n"; - return footer; - } - function buildAIFooter(workflowName, runUrl) { - return "\n\n" + getFooterMessage({ workflowName, runUrl }); - } - function buildIslandStartMarker(runId) { - return ``; - } - function buildIslandEndMarker(runId) { - return ``; - } - function findIsland(body, runId) { - const startMarker = buildIslandStartMarker(runId); - const endMarker = buildIslandEndMarker(runId); - const startIndex = body.indexOf(startMarker); - if (startIndex === -1) { - return { found: false, startIndex: -1, endIndex: -1 }; - } - const endIndex = body.indexOf(endMarker, startIndex); - if (endIndex === -1) { - return { found: false, startIndex: -1, endIndex: -1 }; - } - return { found: true, startIndex, endIndex: endIndex + endMarker.length }; - } - function updatePRBody(params) { - const { currentBody, newContent, operation, workflowName, runUrl, runId } = params; - const aiFooter = buildAIFooter(workflowName, runUrl); - if (operation === "replace") { - core.info("Operation: replace (full body replacement)"); - return newContent; - } - if (operation === "replace-island") { - const island = findIsland(currentBody, runId); - if (island.found) { - core.info(`Operation: replace-island (updating existing island for run ${runId})`); - const startMarker = buildIslandStartMarker(runId); - const endMarker = buildIslandEndMarker(runId); - const islandContent = `${startMarker}\n${newContent}${aiFooter}\n${endMarker}`; - const before = currentBody.substring(0, island.startIndex); - const after = currentBody.substring(island.endIndex); - return before + islandContent + after; - } else { - core.info(`Operation: replace-island (island not found for run ${runId}, falling back to append)`); - const startMarker = buildIslandStartMarker(runId); - const endMarker = buildIslandEndMarker(runId); - const islandContent = `${startMarker}\n${newContent}${aiFooter}\n${endMarker}`; - const appendSection = `\n\n---\n\n${islandContent}`; - return currentBody + appendSection; - } - } - if (operation === "prepend") { - core.info("Operation: prepend (add to start with separator)"); - const prependSection = `${newContent}${aiFooter}\n\n---\n\n`; - return prependSection + currentBody; - } - core.info("Operation: append (add to end with separator)"); - const appendSection = `\n\n---\n\n${newContent}${aiFooter}`; - return currentBody + appendSection; - } + const { runUpdateWorkflow, createRenderStagedItem, createGetSummaryLine } = require("./update_runner.cjs"); + const { updatePRBody } = require("./update_pr_description_helpers.cjs"); function isPRContext(eventName, payload) { const isPR = eventName === "pull_request" || diff --git a/.github/workflows/smoke-copilot-playwright.lock.yml b/.github/workflows/smoke-copilot-playwright.lock.yml index 3b5a5a8d0b9..f05bcff78a9 100644 --- a/.github/workflows/smoke-copilot-playwright.lock.yml +++ b/.github/workflows/smoke-copilot-playwright.lock.yml @@ -9115,272 +9115,21 @@ jobs: with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - function sanitizeLabelContent(content) { - if (!content || typeof content !== "string") { - return ""; - } - let sanitized = content.trim(); - sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); - sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); - sanitized = sanitized.replace( - /(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, - (_m, p1, p2) => `${p1}\`@${p2}\`` - ); - sanitized = sanitized.replace(/[<>&'"]/g, ""); - return sanitized.trim(); - } - const fs = require("fs"); - const crypto = require("crypto"); - const MAX_LOG_CONTENT_LENGTH = 10000; - function truncateForLogging(content) { - if (content.length <= MAX_LOG_CONTENT_LENGTH) { - return content; - } - return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; - } - function loadAgentOutput() { - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; - if (!agentOutputFile) { - core.info("No GH_AW_AGENT_OUTPUT environment variable found"); - return { success: false }; - } - let outputContent; - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - return { success: false, error: errorMessage }; - } - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return { success: false }; - } - core.info(`Agent output content length: ${outputContent.length}`); - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); - return { success: false, error: errorMessage }; - } - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); - return { success: false }; - } - return { success: true, items: validatedOutput.items }; - } - async function generateStagedPreview(options) { - const { title, description, items, renderItem } = options; - let summaryContent = `## 🎭 Staged Mode: ${title} Preview\n\n`; - summaryContent += `${description}\n\n`; - for (let i = 0; i < items.length; i++) { - const item = items[i]; - summaryContent += renderItem(item, i); - summaryContent += "---\n\n"; - } - try { - await core.summary.addRaw(summaryContent).write(); - core.info(summaryContent); - core.info(`📝 ${title} preview written to step summary`); - } catch (error) { - core.setFailed(error instanceof Error ? error : String(error)); - } - } - function generateXMLMarker(workflowName, runUrl) { - const engineId = process.env.GH_AW_ENGINE_ID || ""; - const engineVersion = process.env.GH_AW_ENGINE_VERSION || ""; - const engineModel = process.env.GH_AW_ENGINE_MODEL || ""; - const trackerId = process.env.GH_AW_TRACKER_ID || ""; - const parts = []; - parts.push(`agentic-workflow: ${workflowName}`); - if (trackerId) { - parts.push(`tracker-id: ${trackerId}`); - } - if (engineId) { - parts.push(`engine: ${engineId}`); - } - if (engineVersion) { - parts.push(`version: ${engineVersion}`); - } - if (engineModel) { - parts.push(`model: ${engineModel}`); - } - parts.push(`run: ${runUrl}`); - return ``; - } - function generateFooter( - workflowName, - runUrl, - workflowSource, - workflowSourceURL, - triggeringIssueNumber, - triggeringPRNumber, - triggeringDiscussionNumber - ) { - let footer = `\n\n> AI generated by [${workflowName}](${runUrl})`; - if (triggeringIssueNumber) { - footer += ` for #${triggeringIssueNumber}`; - } else if (triggeringPRNumber) { - footer += ` for #${triggeringPRNumber}`; - } else if (triggeringDiscussionNumber) { - footer += ` for discussion #${triggeringDiscussionNumber}`; - } - if (workflowSource && workflowSourceURL) { - footer += `\n>\n> To add this workflow in your repository, run \`gh aw add ${workflowSource}\`. See [usage guide](https://githubnext.github.io/gh-aw/tools/cli/).`; - } - footer += "\n\n" + generateXMLMarker(workflowName, runUrl); - footer += "\n"; - return footer; - } - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; - function generateTemporaryId() { - return "aw_" + crypto.randomBytes(6).toString("hex"); - } - function isTemporaryId(value) { - if (typeof value === "string") { - return /^aw_[0-9a-f]{12}$/i.test(value); - } - return false; - } - function normalizeTemporaryId(tempId) { - return String(tempId).toLowerCase(); - } - function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); - if (resolved !== undefined) { - if (currentRepo && resolved.repo === currentRepo) { - return `#${resolved.number}`; - } - return `${resolved.repo}#${resolved.number}`; - } - return match; - }); - } - function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); - if (issueNumber !== undefined) { - return `#${issueNumber}`; - } - return match; - }); - } - function loadTemporaryIdMap() { - const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; - if (!mapJson || mapJson === "{}") { - return new Map(); - } - try { - const mapObject = JSON.parse(mapJson); - const result = new Map(); - for (const [key, value] of Object.entries(mapObject)) { - const normalizedKey = normalizeTemporaryId(key); - if (typeof value === "number") { - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - result.set(normalizedKey, { repo: contextRepo, number: value }); - } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { - result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); - } - } - return result; - } catch (error) { - if (typeof core !== "undefined") { - core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); - } - return new Map(); - } - } - function resolveIssueNumber(value, temporaryIdMap) { - if (value === undefined || value === null) { - return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; - } - const valueStr = String(value); - if (isTemporaryId(valueStr)) { - const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); - if (resolvedPair !== undefined) { - return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; - } - return { - resolved: null, - wasTemporaryId: true, - errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, - }; - } - const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); - if (isNaN(issueNumber) || issueNumber <= 0) { - return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; - } - const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; - return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; - } - function serializeTemporaryIdMap(tempIdMap) { - const obj = Object.fromEntries(tempIdMap); - return JSON.stringify(obj); - } - function parseAllowedRepos() { - const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; - const set = new Set(); - if (allowedReposEnv) { - allowedReposEnv - .split(",") - .map(repo => repo.trim()) - .filter(repo => repo) - .forEach(repo => set.add(repo)); - } - return set; - } - function getDefaultTargetRepo() { - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - if (targetRepoSlug) { - return targetRepoSlug; - } - return `${context.repo.owner}/${context.repo.repo}`; - } - function validateRepo(repo, defaultRepo, allowedRepos) { - if (repo === defaultRepo) { - return { valid: true, error: null }; - } - if (allowedRepos.has(repo)) { - return { valid: true, error: null }; - } - return { - valid: false, - error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, - }; - } - function parseRepoSlug(repoSlug) { - const parts = repoSlug.split("/"); - if (parts.length !== 2 || !parts[0] || !parts[1]) { - return null; - } - return { owner: parts[0], repo: parts[1] }; - } - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } + const { sanitizeLabelContent } = require("./sanitize_label_content.cjs"); + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { generateStagedPreview } = require("./staged_preview.cjs"); + const { generateFooter } = require("./generate_footer.cjs"); + const { getTrackerID } = require("./get_tracker_id.cjs"); + const { + generateTemporaryId, + isTemporaryId, + normalizeTemporaryId, + replaceTemporaryIdReferences, + serializeTemporaryIdMap, + } = require("./temporary_id.cjs"); + const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require("./repo_helpers.cjs"); + const { addExpirationComment } = require("./expiration_helpers.cjs"); + const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); async function main() { core.setOutput("issue_number", ""); core.setOutput("issue_url", ""); @@ -9513,6 +9262,7 @@ jobs: .filter((label, index, arr) => arr.indexOf(label) === index); let title = createIssueItem.title ? createIssueItem.title.trim() : ""; let processedBody = replaceTemporaryIdReferences(createIssueItem.body, temporaryIdMap, itemRepo); + processedBody = removeDuplicateTitleFromDescription(title, processedBody); let bodyLines = processedBody.split("\n"); if (!title) { title = createIssueItem.body || "Agent Output"; diff --git a/.github/workflows/smoke-copilot-safe-inputs.lock.yml b/.github/workflows/smoke-copilot-safe-inputs.lock.yml index cda04e80f41..809bb0faf44 100644 --- a/.github/workflows/smoke-copilot-safe-inputs.lock.yml +++ b/.github/workflows/smoke-copilot-safe-inputs.lock.yml @@ -8821,272 +8821,21 @@ jobs: with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - function sanitizeLabelContent(content) { - if (!content || typeof content !== "string") { - return ""; - } - let sanitized = content.trim(); - sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); - sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); - sanitized = sanitized.replace( - /(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, - (_m, p1, p2) => `${p1}\`@${p2}\`` - ); - sanitized = sanitized.replace(/[<>&'"]/g, ""); - return sanitized.trim(); - } - const fs = require("fs"); - const crypto = require("crypto"); - const MAX_LOG_CONTENT_LENGTH = 10000; - function truncateForLogging(content) { - if (content.length <= MAX_LOG_CONTENT_LENGTH) { - return content; - } - return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; - } - function loadAgentOutput() { - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; - if (!agentOutputFile) { - core.info("No GH_AW_AGENT_OUTPUT environment variable found"); - return { success: false }; - } - let outputContent; - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - return { success: false, error: errorMessage }; - } - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return { success: false }; - } - core.info(`Agent output content length: ${outputContent.length}`); - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); - return { success: false, error: errorMessage }; - } - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); - return { success: false }; - } - return { success: true, items: validatedOutput.items }; - } - async function generateStagedPreview(options) { - const { title, description, items, renderItem } = options; - let summaryContent = `## 🎭 Staged Mode: ${title} Preview\n\n`; - summaryContent += `${description}\n\n`; - for (let i = 0; i < items.length; i++) { - const item = items[i]; - summaryContent += renderItem(item, i); - summaryContent += "---\n\n"; - } - try { - await core.summary.addRaw(summaryContent).write(); - core.info(summaryContent); - core.info(`📝 ${title} preview written to step summary`); - } catch (error) { - core.setFailed(error instanceof Error ? error : String(error)); - } - } - function generateXMLMarker(workflowName, runUrl) { - const engineId = process.env.GH_AW_ENGINE_ID || ""; - const engineVersion = process.env.GH_AW_ENGINE_VERSION || ""; - const engineModel = process.env.GH_AW_ENGINE_MODEL || ""; - const trackerId = process.env.GH_AW_TRACKER_ID || ""; - const parts = []; - parts.push(`agentic-workflow: ${workflowName}`); - if (trackerId) { - parts.push(`tracker-id: ${trackerId}`); - } - if (engineId) { - parts.push(`engine: ${engineId}`); - } - if (engineVersion) { - parts.push(`version: ${engineVersion}`); - } - if (engineModel) { - parts.push(`model: ${engineModel}`); - } - parts.push(`run: ${runUrl}`); - return ``; - } - function generateFooter( - workflowName, - runUrl, - workflowSource, - workflowSourceURL, - triggeringIssueNumber, - triggeringPRNumber, - triggeringDiscussionNumber - ) { - let footer = `\n\n> AI generated by [${workflowName}](${runUrl})`; - if (triggeringIssueNumber) { - footer += ` for #${triggeringIssueNumber}`; - } else if (triggeringPRNumber) { - footer += ` for #${triggeringPRNumber}`; - } else if (triggeringDiscussionNumber) { - footer += ` for discussion #${triggeringDiscussionNumber}`; - } - if (workflowSource && workflowSourceURL) { - footer += `\n>\n> To add this workflow in your repository, run \`gh aw add ${workflowSource}\`. See [usage guide](https://githubnext.github.io/gh-aw/tools/cli/).`; - } - footer += "\n\n" + generateXMLMarker(workflowName, runUrl); - footer += "\n"; - return footer; - } - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; - function generateTemporaryId() { - return "aw_" + crypto.randomBytes(6).toString("hex"); - } - function isTemporaryId(value) { - if (typeof value === "string") { - return /^aw_[0-9a-f]{12}$/i.test(value); - } - return false; - } - function normalizeTemporaryId(tempId) { - return String(tempId).toLowerCase(); - } - function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); - if (resolved !== undefined) { - if (currentRepo && resolved.repo === currentRepo) { - return `#${resolved.number}`; - } - return `${resolved.repo}#${resolved.number}`; - } - return match; - }); - } - function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); - if (issueNumber !== undefined) { - return `#${issueNumber}`; - } - return match; - }); - } - function loadTemporaryIdMap() { - const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; - if (!mapJson || mapJson === "{}") { - return new Map(); - } - try { - const mapObject = JSON.parse(mapJson); - const result = new Map(); - for (const [key, value] of Object.entries(mapObject)) { - const normalizedKey = normalizeTemporaryId(key); - if (typeof value === "number") { - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - result.set(normalizedKey, { repo: contextRepo, number: value }); - } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { - result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); - } - } - return result; - } catch (error) { - if (typeof core !== "undefined") { - core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); - } - return new Map(); - } - } - function resolveIssueNumber(value, temporaryIdMap) { - if (value === undefined || value === null) { - return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; - } - const valueStr = String(value); - if (isTemporaryId(valueStr)) { - const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); - if (resolvedPair !== undefined) { - return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; - } - return { - resolved: null, - wasTemporaryId: true, - errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, - }; - } - const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); - if (isNaN(issueNumber) || issueNumber <= 0) { - return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; - } - const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; - return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; - } - function serializeTemporaryIdMap(tempIdMap) { - const obj = Object.fromEntries(tempIdMap); - return JSON.stringify(obj); - } - function parseAllowedRepos() { - const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; - const set = new Set(); - if (allowedReposEnv) { - allowedReposEnv - .split(",") - .map(repo => repo.trim()) - .filter(repo => repo) - .forEach(repo => set.add(repo)); - } - return set; - } - function getDefaultTargetRepo() { - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - if (targetRepoSlug) { - return targetRepoSlug; - } - return `${context.repo.owner}/${context.repo.repo}`; - } - function validateRepo(repo, defaultRepo, allowedRepos) { - if (repo === defaultRepo) { - return { valid: true, error: null }; - } - if (allowedRepos.has(repo)) { - return { valid: true, error: null }; - } - return { - valid: false, - error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, - }; - } - function parseRepoSlug(repoSlug) { - const parts = repoSlug.split("/"); - if (parts.length !== 2 || !parts[0] || !parts[1]) { - return null; - } - return { owner: parts[0], repo: parts[1] }; - } - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } + const { sanitizeLabelContent } = require("./sanitize_label_content.cjs"); + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { generateStagedPreview } = require("./staged_preview.cjs"); + const { generateFooter } = require("./generate_footer.cjs"); + const { getTrackerID } = require("./get_tracker_id.cjs"); + const { + generateTemporaryId, + isTemporaryId, + normalizeTemporaryId, + replaceTemporaryIdReferences, + serializeTemporaryIdMap, + } = require("./temporary_id.cjs"); + const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require("./repo_helpers.cjs"); + const { addExpirationComment } = require("./expiration_helpers.cjs"); + const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); async function main() { core.setOutput("issue_number", ""); core.setOutput("issue_url", ""); @@ -9219,6 +8968,7 @@ jobs: .filter((label, index, arr) => arr.indexOf(label) === index); let title = createIssueItem.title ? createIssueItem.title.trim() : ""; let processedBody = replaceTemporaryIdReferences(createIssueItem.body, temporaryIdMap, itemRepo); + processedBody = removeDuplicateTitleFromDescription(title, processedBody); let bodyLines = processedBody.split("\n"); if (!title) { title = createIssueItem.body || "Agent Output"; diff --git a/.github/workflows/smoke-copilot.lock.yml b/.github/workflows/smoke-copilot.lock.yml index 971c3b71cf7..725de651f10 100644 --- a/.github/workflows/smoke-copilot.lock.yml +++ b/.github/workflows/smoke-copilot.lock.yml @@ -7646,272 +7646,21 @@ jobs: with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - function sanitizeLabelContent(content) { - if (!content || typeof content !== "string") { - return ""; - } - let sanitized = content.trim(); - sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); - sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); - sanitized = sanitized.replace( - /(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, - (_m, p1, p2) => `${p1}\`@${p2}\`` - ); - sanitized = sanitized.replace(/[<>&'"]/g, ""); - return sanitized.trim(); - } - const fs = require("fs"); - const crypto = require("crypto"); - const MAX_LOG_CONTENT_LENGTH = 10000; - function truncateForLogging(content) { - if (content.length <= MAX_LOG_CONTENT_LENGTH) { - return content; - } - return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; - } - function loadAgentOutput() { - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; - if (!agentOutputFile) { - core.info("No GH_AW_AGENT_OUTPUT environment variable found"); - return { success: false }; - } - let outputContent; - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - return { success: false, error: errorMessage }; - } - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return { success: false }; - } - core.info(`Agent output content length: ${outputContent.length}`); - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); - return { success: false, error: errorMessage }; - } - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); - return { success: false }; - } - return { success: true, items: validatedOutput.items }; - } - async function generateStagedPreview(options) { - const { title, description, items, renderItem } = options; - let summaryContent = `## 🎭 Staged Mode: ${title} Preview\n\n`; - summaryContent += `${description}\n\n`; - for (let i = 0; i < items.length; i++) { - const item = items[i]; - summaryContent += renderItem(item, i); - summaryContent += "---\n\n"; - } - try { - await core.summary.addRaw(summaryContent).write(); - core.info(summaryContent); - core.info(`📝 ${title} preview written to step summary`); - } catch (error) { - core.setFailed(error instanceof Error ? error : String(error)); - } - } - function generateXMLMarker(workflowName, runUrl) { - const engineId = process.env.GH_AW_ENGINE_ID || ""; - const engineVersion = process.env.GH_AW_ENGINE_VERSION || ""; - const engineModel = process.env.GH_AW_ENGINE_MODEL || ""; - const trackerId = process.env.GH_AW_TRACKER_ID || ""; - const parts = []; - parts.push(`agentic-workflow: ${workflowName}`); - if (trackerId) { - parts.push(`tracker-id: ${trackerId}`); - } - if (engineId) { - parts.push(`engine: ${engineId}`); - } - if (engineVersion) { - parts.push(`version: ${engineVersion}`); - } - if (engineModel) { - parts.push(`model: ${engineModel}`); - } - parts.push(`run: ${runUrl}`); - return ``; - } - function generateFooter( - workflowName, - runUrl, - workflowSource, - workflowSourceURL, - triggeringIssueNumber, - triggeringPRNumber, - triggeringDiscussionNumber - ) { - let footer = `\n\n> AI generated by [${workflowName}](${runUrl})`; - if (triggeringIssueNumber) { - footer += ` for #${triggeringIssueNumber}`; - } else if (triggeringPRNumber) { - footer += ` for #${triggeringPRNumber}`; - } else if (triggeringDiscussionNumber) { - footer += ` for discussion #${triggeringDiscussionNumber}`; - } - if (workflowSource && workflowSourceURL) { - footer += `\n>\n> To add this workflow in your repository, run \`gh aw add ${workflowSource}\`. See [usage guide](https://githubnext.github.io/gh-aw/tools/cli/).`; - } - footer += "\n\n" + generateXMLMarker(workflowName, runUrl); - footer += "\n"; - return footer; - } - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; - function generateTemporaryId() { - return "aw_" + crypto.randomBytes(6).toString("hex"); - } - function isTemporaryId(value) { - if (typeof value === "string") { - return /^aw_[0-9a-f]{12}$/i.test(value); - } - return false; - } - function normalizeTemporaryId(tempId) { - return String(tempId).toLowerCase(); - } - function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); - if (resolved !== undefined) { - if (currentRepo && resolved.repo === currentRepo) { - return `#${resolved.number}`; - } - return `${resolved.repo}#${resolved.number}`; - } - return match; - }); - } - function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); - if (issueNumber !== undefined) { - return `#${issueNumber}`; - } - return match; - }); - } - function loadTemporaryIdMap() { - const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; - if (!mapJson || mapJson === "{}") { - return new Map(); - } - try { - const mapObject = JSON.parse(mapJson); - const result = new Map(); - for (const [key, value] of Object.entries(mapObject)) { - const normalizedKey = normalizeTemporaryId(key); - if (typeof value === "number") { - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - result.set(normalizedKey, { repo: contextRepo, number: value }); - } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { - result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); - } - } - return result; - } catch (error) { - if (typeof core !== "undefined") { - core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); - } - return new Map(); - } - } - function resolveIssueNumber(value, temporaryIdMap) { - if (value === undefined || value === null) { - return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; - } - const valueStr = String(value); - if (isTemporaryId(valueStr)) { - const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); - if (resolvedPair !== undefined) { - return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; - } - return { - resolved: null, - wasTemporaryId: true, - errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, - }; - } - const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); - if (isNaN(issueNumber) || issueNumber <= 0) { - return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; - } - const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; - return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; - } - function serializeTemporaryIdMap(tempIdMap) { - const obj = Object.fromEntries(tempIdMap); - return JSON.stringify(obj); - } - function parseAllowedRepos() { - const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; - const set = new Set(); - if (allowedReposEnv) { - allowedReposEnv - .split(",") - .map(repo => repo.trim()) - .filter(repo => repo) - .forEach(repo => set.add(repo)); - } - return set; - } - function getDefaultTargetRepo() { - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - if (targetRepoSlug) { - return targetRepoSlug; - } - return `${context.repo.owner}/${context.repo.repo}`; - } - function validateRepo(repo, defaultRepo, allowedRepos) { - if (repo === defaultRepo) { - return { valid: true, error: null }; - } - if (allowedRepos.has(repo)) { - return { valid: true, error: null }; - } - return { - valid: false, - error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, - }; - } - function parseRepoSlug(repoSlug) { - const parts = repoSlug.split("/"); - if (parts.length !== 2 || !parts[0] || !parts[1]) { - return null; - } - return { owner: parts[0], repo: parts[1] }; - } - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } + const { sanitizeLabelContent } = require("./sanitize_label_content.cjs"); + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { generateStagedPreview } = require("./staged_preview.cjs"); + const { generateFooter } = require("./generate_footer.cjs"); + const { getTrackerID } = require("./get_tracker_id.cjs"); + const { + generateTemporaryId, + isTemporaryId, + normalizeTemporaryId, + replaceTemporaryIdReferences, + serializeTemporaryIdMap, + } = require("./temporary_id.cjs"); + const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require("./repo_helpers.cjs"); + const { addExpirationComment } = require("./expiration_helpers.cjs"); + const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); async function main() { core.setOutput("issue_number", ""); core.setOutput("issue_url", ""); @@ -8044,6 +7793,7 @@ jobs: .filter((label, index, arr) => arr.indexOf(label) === index); let title = createIssueItem.title ? createIssueItem.title.trim() : ""; let processedBody = replaceTemporaryIdReferences(createIssueItem.body, temporaryIdMap, itemRepo); + processedBody = removeDuplicateTitleFromDescription(title, processedBody); let bodyLines = processedBody.split("\n"); if (!title) { title = createIssueItem.body || "Agent Output"; diff --git a/.github/workflows/smoke-detector.lock.yml b/.github/workflows/smoke-detector.lock.yml index 46ab4cad34a..8480e96c1fe 100644 --- a/.github/workflows/smoke-detector.lock.yml +++ b/.github/workflows/smoke-detector.lock.yml @@ -7149,272 +7149,21 @@ jobs: with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - function sanitizeLabelContent(content) { - if (!content || typeof content !== "string") { - return ""; - } - let sanitized = content.trim(); - sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); - sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); - sanitized = sanitized.replace( - /(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, - (_m, p1, p2) => `${p1}\`@${p2}\`` - ); - sanitized = sanitized.replace(/[<>&'"]/g, ""); - return sanitized.trim(); - } - const fs = require("fs"); - const crypto = require("crypto"); - const MAX_LOG_CONTENT_LENGTH = 10000; - function truncateForLogging(content) { - if (content.length <= MAX_LOG_CONTENT_LENGTH) { - return content; - } - return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; - } - function loadAgentOutput() { - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; - if (!agentOutputFile) { - core.info("No GH_AW_AGENT_OUTPUT environment variable found"); - return { success: false }; - } - let outputContent; - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - return { success: false, error: errorMessage }; - } - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return { success: false }; - } - core.info(`Agent output content length: ${outputContent.length}`); - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); - return { success: false, error: errorMessage }; - } - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); - return { success: false }; - } - return { success: true, items: validatedOutput.items }; - } - async function generateStagedPreview(options) { - const { title, description, items, renderItem } = options; - let summaryContent = `## 🎭 Staged Mode: ${title} Preview\n\n`; - summaryContent += `${description}\n\n`; - for (let i = 0; i < items.length; i++) { - const item = items[i]; - summaryContent += renderItem(item, i); - summaryContent += "---\n\n"; - } - try { - await core.summary.addRaw(summaryContent).write(); - core.info(summaryContent); - core.info(`📝 ${title} preview written to step summary`); - } catch (error) { - core.setFailed(error instanceof Error ? error : String(error)); - } - } - function generateXMLMarker(workflowName, runUrl) { - const engineId = process.env.GH_AW_ENGINE_ID || ""; - const engineVersion = process.env.GH_AW_ENGINE_VERSION || ""; - const engineModel = process.env.GH_AW_ENGINE_MODEL || ""; - const trackerId = process.env.GH_AW_TRACKER_ID || ""; - const parts = []; - parts.push(`agentic-workflow: ${workflowName}`); - if (trackerId) { - parts.push(`tracker-id: ${trackerId}`); - } - if (engineId) { - parts.push(`engine: ${engineId}`); - } - if (engineVersion) { - parts.push(`version: ${engineVersion}`); - } - if (engineModel) { - parts.push(`model: ${engineModel}`); - } - parts.push(`run: ${runUrl}`); - return ``; - } - function generateFooter( - workflowName, - runUrl, - workflowSource, - workflowSourceURL, - triggeringIssueNumber, - triggeringPRNumber, - triggeringDiscussionNumber - ) { - let footer = `\n\n> AI generated by [${workflowName}](${runUrl})`; - if (triggeringIssueNumber) { - footer += ` for #${triggeringIssueNumber}`; - } else if (triggeringPRNumber) { - footer += ` for #${triggeringPRNumber}`; - } else if (triggeringDiscussionNumber) { - footer += ` for discussion #${triggeringDiscussionNumber}`; - } - if (workflowSource && workflowSourceURL) { - footer += `\n>\n> To add this workflow in your repository, run \`gh aw add ${workflowSource}\`. See [usage guide](https://githubnext.github.io/gh-aw/tools/cli/).`; - } - footer += "\n\n" + generateXMLMarker(workflowName, runUrl); - footer += "\n"; - return footer; - } - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; - function generateTemporaryId() { - return "aw_" + crypto.randomBytes(6).toString("hex"); - } - function isTemporaryId(value) { - if (typeof value === "string") { - return /^aw_[0-9a-f]{12}$/i.test(value); - } - return false; - } - function normalizeTemporaryId(tempId) { - return String(tempId).toLowerCase(); - } - function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); - if (resolved !== undefined) { - if (currentRepo && resolved.repo === currentRepo) { - return `#${resolved.number}`; - } - return `${resolved.repo}#${resolved.number}`; - } - return match; - }); - } - function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); - if (issueNumber !== undefined) { - return `#${issueNumber}`; - } - return match; - }); - } - function loadTemporaryIdMap() { - const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; - if (!mapJson || mapJson === "{}") { - return new Map(); - } - try { - const mapObject = JSON.parse(mapJson); - const result = new Map(); - for (const [key, value] of Object.entries(mapObject)) { - const normalizedKey = normalizeTemporaryId(key); - if (typeof value === "number") { - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - result.set(normalizedKey, { repo: contextRepo, number: value }); - } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { - result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); - } - } - return result; - } catch (error) { - if (typeof core !== "undefined") { - core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); - } - return new Map(); - } - } - function resolveIssueNumber(value, temporaryIdMap) { - if (value === undefined || value === null) { - return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; - } - const valueStr = String(value); - if (isTemporaryId(valueStr)) { - const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); - if (resolvedPair !== undefined) { - return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; - } - return { - resolved: null, - wasTemporaryId: true, - errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, - }; - } - const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); - if (isNaN(issueNumber) || issueNumber <= 0) { - return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; - } - const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; - return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; - } - function serializeTemporaryIdMap(tempIdMap) { - const obj = Object.fromEntries(tempIdMap); - return JSON.stringify(obj); - } - function parseAllowedRepos() { - const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; - const set = new Set(); - if (allowedReposEnv) { - allowedReposEnv - .split(",") - .map(repo => repo.trim()) - .filter(repo => repo) - .forEach(repo => set.add(repo)); - } - return set; - } - function getDefaultTargetRepo() { - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - if (targetRepoSlug) { - return targetRepoSlug; - } - return `${context.repo.owner}/${context.repo.repo}`; - } - function validateRepo(repo, defaultRepo, allowedRepos) { - if (repo === defaultRepo) { - return { valid: true, error: null }; - } - if (allowedRepos.has(repo)) { - return { valid: true, error: null }; - } - return { - valid: false, - error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, - }; - } - function parseRepoSlug(repoSlug) { - const parts = repoSlug.split("/"); - if (parts.length !== 2 || !parts[0] || !parts[1]) { - return null; - } - return { owner: parts[0], repo: parts[1] }; - } - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } + const { sanitizeLabelContent } = require("./sanitize_label_content.cjs"); + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { generateStagedPreview } = require("./staged_preview.cjs"); + const { generateFooter } = require("./generate_footer.cjs"); + const { getTrackerID } = require("./get_tracker_id.cjs"); + const { + generateTemporaryId, + isTemporaryId, + normalizeTemporaryId, + replaceTemporaryIdReferences, + serializeTemporaryIdMap, + } = require("./temporary_id.cjs"); + const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require("./repo_helpers.cjs"); + const { addExpirationComment } = require("./expiration_helpers.cjs"); + const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); async function main() { core.setOutput("issue_number", ""); core.setOutput("issue_url", ""); @@ -7547,6 +7296,7 @@ jobs: .filter((label, index, arr) => arr.indexOf(label) === index); let title = createIssueItem.title ? createIssueItem.title.trim() : ""; let processedBody = replaceTemporaryIdReferences(createIssueItem.body, temporaryIdMap, itemRepo); + processedBody = removeDuplicateTitleFromDescription(title, processedBody); let bodyLines = processedBody.split("\n"); if (!title) { title = createIssueItem.body || "Agent Output"; diff --git a/.github/workflows/spec-kit-execute.lock.yml b/.github/workflows/spec-kit-execute.lock.yml index f44f9f644a1..697f5e4b627 100644 --- a/.github/workflows/spec-kit-execute.lock.yml +++ b/.github/workflows/spec-kit-execute.lock.yml @@ -6770,132 +6770,10 @@ jobs: script: | const fs = require("fs"); const crypto = require("crypto"); - async function updateActivationComment(github, context, core, itemUrl, itemNumber, itemType = "pull_request") { - const itemLabel = itemType === "issue" ? "issue" : "pull request"; - const linkMessage = - itemType === "issue" - ? `\n\n✅ Issue created: [#${itemNumber}](${itemUrl})` - : `\n\n✅ Pull request created: [#${itemNumber}](${itemUrl})`; - await updateActivationCommentWithMessage(github, context, core, linkMessage, itemLabel); - } - async function updateActivationCommentWithCommit(github, context, core, commitSha, commitUrl) { - const shortSha = commitSha.substring(0, 7); - const message = `\n\n✅ Commit pushed: [\`${shortSha}\`](${commitUrl})`; - await updateActivationCommentWithMessage(github, context, core, message, "commit"); - } - async function updateActivationCommentWithMessage(github, context, core, message, label = "") { - const commentId = process.env.GH_AW_COMMENT_ID; - const commentRepo = process.env.GH_AW_COMMENT_REPO; - if (!commentId) { - core.info("No activation comment to update (GH_AW_COMMENT_ID not set)"); - return; - } - core.info(`Updating activation comment ${commentId}`); - let repoOwner = context.repo.owner; - let repoName = context.repo.repo; - if (commentRepo) { - const parts = commentRepo.split("/"); - if (parts.length === 2) { - repoOwner = parts[0]; - repoName = parts[1]; - } else { - core.warning(`Invalid comment repo format: ${commentRepo}, expected "owner/repo". Falling back to context.repo.`); - } - } - core.info(`Updating comment in ${repoOwner}/${repoName}`); - const isDiscussionComment = commentId.startsWith("DC_"); - try { - if (isDiscussionComment) { - const currentComment = await github.graphql( - ` - query($commentId: ID!) { - node(id: $commentId) { - ... on DiscussionComment { - body - } - } - }`, - { commentId: commentId } - ); - if (!currentComment?.node?.body) { - core.warning("Unable to fetch current comment body, comment may have been deleted or is inaccessible"); - return; - } - const currentBody = currentComment.node.body; - const updatedBody = currentBody + message; - const result = await github.graphql( - ` - mutation($commentId: ID!, $body: String!) { - updateDiscussionComment(input: { commentId: $commentId, body: $body }) { - comment { - id - url - } - } - }`, - { commentId: commentId, body: updatedBody } - ); - const comment = result.updateDiscussionComment.comment; - const successMessage = label - ? `Successfully updated discussion comment with ${label} link` - : "Successfully updated discussion comment"; - core.info(successMessage); - core.info(`Comment ID: ${comment.id}`); - core.info(`Comment URL: ${comment.url}`); - } else { - const currentComment = await github.request("GET /repos/{owner}/{repo}/issues/comments/{comment_id}", { - owner: repoOwner, - repo: repoName, - comment_id: parseInt(commentId, 10), - headers: { - Accept: "application/vnd.github+json", - }, - }); - if (!currentComment?.data?.body) { - core.warning("Unable to fetch current comment body, comment may have been deleted"); - return; - } - const currentBody = currentComment.data.body; - const updatedBody = currentBody + message; - const response = await github.request("PATCH /repos/{owner}/{repo}/issues/comments/{comment_id}", { - owner: repoOwner, - repo: repoName, - comment_id: parseInt(commentId, 10), - body: updatedBody, - headers: { - Accept: "application/vnd.github+json", - }, - }); - const successMessage = label ? `Successfully updated comment with ${label} link` : "Successfully updated comment"; - core.info(successMessage); - core.info(`Comment ID: ${response.data.id}`); - core.info(`Comment URL: ${response.data.html_url}`); - } - } catch (error) { - core.warning(`Failed to update activation comment: ${error instanceof Error ? error.message : String(error)}`); - } - } - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } + const { updateActivationComment } = require("./update_activation_comment.cjs"); + const { getTrackerID } = require("./get_tracker_id.cjs"); + const { addExpirationComment } = require("./expiration_helpers.cjs"); + const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); function generatePatchPreview(patchContent) { if (!patchContent || !patchContent.trim()) { return ""; @@ -7087,7 +6965,9 @@ jobs: return; } let title = pullRequestItem.title.trim(); - let bodyLines = pullRequestItem.body.split("\n"); + let processedBody = pullRequestItem.body; + processedBody = removeDuplicateTitleFromDescription(title, processedBody); + let bodyLines = processedBody.split("\n"); let branchName = pullRequestItem.branch ? pullRequestItem.branch.trim() : null; if (!title) { title = "Agent Output"; diff --git a/.github/workflows/spec-kit-executor.lock.yml b/.github/workflows/spec-kit-executor.lock.yml index 84625d7123a..8621729eca9 100644 --- a/.github/workflows/spec-kit-executor.lock.yml +++ b/.github/workflows/spec-kit-executor.lock.yml @@ -6460,132 +6460,10 @@ jobs: script: | const fs = require("fs"); const crypto = require("crypto"); - async function updateActivationComment(github, context, core, itemUrl, itemNumber, itemType = "pull_request") { - const itemLabel = itemType === "issue" ? "issue" : "pull request"; - const linkMessage = - itemType === "issue" - ? `\n\n✅ Issue created: [#${itemNumber}](${itemUrl})` - : `\n\n✅ Pull request created: [#${itemNumber}](${itemUrl})`; - await updateActivationCommentWithMessage(github, context, core, linkMessage, itemLabel); - } - async function updateActivationCommentWithCommit(github, context, core, commitSha, commitUrl) { - const shortSha = commitSha.substring(0, 7); - const message = `\n\n✅ Commit pushed: [\`${shortSha}\`](${commitUrl})`; - await updateActivationCommentWithMessage(github, context, core, message, "commit"); - } - async function updateActivationCommentWithMessage(github, context, core, message, label = "") { - const commentId = process.env.GH_AW_COMMENT_ID; - const commentRepo = process.env.GH_AW_COMMENT_REPO; - if (!commentId) { - core.info("No activation comment to update (GH_AW_COMMENT_ID not set)"); - return; - } - core.info(`Updating activation comment ${commentId}`); - let repoOwner = context.repo.owner; - let repoName = context.repo.repo; - if (commentRepo) { - const parts = commentRepo.split("/"); - if (parts.length === 2) { - repoOwner = parts[0]; - repoName = parts[1]; - } else { - core.warning(`Invalid comment repo format: ${commentRepo}, expected "owner/repo". Falling back to context.repo.`); - } - } - core.info(`Updating comment in ${repoOwner}/${repoName}`); - const isDiscussionComment = commentId.startsWith("DC_"); - try { - if (isDiscussionComment) { - const currentComment = await github.graphql( - ` - query($commentId: ID!) { - node(id: $commentId) { - ... on DiscussionComment { - body - } - } - }`, - { commentId: commentId } - ); - if (!currentComment?.node?.body) { - core.warning("Unable to fetch current comment body, comment may have been deleted or is inaccessible"); - return; - } - const currentBody = currentComment.node.body; - const updatedBody = currentBody + message; - const result = await github.graphql( - ` - mutation($commentId: ID!, $body: String!) { - updateDiscussionComment(input: { commentId: $commentId, body: $body }) { - comment { - id - url - } - } - }`, - { commentId: commentId, body: updatedBody } - ); - const comment = result.updateDiscussionComment.comment; - const successMessage = label - ? `Successfully updated discussion comment with ${label} link` - : "Successfully updated discussion comment"; - core.info(successMessage); - core.info(`Comment ID: ${comment.id}`); - core.info(`Comment URL: ${comment.url}`); - } else { - const currentComment = await github.request("GET /repos/{owner}/{repo}/issues/comments/{comment_id}", { - owner: repoOwner, - repo: repoName, - comment_id: parseInt(commentId, 10), - headers: { - Accept: "application/vnd.github+json", - }, - }); - if (!currentComment?.data?.body) { - core.warning("Unable to fetch current comment body, comment may have been deleted"); - return; - } - const currentBody = currentComment.data.body; - const updatedBody = currentBody + message; - const response = await github.request("PATCH /repos/{owner}/{repo}/issues/comments/{comment_id}", { - owner: repoOwner, - repo: repoName, - comment_id: parseInt(commentId, 10), - body: updatedBody, - headers: { - Accept: "application/vnd.github+json", - }, - }); - const successMessage = label ? `Successfully updated comment with ${label} link` : "Successfully updated comment"; - core.info(successMessage); - core.info(`Comment ID: ${response.data.id}`); - core.info(`Comment URL: ${response.data.html_url}`); - } - } catch (error) { - core.warning(`Failed to update activation comment: ${error instanceof Error ? error.message : String(error)}`); - } - } - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } + const { updateActivationComment } = require("./update_activation_comment.cjs"); + const { getTrackerID } = require("./get_tracker_id.cjs"); + const { addExpirationComment } = require("./expiration_helpers.cjs"); + const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); function generatePatchPreview(patchContent) { if (!patchContent || !patchContent.trim()) { return ""; @@ -6777,7 +6655,9 @@ jobs: return; } let title = pullRequestItem.title.trim(); - let bodyLines = pullRequestItem.body.split("\n"); + let processedBody = pullRequestItem.body; + processedBody = removeDuplicateTitleFromDescription(title, processedBody); + let bodyLines = processedBody.split("\n"); let branchName = pullRequestItem.branch ? pullRequestItem.branch.trim() : null; if (!title) { title = "Agent Output"; diff --git a/.github/workflows/speckit-dispatcher.lock.yml b/.github/workflows/speckit-dispatcher.lock.yml index 9f10f8de835..ce28cf630bb 100644 --- a/.github/workflows/speckit-dispatcher.lock.yml +++ b/.github/workflows/speckit-dispatcher.lock.yml @@ -8116,272 +8116,21 @@ jobs: with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - function sanitizeLabelContent(content) { - if (!content || typeof content !== "string") { - return ""; - } - let sanitized = content.trim(); - sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); - sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); - sanitized = sanitized.replace( - /(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, - (_m, p1, p2) => `${p1}\`@${p2}\`` - ); - sanitized = sanitized.replace(/[<>&'"]/g, ""); - return sanitized.trim(); - } - const fs = require("fs"); - const crypto = require("crypto"); - const MAX_LOG_CONTENT_LENGTH = 10000; - function truncateForLogging(content) { - if (content.length <= MAX_LOG_CONTENT_LENGTH) { - return content; - } - return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; - } - function loadAgentOutput() { - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; - if (!agentOutputFile) { - core.info("No GH_AW_AGENT_OUTPUT environment variable found"); - return { success: false }; - } - let outputContent; - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - return { success: false, error: errorMessage }; - } - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return { success: false }; - } - core.info(`Agent output content length: ${outputContent.length}`); - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); - return { success: false, error: errorMessage }; - } - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); - return { success: false }; - } - return { success: true, items: validatedOutput.items }; - } - async function generateStagedPreview(options) { - const { title, description, items, renderItem } = options; - let summaryContent = `## 🎭 Staged Mode: ${title} Preview\n\n`; - summaryContent += `${description}\n\n`; - for (let i = 0; i < items.length; i++) { - const item = items[i]; - summaryContent += renderItem(item, i); - summaryContent += "---\n\n"; - } - try { - await core.summary.addRaw(summaryContent).write(); - core.info(summaryContent); - core.info(`📝 ${title} preview written to step summary`); - } catch (error) { - core.setFailed(error instanceof Error ? error : String(error)); - } - } - function generateXMLMarker(workflowName, runUrl) { - const engineId = process.env.GH_AW_ENGINE_ID || ""; - const engineVersion = process.env.GH_AW_ENGINE_VERSION || ""; - const engineModel = process.env.GH_AW_ENGINE_MODEL || ""; - const trackerId = process.env.GH_AW_TRACKER_ID || ""; - const parts = []; - parts.push(`agentic-workflow: ${workflowName}`); - if (trackerId) { - parts.push(`tracker-id: ${trackerId}`); - } - if (engineId) { - parts.push(`engine: ${engineId}`); - } - if (engineVersion) { - parts.push(`version: ${engineVersion}`); - } - if (engineModel) { - parts.push(`model: ${engineModel}`); - } - parts.push(`run: ${runUrl}`); - return ``; - } - function generateFooter( - workflowName, - runUrl, - workflowSource, - workflowSourceURL, - triggeringIssueNumber, - triggeringPRNumber, - triggeringDiscussionNumber - ) { - let footer = `\n\n> AI generated by [${workflowName}](${runUrl})`; - if (triggeringIssueNumber) { - footer += ` for #${triggeringIssueNumber}`; - } else if (triggeringPRNumber) { - footer += ` for #${triggeringPRNumber}`; - } else if (triggeringDiscussionNumber) { - footer += ` for discussion #${triggeringDiscussionNumber}`; - } - if (workflowSource && workflowSourceURL) { - footer += `\n>\n> To add this workflow in your repository, run \`gh aw add ${workflowSource}\`. See [usage guide](https://githubnext.github.io/gh-aw/tools/cli/).`; - } - footer += "\n\n" + generateXMLMarker(workflowName, runUrl); - footer += "\n"; - return footer; - } - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; - function generateTemporaryId() { - return "aw_" + crypto.randomBytes(6).toString("hex"); - } - function isTemporaryId(value) { - if (typeof value === "string") { - return /^aw_[0-9a-f]{12}$/i.test(value); - } - return false; - } - function normalizeTemporaryId(tempId) { - return String(tempId).toLowerCase(); - } - function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); - if (resolved !== undefined) { - if (currentRepo && resolved.repo === currentRepo) { - return `#${resolved.number}`; - } - return `${resolved.repo}#${resolved.number}`; - } - return match; - }); - } - function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); - if (issueNumber !== undefined) { - return `#${issueNumber}`; - } - return match; - }); - } - function loadTemporaryIdMap() { - const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; - if (!mapJson || mapJson === "{}") { - return new Map(); - } - try { - const mapObject = JSON.parse(mapJson); - const result = new Map(); - for (const [key, value] of Object.entries(mapObject)) { - const normalizedKey = normalizeTemporaryId(key); - if (typeof value === "number") { - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - result.set(normalizedKey, { repo: contextRepo, number: value }); - } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { - result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); - } - } - return result; - } catch (error) { - if (typeof core !== "undefined") { - core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); - } - return new Map(); - } - } - function resolveIssueNumber(value, temporaryIdMap) { - if (value === undefined || value === null) { - return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; - } - const valueStr = String(value); - if (isTemporaryId(valueStr)) { - const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); - if (resolvedPair !== undefined) { - return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; - } - return { - resolved: null, - wasTemporaryId: true, - errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, - }; - } - const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); - if (isNaN(issueNumber) || issueNumber <= 0) { - return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; - } - const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; - return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; - } - function serializeTemporaryIdMap(tempIdMap) { - const obj = Object.fromEntries(tempIdMap); - return JSON.stringify(obj); - } - function parseAllowedRepos() { - const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; - const set = new Set(); - if (allowedReposEnv) { - allowedReposEnv - .split(",") - .map(repo => repo.trim()) - .filter(repo => repo) - .forEach(repo => set.add(repo)); - } - return set; - } - function getDefaultTargetRepo() { - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - if (targetRepoSlug) { - return targetRepoSlug; - } - return `${context.repo.owner}/${context.repo.repo}`; - } - function validateRepo(repo, defaultRepo, allowedRepos) { - if (repo === defaultRepo) { - return { valid: true, error: null }; - } - if (allowedRepos.has(repo)) { - return { valid: true, error: null }; - } - return { - valid: false, - error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, - }; - } - function parseRepoSlug(repoSlug) { - const parts = repoSlug.split("/"); - if (parts.length !== 2 || !parts[0] || !parts[1]) { - return null; - } - return { owner: parts[0], repo: parts[1] }; - } - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } + const { sanitizeLabelContent } = require("./sanitize_label_content.cjs"); + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { generateStagedPreview } = require("./staged_preview.cjs"); + const { generateFooter } = require("./generate_footer.cjs"); + const { getTrackerID } = require("./get_tracker_id.cjs"); + const { + generateTemporaryId, + isTemporaryId, + normalizeTemporaryId, + replaceTemporaryIdReferences, + serializeTemporaryIdMap, + } = require("./temporary_id.cjs"); + const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require("./repo_helpers.cjs"); + const { addExpirationComment } = require("./expiration_helpers.cjs"); + const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); async function main() { core.setOutput("issue_number", ""); core.setOutput("issue_url", ""); @@ -8514,6 +8263,7 @@ jobs: .filter((label, index, arr) => arr.indexOf(label) === index); let title = createIssueItem.title ? createIssueItem.title.trim() : ""; let processedBody = replaceTemporaryIdReferences(createIssueItem.body, temporaryIdMap, itemRepo); + processedBody = removeDuplicateTitleFromDescription(title, processedBody); let bodyLines = processedBody.split("\n"); if (!title) { title = createIssueItem.body || "Agent Output"; diff --git a/.github/workflows/stale-repo-identifier.lock.yml b/.github/workflows/stale-repo-identifier.lock.yml index 73a5310138a..1668f01df60 100644 --- a/.github/workflows/stale-repo-identifier.lock.yml +++ b/.github/workflows/stale-repo-identifier.lock.yml @@ -7888,272 +7888,21 @@ jobs: with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - function sanitizeLabelContent(content) { - if (!content || typeof content !== "string") { - return ""; - } - let sanitized = content.trim(); - sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); - sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); - sanitized = sanitized.replace( - /(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, - (_m, p1, p2) => `${p1}\`@${p2}\`` - ); - sanitized = sanitized.replace(/[<>&'"]/g, ""); - return sanitized.trim(); - } - const fs = require("fs"); - const crypto = require("crypto"); - const MAX_LOG_CONTENT_LENGTH = 10000; - function truncateForLogging(content) { - if (content.length <= MAX_LOG_CONTENT_LENGTH) { - return content; - } - return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; - } - function loadAgentOutput() { - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; - if (!agentOutputFile) { - core.info("No GH_AW_AGENT_OUTPUT environment variable found"); - return { success: false }; - } - let outputContent; - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - return { success: false, error: errorMessage }; - } - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return { success: false }; - } - core.info(`Agent output content length: ${outputContent.length}`); - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); - return { success: false, error: errorMessage }; - } - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); - return { success: false }; - } - return { success: true, items: validatedOutput.items }; - } - async function generateStagedPreview(options) { - const { title, description, items, renderItem } = options; - let summaryContent = `## 🎭 Staged Mode: ${title} Preview\n\n`; - summaryContent += `${description}\n\n`; - for (let i = 0; i < items.length; i++) { - const item = items[i]; - summaryContent += renderItem(item, i); - summaryContent += "---\n\n"; - } - try { - await core.summary.addRaw(summaryContent).write(); - core.info(summaryContent); - core.info(`📝 ${title} preview written to step summary`); - } catch (error) { - core.setFailed(error instanceof Error ? error : String(error)); - } - } - function generateXMLMarker(workflowName, runUrl) { - const engineId = process.env.GH_AW_ENGINE_ID || ""; - const engineVersion = process.env.GH_AW_ENGINE_VERSION || ""; - const engineModel = process.env.GH_AW_ENGINE_MODEL || ""; - const trackerId = process.env.GH_AW_TRACKER_ID || ""; - const parts = []; - parts.push(`agentic-workflow: ${workflowName}`); - if (trackerId) { - parts.push(`tracker-id: ${trackerId}`); - } - if (engineId) { - parts.push(`engine: ${engineId}`); - } - if (engineVersion) { - parts.push(`version: ${engineVersion}`); - } - if (engineModel) { - parts.push(`model: ${engineModel}`); - } - parts.push(`run: ${runUrl}`); - return ``; - } - function generateFooter( - workflowName, - runUrl, - workflowSource, - workflowSourceURL, - triggeringIssueNumber, - triggeringPRNumber, - triggeringDiscussionNumber - ) { - let footer = `\n\n> AI generated by [${workflowName}](${runUrl})`; - if (triggeringIssueNumber) { - footer += ` for #${triggeringIssueNumber}`; - } else if (triggeringPRNumber) { - footer += ` for #${triggeringPRNumber}`; - } else if (triggeringDiscussionNumber) { - footer += ` for discussion #${triggeringDiscussionNumber}`; - } - if (workflowSource && workflowSourceURL) { - footer += `\n>\n> To add this workflow in your repository, run \`gh aw add ${workflowSource}\`. See [usage guide](https://githubnext.github.io/gh-aw/tools/cli/).`; - } - footer += "\n\n" + generateXMLMarker(workflowName, runUrl); - footer += "\n"; - return footer; - } - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; - function generateTemporaryId() { - return "aw_" + crypto.randomBytes(6).toString("hex"); - } - function isTemporaryId(value) { - if (typeof value === "string") { - return /^aw_[0-9a-f]{12}$/i.test(value); - } - return false; - } - function normalizeTemporaryId(tempId) { - return String(tempId).toLowerCase(); - } - function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); - if (resolved !== undefined) { - if (currentRepo && resolved.repo === currentRepo) { - return `#${resolved.number}`; - } - return `${resolved.repo}#${resolved.number}`; - } - return match; - }); - } - function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); - if (issueNumber !== undefined) { - return `#${issueNumber}`; - } - return match; - }); - } - function loadTemporaryIdMap() { - const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; - if (!mapJson || mapJson === "{}") { - return new Map(); - } - try { - const mapObject = JSON.parse(mapJson); - const result = new Map(); - for (const [key, value] of Object.entries(mapObject)) { - const normalizedKey = normalizeTemporaryId(key); - if (typeof value === "number") { - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - result.set(normalizedKey, { repo: contextRepo, number: value }); - } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { - result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); - } - } - return result; - } catch (error) { - if (typeof core !== "undefined") { - core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); - } - return new Map(); - } - } - function resolveIssueNumber(value, temporaryIdMap) { - if (value === undefined || value === null) { - return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; - } - const valueStr = String(value); - if (isTemporaryId(valueStr)) { - const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); - if (resolvedPair !== undefined) { - return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; - } - return { - resolved: null, - wasTemporaryId: true, - errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, - }; - } - const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); - if (isNaN(issueNumber) || issueNumber <= 0) { - return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; - } - const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; - return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; - } - function serializeTemporaryIdMap(tempIdMap) { - const obj = Object.fromEntries(tempIdMap); - return JSON.stringify(obj); - } - function parseAllowedRepos() { - const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; - const set = new Set(); - if (allowedReposEnv) { - allowedReposEnv - .split(",") - .map(repo => repo.trim()) - .filter(repo => repo) - .forEach(repo => set.add(repo)); - } - return set; - } - function getDefaultTargetRepo() { - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - if (targetRepoSlug) { - return targetRepoSlug; - } - return `${context.repo.owner}/${context.repo.repo}`; - } - function validateRepo(repo, defaultRepo, allowedRepos) { - if (repo === defaultRepo) { - return { valid: true, error: null }; - } - if (allowedRepos.has(repo)) { - return { valid: true, error: null }; - } - return { - valid: false, - error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, - }; - } - function parseRepoSlug(repoSlug) { - const parts = repoSlug.split("/"); - if (parts.length !== 2 || !parts[0] || !parts[1]) { - return null; - } - return { owner: parts[0], repo: parts[1] }; - } - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } + const { sanitizeLabelContent } = require("./sanitize_label_content.cjs"); + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { generateStagedPreview } = require("./staged_preview.cjs"); + const { generateFooter } = require("./generate_footer.cjs"); + const { getTrackerID } = require("./get_tracker_id.cjs"); + const { + generateTemporaryId, + isTemporaryId, + normalizeTemporaryId, + replaceTemporaryIdReferences, + serializeTemporaryIdMap, + } = require("./temporary_id.cjs"); + const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require("./repo_helpers.cjs"); + const { addExpirationComment } = require("./expiration_helpers.cjs"); + const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); async function main() { core.setOutput("issue_number", ""); core.setOutput("issue_url", ""); @@ -8286,6 +8035,7 @@ jobs: .filter((label, index, arr) => arr.indexOf(label) === index); let title = createIssueItem.title ? createIssueItem.title.trim() : ""; let processedBody = replaceTemporaryIdReferences(createIssueItem.body, temporaryIdMap, itemRepo); + processedBody = removeDuplicateTitleFromDescription(title, processedBody); let bodyLines = processedBody.split("\n"); if (!title) { title = createIssueItem.body || "Agent Output"; diff --git a/.github/workflows/static-analysis-report.lock.yml b/.github/workflows/static-analysis-report.lock.yml index 104dc36b85c..be7bfa90fef 100644 --- a/.github/workflows/static-analysis-report.lock.yml +++ b/.github/workflows/static-analysis-report.lock.yml @@ -6206,381 +6206,13 @@ jobs: with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - const fs = require("fs"); - const crypto = require("crypto"); - const MAX_LOG_CONTENT_LENGTH = 10000; - function truncateForLogging(content) { - if (content.length <= MAX_LOG_CONTENT_LENGTH) { - return content; - } - return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; - } - function loadAgentOutput() { - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; - if (!agentOutputFile) { - core.info("No GH_AW_AGENT_OUTPUT environment variable found"); - return { success: false }; - } - let outputContent; - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - return { success: false, error: errorMessage }; - } - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return { success: false }; - } - core.info(`Agent output content length: ${outputContent.length}`); - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); - return { success: false, error: errorMessage }; - } - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); - return { success: false }; - } - return { success: true, items: validatedOutput.items }; - } - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - function getMessages() { - const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES; - if (!messagesEnv) { - return null; - } - try { - return JSON.parse(messagesEnv); - } catch (error) { - core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`); - return null; - } - } - function renderTemplate(template, context) { - return template.replace(/\{(\w+)\}/g, (match, key) => { - const value = context[key]; - return value !== undefined && value !== null ? String(value) : match; - }); - } - function toSnakeCase(obj) { - const result = {}; - for (const [key, value] of Object.entries(obj)) { - const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase(); - result[snakeKey] = value; - result[key] = value; - } - return result; - } - function getCloseOlderDiscussionMessage(ctx) { - const messages = getMessages(); - const templateContext = toSnakeCase(ctx); - const defaultMessage = `⚓ Avast! This discussion be marked as **outdated** by [{workflow_name}]({run_url}). - 🗺️ A newer treasure map awaits ye at **[Discussion #{new_discussion_number}]({new_discussion_url})**. - Fair winds, matey! 🏴‍☠️`; - return messages?.closeOlderDiscussion - ? renderTemplate(messages.closeOlderDiscussion, templateContext) - : renderTemplate(defaultMessage, templateContext); - } - const MAX_CLOSE_COUNT = 10; - const GRAPHQL_DELAY_MS = 500; - function delay(ms) { - return new Promise(resolve => setTimeout(resolve, ms)); - } - async function searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, excludeNumber) { - let searchQuery = `repo:${owner}/${repo} is:open`; - if (titlePrefix) { - const escapedPrefix = titlePrefix.replace(/"/g, '\\"'); - searchQuery += ` in:title "${escapedPrefix}"`; - } - if (labels && labels.length > 0) { - for (const label of labels) { - const escapedLabel = label.replace(/"/g, '\\"'); - searchQuery += ` label:"${escapedLabel}"`; - } - } - const result = await github.graphql( - ` - query($searchTerms: String!, $first: Int!) { - search(query: $searchTerms, type: DISCUSSION, first: $first) { - nodes { - ... on Discussion { - id - number - title - url - category { - id - } - labels(first: 100) { - nodes { - name - } - } - closed - } - } - } - }`, - { searchTerms: searchQuery, first: 50 } - ); - if (!result || !result.search || !result.search.nodes) { - return []; - } - return result.search.nodes - .filter( - d => { - if (!d || d.number === excludeNumber || d.closed) { - return false; - } - if (titlePrefix && d.title && !d.title.startsWith(titlePrefix)) { - return false; - } - if (labels && labels.length > 0) { - const discussionLabels = d.labels?.nodes?.map(( l) => l.name) || []; - const hasAllLabels = labels.every(label => discussionLabels.includes(label)); - if (!hasAllLabels) { - return false; - } - } - if (categoryId && (!d.category || d.category.id !== categoryId)) { - return false; - } - return true; - } - ) - .map( - d => ({ - id: d.id, - number: d.number, - title: d.title, - url: d.url, - }) - ); - } - async function addDiscussionComment(github, discussionId, message) { - const result = await github.graphql( - ` - mutation($dId: ID!, $body: String!) { - addDiscussionComment(input: { discussionId: $dId, body: $body }) { - comment { - id - url - } - } - }`, - { dId: discussionId, body: message } - ); - return result.addDiscussionComment.comment; - } - async function closeDiscussionAsOutdated(github, discussionId) { - const result = await github.graphql( - ` - mutation($dId: ID!) { - closeDiscussion(input: { discussionId: $dId, reason: OUTDATED }) { - discussion { - id - url - } - } - }`, - { dId: discussionId } - ); - return result.closeDiscussion.discussion; - } - async function closeOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion, workflowName, runUrl) { - const searchCriteria = []; - if (titlePrefix) searchCriteria.push(`title prefix: "${titlePrefix}"`); - if (labels && labels.length > 0) searchCriteria.push(`labels: [${labels.join(", ")}]`); - core.info(`Searching for older discussions with ${searchCriteria.join(" and ")}`); - const olderDiscussions = await searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion.number); - if (olderDiscussions.length === 0) { - core.info("No older discussions found to close"); - return []; - } - core.info(`Found ${olderDiscussions.length} older discussion(s) to close`); - const discussionsToClose = olderDiscussions.slice(0, MAX_CLOSE_COUNT); - if (olderDiscussions.length > MAX_CLOSE_COUNT) { - core.warning(`Found ${olderDiscussions.length} older discussions, but only closing the first ${MAX_CLOSE_COUNT}`); - } - const closedDiscussions = []; - for (let i = 0; i < discussionsToClose.length; i++) { - const discussion = discussionsToClose[i]; - try { - const closingMessage = getCloseOlderDiscussionMessage({ - newDiscussionUrl: newDiscussion.url, - newDiscussionNumber: newDiscussion.number, - workflowName, - runUrl, - }); - core.info(`Adding closing comment to discussion #${discussion.number}`); - await addDiscussionComment(github, discussion.id, closingMessage); - core.info(`Closing discussion #${discussion.number} as outdated`); - await closeDiscussionAsOutdated(github, discussion.id); - closedDiscussions.push({ - number: discussion.number, - url: discussion.url, - }); - core.info(`✓ Closed discussion #${discussion.number}: ${discussion.url}`); - } catch (error) { - core.error(`✗ Failed to close discussion #${discussion.number}: ${error instanceof Error ? error.message : String(error)}`); - } - if (i < discussionsToClose.length - 1) { - await delay(GRAPHQL_DELAY_MS); - } - } - return closedDiscussions; - } - const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; - function generateTemporaryId() { - return "aw_" + crypto.randomBytes(6).toString("hex"); - } - function isTemporaryId(value) { - if (typeof value === "string") { - return /^aw_[0-9a-f]{12}$/i.test(value); - } - return false; - } - function normalizeTemporaryId(tempId) { - return String(tempId).toLowerCase(); - } - function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); - if (resolved !== undefined) { - if (currentRepo && resolved.repo === currentRepo) { - return `#${resolved.number}`; - } - return `${resolved.repo}#${resolved.number}`; - } - return match; - }); - } - function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); - if (issueNumber !== undefined) { - return `#${issueNumber}`; - } - return match; - }); - } - function loadTemporaryIdMap() { - const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; - if (!mapJson || mapJson === "{}") { - return new Map(); - } - try { - const mapObject = JSON.parse(mapJson); - const result = new Map(); - for (const [key, value] of Object.entries(mapObject)) { - const normalizedKey = normalizeTemporaryId(key); - if (typeof value === "number") { - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - result.set(normalizedKey, { repo: contextRepo, number: value }); - } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { - result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); - } - } - return result; - } catch (error) { - if (typeof core !== "undefined") { - core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); - } - return new Map(); - } - } - function resolveIssueNumber(value, temporaryIdMap) { - if (value === undefined || value === null) { - return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; - } - const valueStr = String(value); - if (isTemporaryId(valueStr)) { - const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); - if (resolvedPair !== undefined) { - return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; - } - return { - resolved: null, - wasTemporaryId: true, - errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, - }; - } - const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); - if (isNaN(issueNumber) || issueNumber <= 0) { - return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; - } - const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; - return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; - } - function serializeTemporaryIdMap(tempIdMap) { - const obj = Object.fromEntries(tempIdMap); - return JSON.stringify(obj); - } - function parseAllowedRepos() { - const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; - const set = new Set(); - if (allowedReposEnv) { - allowedReposEnv - .split(",") - .map(repo => repo.trim()) - .filter(repo => repo) - .forEach(repo => set.add(repo)); - } - return set; - } - function getDefaultTargetRepo() { - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - if (targetRepoSlug) { - return targetRepoSlug; - } - return `${context.repo.owner}/${context.repo.repo}`; - } - function validateRepo(repo, defaultRepo, allowedRepos) { - if (repo === defaultRepo) { - return { valid: true, error: null }; - } - if (allowedRepos.has(repo)) { - return { valid: true, error: null }; - } - return { - valid: false, - error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, - }; - } - function parseRepoSlug(repoSlug) { - const parts = repoSlug.split("/"); - if (parts.length !== 2 || !parts[0] || !parts[1]) { - return null; - } - return { owner: parts[0], repo: parts[1] }; - } - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { getTrackerID } = require("./get_tracker_id.cjs"); + const { closeOlderDiscussions } = require("./close_older_discussions.cjs"); + const { replaceTemporaryIdReferences, loadTemporaryIdMap } = require("./temporary_id.cjs"); + const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require("./repo_helpers.cjs"); + const { addExpirationComment } = require("./expiration_helpers.cjs"); + const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); async function fetchRepoDiscussionInfo(owner, repo) { const repositoryQuery = ` query($owner: String!, $repo: String!) { @@ -6765,7 +6397,9 @@ jobs: ); let title = createDiscussionItem.title ? replaceTemporaryIdReferences(createDiscussionItem.title.trim(), temporaryIdMap, itemRepo) : ""; const bodyText = createDiscussionItem.body || ""; - let bodyLines = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo).split("\n"); + let processedBody = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo); + processedBody = removeDuplicateTitleFromDescription(title, processedBody); + let bodyLines = processedBody.split("\n"); if (!title) { title = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo) || "Agent Output"; } diff --git a/.github/workflows/super-linter.lock.yml b/.github/workflows/super-linter.lock.yml index 015ee971493..70a43eb51cf 100644 --- a/.github/workflows/super-linter.lock.yml +++ b/.github/workflows/super-linter.lock.yml @@ -6401,272 +6401,21 @@ jobs: with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - function sanitizeLabelContent(content) { - if (!content || typeof content !== "string") { - return ""; - } - let sanitized = content.trim(); - sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); - sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); - sanitized = sanitized.replace( - /(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, - (_m, p1, p2) => `${p1}\`@${p2}\`` - ); - sanitized = sanitized.replace(/[<>&'"]/g, ""); - return sanitized.trim(); - } - const fs = require("fs"); - const crypto = require("crypto"); - const MAX_LOG_CONTENT_LENGTH = 10000; - function truncateForLogging(content) { - if (content.length <= MAX_LOG_CONTENT_LENGTH) { - return content; - } - return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; - } - function loadAgentOutput() { - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; - if (!agentOutputFile) { - core.info("No GH_AW_AGENT_OUTPUT environment variable found"); - return { success: false }; - } - let outputContent; - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - return { success: false, error: errorMessage }; - } - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return { success: false }; - } - core.info(`Agent output content length: ${outputContent.length}`); - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); - return { success: false, error: errorMessage }; - } - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); - return { success: false }; - } - return { success: true, items: validatedOutput.items }; - } - async function generateStagedPreview(options) { - const { title, description, items, renderItem } = options; - let summaryContent = `## 🎭 Staged Mode: ${title} Preview\n\n`; - summaryContent += `${description}\n\n`; - for (let i = 0; i < items.length; i++) { - const item = items[i]; - summaryContent += renderItem(item, i); - summaryContent += "---\n\n"; - } - try { - await core.summary.addRaw(summaryContent).write(); - core.info(summaryContent); - core.info(`📝 ${title} preview written to step summary`); - } catch (error) { - core.setFailed(error instanceof Error ? error : String(error)); - } - } - function generateXMLMarker(workflowName, runUrl) { - const engineId = process.env.GH_AW_ENGINE_ID || ""; - const engineVersion = process.env.GH_AW_ENGINE_VERSION || ""; - const engineModel = process.env.GH_AW_ENGINE_MODEL || ""; - const trackerId = process.env.GH_AW_TRACKER_ID || ""; - const parts = []; - parts.push(`agentic-workflow: ${workflowName}`); - if (trackerId) { - parts.push(`tracker-id: ${trackerId}`); - } - if (engineId) { - parts.push(`engine: ${engineId}`); - } - if (engineVersion) { - parts.push(`version: ${engineVersion}`); - } - if (engineModel) { - parts.push(`model: ${engineModel}`); - } - parts.push(`run: ${runUrl}`); - return ``; - } - function generateFooter( - workflowName, - runUrl, - workflowSource, - workflowSourceURL, - triggeringIssueNumber, - triggeringPRNumber, - triggeringDiscussionNumber - ) { - let footer = `\n\n> AI generated by [${workflowName}](${runUrl})`; - if (triggeringIssueNumber) { - footer += ` for #${triggeringIssueNumber}`; - } else if (triggeringPRNumber) { - footer += ` for #${triggeringPRNumber}`; - } else if (triggeringDiscussionNumber) { - footer += ` for discussion #${triggeringDiscussionNumber}`; - } - if (workflowSource && workflowSourceURL) { - footer += `\n>\n> To add this workflow in your repository, run \`gh aw add ${workflowSource}\`. See [usage guide](https://githubnext.github.io/gh-aw/tools/cli/).`; - } - footer += "\n\n" + generateXMLMarker(workflowName, runUrl); - footer += "\n"; - return footer; - } - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; - function generateTemporaryId() { - return "aw_" + crypto.randomBytes(6).toString("hex"); - } - function isTemporaryId(value) { - if (typeof value === "string") { - return /^aw_[0-9a-f]{12}$/i.test(value); - } - return false; - } - function normalizeTemporaryId(tempId) { - return String(tempId).toLowerCase(); - } - function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); - if (resolved !== undefined) { - if (currentRepo && resolved.repo === currentRepo) { - return `#${resolved.number}`; - } - return `${resolved.repo}#${resolved.number}`; - } - return match; - }); - } - function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); - if (issueNumber !== undefined) { - return `#${issueNumber}`; - } - return match; - }); - } - function loadTemporaryIdMap() { - const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; - if (!mapJson || mapJson === "{}") { - return new Map(); - } - try { - const mapObject = JSON.parse(mapJson); - const result = new Map(); - for (const [key, value] of Object.entries(mapObject)) { - const normalizedKey = normalizeTemporaryId(key); - if (typeof value === "number") { - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - result.set(normalizedKey, { repo: contextRepo, number: value }); - } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { - result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); - } - } - return result; - } catch (error) { - if (typeof core !== "undefined") { - core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); - } - return new Map(); - } - } - function resolveIssueNumber(value, temporaryIdMap) { - if (value === undefined || value === null) { - return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; - } - const valueStr = String(value); - if (isTemporaryId(valueStr)) { - const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); - if (resolvedPair !== undefined) { - return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; - } - return { - resolved: null, - wasTemporaryId: true, - errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, - }; - } - const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); - if (isNaN(issueNumber) || issueNumber <= 0) { - return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; - } - const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; - return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; - } - function serializeTemporaryIdMap(tempIdMap) { - const obj = Object.fromEntries(tempIdMap); - return JSON.stringify(obj); - } - function parseAllowedRepos() { - const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; - const set = new Set(); - if (allowedReposEnv) { - allowedReposEnv - .split(",") - .map(repo => repo.trim()) - .filter(repo => repo) - .forEach(repo => set.add(repo)); - } - return set; - } - function getDefaultTargetRepo() { - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - if (targetRepoSlug) { - return targetRepoSlug; - } - return `${context.repo.owner}/${context.repo.repo}`; - } - function validateRepo(repo, defaultRepo, allowedRepos) { - if (repo === defaultRepo) { - return { valid: true, error: null }; - } - if (allowedRepos.has(repo)) { - return { valid: true, error: null }; - } - return { - valid: false, - error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, - }; - } - function parseRepoSlug(repoSlug) { - const parts = repoSlug.split("/"); - if (parts.length !== 2 || !parts[0] || !parts[1]) { - return null; - } - return { owner: parts[0], repo: parts[1] }; - } - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } + const { sanitizeLabelContent } = require("./sanitize_label_content.cjs"); + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { generateStagedPreview } = require("./staged_preview.cjs"); + const { generateFooter } = require("./generate_footer.cjs"); + const { getTrackerID } = require("./get_tracker_id.cjs"); + const { + generateTemporaryId, + isTemporaryId, + normalizeTemporaryId, + replaceTemporaryIdReferences, + serializeTemporaryIdMap, + } = require("./temporary_id.cjs"); + const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require("./repo_helpers.cjs"); + const { addExpirationComment } = require("./expiration_helpers.cjs"); + const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); async function main() { core.setOutput("issue_number", ""); core.setOutput("issue_url", ""); @@ -6799,6 +6548,7 @@ jobs: .filter((label, index, arr) => arr.indexOf(label) === index); let title = createIssueItem.title ? createIssueItem.title.trim() : ""; let processedBody = replaceTemporaryIdReferences(createIssueItem.body, temporaryIdMap, itemRepo); + processedBody = removeDuplicateTitleFromDescription(title, processedBody); let bodyLines = processedBody.split("\n"); if (!title) { title = createIssueItem.body || "Agent Output"; diff --git a/.github/workflows/technical-doc-writer.lock.yml b/.github/workflows/technical-doc-writer.lock.yml index d6b4d830bdd..d0c1ae2522a 100644 --- a/.github/workflows/technical-doc-writer.lock.yml +++ b/.github/workflows/technical-doc-writer.lock.yml @@ -7512,132 +7512,10 @@ jobs: script: | const fs = require("fs"); const crypto = require("crypto"); - async function updateActivationComment(github, context, core, itemUrl, itemNumber, itemType = "pull_request") { - const itemLabel = itemType === "issue" ? "issue" : "pull request"; - const linkMessage = - itemType === "issue" - ? `\n\n✅ Issue created: [#${itemNumber}](${itemUrl})` - : `\n\n✅ Pull request created: [#${itemNumber}](${itemUrl})`; - await updateActivationCommentWithMessage(github, context, core, linkMessage, itemLabel); - } - async function updateActivationCommentWithCommit(github, context, core, commitSha, commitUrl) { - const shortSha = commitSha.substring(0, 7); - const message = `\n\n✅ Commit pushed: [\`${shortSha}\`](${commitUrl})`; - await updateActivationCommentWithMessage(github, context, core, message, "commit"); - } - async function updateActivationCommentWithMessage(github, context, core, message, label = "") { - const commentId = process.env.GH_AW_COMMENT_ID; - const commentRepo = process.env.GH_AW_COMMENT_REPO; - if (!commentId) { - core.info("No activation comment to update (GH_AW_COMMENT_ID not set)"); - return; - } - core.info(`Updating activation comment ${commentId}`); - let repoOwner = context.repo.owner; - let repoName = context.repo.repo; - if (commentRepo) { - const parts = commentRepo.split("/"); - if (parts.length === 2) { - repoOwner = parts[0]; - repoName = parts[1]; - } else { - core.warning(`Invalid comment repo format: ${commentRepo}, expected "owner/repo". Falling back to context.repo.`); - } - } - core.info(`Updating comment in ${repoOwner}/${repoName}`); - const isDiscussionComment = commentId.startsWith("DC_"); - try { - if (isDiscussionComment) { - const currentComment = await github.graphql( - ` - query($commentId: ID!) { - node(id: $commentId) { - ... on DiscussionComment { - body - } - } - }`, - { commentId: commentId } - ); - if (!currentComment?.node?.body) { - core.warning("Unable to fetch current comment body, comment may have been deleted or is inaccessible"); - return; - } - const currentBody = currentComment.node.body; - const updatedBody = currentBody + message; - const result = await github.graphql( - ` - mutation($commentId: ID!, $body: String!) { - updateDiscussionComment(input: { commentId: $commentId, body: $body }) { - comment { - id - url - } - } - }`, - { commentId: commentId, body: updatedBody } - ); - const comment = result.updateDiscussionComment.comment; - const successMessage = label - ? `Successfully updated discussion comment with ${label} link` - : "Successfully updated discussion comment"; - core.info(successMessage); - core.info(`Comment ID: ${comment.id}`); - core.info(`Comment URL: ${comment.url}`); - } else { - const currentComment = await github.request("GET /repos/{owner}/{repo}/issues/comments/{comment_id}", { - owner: repoOwner, - repo: repoName, - comment_id: parseInt(commentId, 10), - headers: { - Accept: "application/vnd.github+json", - }, - }); - if (!currentComment?.data?.body) { - core.warning("Unable to fetch current comment body, comment may have been deleted"); - return; - } - const currentBody = currentComment.data.body; - const updatedBody = currentBody + message; - const response = await github.request("PATCH /repos/{owner}/{repo}/issues/comments/{comment_id}", { - owner: repoOwner, - repo: repoName, - comment_id: parseInt(commentId, 10), - body: updatedBody, - headers: { - Accept: "application/vnd.github+json", - }, - }); - const successMessage = label ? `Successfully updated comment with ${label} link` : "Successfully updated comment"; - core.info(successMessage); - core.info(`Comment ID: ${response.data.id}`); - core.info(`Comment URL: ${response.data.html_url}`); - } - } catch (error) { - core.warning(`Failed to update activation comment: ${error instanceof Error ? error.message : String(error)}`); - } - } - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } + const { updateActivationComment } = require("./update_activation_comment.cjs"); + const { getTrackerID } = require("./get_tracker_id.cjs"); + const { addExpirationComment } = require("./expiration_helpers.cjs"); + const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); function generatePatchPreview(patchContent) { if (!patchContent || !patchContent.trim()) { return ""; @@ -7829,7 +7707,9 @@ jobs: return; } let title = pullRequestItem.title.trim(); - let bodyLines = pullRequestItem.body.split("\n"); + let processedBody = pullRequestItem.body; + processedBody = removeDuplicateTitleFromDescription(title, processedBody); + let bodyLines = processedBody.split("\n"); let branchName = pullRequestItem.branch ? pullRequestItem.branch.trim() : null; if (!title) { title = "Agent Output"; diff --git a/.github/workflows/test-discussion-expires.lock.yml b/.github/workflows/test-discussion-expires.lock.yml index a7605fc0daa..b13b6a5b56f 100644 --- a/.github/workflows/test-discussion-expires.lock.yml +++ b/.github/workflows/test-discussion-expires.lock.yml @@ -5778,381 +5778,13 @@ jobs: with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - const fs = require("fs"); - const crypto = require("crypto"); - const MAX_LOG_CONTENT_LENGTH = 10000; - function truncateForLogging(content) { - if (content.length <= MAX_LOG_CONTENT_LENGTH) { - return content; - } - return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; - } - function loadAgentOutput() { - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; - if (!agentOutputFile) { - core.info("No GH_AW_AGENT_OUTPUT environment variable found"); - return { success: false }; - } - let outputContent; - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - return { success: false, error: errorMessage }; - } - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return { success: false }; - } - core.info(`Agent output content length: ${outputContent.length}`); - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); - return { success: false, error: errorMessage }; - } - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); - return { success: false }; - } - return { success: true, items: validatedOutput.items }; - } - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - function getMessages() { - const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES; - if (!messagesEnv) { - return null; - } - try { - return JSON.parse(messagesEnv); - } catch (error) { - core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`); - return null; - } - } - function renderTemplate(template, context) { - return template.replace(/\{(\w+)\}/g, (match, key) => { - const value = context[key]; - return value !== undefined && value !== null ? String(value) : match; - }); - } - function toSnakeCase(obj) { - const result = {}; - for (const [key, value] of Object.entries(obj)) { - const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase(); - result[snakeKey] = value; - result[key] = value; - } - return result; - } - function getCloseOlderDiscussionMessage(ctx) { - const messages = getMessages(); - const templateContext = toSnakeCase(ctx); - const defaultMessage = `⚓ Avast! This discussion be marked as **outdated** by [{workflow_name}]({run_url}). - 🗺️ A newer treasure map awaits ye at **[Discussion #{new_discussion_number}]({new_discussion_url})**. - Fair winds, matey! 🏴‍☠️`; - return messages?.closeOlderDiscussion - ? renderTemplate(messages.closeOlderDiscussion, templateContext) - : renderTemplate(defaultMessage, templateContext); - } - const MAX_CLOSE_COUNT = 10; - const GRAPHQL_DELAY_MS = 500; - function delay(ms) { - return new Promise(resolve => setTimeout(resolve, ms)); - } - async function searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, excludeNumber) { - let searchQuery = `repo:${owner}/${repo} is:open`; - if (titlePrefix) { - const escapedPrefix = titlePrefix.replace(/"/g, '\\"'); - searchQuery += ` in:title "${escapedPrefix}"`; - } - if (labels && labels.length > 0) { - for (const label of labels) { - const escapedLabel = label.replace(/"/g, '\\"'); - searchQuery += ` label:"${escapedLabel}"`; - } - } - const result = await github.graphql( - ` - query($searchTerms: String!, $first: Int!) { - search(query: $searchTerms, type: DISCUSSION, first: $first) { - nodes { - ... on Discussion { - id - number - title - url - category { - id - } - labels(first: 100) { - nodes { - name - } - } - closed - } - } - } - }`, - { searchTerms: searchQuery, first: 50 } - ); - if (!result || !result.search || !result.search.nodes) { - return []; - } - return result.search.nodes - .filter( - d => { - if (!d || d.number === excludeNumber || d.closed) { - return false; - } - if (titlePrefix && d.title && !d.title.startsWith(titlePrefix)) { - return false; - } - if (labels && labels.length > 0) { - const discussionLabels = d.labels?.nodes?.map(( l) => l.name) || []; - const hasAllLabels = labels.every(label => discussionLabels.includes(label)); - if (!hasAllLabels) { - return false; - } - } - if (categoryId && (!d.category || d.category.id !== categoryId)) { - return false; - } - return true; - } - ) - .map( - d => ({ - id: d.id, - number: d.number, - title: d.title, - url: d.url, - }) - ); - } - async function addDiscussionComment(github, discussionId, message) { - const result = await github.graphql( - ` - mutation($dId: ID!, $body: String!) { - addDiscussionComment(input: { discussionId: $dId, body: $body }) { - comment { - id - url - } - } - }`, - { dId: discussionId, body: message } - ); - return result.addDiscussionComment.comment; - } - async function closeDiscussionAsOutdated(github, discussionId) { - const result = await github.graphql( - ` - mutation($dId: ID!) { - closeDiscussion(input: { discussionId: $dId, reason: OUTDATED }) { - discussion { - id - url - } - } - }`, - { dId: discussionId } - ); - return result.closeDiscussion.discussion; - } - async function closeOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion, workflowName, runUrl) { - const searchCriteria = []; - if (titlePrefix) searchCriteria.push(`title prefix: "${titlePrefix}"`); - if (labels && labels.length > 0) searchCriteria.push(`labels: [${labels.join(", ")}]`); - core.info(`Searching for older discussions with ${searchCriteria.join(" and ")}`); - const olderDiscussions = await searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion.number); - if (olderDiscussions.length === 0) { - core.info("No older discussions found to close"); - return []; - } - core.info(`Found ${olderDiscussions.length} older discussion(s) to close`); - const discussionsToClose = olderDiscussions.slice(0, MAX_CLOSE_COUNT); - if (olderDiscussions.length > MAX_CLOSE_COUNT) { - core.warning(`Found ${olderDiscussions.length} older discussions, but only closing the first ${MAX_CLOSE_COUNT}`); - } - const closedDiscussions = []; - for (let i = 0; i < discussionsToClose.length; i++) { - const discussion = discussionsToClose[i]; - try { - const closingMessage = getCloseOlderDiscussionMessage({ - newDiscussionUrl: newDiscussion.url, - newDiscussionNumber: newDiscussion.number, - workflowName, - runUrl, - }); - core.info(`Adding closing comment to discussion #${discussion.number}`); - await addDiscussionComment(github, discussion.id, closingMessage); - core.info(`Closing discussion #${discussion.number} as outdated`); - await closeDiscussionAsOutdated(github, discussion.id); - closedDiscussions.push({ - number: discussion.number, - url: discussion.url, - }); - core.info(`✓ Closed discussion #${discussion.number}: ${discussion.url}`); - } catch (error) { - core.error(`✗ Failed to close discussion #${discussion.number}: ${error instanceof Error ? error.message : String(error)}`); - } - if (i < discussionsToClose.length - 1) { - await delay(GRAPHQL_DELAY_MS); - } - } - return closedDiscussions; - } - const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; - function generateTemporaryId() { - return "aw_" + crypto.randomBytes(6).toString("hex"); - } - function isTemporaryId(value) { - if (typeof value === "string") { - return /^aw_[0-9a-f]{12}$/i.test(value); - } - return false; - } - function normalizeTemporaryId(tempId) { - return String(tempId).toLowerCase(); - } - function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); - if (resolved !== undefined) { - if (currentRepo && resolved.repo === currentRepo) { - return `#${resolved.number}`; - } - return `${resolved.repo}#${resolved.number}`; - } - return match; - }); - } - function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); - if (issueNumber !== undefined) { - return `#${issueNumber}`; - } - return match; - }); - } - function loadTemporaryIdMap() { - const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; - if (!mapJson || mapJson === "{}") { - return new Map(); - } - try { - const mapObject = JSON.parse(mapJson); - const result = new Map(); - for (const [key, value] of Object.entries(mapObject)) { - const normalizedKey = normalizeTemporaryId(key); - if (typeof value === "number") { - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - result.set(normalizedKey, { repo: contextRepo, number: value }); - } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { - result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); - } - } - return result; - } catch (error) { - if (typeof core !== "undefined") { - core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); - } - return new Map(); - } - } - function resolveIssueNumber(value, temporaryIdMap) { - if (value === undefined || value === null) { - return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; - } - const valueStr = String(value); - if (isTemporaryId(valueStr)) { - const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); - if (resolvedPair !== undefined) { - return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; - } - return { - resolved: null, - wasTemporaryId: true, - errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, - }; - } - const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); - if (isNaN(issueNumber) || issueNumber <= 0) { - return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; - } - const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; - return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; - } - function serializeTemporaryIdMap(tempIdMap) { - const obj = Object.fromEntries(tempIdMap); - return JSON.stringify(obj); - } - function parseAllowedRepos() { - const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; - const set = new Set(); - if (allowedReposEnv) { - allowedReposEnv - .split(",") - .map(repo => repo.trim()) - .filter(repo => repo) - .forEach(repo => set.add(repo)); - } - return set; - } - function getDefaultTargetRepo() { - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - if (targetRepoSlug) { - return targetRepoSlug; - } - return `${context.repo.owner}/${context.repo.repo}`; - } - function validateRepo(repo, defaultRepo, allowedRepos) { - if (repo === defaultRepo) { - return { valid: true, error: null }; - } - if (allowedRepos.has(repo)) { - return { valid: true, error: null }; - } - return { - valid: false, - error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, - }; - } - function parseRepoSlug(repoSlug) { - const parts = repoSlug.split("/"); - if (parts.length !== 2 || !parts[0] || !parts[1]) { - return null; - } - return { owner: parts[0], repo: parts[1] }; - } - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { getTrackerID } = require("./get_tracker_id.cjs"); + const { closeOlderDiscussions } = require("./close_older_discussions.cjs"); + const { replaceTemporaryIdReferences, loadTemporaryIdMap } = require("./temporary_id.cjs"); + const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require("./repo_helpers.cjs"); + const { addExpirationComment } = require("./expiration_helpers.cjs"); + const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); async function fetchRepoDiscussionInfo(owner, repo) { const repositoryQuery = ` query($owner: String!, $repo: String!) { @@ -6337,7 +5969,9 @@ jobs: ); let title = createDiscussionItem.title ? replaceTemporaryIdReferences(createDiscussionItem.title.trim(), temporaryIdMap, itemRepo) : ""; const bodyText = createDiscussionItem.body || ""; - let bodyLines = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo).split("\n"); + let processedBody = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo); + processedBody = removeDuplicateTitleFromDescription(title, processedBody); + let bodyLines = processedBody.split("\n"); if (!title) { title = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo) || "Agent Output"; } diff --git a/.github/workflows/test-python-safe-input.lock.yml b/.github/workflows/test-python-safe-input.lock.yml index 6767705135b..5a0c386847a 100644 --- a/.github/workflows/test-python-safe-input.lock.yml +++ b/.github/workflows/test-python-safe-input.lock.yml @@ -7396,272 +7396,21 @@ jobs: with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - function sanitizeLabelContent(content) { - if (!content || typeof content !== "string") { - return ""; - } - let sanitized = content.trim(); - sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); - sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); - sanitized = sanitized.replace( - /(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, - (_m, p1, p2) => `${p1}\`@${p2}\`` - ); - sanitized = sanitized.replace(/[<>&'"]/g, ""); - return sanitized.trim(); - } - const fs = require("fs"); - const crypto = require("crypto"); - const MAX_LOG_CONTENT_LENGTH = 10000; - function truncateForLogging(content) { - if (content.length <= MAX_LOG_CONTENT_LENGTH) { - return content; - } - return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; - } - function loadAgentOutput() { - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; - if (!agentOutputFile) { - core.info("No GH_AW_AGENT_OUTPUT environment variable found"); - return { success: false }; - } - let outputContent; - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - return { success: false, error: errorMessage }; - } - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return { success: false }; - } - core.info(`Agent output content length: ${outputContent.length}`); - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); - return { success: false, error: errorMessage }; - } - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); - return { success: false }; - } - return { success: true, items: validatedOutput.items }; - } - async function generateStagedPreview(options) { - const { title, description, items, renderItem } = options; - let summaryContent = `## 🎭 Staged Mode: ${title} Preview\n\n`; - summaryContent += `${description}\n\n`; - for (let i = 0; i < items.length; i++) { - const item = items[i]; - summaryContent += renderItem(item, i); - summaryContent += "---\n\n"; - } - try { - await core.summary.addRaw(summaryContent).write(); - core.info(summaryContent); - core.info(`📝 ${title} preview written to step summary`); - } catch (error) { - core.setFailed(error instanceof Error ? error : String(error)); - } - } - function generateXMLMarker(workflowName, runUrl) { - const engineId = process.env.GH_AW_ENGINE_ID || ""; - const engineVersion = process.env.GH_AW_ENGINE_VERSION || ""; - const engineModel = process.env.GH_AW_ENGINE_MODEL || ""; - const trackerId = process.env.GH_AW_TRACKER_ID || ""; - const parts = []; - parts.push(`agentic-workflow: ${workflowName}`); - if (trackerId) { - parts.push(`tracker-id: ${trackerId}`); - } - if (engineId) { - parts.push(`engine: ${engineId}`); - } - if (engineVersion) { - parts.push(`version: ${engineVersion}`); - } - if (engineModel) { - parts.push(`model: ${engineModel}`); - } - parts.push(`run: ${runUrl}`); - return ``; - } - function generateFooter( - workflowName, - runUrl, - workflowSource, - workflowSourceURL, - triggeringIssueNumber, - triggeringPRNumber, - triggeringDiscussionNumber - ) { - let footer = `\n\n> AI generated by [${workflowName}](${runUrl})`; - if (triggeringIssueNumber) { - footer += ` for #${triggeringIssueNumber}`; - } else if (triggeringPRNumber) { - footer += ` for #${triggeringPRNumber}`; - } else if (triggeringDiscussionNumber) { - footer += ` for discussion #${triggeringDiscussionNumber}`; - } - if (workflowSource && workflowSourceURL) { - footer += `\n>\n> To add this workflow in your repository, run \`gh aw add ${workflowSource}\`. See [usage guide](https://githubnext.github.io/gh-aw/tools/cli/).`; - } - footer += "\n\n" + generateXMLMarker(workflowName, runUrl); - footer += "\n"; - return footer; - } - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; - function generateTemporaryId() { - return "aw_" + crypto.randomBytes(6).toString("hex"); - } - function isTemporaryId(value) { - if (typeof value === "string") { - return /^aw_[0-9a-f]{12}$/i.test(value); - } - return false; - } - function normalizeTemporaryId(tempId) { - return String(tempId).toLowerCase(); - } - function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); - if (resolved !== undefined) { - if (currentRepo && resolved.repo === currentRepo) { - return `#${resolved.number}`; - } - return `${resolved.repo}#${resolved.number}`; - } - return match; - }); - } - function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); - if (issueNumber !== undefined) { - return `#${issueNumber}`; - } - return match; - }); - } - function loadTemporaryIdMap() { - const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; - if (!mapJson || mapJson === "{}") { - return new Map(); - } - try { - const mapObject = JSON.parse(mapJson); - const result = new Map(); - for (const [key, value] of Object.entries(mapObject)) { - const normalizedKey = normalizeTemporaryId(key); - if (typeof value === "number") { - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - result.set(normalizedKey, { repo: contextRepo, number: value }); - } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { - result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); - } - } - return result; - } catch (error) { - if (typeof core !== "undefined") { - core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); - } - return new Map(); - } - } - function resolveIssueNumber(value, temporaryIdMap) { - if (value === undefined || value === null) { - return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; - } - const valueStr = String(value); - if (isTemporaryId(valueStr)) { - const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); - if (resolvedPair !== undefined) { - return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; - } - return { - resolved: null, - wasTemporaryId: true, - errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, - }; - } - const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); - if (isNaN(issueNumber) || issueNumber <= 0) { - return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; - } - const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; - return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; - } - function serializeTemporaryIdMap(tempIdMap) { - const obj = Object.fromEntries(tempIdMap); - return JSON.stringify(obj); - } - function parseAllowedRepos() { - const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; - const set = new Set(); - if (allowedReposEnv) { - allowedReposEnv - .split(",") - .map(repo => repo.trim()) - .filter(repo => repo) - .forEach(repo => set.add(repo)); - } - return set; - } - function getDefaultTargetRepo() { - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - if (targetRepoSlug) { - return targetRepoSlug; - } - return `${context.repo.owner}/${context.repo.repo}`; - } - function validateRepo(repo, defaultRepo, allowedRepos) { - if (repo === defaultRepo) { - return { valid: true, error: null }; - } - if (allowedRepos.has(repo)) { - return { valid: true, error: null }; - } - return { - valid: false, - error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, - }; - } - function parseRepoSlug(repoSlug) { - const parts = repoSlug.split("/"); - if (parts.length !== 2 || !parts[0] || !parts[1]) { - return null; - } - return { owner: parts[0], repo: parts[1] }; - } - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } + const { sanitizeLabelContent } = require("./sanitize_label_content.cjs"); + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { generateStagedPreview } = require("./staged_preview.cjs"); + const { generateFooter } = require("./generate_footer.cjs"); + const { getTrackerID } = require("./get_tracker_id.cjs"); + const { + generateTemporaryId, + isTemporaryId, + normalizeTemporaryId, + replaceTemporaryIdReferences, + serializeTemporaryIdMap, + } = require("./temporary_id.cjs"); + const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require("./repo_helpers.cjs"); + const { addExpirationComment } = require("./expiration_helpers.cjs"); + const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); async function main() { core.setOutput("issue_number", ""); core.setOutput("issue_url", ""); @@ -7794,6 +7543,7 @@ jobs: .filter((label, index, arr) => arr.indexOf(label) === index); let title = createIssueItem.title ? createIssueItem.title.trim() : ""; let processedBody = replaceTemporaryIdReferences(createIssueItem.body, temporaryIdMap, itemRepo); + processedBody = removeDuplicateTitleFromDescription(title, processedBody); let bodyLines = processedBody.split("\n"); if (!title) { title = createIssueItem.body || "Agent Output"; diff --git a/.github/workflows/tidy.lock.yml b/.github/workflows/tidy.lock.yml index 7235fd0e16a..83694c84b82 100644 --- a/.github/workflows/tidy.lock.yml +++ b/.github/workflows/tidy.lock.yml @@ -6557,132 +6557,10 @@ jobs: script: | const fs = require("fs"); const crypto = require("crypto"); - async function updateActivationComment(github, context, core, itemUrl, itemNumber, itemType = "pull_request") { - const itemLabel = itemType === "issue" ? "issue" : "pull request"; - const linkMessage = - itemType === "issue" - ? `\n\n✅ Issue created: [#${itemNumber}](${itemUrl})` - : `\n\n✅ Pull request created: [#${itemNumber}](${itemUrl})`; - await updateActivationCommentWithMessage(github, context, core, linkMessage, itemLabel); - } - async function updateActivationCommentWithCommit(github, context, core, commitSha, commitUrl) { - const shortSha = commitSha.substring(0, 7); - const message = `\n\n✅ Commit pushed: [\`${shortSha}\`](${commitUrl})`; - await updateActivationCommentWithMessage(github, context, core, message, "commit"); - } - async function updateActivationCommentWithMessage(github, context, core, message, label = "") { - const commentId = process.env.GH_AW_COMMENT_ID; - const commentRepo = process.env.GH_AW_COMMENT_REPO; - if (!commentId) { - core.info("No activation comment to update (GH_AW_COMMENT_ID not set)"); - return; - } - core.info(`Updating activation comment ${commentId}`); - let repoOwner = context.repo.owner; - let repoName = context.repo.repo; - if (commentRepo) { - const parts = commentRepo.split("/"); - if (parts.length === 2) { - repoOwner = parts[0]; - repoName = parts[1]; - } else { - core.warning(`Invalid comment repo format: ${commentRepo}, expected "owner/repo". Falling back to context.repo.`); - } - } - core.info(`Updating comment in ${repoOwner}/${repoName}`); - const isDiscussionComment = commentId.startsWith("DC_"); - try { - if (isDiscussionComment) { - const currentComment = await github.graphql( - ` - query($commentId: ID!) { - node(id: $commentId) { - ... on DiscussionComment { - body - } - } - }`, - { commentId: commentId } - ); - if (!currentComment?.node?.body) { - core.warning("Unable to fetch current comment body, comment may have been deleted or is inaccessible"); - return; - } - const currentBody = currentComment.node.body; - const updatedBody = currentBody + message; - const result = await github.graphql( - ` - mutation($commentId: ID!, $body: String!) { - updateDiscussionComment(input: { commentId: $commentId, body: $body }) { - comment { - id - url - } - } - }`, - { commentId: commentId, body: updatedBody } - ); - const comment = result.updateDiscussionComment.comment; - const successMessage = label - ? `Successfully updated discussion comment with ${label} link` - : "Successfully updated discussion comment"; - core.info(successMessage); - core.info(`Comment ID: ${comment.id}`); - core.info(`Comment URL: ${comment.url}`); - } else { - const currentComment = await github.request("GET /repos/{owner}/{repo}/issues/comments/{comment_id}", { - owner: repoOwner, - repo: repoName, - comment_id: parseInt(commentId, 10), - headers: { - Accept: "application/vnd.github+json", - }, - }); - if (!currentComment?.data?.body) { - core.warning("Unable to fetch current comment body, comment may have been deleted"); - return; - } - const currentBody = currentComment.data.body; - const updatedBody = currentBody + message; - const response = await github.request("PATCH /repos/{owner}/{repo}/issues/comments/{comment_id}", { - owner: repoOwner, - repo: repoName, - comment_id: parseInt(commentId, 10), - body: updatedBody, - headers: { - Accept: "application/vnd.github+json", - }, - }); - const successMessage = label ? `Successfully updated comment with ${label} link` : "Successfully updated comment"; - core.info(successMessage); - core.info(`Comment ID: ${response.data.id}`); - core.info(`Comment URL: ${response.data.html_url}`); - } - } catch (error) { - core.warning(`Failed to update activation comment: ${error instanceof Error ? error.message : String(error)}`); - } - } - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } + const { updateActivationComment } = require("./update_activation_comment.cjs"); + const { getTrackerID } = require("./get_tracker_id.cjs"); + const { addExpirationComment } = require("./expiration_helpers.cjs"); + const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); function generatePatchPreview(patchContent) { if (!patchContent || !patchContent.trim()) { return ""; @@ -6874,7 +6752,9 @@ jobs: return; } let title = pullRequestItem.title.trim(); - let bodyLines = pullRequestItem.body.split("\n"); + let processedBody = pullRequestItem.body; + processedBody = removeDuplicateTitleFromDescription(title, processedBody); + let bodyLines = processedBody.split("\n"); let branchName = pullRequestItem.branch ? pullRequestItem.branch.trim() : null; if (!title) { title = "Agent Output"; diff --git a/.github/workflows/typist.lock.yml b/.github/workflows/typist.lock.yml index fef8aef74f4..e907ca3dd9d 100644 --- a/.github/workflows/typist.lock.yml +++ b/.github/workflows/typist.lock.yml @@ -6524,381 +6524,13 @@ jobs: with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - const fs = require("fs"); - const crypto = require("crypto"); - const MAX_LOG_CONTENT_LENGTH = 10000; - function truncateForLogging(content) { - if (content.length <= MAX_LOG_CONTENT_LENGTH) { - return content; - } - return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; - } - function loadAgentOutput() { - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; - if (!agentOutputFile) { - core.info("No GH_AW_AGENT_OUTPUT environment variable found"); - return { success: false }; - } - let outputContent; - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - return { success: false, error: errorMessage }; - } - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return { success: false }; - } - core.info(`Agent output content length: ${outputContent.length}`); - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); - return { success: false, error: errorMessage }; - } - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); - return { success: false }; - } - return { success: true, items: validatedOutput.items }; - } - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - function getMessages() { - const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES; - if (!messagesEnv) { - return null; - } - try { - return JSON.parse(messagesEnv); - } catch (error) { - core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`); - return null; - } - } - function renderTemplate(template, context) { - return template.replace(/\{(\w+)\}/g, (match, key) => { - const value = context[key]; - return value !== undefined && value !== null ? String(value) : match; - }); - } - function toSnakeCase(obj) { - const result = {}; - for (const [key, value] of Object.entries(obj)) { - const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase(); - result[snakeKey] = value; - result[key] = value; - } - return result; - } - function getCloseOlderDiscussionMessage(ctx) { - const messages = getMessages(); - const templateContext = toSnakeCase(ctx); - const defaultMessage = `⚓ Avast! This discussion be marked as **outdated** by [{workflow_name}]({run_url}). - 🗺️ A newer treasure map awaits ye at **[Discussion #{new_discussion_number}]({new_discussion_url})**. - Fair winds, matey! 🏴‍☠️`; - return messages?.closeOlderDiscussion - ? renderTemplate(messages.closeOlderDiscussion, templateContext) - : renderTemplate(defaultMessage, templateContext); - } - const MAX_CLOSE_COUNT = 10; - const GRAPHQL_DELAY_MS = 500; - function delay(ms) { - return new Promise(resolve => setTimeout(resolve, ms)); - } - async function searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, excludeNumber) { - let searchQuery = `repo:${owner}/${repo} is:open`; - if (titlePrefix) { - const escapedPrefix = titlePrefix.replace(/"/g, '\\"'); - searchQuery += ` in:title "${escapedPrefix}"`; - } - if (labels && labels.length > 0) { - for (const label of labels) { - const escapedLabel = label.replace(/"/g, '\\"'); - searchQuery += ` label:"${escapedLabel}"`; - } - } - const result = await github.graphql( - ` - query($searchTerms: String!, $first: Int!) { - search(query: $searchTerms, type: DISCUSSION, first: $first) { - nodes { - ... on Discussion { - id - number - title - url - category { - id - } - labels(first: 100) { - nodes { - name - } - } - closed - } - } - } - }`, - { searchTerms: searchQuery, first: 50 } - ); - if (!result || !result.search || !result.search.nodes) { - return []; - } - return result.search.nodes - .filter( - d => { - if (!d || d.number === excludeNumber || d.closed) { - return false; - } - if (titlePrefix && d.title && !d.title.startsWith(titlePrefix)) { - return false; - } - if (labels && labels.length > 0) { - const discussionLabels = d.labels?.nodes?.map(( l) => l.name) || []; - const hasAllLabels = labels.every(label => discussionLabels.includes(label)); - if (!hasAllLabels) { - return false; - } - } - if (categoryId && (!d.category || d.category.id !== categoryId)) { - return false; - } - return true; - } - ) - .map( - d => ({ - id: d.id, - number: d.number, - title: d.title, - url: d.url, - }) - ); - } - async function addDiscussionComment(github, discussionId, message) { - const result = await github.graphql( - ` - mutation($dId: ID!, $body: String!) { - addDiscussionComment(input: { discussionId: $dId, body: $body }) { - comment { - id - url - } - } - }`, - { dId: discussionId, body: message } - ); - return result.addDiscussionComment.comment; - } - async function closeDiscussionAsOutdated(github, discussionId) { - const result = await github.graphql( - ` - mutation($dId: ID!) { - closeDiscussion(input: { discussionId: $dId, reason: OUTDATED }) { - discussion { - id - url - } - } - }`, - { dId: discussionId } - ); - return result.closeDiscussion.discussion; - } - async function closeOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion, workflowName, runUrl) { - const searchCriteria = []; - if (titlePrefix) searchCriteria.push(`title prefix: "${titlePrefix}"`); - if (labels && labels.length > 0) searchCriteria.push(`labels: [${labels.join(", ")}]`); - core.info(`Searching for older discussions with ${searchCriteria.join(" and ")}`); - const olderDiscussions = await searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion.number); - if (olderDiscussions.length === 0) { - core.info("No older discussions found to close"); - return []; - } - core.info(`Found ${olderDiscussions.length} older discussion(s) to close`); - const discussionsToClose = olderDiscussions.slice(0, MAX_CLOSE_COUNT); - if (olderDiscussions.length > MAX_CLOSE_COUNT) { - core.warning(`Found ${olderDiscussions.length} older discussions, but only closing the first ${MAX_CLOSE_COUNT}`); - } - const closedDiscussions = []; - for (let i = 0; i < discussionsToClose.length; i++) { - const discussion = discussionsToClose[i]; - try { - const closingMessage = getCloseOlderDiscussionMessage({ - newDiscussionUrl: newDiscussion.url, - newDiscussionNumber: newDiscussion.number, - workflowName, - runUrl, - }); - core.info(`Adding closing comment to discussion #${discussion.number}`); - await addDiscussionComment(github, discussion.id, closingMessage); - core.info(`Closing discussion #${discussion.number} as outdated`); - await closeDiscussionAsOutdated(github, discussion.id); - closedDiscussions.push({ - number: discussion.number, - url: discussion.url, - }); - core.info(`✓ Closed discussion #${discussion.number}: ${discussion.url}`); - } catch (error) { - core.error(`✗ Failed to close discussion #${discussion.number}: ${error instanceof Error ? error.message : String(error)}`); - } - if (i < discussionsToClose.length - 1) { - await delay(GRAPHQL_DELAY_MS); - } - } - return closedDiscussions; - } - const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; - function generateTemporaryId() { - return "aw_" + crypto.randomBytes(6).toString("hex"); - } - function isTemporaryId(value) { - if (typeof value === "string") { - return /^aw_[0-9a-f]{12}$/i.test(value); - } - return false; - } - function normalizeTemporaryId(tempId) { - return String(tempId).toLowerCase(); - } - function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); - if (resolved !== undefined) { - if (currentRepo && resolved.repo === currentRepo) { - return `#${resolved.number}`; - } - return `${resolved.repo}#${resolved.number}`; - } - return match; - }); - } - function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); - if (issueNumber !== undefined) { - return `#${issueNumber}`; - } - return match; - }); - } - function loadTemporaryIdMap() { - const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; - if (!mapJson || mapJson === "{}") { - return new Map(); - } - try { - const mapObject = JSON.parse(mapJson); - const result = new Map(); - for (const [key, value] of Object.entries(mapObject)) { - const normalizedKey = normalizeTemporaryId(key); - if (typeof value === "number") { - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - result.set(normalizedKey, { repo: contextRepo, number: value }); - } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { - result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); - } - } - return result; - } catch (error) { - if (typeof core !== "undefined") { - core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); - } - return new Map(); - } - } - function resolveIssueNumber(value, temporaryIdMap) { - if (value === undefined || value === null) { - return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; - } - const valueStr = String(value); - if (isTemporaryId(valueStr)) { - const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); - if (resolvedPair !== undefined) { - return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; - } - return { - resolved: null, - wasTemporaryId: true, - errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, - }; - } - const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); - if (isNaN(issueNumber) || issueNumber <= 0) { - return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; - } - const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; - return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; - } - function serializeTemporaryIdMap(tempIdMap) { - const obj = Object.fromEntries(tempIdMap); - return JSON.stringify(obj); - } - function parseAllowedRepos() { - const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; - const set = new Set(); - if (allowedReposEnv) { - allowedReposEnv - .split(",") - .map(repo => repo.trim()) - .filter(repo => repo) - .forEach(repo => set.add(repo)); - } - return set; - } - function getDefaultTargetRepo() { - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - if (targetRepoSlug) { - return targetRepoSlug; - } - return `${context.repo.owner}/${context.repo.repo}`; - } - function validateRepo(repo, defaultRepo, allowedRepos) { - if (repo === defaultRepo) { - return { valid: true, error: null }; - } - if (allowedRepos.has(repo)) { - return { valid: true, error: null }; - } - return { - valid: false, - error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, - }; - } - function parseRepoSlug(repoSlug) { - const parts = repoSlug.split("/"); - if (parts.length !== 2 || !parts[0] || !parts[1]) { - return null; - } - return { owner: parts[0], repo: parts[1] }; - } - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { getTrackerID } = require("./get_tracker_id.cjs"); + const { closeOlderDiscussions } = require("./close_older_discussions.cjs"); + const { replaceTemporaryIdReferences, loadTemporaryIdMap } = require("./temporary_id.cjs"); + const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require("./repo_helpers.cjs"); + const { addExpirationComment } = require("./expiration_helpers.cjs"); + const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); async function fetchRepoDiscussionInfo(owner, repo) { const repositoryQuery = ` query($owner: String!, $repo: String!) { @@ -7083,7 +6715,9 @@ jobs: ); let title = createDiscussionItem.title ? replaceTemporaryIdReferences(createDiscussionItem.title.trim(), temporaryIdMap, itemRepo) : ""; const bodyText = createDiscussionItem.body || ""; - let bodyLines = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo).split("\n"); + let processedBody = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo); + processedBody = removeDuplicateTitleFromDescription(title, processedBody); + let bodyLines = processedBody.split("\n"); if (!title) { title = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo) || "Agent Output"; } diff --git a/.github/workflows/unbloat-docs.lock.yml b/.github/workflows/unbloat-docs.lock.yml index 13a1a55839e..9ed5c7e07f1 100644 --- a/.github/workflows/unbloat-docs.lock.yml +++ b/.github/workflows/unbloat-docs.lock.yml @@ -7321,132 +7321,10 @@ jobs: script: | const fs = require("fs"); const crypto = require("crypto"); - async function updateActivationComment(github, context, core, itemUrl, itemNumber, itemType = "pull_request") { - const itemLabel = itemType === "issue" ? "issue" : "pull request"; - const linkMessage = - itemType === "issue" - ? `\n\n✅ Issue created: [#${itemNumber}](${itemUrl})` - : `\n\n✅ Pull request created: [#${itemNumber}](${itemUrl})`; - await updateActivationCommentWithMessage(github, context, core, linkMessage, itemLabel); - } - async function updateActivationCommentWithCommit(github, context, core, commitSha, commitUrl) { - const shortSha = commitSha.substring(0, 7); - const message = `\n\n✅ Commit pushed: [\`${shortSha}\`](${commitUrl})`; - await updateActivationCommentWithMessage(github, context, core, message, "commit"); - } - async function updateActivationCommentWithMessage(github, context, core, message, label = "") { - const commentId = process.env.GH_AW_COMMENT_ID; - const commentRepo = process.env.GH_AW_COMMENT_REPO; - if (!commentId) { - core.info("No activation comment to update (GH_AW_COMMENT_ID not set)"); - return; - } - core.info(`Updating activation comment ${commentId}`); - let repoOwner = context.repo.owner; - let repoName = context.repo.repo; - if (commentRepo) { - const parts = commentRepo.split("/"); - if (parts.length === 2) { - repoOwner = parts[0]; - repoName = parts[1]; - } else { - core.warning(`Invalid comment repo format: ${commentRepo}, expected "owner/repo". Falling back to context.repo.`); - } - } - core.info(`Updating comment in ${repoOwner}/${repoName}`); - const isDiscussionComment = commentId.startsWith("DC_"); - try { - if (isDiscussionComment) { - const currentComment = await github.graphql( - ` - query($commentId: ID!) { - node(id: $commentId) { - ... on DiscussionComment { - body - } - } - }`, - { commentId: commentId } - ); - if (!currentComment?.node?.body) { - core.warning("Unable to fetch current comment body, comment may have been deleted or is inaccessible"); - return; - } - const currentBody = currentComment.node.body; - const updatedBody = currentBody + message; - const result = await github.graphql( - ` - mutation($commentId: ID!, $body: String!) { - updateDiscussionComment(input: { commentId: $commentId, body: $body }) { - comment { - id - url - } - } - }`, - { commentId: commentId, body: updatedBody } - ); - const comment = result.updateDiscussionComment.comment; - const successMessage = label - ? `Successfully updated discussion comment with ${label} link` - : "Successfully updated discussion comment"; - core.info(successMessage); - core.info(`Comment ID: ${comment.id}`); - core.info(`Comment URL: ${comment.url}`); - } else { - const currentComment = await github.request("GET /repos/{owner}/{repo}/issues/comments/{comment_id}", { - owner: repoOwner, - repo: repoName, - comment_id: parseInt(commentId, 10), - headers: { - Accept: "application/vnd.github+json", - }, - }); - if (!currentComment?.data?.body) { - core.warning("Unable to fetch current comment body, comment may have been deleted"); - return; - } - const currentBody = currentComment.data.body; - const updatedBody = currentBody + message; - const response = await github.request("PATCH /repos/{owner}/{repo}/issues/comments/{comment_id}", { - owner: repoOwner, - repo: repoName, - comment_id: parseInt(commentId, 10), - body: updatedBody, - headers: { - Accept: "application/vnd.github+json", - }, - }); - const successMessage = label ? `Successfully updated comment with ${label} link` : "Successfully updated comment"; - core.info(successMessage); - core.info(`Comment ID: ${response.data.id}`); - core.info(`Comment URL: ${response.data.html_url}`); - } - } catch (error) { - core.warning(`Failed to update activation comment: ${error instanceof Error ? error.message : String(error)}`); - } - } - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } + const { updateActivationComment } = require("./update_activation_comment.cjs"); + const { getTrackerID } = require("./get_tracker_id.cjs"); + const { addExpirationComment } = require("./expiration_helpers.cjs"); + const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); function generatePatchPreview(patchContent) { if (!patchContent || !patchContent.trim()) { return ""; @@ -7638,7 +7516,9 @@ jobs: return; } let title = pullRequestItem.title.trim(); - let bodyLines = pullRequestItem.body.split("\n"); + let processedBody = pullRequestItem.body; + processedBody = removeDuplicateTitleFromDescription(title, processedBody); + let bodyLines = processedBody.split("\n"); let branchName = pullRequestItem.branch ? pullRequestItem.branch.trim() : null; if (!title) { title = "Agent Output"; diff --git a/.github/workflows/video-analyzer.lock.yml b/.github/workflows/video-analyzer.lock.yml index 827e1dd03a1..61e1545ea40 100644 --- a/.github/workflows/video-analyzer.lock.yml +++ b/.github/workflows/video-analyzer.lock.yml @@ -6435,272 +6435,21 @@ jobs: with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - function sanitizeLabelContent(content) { - if (!content || typeof content !== "string") { - return ""; - } - let sanitized = content.trim(); - sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); - sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); - sanitized = sanitized.replace( - /(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, - (_m, p1, p2) => `${p1}\`@${p2}\`` - ); - sanitized = sanitized.replace(/[<>&'"]/g, ""); - return sanitized.trim(); - } - const fs = require("fs"); - const crypto = require("crypto"); - const MAX_LOG_CONTENT_LENGTH = 10000; - function truncateForLogging(content) { - if (content.length <= MAX_LOG_CONTENT_LENGTH) { - return content; - } - return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; - } - function loadAgentOutput() { - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; - if (!agentOutputFile) { - core.info("No GH_AW_AGENT_OUTPUT environment variable found"); - return { success: false }; - } - let outputContent; - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - return { success: false, error: errorMessage }; - } - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return { success: false }; - } - core.info(`Agent output content length: ${outputContent.length}`); - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); - return { success: false, error: errorMessage }; - } - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); - return { success: false }; - } - return { success: true, items: validatedOutput.items }; - } - async function generateStagedPreview(options) { - const { title, description, items, renderItem } = options; - let summaryContent = `## 🎭 Staged Mode: ${title} Preview\n\n`; - summaryContent += `${description}\n\n`; - for (let i = 0; i < items.length; i++) { - const item = items[i]; - summaryContent += renderItem(item, i); - summaryContent += "---\n\n"; - } - try { - await core.summary.addRaw(summaryContent).write(); - core.info(summaryContent); - core.info(`📝 ${title} preview written to step summary`); - } catch (error) { - core.setFailed(error instanceof Error ? error : String(error)); - } - } - function generateXMLMarker(workflowName, runUrl) { - const engineId = process.env.GH_AW_ENGINE_ID || ""; - const engineVersion = process.env.GH_AW_ENGINE_VERSION || ""; - const engineModel = process.env.GH_AW_ENGINE_MODEL || ""; - const trackerId = process.env.GH_AW_TRACKER_ID || ""; - const parts = []; - parts.push(`agentic-workflow: ${workflowName}`); - if (trackerId) { - parts.push(`tracker-id: ${trackerId}`); - } - if (engineId) { - parts.push(`engine: ${engineId}`); - } - if (engineVersion) { - parts.push(`version: ${engineVersion}`); - } - if (engineModel) { - parts.push(`model: ${engineModel}`); - } - parts.push(`run: ${runUrl}`); - return ``; - } - function generateFooter( - workflowName, - runUrl, - workflowSource, - workflowSourceURL, - triggeringIssueNumber, - triggeringPRNumber, - triggeringDiscussionNumber - ) { - let footer = `\n\n> AI generated by [${workflowName}](${runUrl})`; - if (triggeringIssueNumber) { - footer += ` for #${triggeringIssueNumber}`; - } else if (triggeringPRNumber) { - footer += ` for #${triggeringPRNumber}`; - } else if (triggeringDiscussionNumber) { - footer += ` for discussion #${triggeringDiscussionNumber}`; - } - if (workflowSource && workflowSourceURL) { - footer += `\n>\n> To add this workflow in your repository, run \`gh aw add ${workflowSource}\`. See [usage guide](https://githubnext.github.io/gh-aw/tools/cli/).`; - } - footer += "\n\n" + generateXMLMarker(workflowName, runUrl); - footer += "\n"; - return footer; - } - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; - function generateTemporaryId() { - return "aw_" + crypto.randomBytes(6).toString("hex"); - } - function isTemporaryId(value) { - if (typeof value === "string") { - return /^aw_[0-9a-f]{12}$/i.test(value); - } - return false; - } - function normalizeTemporaryId(tempId) { - return String(tempId).toLowerCase(); - } - function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); - if (resolved !== undefined) { - if (currentRepo && resolved.repo === currentRepo) { - return `#${resolved.number}`; - } - return `${resolved.repo}#${resolved.number}`; - } - return match; - }); - } - function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); - if (issueNumber !== undefined) { - return `#${issueNumber}`; - } - return match; - }); - } - function loadTemporaryIdMap() { - const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; - if (!mapJson || mapJson === "{}") { - return new Map(); - } - try { - const mapObject = JSON.parse(mapJson); - const result = new Map(); - for (const [key, value] of Object.entries(mapObject)) { - const normalizedKey = normalizeTemporaryId(key); - if (typeof value === "number") { - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - result.set(normalizedKey, { repo: contextRepo, number: value }); - } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { - result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); - } - } - return result; - } catch (error) { - if (typeof core !== "undefined") { - core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); - } - return new Map(); - } - } - function resolveIssueNumber(value, temporaryIdMap) { - if (value === undefined || value === null) { - return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; - } - const valueStr = String(value); - if (isTemporaryId(valueStr)) { - const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); - if (resolvedPair !== undefined) { - return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; - } - return { - resolved: null, - wasTemporaryId: true, - errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, - }; - } - const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); - if (isNaN(issueNumber) || issueNumber <= 0) { - return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; - } - const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; - return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; - } - function serializeTemporaryIdMap(tempIdMap) { - const obj = Object.fromEntries(tempIdMap); - return JSON.stringify(obj); - } - function parseAllowedRepos() { - const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; - const set = new Set(); - if (allowedReposEnv) { - allowedReposEnv - .split(",") - .map(repo => repo.trim()) - .filter(repo => repo) - .forEach(repo => set.add(repo)); - } - return set; - } - function getDefaultTargetRepo() { - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - if (targetRepoSlug) { - return targetRepoSlug; - } - return `${context.repo.owner}/${context.repo.repo}`; - } - function validateRepo(repo, defaultRepo, allowedRepos) { - if (repo === defaultRepo) { - return { valid: true, error: null }; - } - if (allowedRepos.has(repo)) { - return { valid: true, error: null }; - } - return { - valid: false, - error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, - }; - } - function parseRepoSlug(repoSlug) { - const parts = repoSlug.split("/"); - if (parts.length !== 2 || !parts[0] || !parts[1]) { - return null; - } - return { owner: parts[0], repo: parts[1] }; - } - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } + const { sanitizeLabelContent } = require("./sanitize_label_content.cjs"); + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { generateStagedPreview } = require("./staged_preview.cjs"); + const { generateFooter } = require("./generate_footer.cjs"); + const { getTrackerID } = require("./get_tracker_id.cjs"); + const { + generateTemporaryId, + isTemporaryId, + normalizeTemporaryId, + replaceTemporaryIdReferences, + serializeTemporaryIdMap, + } = require("./temporary_id.cjs"); + const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require("./repo_helpers.cjs"); + const { addExpirationComment } = require("./expiration_helpers.cjs"); + const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); async function main() { core.setOutput("issue_number", ""); core.setOutput("issue_url", ""); @@ -6833,6 +6582,7 @@ jobs: .filter((label, index, arr) => arr.indexOf(label) === index); let title = createIssueItem.title ? createIssueItem.title.trim() : ""; let processedBody = replaceTemporaryIdReferences(createIssueItem.body, temporaryIdMap, itemRepo); + processedBody = removeDuplicateTitleFromDescription(title, processedBody); let bodyLines = processedBody.split("\n"); if (!title) { title = createIssueItem.body || "Agent Output"; diff --git a/.github/workflows/weekly-issue-summary.lock.yml b/.github/workflows/weekly-issue-summary.lock.yml index 67ebf5f3b6c..609429a4669 100644 --- a/.github/workflows/weekly-issue-summary.lock.yml +++ b/.github/workflows/weekly-issue-summary.lock.yml @@ -7246,381 +7246,13 @@ jobs: with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - const fs = require("fs"); - const crypto = require("crypto"); - const MAX_LOG_CONTENT_LENGTH = 10000; - function truncateForLogging(content) { - if (content.length <= MAX_LOG_CONTENT_LENGTH) { - return content; - } - return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; - } - function loadAgentOutput() { - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; - if (!agentOutputFile) { - core.info("No GH_AW_AGENT_OUTPUT environment variable found"); - return { success: false }; - } - let outputContent; - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - return { success: false, error: errorMessage }; - } - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return { success: false }; - } - core.info(`Agent output content length: ${outputContent.length}`); - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); - return { success: false, error: errorMessage }; - } - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); - return { success: false }; - } - return { success: true, items: validatedOutput.items }; - } - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - function getMessages() { - const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES; - if (!messagesEnv) { - return null; - } - try { - return JSON.parse(messagesEnv); - } catch (error) { - core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`); - return null; - } - } - function renderTemplate(template, context) { - return template.replace(/\{(\w+)\}/g, (match, key) => { - const value = context[key]; - return value !== undefined && value !== null ? String(value) : match; - }); - } - function toSnakeCase(obj) { - const result = {}; - for (const [key, value] of Object.entries(obj)) { - const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase(); - result[snakeKey] = value; - result[key] = value; - } - return result; - } - function getCloseOlderDiscussionMessage(ctx) { - const messages = getMessages(); - const templateContext = toSnakeCase(ctx); - const defaultMessage = `⚓ Avast! This discussion be marked as **outdated** by [{workflow_name}]({run_url}). - 🗺️ A newer treasure map awaits ye at **[Discussion #{new_discussion_number}]({new_discussion_url})**. - Fair winds, matey! 🏴‍☠️`; - return messages?.closeOlderDiscussion - ? renderTemplate(messages.closeOlderDiscussion, templateContext) - : renderTemplate(defaultMessage, templateContext); - } - const MAX_CLOSE_COUNT = 10; - const GRAPHQL_DELAY_MS = 500; - function delay(ms) { - return new Promise(resolve => setTimeout(resolve, ms)); - } - async function searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, excludeNumber) { - let searchQuery = `repo:${owner}/${repo} is:open`; - if (titlePrefix) { - const escapedPrefix = titlePrefix.replace(/"/g, '\\"'); - searchQuery += ` in:title "${escapedPrefix}"`; - } - if (labels && labels.length > 0) { - for (const label of labels) { - const escapedLabel = label.replace(/"/g, '\\"'); - searchQuery += ` label:"${escapedLabel}"`; - } - } - const result = await github.graphql( - ` - query($searchTerms: String!, $first: Int!) { - search(query: $searchTerms, type: DISCUSSION, first: $first) { - nodes { - ... on Discussion { - id - number - title - url - category { - id - } - labels(first: 100) { - nodes { - name - } - } - closed - } - } - } - }`, - { searchTerms: searchQuery, first: 50 } - ); - if (!result || !result.search || !result.search.nodes) { - return []; - } - return result.search.nodes - .filter( - d => { - if (!d || d.number === excludeNumber || d.closed) { - return false; - } - if (titlePrefix && d.title && !d.title.startsWith(titlePrefix)) { - return false; - } - if (labels && labels.length > 0) { - const discussionLabels = d.labels?.nodes?.map(( l) => l.name) || []; - const hasAllLabels = labels.every(label => discussionLabels.includes(label)); - if (!hasAllLabels) { - return false; - } - } - if (categoryId && (!d.category || d.category.id !== categoryId)) { - return false; - } - return true; - } - ) - .map( - d => ({ - id: d.id, - number: d.number, - title: d.title, - url: d.url, - }) - ); - } - async function addDiscussionComment(github, discussionId, message) { - const result = await github.graphql( - ` - mutation($dId: ID!, $body: String!) { - addDiscussionComment(input: { discussionId: $dId, body: $body }) { - comment { - id - url - } - } - }`, - { dId: discussionId, body: message } - ); - return result.addDiscussionComment.comment; - } - async function closeDiscussionAsOutdated(github, discussionId) { - const result = await github.graphql( - ` - mutation($dId: ID!) { - closeDiscussion(input: { discussionId: $dId, reason: OUTDATED }) { - discussion { - id - url - } - } - }`, - { dId: discussionId } - ); - return result.closeDiscussion.discussion; - } - async function closeOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion, workflowName, runUrl) { - const searchCriteria = []; - if (titlePrefix) searchCriteria.push(`title prefix: "${titlePrefix}"`); - if (labels && labels.length > 0) searchCriteria.push(`labels: [${labels.join(", ")}]`); - core.info(`Searching for older discussions with ${searchCriteria.join(" and ")}`); - const olderDiscussions = await searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion.number); - if (olderDiscussions.length === 0) { - core.info("No older discussions found to close"); - return []; - } - core.info(`Found ${olderDiscussions.length} older discussion(s) to close`); - const discussionsToClose = olderDiscussions.slice(0, MAX_CLOSE_COUNT); - if (olderDiscussions.length > MAX_CLOSE_COUNT) { - core.warning(`Found ${olderDiscussions.length} older discussions, but only closing the first ${MAX_CLOSE_COUNT}`); - } - const closedDiscussions = []; - for (let i = 0; i < discussionsToClose.length; i++) { - const discussion = discussionsToClose[i]; - try { - const closingMessage = getCloseOlderDiscussionMessage({ - newDiscussionUrl: newDiscussion.url, - newDiscussionNumber: newDiscussion.number, - workflowName, - runUrl, - }); - core.info(`Adding closing comment to discussion #${discussion.number}`); - await addDiscussionComment(github, discussion.id, closingMessage); - core.info(`Closing discussion #${discussion.number} as outdated`); - await closeDiscussionAsOutdated(github, discussion.id); - closedDiscussions.push({ - number: discussion.number, - url: discussion.url, - }); - core.info(`✓ Closed discussion #${discussion.number}: ${discussion.url}`); - } catch (error) { - core.error(`✗ Failed to close discussion #${discussion.number}: ${error instanceof Error ? error.message : String(error)}`); - } - if (i < discussionsToClose.length - 1) { - await delay(GRAPHQL_DELAY_MS); - } - } - return closedDiscussions; - } - const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; - function generateTemporaryId() { - return "aw_" + crypto.randomBytes(6).toString("hex"); - } - function isTemporaryId(value) { - if (typeof value === "string") { - return /^aw_[0-9a-f]{12}$/i.test(value); - } - return false; - } - function normalizeTemporaryId(tempId) { - return String(tempId).toLowerCase(); - } - function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); - if (resolved !== undefined) { - if (currentRepo && resolved.repo === currentRepo) { - return `#${resolved.number}`; - } - return `${resolved.repo}#${resolved.number}`; - } - return match; - }); - } - function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); - if (issueNumber !== undefined) { - return `#${issueNumber}`; - } - return match; - }); - } - function loadTemporaryIdMap() { - const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; - if (!mapJson || mapJson === "{}") { - return new Map(); - } - try { - const mapObject = JSON.parse(mapJson); - const result = new Map(); - for (const [key, value] of Object.entries(mapObject)) { - const normalizedKey = normalizeTemporaryId(key); - if (typeof value === "number") { - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - result.set(normalizedKey, { repo: contextRepo, number: value }); - } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { - result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); - } - } - return result; - } catch (error) { - if (typeof core !== "undefined") { - core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); - } - return new Map(); - } - } - function resolveIssueNumber(value, temporaryIdMap) { - if (value === undefined || value === null) { - return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; - } - const valueStr = String(value); - if (isTemporaryId(valueStr)) { - const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); - if (resolvedPair !== undefined) { - return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; - } - return { - resolved: null, - wasTemporaryId: true, - errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, - }; - } - const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); - if (isNaN(issueNumber) || issueNumber <= 0) { - return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; - } - const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; - return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; - } - function serializeTemporaryIdMap(tempIdMap) { - const obj = Object.fromEntries(tempIdMap); - return JSON.stringify(obj); - } - function parseAllowedRepos() { - const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; - const set = new Set(); - if (allowedReposEnv) { - allowedReposEnv - .split(",") - .map(repo => repo.trim()) - .filter(repo => repo) - .forEach(repo => set.add(repo)); - } - return set; - } - function getDefaultTargetRepo() { - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - if (targetRepoSlug) { - return targetRepoSlug; - } - return `${context.repo.owner}/${context.repo.repo}`; - } - function validateRepo(repo, defaultRepo, allowedRepos) { - if (repo === defaultRepo) { - return { valid: true, error: null }; - } - if (allowedRepos.has(repo)) { - return { valid: true, error: null }; - } - return { - valid: false, - error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, - }; - } - function parseRepoSlug(repoSlug) { - const parts = repoSlug.split("/"); - if (parts.length !== 2 || !parts[0] || !parts[1]) { - return null; - } - return { owner: parts[0], repo: parts[1] }; - } - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { getTrackerID } = require("./get_tracker_id.cjs"); + const { closeOlderDiscussions } = require("./close_older_discussions.cjs"); + const { replaceTemporaryIdReferences, loadTemporaryIdMap } = require("./temporary_id.cjs"); + const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require("./repo_helpers.cjs"); + const { addExpirationComment } = require("./expiration_helpers.cjs"); + const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); async function fetchRepoDiscussionInfo(owner, repo) { const repositoryQuery = ` query($owner: String!, $repo: String!) { @@ -7805,7 +7437,9 @@ jobs: ); let title = createDiscussionItem.title ? replaceTemporaryIdReferences(createDiscussionItem.title.trim(), temporaryIdMap, itemRepo) : ""; const bodyText = createDiscussionItem.body || ""; - let bodyLines = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo).split("\n"); + let processedBody = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo); + processedBody = removeDuplicateTitleFromDescription(title, processedBody); + let bodyLines = processedBody.split("\n"); if (!title) { title = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo) || "Agent Output"; } diff --git a/pkg/workflow/js/create_pull_request.test.cjs b/pkg/workflow/js/create_pull_request.test.cjs index c2beb91094a..dbafbc330af 100644 --- a/pkg/workflow/js/create_pull_request.test.cjs +++ b/pkg/workflow/js/create_pull_request.test.cjs @@ -18,7 +18,10 @@ const createTestableFunction = scriptContent => { scriptBody = scriptBody.replace(/const \{ updateActivationComment \} = require\("\.\/update_activation_comment\.cjs"\);?\s*/g, ""); scriptBody = scriptBody.replace(/const \{ getTrackerID \} = require\("\.\/get_tracker_id\.cjs"\);?\s*/g, ""); scriptBody = scriptBody.replace(/const \{ addExpirationComment \} = require\("\.\/expiration_helpers\.cjs"\);?\s*/g, ""); - scriptBody = scriptBody.replace(/const \{ removeDuplicateTitleFromDescription \} = require\("\.\/remove_duplicate_title\.cjs"\);?\s*/g, ""); + scriptBody = scriptBody.replace( + /const \{ removeDuplicateTitleFromDescription \} = require\("\.\/remove_duplicate_title\.cjs"\);?\s*/g, + "" + ); // Create a testable function that has the same logic but can be called with dependencies return new Function(` diff --git a/pkg/workflow/js/remove_duplicate_title.test.cjs b/pkg/workflow/js/remove_duplicate_title.test.cjs index da1bbaff5e0..ac38eb947a8 100644 --- a/pkg/workflow/js/remove_duplicate_title.test.cjs +++ b/pkg/workflow/js/remove_duplicate_title.test.cjs @@ -316,14 +316,17 @@ describe("remove_duplicate_title.cjs", () => { describe("real-world examples", () => { it("should handle GitHub issue format", () => { const title = "Feature: Add dark mode"; - const description = "# Feature: Add dark mode\n\n## Description\n\nWe need dark mode support.\n\n## Acceptance Criteria\n\n- [ ] Dark mode toggle\n- [ ] Persistent preference"; - const expected = "## Description\n\nWe need dark mode support.\n\n## Acceptance Criteria\n\n- [ ] Dark mode toggle\n- [ ] Persistent preference"; + const description = + "# Feature: Add dark mode\n\n## Description\n\nWe need dark mode support.\n\n## Acceptance Criteria\n\n- [ ] Dark mode toggle\n- [ ] Persistent preference"; + const expected = + "## Description\n\nWe need dark mode support.\n\n## Acceptance Criteria\n\n- [ ] Dark mode toggle\n- [ ] Persistent preference"; expect(removeDuplicateTitleFromDescription(title, description)).toBe(expected); }); it("should handle pull request format", () => { const title = "Fix authentication bug"; - const description = "# Fix authentication bug\n\n## Changes\n\n- Updated auth flow\n- Added tests\n\n## Testing\n\nManually tested all scenarios."; + const description = + "# Fix authentication bug\n\n## Changes\n\n- Updated auth flow\n- Added tests\n\n## Testing\n\nManually tested all scenarios."; const expected = "## Changes\n\n- Updated auth flow\n- Added tests\n\n## Testing\n\nManually tested all scenarios."; expect(removeDuplicateTitleFromDescription(title, description)).toBe(expected); }); diff --git a/pkg/workflow/js/update_runner.cjs b/pkg/workflow/js/update_runner.cjs index 7530ff74ee3..b3737dc0a9f 100644 --- a/pkg/workflow/js/update_runner.cjs +++ b/pkg/workflow/js/update_runner.cjs @@ -122,12 +122,12 @@ function buildUpdateData(params) { if (canUpdateBody && item.body !== undefined) { if (typeof item.body === "string") { let processedBody = item.body; - + // If we're updating the title at the same time, remove duplicate title from body if (titleForDedup) { processedBody = removeDuplicateTitleFromDescription(titleForDedup, processedBody); } - + updateData.body = processedBody; hasUpdates = true; logMessages.push(`Will update body (length: ${processedBody.length})`); From cdf1997efe5f01afe2bb30b3d8039f695961a34a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 11 Dec 2025 04:10:13 +0000 Subject: [PATCH 4/5] Register remove_duplicate_title helper in script registry and bundler Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .github/workflows/artifacts-summary.lock.yml | 401 ++++- .github/workflows/audit-workflows.lock.yml | 401 ++++- .github/workflows/blog-auditor.lock.yml | 401 ++++- .../breaking-change-checker.lock.yml | 300 +++- .github/workflows/changeset.lock.yml | 453 +++++- .github/workflows/ci-coach.lock.yml | 149 +- .github/workflows/ci-doctor.lock.yml | 300 +++- .../cli-consistency-checker.lock.yml | 300 +++- .../workflows/cli-version-checker.lock.yml | 300 +++- .github/workflows/cloclo.lock.yml | 149 +- .../commit-changes-analyzer.lock.yml | 401 ++++- .../workflows/copilot-agent-analysis.lock.yml | 401 ++++- .../copilot-pr-merged-report.lock.yml | 401 ++++- .../copilot-pr-nlp-analysis.lock.yml | 401 ++++- .../copilot-pr-prompt-analysis.lock.yml | 401 ++++- .../copilot-session-insights.lock.yml | 401 ++++- .github/workflows/daily-code-metrics.lock.yml | 401 ++++- .../daily-copilot-token-report.lock.yml | 401 ++++- .github/workflows/daily-doc-updater.lock.yml | 149 +- .github/workflows/daily-file-diet.lock.yml | 300 +++- .../workflows/daily-firewall-report.lock.yml | 401 ++++- .../workflows/daily-issues-report.lock.yml | 401 ++++- .../daily-multi-device-docs-tester.lock.yml | 300 +++- .github/workflows/daily-news.lock.yml | 401 ++++- .../daily-performance-summary.lock.yml | 401 ++++- .../workflows/daily-repo-chronicle.lock.yml | 401 ++++- .../workflows/daily-workflow-updater.lock.yml | 149 +- .github/workflows/deep-report.lock.yml | 401 ++++- .../workflows/dependabot-go-checker.lock.yml | 300 +++- .github/workflows/dev.lock.yml | 149 +- .../developer-docs-consolidator.lock.yml | 550 ++++++- .github/workflows/dictation-prompt.lock.yml | 149 +- .github/workflows/docs-noob-tester.lock.yml | 401 ++++- .../duplicate-code-detector.lock.yml | 300 +++- .../example-workflow-analyzer.lock.yml | 401 ++++- .../github-mcp-structural-analysis.lock.yml | 401 ++++- .../github-mcp-tools-report.lock.yml | 550 ++++++- .../workflows/glossary-maintainer.lock.yml | 149 +- .github/workflows/go-fan.lock.yml | 401 ++++- .github/workflows/go-logger.lock.yml | 149 +- .../workflows/go-pattern-detector.lock.yml | 300 +++- .../workflows/instructions-janitor.lock.yml | 149 +- .github/workflows/issue-arborist.lock.yml | 401 ++++- .../workflows/layout-spec-maintainer.lock.yml | 149 +- .github/workflows/lockfile-stats.lock.yml | 401 ++++- .github/workflows/mcp-inspector.lock.yml | 401 ++++- .github/workflows/org-health-report.lock.yml | 401 ++++- .github/workflows/plan.lock.yml | 300 +++- .github/workflows/poem-bot.lock.yml | 1435 +++++++++++++++-- .github/workflows/portfolio-analyst.lock.yml | 401 ++++- .../workflows/pr-nitpick-reviewer.lock.yml | 401 ++++- .../prompt-clustering-analysis.lock.yml | 401 ++++- .github/workflows/python-data-charts.lock.yml | 401 ++++- .github/workflows/q.lock.yml | 149 +- .github/workflows/repo-tree-map.lock.yml | 401 ++++- .../repository-quality-improver.lock.yml | 401 ++++- .github/workflows/research.lock.yml | 401 ++++- .github/workflows/safe-output-health.lock.yml | 401 ++++- .../schema-consistency-checker.lock.yml | 401 ++++- .github/workflows/security-fix-pr.lock.yml | 149 +- .../semantic-function-refactor.lock.yml | 300 +++- .github/workflows/smoke-claude.lock.yml | 300 +++- .github/workflows/smoke-codex.lock.yml | 300 +++- .../smoke-copilot-no-firewall.lock.yml | 753 ++++++++- .../smoke-copilot-playwright.lock.yml | 300 +++- .../smoke-copilot-safe-inputs.lock.yml | 300 +++- .github/workflows/smoke-copilot.lock.yml | 300 +++- .github/workflows/smoke-detector.lock.yml | 300 +++- .github/workflows/spec-kit-execute.lock.yml | 149 +- .github/workflows/spec-kit-executor.lock.yml | 149 +- .github/workflows/speckit-dispatcher.lock.yml | 300 +++- .../workflows/stale-repo-identifier.lock.yml | 300 +++- .../workflows/static-analysis-report.lock.yml | 401 ++++- .github/workflows/super-linter.lock.yml | 300 +++- .../workflows/technical-doc-writer.lock.yml | 149 +- .../test-discussion-expires.lock.yml | 401 ++++- .../workflows/test-python-safe-input.lock.yml | 300 +++- .github/workflows/tidy.lock.yml | 149 +- .github/workflows/typist.lock.yml | 401 ++++- .github/workflows/unbloat-docs.lock.yml | 149 +- .github/workflows/video-analyzer.lock.yml | 300 +++- .../workflows/weekly-issue-summary.lock.yml | 401 ++++- pkg/workflow/js.go | 4 + 83 files changed, 27237 insertions(+), 879 deletions(-) diff --git a/.github/workflows/artifacts-summary.lock.yml b/.github/workflows/artifacts-summary.lock.yml index 32254e5cfeb..a51bed6e3cb 100644 --- a/.github/workflows/artifacts-summary.lock.yml +++ b/.github/workflows/artifacts-summary.lock.yml @@ -6185,13 +6185,400 @@ jobs: with: github-token: ${{ steps.app-token.outputs.token }} script: | - const { loadAgentOutput } = require("./load_agent_output.cjs"); - const { getTrackerID } = require("./get_tracker_id.cjs"); - const { closeOlderDiscussions } = require("./close_older_discussions.cjs"); - const { replaceTemporaryIdReferences, loadTemporaryIdMap } = require("./temporary_id.cjs"); - const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require("./repo_helpers.cjs"); - const { addExpirationComment } = require("./expiration_helpers.cjs"); - const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); + const fs = require("fs"); + const crypto = require("crypto"); + const MAX_LOG_CONTENT_LENGTH = 10000; + function truncateForLogging(content) { + if (content.length <= MAX_LOG_CONTENT_LENGTH) { + return content; + } + return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; + } + function loadAgentOutput() { + const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; + if (!agentOutputFile) { + core.info("No GH_AW_AGENT_OUTPUT environment variable found"); + return { success: false }; + } + let outputContent; + try { + outputContent = fs.readFileSync(agentOutputFile, "utf8"); + } catch (error) { + const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; + core.error(errorMessage); + return { success: false, error: errorMessage }; + } + if (outputContent.trim() === "") { + core.info("Agent output content is empty"); + return { success: false }; + } + core.info(`Agent output content length: ${outputContent.length}`); + let validatedOutput; + try { + validatedOutput = JSON.parse(outputContent); + } catch (error) { + const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; + core.error(errorMessage); + core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); + return { success: false, error: errorMessage }; + } + if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { + core.info("No valid items found in agent output"); + core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); + return { success: false }; + } + return { success: true, items: validatedOutput.items }; + } + function getTrackerID(format) { + const trackerID = process.env.GH_AW_TRACKER_ID || ""; + if (trackerID) { + core.info(`Tracker ID: ${trackerID}`); + return format === "markdown" ? `\n\n` : trackerID; + } + return ""; + } + function getMessages() { + const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES; + if (!messagesEnv) { + return null; + } + try { + return JSON.parse(messagesEnv); + } catch (error) { + core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`); + return null; + } + } + function renderTemplate(template, context) { + return template.replace(/\{(\w+)\}/g, (match, key) => { + const value = context[key]; + return value !== undefined && value !== null ? String(value) : match; + }); + } + function toSnakeCase(obj) { + const result = {}; + for (const [key, value] of Object.entries(obj)) { + const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase(); + result[snakeKey] = value; + result[key] = value; + } + return result; + } + function getCloseOlderDiscussionMessage(ctx) { + const messages = getMessages(); + const templateContext = toSnakeCase(ctx); + const defaultMessage = `⚓ Avast! This discussion be marked as **outdated** by [{workflow_name}]({run_url}). + 🗺️ A newer treasure map awaits ye at **[Discussion #{new_discussion_number}]({new_discussion_url})**. + Fair winds, matey! 🏴‍☠️`; + return messages?.closeOlderDiscussion + ? renderTemplate(messages.closeOlderDiscussion, templateContext) + : renderTemplate(defaultMessage, templateContext); + } + const MAX_CLOSE_COUNT = 10; + const GRAPHQL_DELAY_MS = 500; + function delay(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); + } + async function searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, excludeNumber) { + let searchQuery = `repo:${owner}/${repo} is:open`; + if (titlePrefix) { + const escapedPrefix = titlePrefix.replace(/"/g, '\\"'); + searchQuery += ` in:title "${escapedPrefix}"`; + } + if (labels && labels.length > 0) { + for (const label of labels) { + const escapedLabel = label.replace(/"/g, '\\"'); + searchQuery += ` label:"${escapedLabel}"`; + } + } + const result = await github.graphql( + ` + query($searchTerms: String!, $first: Int!) { + search(query: $searchTerms, type: DISCUSSION, first: $first) { + nodes { + ... on Discussion { + id + number + title + url + category { + id + } + labels(first: 100) { + nodes { + name + } + } + closed + } + } + } + }`, + { searchTerms: searchQuery, first: 50 } + ); + if (!result || !result.search || !result.search.nodes) { + return []; + } + return result.search.nodes + .filter( + d => { + if (!d || d.number === excludeNumber || d.closed) { + return false; + } + if (titlePrefix && d.title && !d.title.startsWith(titlePrefix)) { + return false; + } + if (labels && labels.length > 0) { + const discussionLabels = d.labels?.nodes?.map(( l) => l.name) || []; + const hasAllLabels = labels.every(label => discussionLabels.includes(label)); + if (!hasAllLabels) { + return false; + } + } + if (categoryId && (!d.category || d.category.id !== categoryId)) { + return false; + } + return true; + } + ) + .map( + d => ({ + id: d.id, + number: d.number, + title: d.title, + url: d.url, + }) + ); + } + async function addDiscussionComment(github, discussionId, message) { + const result = await github.graphql( + ` + mutation($dId: ID!, $body: String!) { + addDiscussionComment(input: { discussionId: $dId, body: $body }) { + comment { + id + url + } + } + }`, + { dId: discussionId, body: message } + ); + return result.addDiscussionComment.comment; + } + async function closeDiscussionAsOutdated(github, discussionId) { + const result = await github.graphql( + ` + mutation($dId: ID!) { + closeDiscussion(input: { discussionId: $dId, reason: OUTDATED }) { + discussion { + id + url + } + } + }`, + { dId: discussionId } + ); + return result.closeDiscussion.discussion; + } + async function closeOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion, workflowName, runUrl) { + const searchCriteria = []; + if (titlePrefix) searchCriteria.push(`title prefix: "${titlePrefix}"`); + if (labels && labels.length > 0) searchCriteria.push(`labels: [${labels.join(", ")}]`); + core.info(`Searching for older discussions with ${searchCriteria.join(" and ")}`); + const olderDiscussions = await searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion.number); + if (olderDiscussions.length === 0) { + core.info("No older discussions found to close"); + return []; + } + core.info(`Found ${olderDiscussions.length} older discussion(s) to close`); + const discussionsToClose = olderDiscussions.slice(0, MAX_CLOSE_COUNT); + if (olderDiscussions.length > MAX_CLOSE_COUNT) { + core.warning(`Found ${olderDiscussions.length} older discussions, but only closing the first ${MAX_CLOSE_COUNT}`); + } + const closedDiscussions = []; + for (let i = 0; i < discussionsToClose.length; i++) { + const discussion = discussionsToClose[i]; + try { + const closingMessage = getCloseOlderDiscussionMessage({ + newDiscussionUrl: newDiscussion.url, + newDiscussionNumber: newDiscussion.number, + workflowName, + runUrl, + }); + core.info(`Adding closing comment to discussion #${discussion.number}`); + await addDiscussionComment(github, discussion.id, closingMessage); + core.info(`Closing discussion #${discussion.number} as outdated`); + await closeDiscussionAsOutdated(github, discussion.id); + closedDiscussions.push({ + number: discussion.number, + url: discussion.url, + }); + core.info(`✓ Closed discussion #${discussion.number}: ${discussion.url}`); + } catch (error) { + core.error(`✗ Failed to close discussion #${discussion.number}: ${error instanceof Error ? error.message : String(error)}`); + } + if (i < discussionsToClose.length - 1) { + await delay(GRAPHQL_DELAY_MS); + } + } + return closedDiscussions; + } + const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; + function generateTemporaryId() { + return "aw_" + crypto.randomBytes(6).toString("hex"); + } + function isTemporaryId(value) { + if (typeof value === "string") { + return /^aw_[0-9a-f]{12}$/i.test(value); + } + return false; + } + function normalizeTemporaryId(tempId) { + return String(tempId).toLowerCase(); + } + function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { + return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { + const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); + if (resolved !== undefined) { + if (currentRepo && resolved.repo === currentRepo) { + return `#${resolved.number}`; + } + return `${resolved.repo}#${resolved.number}`; + } + return match; + }); + } + function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { + return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { + const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); + if (issueNumber !== undefined) { + return `#${issueNumber}`; + } + return match; + }); + } + function loadTemporaryIdMap() { + const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; + if (!mapJson || mapJson === "{}") { + return new Map(); + } + try { + const mapObject = JSON.parse(mapJson); + const result = new Map(); + for (const [key, value] of Object.entries(mapObject)) { + const normalizedKey = normalizeTemporaryId(key); + if (typeof value === "number") { + const contextRepo = `${context.repo.owner}/${context.repo.repo}`; + result.set(normalizedKey, { repo: contextRepo, number: value }); + } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { + result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); + } + } + return result; + } catch (error) { + if (typeof core !== "undefined") { + core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); + } + return new Map(); + } + } + function resolveIssueNumber(value, temporaryIdMap) { + if (value === undefined || value === null) { + return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; + } + const valueStr = String(value); + if (isTemporaryId(valueStr)) { + const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); + if (resolvedPair !== undefined) { + return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; + } + return { + resolved: null, + wasTemporaryId: true, + errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, + }; + } + const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); + if (isNaN(issueNumber) || issueNumber <= 0) { + return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; + } + const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; + return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; + } + function serializeTemporaryIdMap(tempIdMap) { + const obj = Object.fromEntries(tempIdMap); + return JSON.stringify(obj); + } + function parseAllowedRepos() { + const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; + const set = new Set(); + if (allowedReposEnv) { + allowedReposEnv + .split(",") + .map(repo => repo.trim()) + .filter(repo => repo) + .forEach(repo => set.add(repo)); + } + return set; + } + function getDefaultTargetRepo() { + const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; + if (targetRepoSlug) { + return targetRepoSlug; + } + return `${context.repo.owner}/${context.repo.repo}`; + } + function validateRepo(repo, defaultRepo, allowedRepos) { + if (repo === defaultRepo) { + return { valid: true, error: null }; + } + if (allowedRepos.has(repo)) { + return { valid: true, error: null }; + } + return { + valid: false, + error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, + }; + } + function parseRepoSlug(repoSlug) { + const parts = repoSlug.split("/"); + if (parts.length !== 2 || !parts[0] || !parts[1]) { + return null; + } + return { owner: parts[0], repo: parts[1] }; + } + function addExpirationComment(bodyLines, envVarName, entityType) { + const expiresEnv = process.env[envVarName]; + if (expiresEnv) { + const expiresDays = parseInt(expiresEnv, 10); + if (!isNaN(expiresDays) && expiresDays > 0) { + const expirationDate = new Date(); + expirationDate.setDate(expirationDate.getDate() + expiresDays); + const expirationISO = expirationDate.toISOString(); + bodyLines.push(``); + core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); + } + } + } + function removeDuplicateTitleFromDescription(title, description) { + if (!title || typeof title !== "string") { + return description || ""; + } + if (!description || typeof description !== "string") { + return ""; + } + const trimmedTitle = title.trim(); + const trimmedDescription = description.trim(); + if (!trimmedTitle || !trimmedDescription) { + return trimmedDescription; + } + const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); + if (headerRegex.test(trimmedDescription)) { + return trimmedDescription.replace(headerRegex, "").trim(); + } + return trimmedDescription; + } async function fetchRepoDiscussionInfo(owner, repo) { const repositoryQuery = ` query($owner: String!, $repo: String!) { diff --git a/.github/workflows/audit-workflows.lock.yml b/.github/workflows/audit-workflows.lock.yml index 4230223dd7d..e5bfc987b1b 100644 --- a/.github/workflows/audit-workflows.lock.yml +++ b/.github/workflows/audit-workflows.lock.yml @@ -7007,13 +7007,400 @@ jobs: with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - const { loadAgentOutput } = require("./load_agent_output.cjs"); - const { getTrackerID } = require("./get_tracker_id.cjs"); - const { closeOlderDiscussions } = require("./close_older_discussions.cjs"); - const { replaceTemporaryIdReferences, loadTemporaryIdMap } = require("./temporary_id.cjs"); - const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require("./repo_helpers.cjs"); - const { addExpirationComment } = require("./expiration_helpers.cjs"); - const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); + const fs = require("fs"); + const crypto = require("crypto"); + const MAX_LOG_CONTENT_LENGTH = 10000; + function truncateForLogging(content) { + if (content.length <= MAX_LOG_CONTENT_LENGTH) { + return content; + } + return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; + } + function loadAgentOutput() { + const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; + if (!agentOutputFile) { + core.info("No GH_AW_AGENT_OUTPUT environment variable found"); + return { success: false }; + } + let outputContent; + try { + outputContent = fs.readFileSync(agentOutputFile, "utf8"); + } catch (error) { + const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; + core.error(errorMessage); + return { success: false, error: errorMessage }; + } + if (outputContent.trim() === "") { + core.info("Agent output content is empty"); + return { success: false }; + } + core.info(`Agent output content length: ${outputContent.length}`); + let validatedOutput; + try { + validatedOutput = JSON.parse(outputContent); + } catch (error) { + const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; + core.error(errorMessage); + core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); + return { success: false, error: errorMessage }; + } + if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { + core.info("No valid items found in agent output"); + core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); + return { success: false }; + } + return { success: true, items: validatedOutput.items }; + } + function getTrackerID(format) { + const trackerID = process.env.GH_AW_TRACKER_ID || ""; + if (trackerID) { + core.info(`Tracker ID: ${trackerID}`); + return format === "markdown" ? `\n\n` : trackerID; + } + return ""; + } + function getMessages() { + const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES; + if (!messagesEnv) { + return null; + } + try { + return JSON.parse(messagesEnv); + } catch (error) { + core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`); + return null; + } + } + function renderTemplate(template, context) { + return template.replace(/\{(\w+)\}/g, (match, key) => { + const value = context[key]; + return value !== undefined && value !== null ? String(value) : match; + }); + } + function toSnakeCase(obj) { + const result = {}; + for (const [key, value] of Object.entries(obj)) { + const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase(); + result[snakeKey] = value; + result[key] = value; + } + return result; + } + function getCloseOlderDiscussionMessage(ctx) { + const messages = getMessages(); + const templateContext = toSnakeCase(ctx); + const defaultMessage = `⚓ Avast! This discussion be marked as **outdated** by [{workflow_name}]({run_url}). + 🗺️ A newer treasure map awaits ye at **[Discussion #{new_discussion_number}]({new_discussion_url})**. + Fair winds, matey! 🏴‍☠️`; + return messages?.closeOlderDiscussion + ? renderTemplate(messages.closeOlderDiscussion, templateContext) + : renderTemplate(defaultMessage, templateContext); + } + const MAX_CLOSE_COUNT = 10; + const GRAPHQL_DELAY_MS = 500; + function delay(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); + } + async function searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, excludeNumber) { + let searchQuery = `repo:${owner}/${repo} is:open`; + if (titlePrefix) { + const escapedPrefix = titlePrefix.replace(/"/g, '\\"'); + searchQuery += ` in:title "${escapedPrefix}"`; + } + if (labels && labels.length > 0) { + for (const label of labels) { + const escapedLabel = label.replace(/"/g, '\\"'); + searchQuery += ` label:"${escapedLabel}"`; + } + } + const result = await github.graphql( + ` + query($searchTerms: String!, $first: Int!) { + search(query: $searchTerms, type: DISCUSSION, first: $first) { + nodes { + ... on Discussion { + id + number + title + url + category { + id + } + labels(first: 100) { + nodes { + name + } + } + closed + } + } + } + }`, + { searchTerms: searchQuery, first: 50 } + ); + if (!result || !result.search || !result.search.nodes) { + return []; + } + return result.search.nodes + .filter( + d => { + if (!d || d.number === excludeNumber || d.closed) { + return false; + } + if (titlePrefix && d.title && !d.title.startsWith(titlePrefix)) { + return false; + } + if (labels && labels.length > 0) { + const discussionLabels = d.labels?.nodes?.map(( l) => l.name) || []; + const hasAllLabels = labels.every(label => discussionLabels.includes(label)); + if (!hasAllLabels) { + return false; + } + } + if (categoryId && (!d.category || d.category.id !== categoryId)) { + return false; + } + return true; + } + ) + .map( + d => ({ + id: d.id, + number: d.number, + title: d.title, + url: d.url, + }) + ); + } + async function addDiscussionComment(github, discussionId, message) { + const result = await github.graphql( + ` + mutation($dId: ID!, $body: String!) { + addDiscussionComment(input: { discussionId: $dId, body: $body }) { + comment { + id + url + } + } + }`, + { dId: discussionId, body: message } + ); + return result.addDiscussionComment.comment; + } + async function closeDiscussionAsOutdated(github, discussionId) { + const result = await github.graphql( + ` + mutation($dId: ID!) { + closeDiscussion(input: { discussionId: $dId, reason: OUTDATED }) { + discussion { + id + url + } + } + }`, + { dId: discussionId } + ); + return result.closeDiscussion.discussion; + } + async function closeOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion, workflowName, runUrl) { + const searchCriteria = []; + if (titlePrefix) searchCriteria.push(`title prefix: "${titlePrefix}"`); + if (labels && labels.length > 0) searchCriteria.push(`labels: [${labels.join(", ")}]`); + core.info(`Searching for older discussions with ${searchCriteria.join(" and ")}`); + const olderDiscussions = await searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion.number); + if (olderDiscussions.length === 0) { + core.info("No older discussions found to close"); + return []; + } + core.info(`Found ${olderDiscussions.length} older discussion(s) to close`); + const discussionsToClose = olderDiscussions.slice(0, MAX_CLOSE_COUNT); + if (olderDiscussions.length > MAX_CLOSE_COUNT) { + core.warning(`Found ${olderDiscussions.length} older discussions, but only closing the first ${MAX_CLOSE_COUNT}`); + } + const closedDiscussions = []; + for (let i = 0; i < discussionsToClose.length; i++) { + const discussion = discussionsToClose[i]; + try { + const closingMessage = getCloseOlderDiscussionMessage({ + newDiscussionUrl: newDiscussion.url, + newDiscussionNumber: newDiscussion.number, + workflowName, + runUrl, + }); + core.info(`Adding closing comment to discussion #${discussion.number}`); + await addDiscussionComment(github, discussion.id, closingMessage); + core.info(`Closing discussion #${discussion.number} as outdated`); + await closeDiscussionAsOutdated(github, discussion.id); + closedDiscussions.push({ + number: discussion.number, + url: discussion.url, + }); + core.info(`✓ Closed discussion #${discussion.number}: ${discussion.url}`); + } catch (error) { + core.error(`✗ Failed to close discussion #${discussion.number}: ${error instanceof Error ? error.message : String(error)}`); + } + if (i < discussionsToClose.length - 1) { + await delay(GRAPHQL_DELAY_MS); + } + } + return closedDiscussions; + } + const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; + function generateTemporaryId() { + return "aw_" + crypto.randomBytes(6).toString("hex"); + } + function isTemporaryId(value) { + if (typeof value === "string") { + return /^aw_[0-9a-f]{12}$/i.test(value); + } + return false; + } + function normalizeTemporaryId(tempId) { + return String(tempId).toLowerCase(); + } + function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { + return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { + const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); + if (resolved !== undefined) { + if (currentRepo && resolved.repo === currentRepo) { + return `#${resolved.number}`; + } + return `${resolved.repo}#${resolved.number}`; + } + return match; + }); + } + function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { + return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { + const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); + if (issueNumber !== undefined) { + return `#${issueNumber}`; + } + return match; + }); + } + function loadTemporaryIdMap() { + const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; + if (!mapJson || mapJson === "{}") { + return new Map(); + } + try { + const mapObject = JSON.parse(mapJson); + const result = new Map(); + for (const [key, value] of Object.entries(mapObject)) { + const normalizedKey = normalizeTemporaryId(key); + if (typeof value === "number") { + const contextRepo = `${context.repo.owner}/${context.repo.repo}`; + result.set(normalizedKey, { repo: contextRepo, number: value }); + } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { + result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); + } + } + return result; + } catch (error) { + if (typeof core !== "undefined") { + core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); + } + return new Map(); + } + } + function resolveIssueNumber(value, temporaryIdMap) { + if (value === undefined || value === null) { + return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; + } + const valueStr = String(value); + if (isTemporaryId(valueStr)) { + const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); + if (resolvedPair !== undefined) { + return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; + } + return { + resolved: null, + wasTemporaryId: true, + errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, + }; + } + const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); + if (isNaN(issueNumber) || issueNumber <= 0) { + return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; + } + const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; + return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; + } + function serializeTemporaryIdMap(tempIdMap) { + const obj = Object.fromEntries(tempIdMap); + return JSON.stringify(obj); + } + function parseAllowedRepos() { + const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; + const set = new Set(); + if (allowedReposEnv) { + allowedReposEnv + .split(",") + .map(repo => repo.trim()) + .filter(repo => repo) + .forEach(repo => set.add(repo)); + } + return set; + } + function getDefaultTargetRepo() { + const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; + if (targetRepoSlug) { + return targetRepoSlug; + } + return `${context.repo.owner}/${context.repo.repo}`; + } + function validateRepo(repo, defaultRepo, allowedRepos) { + if (repo === defaultRepo) { + return { valid: true, error: null }; + } + if (allowedRepos.has(repo)) { + return { valid: true, error: null }; + } + return { + valid: false, + error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, + }; + } + function parseRepoSlug(repoSlug) { + const parts = repoSlug.split("/"); + if (parts.length !== 2 || !parts[0] || !parts[1]) { + return null; + } + return { owner: parts[0], repo: parts[1] }; + } + function addExpirationComment(bodyLines, envVarName, entityType) { + const expiresEnv = process.env[envVarName]; + if (expiresEnv) { + const expiresDays = parseInt(expiresEnv, 10); + if (!isNaN(expiresDays) && expiresDays > 0) { + const expirationDate = new Date(); + expirationDate.setDate(expirationDate.getDate() + expiresDays); + const expirationISO = expirationDate.toISOString(); + bodyLines.push(``); + core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); + } + } + } + function removeDuplicateTitleFromDescription(title, description) { + if (!title || typeof title !== "string") { + return description || ""; + } + if (!description || typeof description !== "string") { + return ""; + } + const trimmedTitle = title.trim(); + const trimmedDescription = description.trim(); + if (!trimmedTitle || !trimmedDescription) { + return trimmedDescription; + } + const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); + if (headerRegex.test(trimmedDescription)) { + return trimmedDescription.replace(headerRegex, "").trim(); + } + return trimmedDescription; + } async function fetchRepoDiscussionInfo(owner, repo) { const repositoryQuery = ` query($owner: String!, $repo: String!) { diff --git a/.github/workflows/blog-auditor.lock.yml b/.github/workflows/blog-auditor.lock.yml index 4d06a418735..c5b4eb6ec3c 100644 --- a/.github/workflows/blog-auditor.lock.yml +++ b/.github/workflows/blog-auditor.lock.yml @@ -6053,13 +6053,400 @@ jobs: with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - const { loadAgentOutput } = require("./load_agent_output.cjs"); - const { getTrackerID } = require("./get_tracker_id.cjs"); - const { closeOlderDiscussions } = require("./close_older_discussions.cjs"); - const { replaceTemporaryIdReferences, loadTemporaryIdMap } = require("./temporary_id.cjs"); - const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require("./repo_helpers.cjs"); - const { addExpirationComment } = require("./expiration_helpers.cjs"); - const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); + const fs = require("fs"); + const crypto = require("crypto"); + const MAX_LOG_CONTENT_LENGTH = 10000; + function truncateForLogging(content) { + if (content.length <= MAX_LOG_CONTENT_LENGTH) { + return content; + } + return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; + } + function loadAgentOutput() { + const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; + if (!agentOutputFile) { + core.info("No GH_AW_AGENT_OUTPUT environment variable found"); + return { success: false }; + } + let outputContent; + try { + outputContent = fs.readFileSync(agentOutputFile, "utf8"); + } catch (error) { + const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; + core.error(errorMessage); + return { success: false, error: errorMessage }; + } + if (outputContent.trim() === "") { + core.info("Agent output content is empty"); + return { success: false }; + } + core.info(`Agent output content length: ${outputContent.length}`); + let validatedOutput; + try { + validatedOutput = JSON.parse(outputContent); + } catch (error) { + const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; + core.error(errorMessage); + core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); + return { success: false, error: errorMessage }; + } + if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { + core.info("No valid items found in agent output"); + core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); + return { success: false }; + } + return { success: true, items: validatedOutput.items }; + } + function getTrackerID(format) { + const trackerID = process.env.GH_AW_TRACKER_ID || ""; + if (trackerID) { + core.info(`Tracker ID: ${trackerID}`); + return format === "markdown" ? `\n\n` : trackerID; + } + return ""; + } + function getMessages() { + const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES; + if (!messagesEnv) { + return null; + } + try { + return JSON.parse(messagesEnv); + } catch (error) { + core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`); + return null; + } + } + function renderTemplate(template, context) { + return template.replace(/\{(\w+)\}/g, (match, key) => { + const value = context[key]; + return value !== undefined && value !== null ? String(value) : match; + }); + } + function toSnakeCase(obj) { + const result = {}; + for (const [key, value] of Object.entries(obj)) { + const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase(); + result[snakeKey] = value; + result[key] = value; + } + return result; + } + function getCloseOlderDiscussionMessage(ctx) { + const messages = getMessages(); + const templateContext = toSnakeCase(ctx); + const defaultMessage = `⚓ Avast! This discussion be marked as **outdated** by [{workflow_name}]({run_url}). + 🗺️ A newer treasure map awaits ye at **[Discussion #{new_discussion_number}]({new_discussion_url})**. + Fair winds, matey! 🏴‍☠️`; + return messages?.closeOlderDiscussion + ? renderTemplate(messages.closeOlderDiscussion, templateContext) + : renderTemplate(defaultMessage, templateContext); + } + const MAX_CLOSE_COUNT = 10; + const GRAPHQL_DELAY_MS = 500; + function delay(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); + } + async function searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, excludeNumber) { + let searchQuery = `repo:${owner}/${repo} is:open`; + if (titlePrefix) { + const escapedPrefix = titlePrefix.replace(/"/g, '\\"'); + searchQuery += ` in:title "${escapedPrefix}"`; + } + if (labels && labels.length > 0) { + for (const label of labels) { + const escapedLabel = label.replace(/"/g, '\\"'); + searchQuery += ` label:"${escapedLabel}"`; + } + } + const result = await github.graphql( + ` + query($searchTerms: String!, $first: Int!) { + search(query: $searchTerms, type: DISCUSSION, first: $first) { + nodes { + ... on Discussion { + id + number + title + url + category { + id + } + labels(first: 100) { + nodes { + name + } + } + closed + } + } + } + }`, + { searchTerms: searchQuery, first: 50 } + ); + if (!result || !result.search || !result.search.nodes) { + return []; + } + return result.search.nodes + .filter( + d => { + if (!d || d.number === excludeNumber || d.closed) { + return false; + } + if (titlePrefix && d.title && !d.title.startsWith(titlePrefix)) { + return false; + } + if (labels && labels.length > 0) { + const discussionLabels = d.labels?.nodes?.map(( l) => l.name) || []; + const hasAllLabels = labels.every(label => discussionLabels.includes(label)); + if (!hasAllLabels) { + return false; + } + } + if (categoryId && (!d.category || d.category.id !== categoryId)) { + return false; + } + return true; + } + ) + .map( + d => ({ + id: d.id, + number: d.number, + title: d.title, + url: d.url, + }) + ); + } + async function addDiscussionComment(github, discussionId, message) { + const result = await github.graphql( + ` + mutation($dId: ID!, $body: String!) { + addDiscussionComment(input: { discussionId: $dId, body: $body }) { + comment { + id + url + } + } + }`, + { dId: discussionId, body: message } + ); + return result.addDiscussionComment.comment; + } + async function closeDiscussionAsOutdated(github, discussionId) { + const result = await github.graphql( + ` + mutation($dId: ID!) { + closeDiscussion(input: { discussionId: $dId, reason: OUTDATED }) { + discussion { + id + url + } + } + }`, + { dId: discussionId } + ); + return result.closeDiscussion.discussion; + } + async function closeOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion, workflowName, runUrl) { + const searchCriteria = []; + if (titlePrefix) searchCriteria.push(`title prefix: "${titlePrefix}"`); + if (labels && labels.length > 0) searchCriteria.push(`labels: [${labels.join(", ")}]`); + core.info(`Searching for older discussions with ${searchCriteria.join(" and ")}`); + const olderDiscussions = await searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion.number); + if (olderDiscussions.length === 0) { + core.info("No older discussions found to close"); + return []; + } + core.info(`Found ${olderDiscussions.length} older discussion(s) to close`); + const discussionsToClose = olderDiscussions.slice(0, MAX_CLOSE_COUNT); + if (olderDiscussions.length > MAX_CLOSE_COUNT) { + core.warning(`Found ${olderDiscussions.length} older discussions, but only closing the first ${MAX_CLOSE_COUNT}`); + } + const closedDiscussions = []; + for (let i = 0; i < discussionsToClose.length; i++) { + const discussion = discussionsToClose[i]; + try { + const closingMessage = getCloseOlderDiscussionMessage({ + newDiscussionUrl: newDiscussion.url, + newDiscussionNumber: newDiscussion.number, + workflowName, + runUrl, + }); + core.info(`Adding closing comment to discussion #${discussion.number}`); + await addDiscussionComment(github, discussion.id, closingMessage); + core.info(`Closing discussion #${discussion.number} as outdated`); + await closeDiscussionAsOutdated(github, discussion.id); + closedDiscussions.push({ + number: discussion.number, + url: discussion.url, + }); + core.info(`✓ Closed discussion #${discussion.number}: ${discussion.url}`); + } catch (error) { + core.error(`✗ Failed to close discussion #${discussion.number}: ${error instanceof Error ? error.message : String(error)}`); + } + if (i < discussionsToClose.length - 1) { + await delay(GRAPHQL_DELAY_MS); + } + } + return closedDiscussions; + } + const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; + function generateTemporaryId() { + return "aw_" + crypto.randomBytes(6).toString("hex"); + } + function isTemporaryId(value) { + if (typeof value === "string") { + return /^aw_[0-9a-f]{12}$/i.test(value); + } + return false; + } + function normalizeTemporaryId(tempId) { + return String(tempId).toLowerCase(); + } + function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { + return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { + const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); + if (resolved !== undefined) { + if (currentRepo && resolved.repo === currentRepo) { + return `#${resolved.number}`; + } + return `${resolved.repo}#${resolved.number}`; + } + return match; + }); + } + function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { + return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { + const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); + if (issueNumber !== undefined) { + return `#${issueNumber}`; + } + return match; + }); + } + function loadTemporaryIdMap() { + const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; + if (!mapJson || mapJson === "{}") { + return new Map(); + } + try { + const mapObject = JSON.parse(mapJson); + const result = new Map(); + for (const [key, value] of Object.entries(mapObject)) { + const normalizedKey = normalizeTemporaryId(key); + if (typeof value === "number") { + const contextRepo = `${context.repo.owner}/${context.repo.repo}`; + result.set(normalizedKey, { repo: contextRepo, number: value }); + } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { + result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); + } + } + return result; + } catch (error) { + if (typeof core !== "undefined") { + core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); + } + return new Map(); + } + } + function resolveIssueNumber(value, temporaryIdMap) { + if (value === undefined || value === null) { + return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; + } + const valueStr = String(value); + if (isTemporaryId(valueStr)) { + const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); + if (resolvedPair !== undefined) { + return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; + } + return { + resolved: null, + wasTemporaryId: true, + errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, + }; + } + const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); + if (isNaN(issueNumber) || issueNumber <= 0) { + return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; + } + const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; + return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; + } + function serializeTemporaryIdMap(tempIdMap) { + const obj = Object.fromEntries(tempIdMap); + return JSON.stringify(obj); + } + function parseAllowedRepos() { + const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; + const set = new Set(); + if (allowedReposEnv) { + allowedReposEnv + .split(",") + .map(repo => repo.trim()) + .filter(repo => repo) + .forEach(repo => set.add(repo)); + } + return set; + } + function getDefaultTargetRepo() { + const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; + if (targetRepoSlug) { + return targetRepoSlug; + } + return `${context.repo.owner}/${context.repo.repo}`; + } + function validateRepo(repo, defaultRepo, allowedRepos) { + if (repo === defaultRepo) { + return { valid: true, error: null }; + } + if (allowedRepos.has(repo)) { + return { valid: true, error: null }; + } + return { + valid: false, + error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, + }; + } + function parseRepoSlug(repoSlug) { + const parts = repoSlug.split("/"); + if (parts.length !== 2 || !parts[0] || !parts[1]) { + return null; + } + return { owner: parts[0], repo: parts[1] }; + } + function addExpirationComment(bodyLines, envVarName, entityType) { + const expiresEnv = process.env[envVarName]; + if (expiresEnv) { + const expiresDays = parseInt(expiresEnv, 10); + if (!isNaN(expiresDays) && expiresDays > 0) { + const expirationDate = new Date(); + expirationDate.setDate(expirationDate.getDate() + expiresDays); + const expirationISO = expirationDate.toISOString(); + bodyLines.push(``); + core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); + } + } + } + function removeDuplicateTitleFromDescription(title, description) { + if (!title || typeof title !== "string") { + return description || ""; + } + if (!description || typeof description !== "string") { + return ""; + } + const trimmedTitle = title.trim(); + const trimmedDescription = description.trim(); + if (!trimmedTitle || !trimmedDescription) { + return trimmedDescription; + } + const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); + if (headerRegex.test(trimmedDescription)) { + return trimmedDescription.replace(headerRegex, "").trim(); + } + return trimmedDescription; + } async function fetchRepoDiscussionInfo(owner, repo) { const repositoryQuery = ` query($owner: String!, $repo: String!) { diff --git a/.github/workflows/breaking-change-checker.lock.yml b/.github/workflows/breaking-change-checker.lock.yml index be041c82c68..3fcd7c6e395 100644 --- a/.github/workflows/breaking-change-checker.lock.yml +++ b/.github/workflows/breaking-change-checker.lock.yml @@ -6243,21 +6243,291 @@ jobs: with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - const { sanitizeLabelContent } = require("./sanitize_label_content.cjs"); - const { loadAgentOutput } = require("./load_agent_output.cjs"); - const { generateStagedPreview } = require("./staged_preview.cjs"); - const { generateFooter } = require("./generate_footer.cjs"); - const { getTrackerID } = require("./get_tracker_id.cjs"); - const { - generateTemporaryId, - isTemporaryId, - normalizeTemporaryId, - replaceTemporaryIdReferences, - serializeTemporaryIdMap, - } = require("./temporary_id.cjs"); - const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require("./repo_helpers.cjs"); - const { addExpirationComment } = require("./expiration_helpers.cjs"); - const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); + function sanitizeLabelContent(content) { + if (!content || typeof content !== "string") { + return ""; + } + let sanitized = content.trim(); + sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); + sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); + sanitized = sanitized.replace( + /(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, + (_m, p1, p2) => `${p1}\`@${p2}\`` + ); + sanitized = sanitized.replace(/[<>&'"]/g, ""); + return sanitized.trim(); + } + const fs = require("fs"); + const crypto = require("crypto"); + const MAX_LOG_CONTENT_LENGTH = 10000; + function truncateForLogging(content) { + if (content.length <= MAX_LOG_CONTENT_LENGTH) { + return content; + } + return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; + } + function loadAgentOutput() { + const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; + if (!agentOutputFile) { + core.info("No GH_AW_AGENT_OUTPUT environment variable found"); + return { success: false }; + } + let outputContent; + try { + outputContent = fs.readFileSync(agentOutputFile, "utf8"); + } catch (error) { + const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; + core.error(errorMessage); + return { success: false, error: errorMessage }; + } + if (outputContent.trim() === "") { + core.info("Agent output content is empty"); + return { success: false }; + } + core.info(`Agent output content length: ${outputContent.length}`); + let validatedOutput; + try { + validatedOutput = JSON.parse(outputContent); + } catch (error) { + const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; + core.error(errorMessage); + core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); + return { success: false, error: errorMessage }; + } + if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { + core.info("No valid items found in agent output"); + core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); + return { success: false }; + } + return { success: true, items: validatedOutput.items }; + } + async function generateStagedPreview(options) { + const { title, description, items, renderItem } = options; + let summaryContent = `## 🎭 Staged Mode: ${title} Preview\n\n`; + summaryContent += `${description}\n\n`; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + summaryContent += renderItem(item, i); + summaryContent += "---\n\n"; + } + try { + await core.summary.addRaw(summaryContent).write(); + core.info(summaryContent); + core.info(`📝 ${title} preview written to step summary`); + } catch (error) { + core.setFailed(error instanceof Error ? error : String(error)); + } + } + function generateXMLMarker(workflowName, runUrl) { + const engineId = process.env.GH_AW_ENGINE_ID || ""; + const engineVersion = process.env.GH_AW_ENGINE_VERSION || ""; + const engineModel = process.env.GH_AW_ENGINE_MODEL || ""; + const trackerId = process.env.GH_AW_TRACKER_ID || ""; + const parts = []; + parts.push(`agentic-workflow: ${workflowName}`); + if (trackerId) { + parts.push(`tracker-id: ${trackerId}`); + } + if (engineId) { + parts.push(`engine: ${engineId}`); + } + if (engineVersion) { + parts.push(`version: ${engineVersion}`); + } + if (engineModel) { + parts.push(`model: ${engineModel}`); + } + parts.push(`run: ${runUrl}`); + return ``; + } + function generateFooter( + workflowName, + runUrl, + workflowSource, + workflowSourceURL, + triggeringIssueNumber, + triggeringPRNumber, + triggeringDiscussionNumber + ) { + let footer = `\n\n> AI generated by [${workflowName}](${runUrl})`; + if (triggeringIssueNumber) { + footer += ` for #${triggeringIssueNumber}`; + } else if (triggeringPRNumber) { + footer += ` for #${triggeringPRNumber}`; + } else if (triggeringDiscussionNumber) { + footer += ` for discussion #${triggeringDiscussionNumber}`; + } + if (workflowSource && workflowSourceURL) { + footer += `\n>\n> To add this workflow in your repository, run \`gh aw add ${workflowSource}\`. See [usage guide](https://githubnext.github.io/gh-aw/tools/cli/).`; + } + footer += "\n\n" + generateXMLMarker(workflowName, runUrl); + footer += "\n"; + return footer; + } + function getTrackerID(format) { + const trackerID = process.env.GH_AW_TRACKER_ID || ""; + if (trackerID) { + core.info(`Tracker ID: ${trackerID}`); + return format === "markdown" ? `\n\n` : trackerID; + } + return ""; + } + const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; + function generateTemporaryId() { + return "aw_" + crypto.randomBytes(6).toString("hex"); + } + function isTemporaryId(value) { + if (typeof value === "string") { + return /^aw_[0-9a-f]{12}$/i.test(value); + } + return false; + } + function normalizeTemporaryId(tempId) { + return String(tempId).toLowerCase(); + } + function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { + return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { + const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); + if (resolved !== undefined) { + if (currentRepo && resolved.repo === currentRepo) { + return `#${resolved.number}`; + } + return `${resolved.repo}#${resolved.number}`; + } + return match; + }); + } + function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { + return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { + const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); + if (issueNumber !== undefined) { + return `#${issueNumber}`; + } + return match; + }); + } + function loadTemporaryIdMap() { + const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; + if (!mapJson || mapJson === "{}") { + return new Map(); + } + try { + const mapObject = JSON.parse(mapJson); + const result = new Map(); + for (const [key, value] of Object.entries(mapObject)) { + const normalizedKey = normalizeTemporaryId(key); + if (typeof value === "number") { + const contextRepo = `${context.repo.owner}/${context.repo.repo}`; + result.set(normalizedKey, { repo: contextRepo, number: value }); + } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { + result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); + } + } + return result; + } catch (error) { + if (typeof core !== "undefined") { + core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); + } + return new Map(); + } + } + function resolveIssueNumber(value, temporaryIdMap) { + if (value === undefined || value === null) { + return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; + } + const valueStr = String(value); + if (isTemporaryId(valueStr)) { + const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); + if (resolvedPair !== undefined) { + return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; + } + return { + resolved: null, + wasTemporaryId: true, + errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, + }; + } + const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); + if (isNaN(issueNumber) || issueNumber <= 0) { + return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; + } + const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; + return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; + } + function serializeTemporaryIdMap(tempIdMap) { + const obj = Object.fromEntries(tempIdMap); + return JSON.stringify(obj); + } + function parseAllowedRepos() { + const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; + const set = new Set(); + if (allowedReposEnv) { + allowedReposEnv + .split(",") + .map(repo => repo.trim()) + .filter(repo => repo) + .forEach(repo => set.add(repo)); + } + return set; + } + function getDefaultTargetRepo() { + const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; + if (targetRepoSlug) { + return targetRepoSlug; + } + return `${context.repo.owner}/${context.repo.repo}`; + } + function validateRepo(repo, defaultRepo, allowedRepos) { + if (repo === defaultRepo) { + return { valid: true, error: null }; + } + if (allowedRepos.has(repo)) { + return { valid: true, error: null }; + } + return { + valid: false, + error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, + }; + } + function parseRepoSlug(repoSlug) { + const parts = repoSlug.split("/"); + if (parts.length !== 2 || !parts[0] || !parts[1]) { + return null; + } + return { owner: parts[0], repo: parts[1] }; + } + function addExpirationComment(bodyLines, envVarName, entityType) { + const expiresEnv = process.env[envVarName]; + if (expiresEnv) { + const expiresDays = parseInt(expiresEnv, 10); + if (!isNaN(expiresDays) && expiresDays > 0) { + const expirationDate = new Date(); + expirationDate.setDate(expirationDate.getDate() + expiresDays); + const expirationISO = expirationDate.toISOString(); + bodyLines.push(``); + core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); + } + } + } + function removeDuplicateTitleFromDescription(title, description) { + if (!title || typeof title !== "string") { + return description || ""; + } + if (!description || typeof description !== "string") { + return ""; + } + const trimmedTitle = title.trim(); + const trimmedDescription = description.trim(); + if (!trimmedTitle || !trimmedDescription) { + return trimmedDescription; + } + const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); + if (headerRegex.test(trimmedDescription)) { + return trimmedDescription.replace(headerRegex, "").trim(); + } + return trimmedDescription; + } async function main() { core.setOutput("issue_number", ""); core.setOutput("issue_url", ""); diff --git a/.github/workflows/changeset.lock.yml b/.github/workflows/changeset.lock.yml index c7a4f0aeb50..5357beed264 100644 --- a/.github/workflows/changeset.lock.yml +++ b/.github/workflows/changeset.lock.yml @@ -7540,8 +7540,457 @@ jobs: with: github-token: ${{ steps.app-token.outputs.token }} script: | - const { runUpdateWorkflow, createRenderStagedItem, createGetSummaryLine } = require("./update_runner.cjs"); - const { updatePRBody } = require("./update_pr_description_helpers.cjs"); + const fs = require("fs"); + const MAX_LOG_CONTENT_LENGTH = 10000; + function truncateForLogging(content) { + if (content.length <= MAX_LOG_CONTENT_LENGTH) { + return content; + } + return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; + } + function loadAgentOutput() { + const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; + if (!agentOutputFile) { + core.info("No GH_AW_AGENT_OUTPUT environment variable found"); + return { success: false }; + } + let outputContent; + try { + outputContent = fs.readFileSync(agentOutputFile, "utf8"); + } catch (error) { + const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; + core.error(errorMessage); + return { success: false, error: errorMessage }; + } + if (outputContent.trim() === "") { + core.info("Agent output content is empty"); + return { success: false }; + } + core.info(`Agent output content length: ${outputContent.length}`); + let validatedOutput; + try { + validatedOutput = JSON.parse(outputContent); + } catch (error) { + const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; + core.error(errorMessage); + core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); + return { success: false, error: errorMessage }; + } + if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { + core.info("No valid items found in agent output"); + core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); + return { success: false }; + } + return { success: true, items: validatedOutput.items }; + } + async function generateStagedPreview(options) { + const { title, description, items, renderItem } = options; + let summaryContent = `## 🎭 Staged Mode: ${title} Preview\n\n`; + summaryContent += `${description}\n\n`; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + summaryContent += renderItem(item, i); + summaryContent += "---\n\n"; + } + try { + await core.summary.addRaw(summaryContent).write(); + core.info(summaryContent); + core.info(`📝 ${title} preview written to step summary`); + } catch (error) { + core.setFailed(error instanceof Error ? error : String(error)); + } + } + function removeDuplicateTitleFromDescription(title, description) { + if (!title || typeof title !== "string") { + return description || ""; + } + if (!description || typeof description !== "string") { + return ""; + } + const trimmedTitle = title.trim(); + const trimmedDescription = description.trim(); + if (!trimmedTitle || !trimmedDescription) { + return trimmedDescription; + } + const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); + if (headerRegex.test(trimmedDescription)) { + return trimmedDescription.replace(headerRegex, "").trim(); + } + return trimmedDescription; + } + function resolveTargetNumber(params) { + const { updateTarget, item, numberField, isValidContext, contextNumber, displayName } = params; + if (updateTarget === "*") { + const explicitNumber = item[numberField]; + if (explicitNumber) { + const parsed = parseInt(explicitNumber, 10); + if (isNaN(parsed) || parsed <= 0) { + return { success: false, error: `Invalid ${numberField} specified: ${explicitNumber}` }; + } + return { success: true, number: parsed }; + } else { + return { success: false, error: `Target is "*" but no ${numberField} specified in update item` }; + } + } else if (updateTarget && updateTarget !== "triggering") { + const parsed = parseInt(updateTarget, 10); + if (isNaN(parsed) || parsed <= 0) { + return { success: false, error: `Invalid ${displayName} number in target configuration: ${updateTarget}` }; + } + return { success: true, number: parsed }; + } else { + if (isValidContext && contextNumber) { + return { success: true, number: contextNumber }; + } + return { success: false, error: `Could not determine ${displayName} number` }; + } + } + function buildUpdateData(params) { + const { item, canUpdateStatus, canUpdateTitle, canUpdateBody, supportsStatus } = params; + const updateData = {}; + let hasUpdates = false; + const logMessages = []; + if (supportsStatus && canUpdateStatus && item.status !== undefined) { + if (item.status === "open" || item.status === "closed") { + updateData.state = item.status; + hasUpdates = true; + logMessages.push(`Will update status to: ${item.status}`); + } else { + logMessages.push(`Invalid status value: ${item.status}. Must be 'open' or 'closed'`); + } + } + let titleForDedup = null; + if (canUpdateTitle && item.title !== undefined) { + const trimmedTitle = typeof item.title === "string" ? item.title.trim() : ""; + if (trimmedTitle.length > 0) { + updateData.title = trimmedTitle; + titleForDedup = trimmedTitle; + hasUpdates = true; + logMessages.push(`Will update title to: ${trimmedTitle}`); + } else { + logMessages.push("Invalid title value: must be a non-empty string"); + } + } + if (canUpdateBody && item.body !== undefined) { + if (typeof item.body === "string") { + let processedBody = item.body; + if (titleForDedup) { + processedBody = removeDuplicateTitleFromDescription(titleForDedup, processedBody); + } + updateData.body = processedBody; + hasUpdates = true; + logMessages.push(`Will update body (length: ${processedBody.length})`); + } else { + logMessages.push("Invalid body value: must be a string"); + } + } + return { hasUpdates, updateData, logMessages }; + } + async function runUpdateWorkflow(config) { + const { + itemType, + displayName, + displayNamePlural, + numberField, + outputNumberKey, + outputUrlKey, + isValidContext, + getContextNumber, + supportsStatus, + supportsOperation, + renderStagedItem, + executeUpdate, + getSummaryLine, + } = config; + const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; + const result = loadAgentOutput(); + if (!result.success) { + return; + } + const updateItems = result.items.filter( item => item.type === itemType); + if (updateItems.length === 0) { + core.info(`No ${itemType} items found in agent output`); + return; + } + core.info(`Found ${updateItems.length} ${itemType} item(s)`); + if (isStaged) { + await generateStagedPreview({ + title: `Update ${displayNamePlural.charAt(0).toUpperCase() + displayNamePlural.slice(1)}`, + description: `The following ${displayName} updates would be applied if staged mode was disabled:`, + items: updateItems, + renderItem: renderStagedItem, + }); + return; + } + const updateTarget = process.env.GH_AW_UPDATE_TARGET || "triggering"; + const canUpdateStatus = process.env.GH_AW_UPDATE_STATUS === "true"; + const canUpdateTitle = process.env.GH_AW_UPDATE_TITLE === "true"; + const canUpdateBody = process.env.GH_AW_UPDATE_BODY === "true"; + core.info(`Update target configuration: ${updateTarget}`); + if (supportsStatus) { + core.info(`Can update status: ${canUpdateStatus}, title: ${canUpdateTitle}, body: ${canUpdateBody}`); + } else { + core.info(`Can update title: ${canUpdateTitle}, body: ${canUpdateBody}`); + } + const contextIsValid = isValidContext(context.eventName, context.payload); + const contextNumber = getContextNumber(context.payload); + if (updateTarget === "triggering" && !contextIsValid) { + core.info(`Target is "triggering" but not running in ${displayName} context, skipping ${displayName} update`); + return; + } + const updatedItems = []; + for (let i = 0; i < updateItems.length; i++) { + const updateItem = updateItems[i]; + core.info(`Processing ${itemType} item ${i + 1}/${updateItems.length}`); + const targetResult = resolveTargetNumber({ + updateTarget, + item: updateItem, + numberField, + isValidContext: contextIsValid, + contextNumber, + displayName, + }); + if (!targetResult.success) { + core.info(targetResult.error); + continue; + } + const targetNumber = targetResult.number; + core.info(`Updating ${displayName} #${targetNumber}`); + const { hasUpdates, updateData, logMessages } = buildUpdateData({ + item: updateItem, + canUpdateStatus, + canUpdateTitle, + canUpdateBody, + supportsStatus, + }); + for (const msg of logMessages) { + core.info(msg); + } + if (supportsOperation && canUpdateBody && updateItem.body !== undefined && typeof updateItem.body === "string") { + updateData._operation = updateItem.operation || "append"; + updateData._rawBody = updateItem.body; + } + if (!hasUpdates) { + core.info("No valid updates to apply for this item"); + continue; + } + try { + const updatedItem = await executeUpdate(github, context, targetNumber, updateData); + core.info(`Updated ${displayName} #${updatedItem.number}: ${updatedItem.html_url}`); + updatedItems.push(updatedItem); + if (i === updateItems.length - 1) { + core.setOutput(outputNumberKey, updatedItem.number); + core.setOutput(outputUrlKey, updatedItem.html_url); + } + } catch (error) { + core.error(`✗ Failed to update ${displayName} #${targetNumber}: ${error instanceof Error ? error.message : String(error)}`); + throw error; + } + } + if (updatedItems.length > 0) { + let summaryContent = `\n\n## Updated ${displayNamePlural.charAt(0).toUpperCase() + displayNamePlural.slice(1)}\n`; + for (const item of updatedItems) { + summaryContent += getSummaryLine(item); + } + await core.summary.addRaw(summaryContent).write(); + } + core.info(`Successfully updated ${updatedItems.length} ${displayName}(s)`); + return updatedItems; + } + function createRenderStagedItem(config) { + const { entityName, numberField, targetLabel, currentTargetText, includeOperation = false } = config; + return function renderStagedItem(item, index) { + let content = `### ${entityName} Update ${index + 1}\n`; + if (item[numberField]) { + content += `**${targetLabel}** #${item[numberField]}\n\n`; + } else { + content += `**Target:** ${currentTargetText}\n\n`; + } + if (item.title !== undefined) { + content += `**New Title:** ${item.title}\n\n`; + } + if (item.body !== undefined) { + if (includeOperation) { + const operation = item.operation || "append"; + content += `**Operation:** ${operation}\n`; + content += `**Body Content:**\n${item.body}\n\n`; + } else { + content += `**New Body:**\n${item.body}\n\n`; + } + } + if (item.status !== undefined) { + content += `**New Status:** ${item.status}\n\n`; + } + return content; + }; + } + function createGetSummaryLine(config) { + const { entityPrefix } = config; + return function getSummaryLine(item) { + return `- ${entityPrefix} #${item.number}: [${item.title}](${item.html_url})\n`; + }; + } + function getMessages() { + const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES; + if (!messagesEnv) { + return null; + } + try { + return JSON.parse(messagesEnv); + } catch (error) { + core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`); + return null; + } + } + function renderTemplate(template, context) { + return template.replace(/\{(\w+)\}/g, (match, key) => { + const value = context[key]; + return value !== undefined && value !== null ? String(value) : match; + }); + } + function toSnakeCase(obj) { + const result = {}; + for (const [key, value] of Object.entries(obj)) { + const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase(); + result[snakeKey] = value; + result[key] = value; + } + return result; + } + function getFooterMessage(ctx) { + const messages = getMessages(); + const templateContext = toSnakeCase(ctx); + const defaultFooter = "> Ahoy! This treasure was crafted by [🏴‍☠️ {workflow_name}]({run_url})"; + let footer = messages?.footer ? renderTemplate(messages.footer, templateContext) : renderTemplate(defaultFooter, templateContext); + if (ctx.triggeringNumber) { + footer += ` fer issue #{triggering_number} 🗺️`.replace("{triggering_number}", String(ctx.triggeringNumber)); + } + return footer; + } + function getFooterInstallMessage(ctx) { + if (!ctx.workflowSource || !ctx.workflowSourceUrl) { + return ""; + } + const messages = getMessages(); + const templateContext = toSnakeCase(ctx); + const defaultInstall = + "> Arr! To plunder this workflow fer yer own ship, run `gh aw add {workflow_source}`. Chart yer course at [🦜 {workflow_source_url}]({workflow_source_url})!"; + return messages?.footerInstall + ? renderTemplate(messages.footerInstall, templateContext) + : renderTemplate(defaultInstall, templateContext); + } + function generateXMLMarker(workflowName, runUrl) { + const engineId = process.env.GH_AW_ENGINE_ID || ""; + const engineVersion = process.env.GH_AW_ENGINE_VERSION || ""; + const engineModel = process.env.GH_AW_ENGINE_MODEL || ""; + const trackerId = process.env.GH_AW_TRACKER_ID || ""; + const parts = []; + parts.push(`agentic-workflow: ${workflowName}`); + if (trackerId) { + parts.push(`tracker-id: ${trackerId}`); + } + if (engineId) { + parts.push(`engine: ${engineId}`); + } + if (engineVersion) { + parts.push(`version: ${engineVersion}`); + } + if (engineModel) { + parts.push(`model: ${engineModel}`); + } + parts.push(`run: ${runUrl}`); + return ``; + } + function generateFooterWithMessages( + workflowName, + runUrl, + workflowSource, + workflowSourceURL, + triggeringIssueNumber, + triggeringPRNumber, + triggeringDiscussionNumber + ) { + let triggeringNumber; + if (triggeringIssueNumber) { + triggeringNumber = triggeringIssueNumber; + } else if (triggeringPRNumber) { + triggeringNumber = triggeringPRNumber; + } else if (triggeringDiscussionNumber) { + triggeringNumber = `discussion #${triggeringDiscussionNumber}`; + } + const ctx = { + workflowName, + runUrl, + workflowSource, + workflowSourceUrl: workflowSourceURL, + triggeringNumber, + }; + let footer = "\n\n" + getFooterMessage(ctx); + const installMessage = getFooterInstallMessage(ctx); + if (installMessage) { + footer += "\n>\n" + installMessage; + } + footer += "\n\n" + generateXMLMarker(workflowName, runUrl); + footer += "\n"; + return footer; + } + function buildAIFooter(workflowName, runUrl) { + return "\n\n" + getFooterMessage({ workflowName, runUrl }); + } + function buildIslandStartMarker(runId) { + return ``; + } + function buildIslandEndMarker(runId) { + return ``; + } + function findIsland(body, runId) { + const startMarker = buildIslandStartMarker(runId); + const endMarker = buildIslandEndMarker(runId); + const startIndex = body.indexOf(startMarker); + if (startIndex === -1) { + return { found: false, startIndex: -1, endIndex: -1 }; + } + const endIndex = body.indexOf(endMarker, startIndex); + if (endIndex === -1) { + return { found: false, startIndex: -1, endIndex: -1 }; + } + return { found: true, startIndex, endIndex: endIndex + endMarker.length }; + } + function updatePRBody(params) { + const { currentBody, newContent, operation, workflowName, runUrl, runId } = params; + const aiFooter = buildAIFooter(workflowName, runUrl); + if (operation === "replace") { + core.info("Operation: replace (full body replacement)"); + return newContent; + } + if (operation === "replace-island") { + const island = findIsland(currentBody, runId); + if (island.found) { + core.info(`Operation: replace-island (updating existing island for run ${runId})`); + const startMarker = buildIslandStartMarker(runId); + const endMarker = buildIslandEndMarker(runId); + const islandContent = `${startMarker}\n${newContent}${aiFooter}\n${endMarker}`; + const before = currentBody.substring(0, island.startIndex); + const after = currentBody.substring(island.endIndex); + return before + islandContent + after; + } else { + core.info(`Operation: replace-island (island not found for run ${runId}, falling back to append)`); + const startMarker = buildIslandStartMarker(runId); + const endMarker = buildIslandEndMarker(runId); + const islandContent = `${startMarker}\n${newContent}${aiFooter}\n${endMarker}`; + const appendSection = `\n\n---\n\n${islandContent}`; + return currentBody + appendSection; + } + } + if (operation === "prepend") { + core.info("Operation: prepend (add to start with separator)"); + const prependSection = `${newContent}${aiFooter}\n\n---\n\n`; + return prependSection + currentBody; + } + core.info("Operation: append (add to end with separator)"); + const appendSection = `\n\n---\n\n${newContent}${aiFooter}`; + return currentBody + appendSection; + } function isPRContext(eventName, payload) { const isPR = eventName === "pull_request" || diff --git a/.github/workflows/ci-coach.lock.yml b/.github/workflows/ci-coach.lock.yml index 11cf0b4d38f..637c23a3711 100644 --- a/.github/workflows/ci-coach.lock.yml +++ b/.github/workflows/ci-coach.lock.yml @@ -7321,10 +7321,151 @@ jobs: script: | const fs = require("fs"); const crypto = require("crypto"); - const { updateActivationComment } = require("./update_activation_comment.cjs"); - const { getTrackerID } = require("./get_tracker_id.cjs"); - const { addExpirationComment } = require("./expiration_helpers.cjs"); - const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); + async function updateActivationComment(github, context, core, itemUrl, itemNumber, itemType = "pull_request") { + const itemLabel = itemType === "issue" ? "issue" : "pull request"; + const linkMessage = + itemType === "issue" + ? `\n\n✅ Issue created: [#${itemNumber}](${itemUrl})` + : `\n\n✅ Pull request created: [#${itemNumber}](${itemUrl})`; + await updateActivationCommentWithMessage(github, context, core, linkMessage, itemLabel); + } + async function updateActivationCommentWithCommit(github, context, core, commitSha, commitUrl) { + const shortSha = commitSha.substring(0, 7); + const message = `\n\n✅ Commit pushed: [\`${shortSha}\`](${commitUrl})`; + await updateActivationCommentWithMessage(github, context, core, message, "commit"); + } + async function updateActivationCommentWithMessage(github, context, core, message, label = "") { + const commentId = process.env.GH_AW_COMMENT_ID; + const commentRepo = process.env.GH_AW_COMMENT_REPO; + if (!commentId) { + core.info("No activation comment to update (GH_AW_COMMENT_ID not set)"); + return; + } + core.info(`Updating activation comment ${commentId}`); + let repoOwner = context.repo.owner; + let repoName = context.repo.repo; + if (commentRepo) { + const parts = commentRepo.split("/"); + if (parts.length === 2) { + repoOwner = parts[0]; + repoName = parts[1]; + } else { + core.warning(`Invalid comment repo format: ${commentRepo}, expected "owner/repo". Falling back to context.repo.`); + } + } + core.info(`Updating comment in ${repoOwner}/${repoName}`); + const isDiscussionComment = commentId.startsWith("DC_"); + try { + if (isDiscussionComment) { + const currentComment = await github.graphql( + ` + query($commentId: ID!) { + node(id: $commentId) { + ... on DiscussionComment { + body + } + } + }`, + { commentId: commentId } + ); + if (!currentComment?.node?.body) { + core.warning("Unable to fetch current comment body, comment may have been deleted or is inaccessible"); + return; + } + const currentBody = currentComment.node.body; + const updatedBody = currentBody + message; + const result = await github.graphql( + ` + mutation($commentId: ID!, $body: String!) { + updateDiscussionComment(input: { commentId: $commentId, body: $body }) { + comment { + id + url + } + } + }`, + { commentId: commentId, body: updatedBody } + ); + const comment = result.updateDiscussionComment.comment; + const successMessage = label + ? `Successfully updated discussion comment with ${label} link` + : "Successfully updated discussion comment"; + core.info(successMessage); + core.info(`Comment ID: ${comment.id}`); + core.info(`Comment URL: ${comment.url}`); + } else { + const currentComment = await github.request("GET /repos/{owner}/{repo}/issues/comments/{comment_id}", { + owner: repoOwner, + repo: repoName, + comment_id: parseInt(commentId, 10), + headers: { + Accept: "application/vnd.github+json", + }, + }); + if (!currentComment?.data?.body) { + core.warning("Unable to fetch current comment body, comment may have been deleted"); + return; + } + const currentBody = currentComment.data.body; + const updatedBody = currentBody + message; + const response = await github.request("PATCH /repos/{owner}/{repo}/issues/comments/{comment_id}", { + owner: repoOwner, + repo: repoName, + comment_id: parseInt(commentId, 10), + body: updatedBody, + headers: { + Accept: "application/vnd.github+json", + }, + }); + const successMessage = label ? `Successfully updated comment with ${label} link` : "Successfully updated comment"; + core.info(successMessage); + core.info(`Comment ID: ${response.data.id}`); + core.info(`Comment URL: ${response.data.html_url}`); + } + } catch (error) { + core.warning(`Failed to update activation comment: ${error instanceof Error ? error.message : String(error)}`); + } + } + function getTrackerID(format) { + const trackerID = process.env.GH_AW_TRACKER_ID || ""; + if (trackerID) { + core.info(`Tracker ID: ${trackerID}`); + return format === "markdown" ? `\n\n` : trackerID; + } + return ""; + } + function addExpirationComment(bodyLines, envVarName, entityType) { + const expiresEnv = process.env[envVarName]; + if (expiresEnv) { + const expiresDays = parseInt(expiresEnv, 10); + if (!isNaN(expiresDays) && expiresDays > 0) { + const expirationDate = new Date(); + expirationDate.setDate(expirationDate.getDate() + expiresDays); + const expirationISO = expirationDate.toISOString(); + bodyLines.push(``); + core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); + } + } + } + function removeDuplicateTitleFromDescription(title, description) { + if (!title || typeof title !== "string") { + return description || ""; + } + if (!description || typeof description !== "string") { + return ""; + } + const trimmedTitle = title.trim(); + const trimmedDescription = description.trim(); + if (!trimmedTitle || !trimmedDescription) { + return trimmedDescription; + } + const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); + if (headerRegex.test(trimmedDescription)) { + return trimmedDescription.replace(headerRegex, "").trim(); + } + return trimmedDescription; + } function generatePatchPreview(patchContent) { if (!patchContent || !patchContent.trim()) { return ""; diff --git a/.github/workflows/ci-doctor.lock.yml b/.github/workflows/ci-doctor.lock.yml index 4e629385318..9954d9908ad 100644 --- a/.github/workflows/ci-doctor.lock.yml +++ b/.github/workflows/ci-doctor.lock.yml @@ -6946,21 +6946,291 @@ jobs: with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - const { sanitizeLabelContent } = require("./sanitize_label_content.cjs"); - const { loadAgentOutput } = require("./load_agent_output.cjs"); - const { generateStagedPreview } = require("./staged_preview.cjs"); - const { generateFooter } = require("./generate_footer.cjs"); - const { getTrackerID } = require("./get_tracker_id.cjs"); - const { - generateTemporaryId, - isTemporaryId, - normalizeTemporaryId, - replaceTemporaryIdReferences, - serializeTemporaryIdMap, - } = require("./temporary_id.cjs"); - const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require("./repo_helpers.cjs"); - const { addExpirationComment } = require("./expiration_helpers.cjs"); - const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); + function sanitizeLabelContent(content) { + if (!content || typeof content !== "string") { + return ""; + } + let sanitized = content.trim(); + sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); + sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); + sanitized = sanitized.replace( + /(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, + (_m, p1, p2) => `${p1}\`@${p2}\`` + ); + sanitized = sanitized.replace(/[<>&'"]/g, ""); + return sanitized.trim(); + } + const fs = require("fs"); + const crypto = require("crypto"); + const MAX_LOG_CONTENT_LENGTH = 10000; + function truncateForLogging(content) { + if (content.length <= MAX_LOG_CONTENT_LENGTH) { + return content; + } + return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; + } + function loadAgentOutput() { + const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; + if (!agentOutputFile) { + core.info("No GH_AW_AGENT_OUTPUT environment variable found"); + return { success: false }; + } + let outputContent; + try { + outputContent = fs.readFileSync(agentOutputFile, "utf8"); + } catch (error) { + const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; + core.error(errorMessage); + return { success: false, error: errorMessage }; + } + if (outputContent.trim() === "") { + core.info("Agent output content is empty"); + return { success: false }; + } + core.info(`Agent output content length: ${outputContent.length}`); + let validatedOutput; + try { + validatedOutput = JSON.parse(outputContent); + } catch (error) { + const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; + core.error(errorMessage); + core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); + return { success: false, error: errorMessage }; + } + if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { + core.info("No valid items found in agent output"); + core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); + return { success: false }; + } + return { success: true, items: validatedOutput.items }; + } + async function generateStagedPreview(options) { + const { title, description, items, renderItem } = options; + let summaryContent = `## 🎭 Staged Mode: ${title} Preview\n\n`; + summaryContent += `${description}\n\n`; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + summaryContent += renderItem(item, i); + summaryContent += "---\n\n"; + } + try { + await core.summary.addRaw(summaryContent).write(); + core.info(summaryContent); + core.info(`📝 ${title} preview written to step summary`); + } catch (error) { + core.setFailed(error instanceof Error ? error : String(error)); + } + } + function generateXMLMarker(workflowName, runUrl) { + const engineId = process.env.GH_AW_ENGINE_ID || ""; + const engineVersion = process.env.GH_AW_ENGINE_VERSION || ""; + const engineModel = process.env.GH_AW_ENGINE_MODEL || ""; + const trackerId = process.env.GH_AW_TRACKER_ID || ""; + const parts = []; + parts.push(`agentic-workflow: ${workflowName}`); + if (trackerId) { + parts.push(`tracker-id: ${trackerId}`); + } + if (engineId) { + parts.push(`engine: ${engineId}`); + } + if (engineVersion) { + parts.push(`version: ${engineVersion}`); + } + if (engineModel) { + parts.push(`model: ${engineModel}`); + } + parts.push(`run: ${runUrl}`); + return ``; + } + function generateFooter( + workflowName, + runUrl, + workflowSource, + workflowSourceURL, + triggeringIssueNumber, + triggeringPRNumber, + triggeringDiscussionNumber + ) { + let footer = `\n\n> AI generated by [${workflowName}](${runUrl})`; + if (triggeringIssueNumber) { + footer += ` for #${triggeringIssueNumber}`; + } else if (triggeringPRNumber) { + footer += ` for #${triggeringPRNumber}`; + } else if (triggeringDiscussionNumber) { + footer += ` for discussion #${triggeringDiscussionNumber}`; + } + if (workflowSource && workflowSourceURL) { + footer += `\n>\n> To add this workflow in your repository, run \`gh aw add ${workflowSource}\`. See [usage guide](https://githubnext.github.io/gh-aw/tools/cli/).`; + } + footer += "\n\n" + generateXMLMarker(workflowName, runUrl); + footer += "\n"; + return footer; + } + function getTrackerID(format) { + const trackerID = process.env.GH_AW_TRACKER_ID || ""; + if (trackerID) { + core.info(`Tracker ID: ${trackerID}`); + return format === "markdown" ? `\n\n` : trackerID; + } + return ""; + } + const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; + function generateTemporaryId() { + return "aw_" + crypto.randomBytes(6).toString("hex"); + } + function isTemporaryId(value) { + if (typeof value === "string") { + return /^aw_[0-9a-f]{12}$/i.test(value); + } + return false; + } + function normalizeTemporaryId(tempId) { + return String(tempId).toLowerCase(); + } + function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { + return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { + const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); + if (resolved !== undefined) { + if (currentRepo && resolved.repo === currentRepo) { + return `#${resolved.number}`; + } + return `${resolved.repo}#${resolved.number}`; + } + return match; + }); + } + function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { + return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { + const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); + if (issueNumber !== undefined) { + return `#${issueNumber}`; + } + return match; + }); + } + function loadTemporaryIdMap() { + const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; + if (!mapJson || mapJson === "{}") { + return new Map(); + } + try { + const mapObject = JSON.parse(mapJson); + const result = new Map(); + for (const [key, value] of Object.entries(mapObject)) { + const normalizedKey = normalizeTemporaryId(key); + if (typeof value === "number") { + const contextRepo = `${context.repo.owner}/${context.repo.repo}`; + result.set(normalizedKey, { repo: contextRepo, number: value }); + } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { + result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); + } + } + return result; + } catch (error) { + if (typeof core !== "undefined") { + core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); + } + return new Map(); + } + } + function resolveIssueNumber(value, temporaryIdMap) { + if (value === undefined || value === null) { + return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; + } + const valueStr = String(value); + if (isTemporaryId(valueStr)) { + const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); + if (resolvedPair !== undefined) { + return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; + } + return { + resolved: null, + wasTemporaryId: true, + errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, + }; + } + const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); + if (isNaN(issueNumber) || issueNumber <= 0) { + return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; + } + const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; + return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; + } + function serializeTemporaryIdMap(tempIdMap) { + const obj = Object.fromEntries(tempIdMap); + return JSON.stringify(obj); + } + function parseAllowedRepos() { + const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; + const set = new Set(); + if (allowedReposEnv) { + allowedReposEnv + .split(",") + .map(repo => repo.trim()) + .filter(repo => repo) + .forEach(repo => set.add(repo)); + } + return set; + } + function getDefaultTargetRepo() { + const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; + if (targetRepoSlug) { + return targetRepoSlug; + } + return `${context.repo.owner}/${context.repo.repo}`; + } + function validateRepo(repo, defaultRepo, allowedRepos) { + if (repo === defaultRepo) { + return { valid: true, error: null }; + } + if (allowedRepos.has(repo)) { + return { valid: true, error: null }; + } + return { + valid: false, + error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, + }; + } + function parseRepoSlug(repoSlug) { + const parts = repoSlug.split("/"); + if (parts.length !== 2 || !parts[0] || !parts[1]) { + return null; + } + return { owner: parts[0], repo: parts[1] }; + } + function addExpirationComment(bodyLines, envVarName, entityType) { + const expiresEnv = process.env[envVarName]; + if (expiresEnv) { + const expiresDays = parseInt(expiresEnv, 10); + if (!isNaN(expiresDays) && expiresDays > 0) { + const expirationDate = new Date(); + expirationDate.setDate(expirationDate.getDate() + expiresDays); + const expirationISO = expirationDate.toISOString(); + bodyLines.push(``); + core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); + } + } + } + function removeDuplicateTitleFromDescription(title, description) { + if (!title || typeof title !== "string") { + return description || ""; + } + if (!description || typeof description !== "string") { + return ""; + } + const trimmedTitle = title.trim(); + const trimmedDescription = description.trim(); + if (!trimmedTitle || !trimmedDescription) { + return trimmedDescription; + } + const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); + if (headerRegex.test(trimmedDescription)) { + return trimmedDescription.replace(headerRegex, "").trim(); + } + return trimmedDescription; + } async function main() { core.setOutput("issue_number", ""); core.setOutput("issue_url", ""); diff --git a/.github/workflows/cli-consistency-checker.lock.yml b/.github/workflows/cli-consistency-checker.lock.yml index f868ed25b45..aee2f65af2b 100644 --- a/.github/workflows/cli-consistency-checker.lock.yml +++ b/.github/workflows/cli-consistency-checker.lock.yml @@ -6232,21 +6232,291 @@ jobs: with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - const { sanitizeLabelContent } = require("./sanitize_label_content.cjs"); - const { loadAgentOutput } = require("./load_agent_output.cjs"); - const { generateStagedPreview } = require("./staged_preview.cjs"); - const { generateFooter } = require("./generate_footer.cjs"); - const { getTrackerID } = require("./get_tracker_id.cjs"); - const { - generateTemporaryId, - isTemporaryId, - normalizeTemporaryId, - replaceTemporaryIdReferences, - serializeTemporaryIdMap, - } = require("./temporary_id.cjs"); - const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require("./repo_helpers.cjs"); - const { addExpirationComment } = require("./expiration_helpers.cjs"); - const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); + function sanitizeLabelContent(content) { + if (!content || typeof content !== "string") { + return ""; + } + let sanitized = content.trim(); + sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); + sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); + sanitized = sanitized.replace( + /(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, + (_m, p1, p2) => `${p1}\`@${p2}\`` + ); + sanitized = sanitized.replace(/[<>&'"]/g, ""); + return sanitized.trim(); + } + const fs = require("fs"); + const crypto = require("crypto"); + const MAX_LOG_CONTENT_LENGTH = 10000; + function truncateForLogging(content) { + if (content.length <= MAX_LOG_CONTENT_LENGTH) { + return content; + } + return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; + } + function loadAgentOutput() { + const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; + if (!agentOutputFile) { + core.info("No GH_AW_AGENT_OUTPUT environment variable found"); + return { success: false }; + } + let outputContent; + try { + outputContent = fs.readFileSync(agentOutputFile, "utf8"); + } catch (error) { + const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; + core.error(errorMessage); + return { success: false, error: errorMessage }; + } + if (outputContent.trim() === "") { + core.info("Agent output content is empty"); + return { success: false }; + } + core.info(`Agent output content length: ${outputContent.length}`); + let validatedOutput; + try { + validatedOutput = JSON.parse(outputContent); + } catch (error) { + const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; + core.error(errorMessage); + core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); + return { success: false, error: errorMessage }; + } + if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { + core.info("No valid items found in agent output"); + core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); + return { success: false }; + } + return { success: true, items: validatedOutput.items }; + } + async function generateStagedPreview(options) { + const { title, description, items, renderItem } = options; + let summaryContent = `## 🎭 Staged Mode: ${title} Preview\n\n`; + summaryContent += `${description}\n\n`; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + summaryContent += renderItem(item, i); + summaryContent += "---\n\n"; + } + try { + await core.summary.addRaw(summaryContent).write(); + core.info(summaryContent); + core.info(`📝 ${title} preview written to step summary`); + } catch (error) { + core.setFailed(error instanceof Error ? error : String(error)); + } + } + function generateXMLMarker(workflowName, runUrl) { + const engineId = process.env.GH_AW_ENGINE_ID || ""; + const engineVersion = process.env.GH_AW_ENGINE_VERSION || ""; + const engineModel = process.env.GH_AW_ENGINE_MODEL || ""; + const trackerId = process.env.GH_AW_TRACKER_ID || ""; + const parts = []; + parts.push(`agentic-workflow: ${workflowName}`); + if (trackerId) { + parts.push(`tracker-id: ${trackerId}`); + } + if (engineId) { + parts.push(`engine: ${engineId}`); + } + if (engineVersion) { + parts.push(`version: ${engineVersion}`); + } + if (engineModel) { + parts.push(`model: ${engineModel}`); + } + parts.push(`run: ${runUrl}`); + return ``; + } + function generateFooter( + workflowName, + runUrl, + workflowSource, + workflowSourceURL, + triggeringIssueNumber, + triggeringPRNumber, + triggeringDiscussionNumber + ) { + let footer = `\n\n> AI generated by [${workflowName}](${runUrl})`; + if (triggeringIssueNumber) { + footer += ` for #${triggeringIssueNumber}`; + } else if (triggeringPRNumber) { + footer += ` for #${triggeringPRNumber}`; + } else if (triggeringDiscussionNumber) { + footer += ` for discussion #${triggeringDiscussionNumber}`; + } + if (workflowSource && workflowSourceURL) { + footer += `\n>\n> To add this workflow in your repository, run \`gh aw add ${workflowSource}\`. See [usage guide](https://githubnext.github.io/gh-aw/tools/cli/).`; + } + footer += "\n\n" + generateXMLMarker(workflowName, runUrl); + footer += "\n"; + return footer; + } + function getTrackerID(format) { + const trackerID = process.env.GH_AW_TRACKER_ID || ""; + if (trackerID) { + core.info(`Tracker ID: ${trackerID}`); + return format === "markdown" ? `\n\n` : trackerID; + } + return ""; + } + const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; + function generateTemporaryId() { + return "aw_" + crypto.randomBytes(6).toString("hex"); + } + function isTemporaryId(value) { + if (typeof value === "string") { + return /^aw_[0-9a-f]{12}$/i.test(value); + } + return false; + } + function normalizeTemporaryId(tempId) { + return String(tempId).toLowerCase(); + } + function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { + return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { + const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); + if (resolved !== undefined) { + if (currentRepo && resolved.repo === currentRepo) { + return `#${resolved.number}`; + } + return `${resolved.repo}#${resolved.number}`; + } + return match; + }); + } + function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { + return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { + const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); + if (issueNumber !== undefined) { + return `#${issueNumber}`; + } + return match; + }); + } + function loadTemporaryIdMap() { + const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; + if (!mapJson || mapJson === "{}") { + return new Map(); + } + try { + const mapObject = JSON.parse(mapJson); + const result = new Map(); + for (const [key, value] of Object.entries(mapObject)) { + const normalizedKey = normalizeTemporaryId(key); + if (typeof value === "number") { + const contextRepo = `${context.repo.owner}/${context.repo.repo}`; + result.set(normalizedKey, { repo: contextRepo, number: value }); + } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { + result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); + } + } + return result; + } catch (error) { + if (typeof core !== "undefined") { + core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); + } + return new Map(); + } + } + function resolveIssueNumber(value, temporaryIdMap) { + if (value === undefined || value === null) { + return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; + } + const valueStr = String(value); + if (isTemporaryId(valueStr)) { + const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); + if (resolvedPair !== undefined) { + return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; + } + return { + resolved: null, + wasTemporaryId: true, + errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, + }; + } + const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); + if (isNaN(issueNumber) || issueNumber <= 0) { + return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; + } + const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; + return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; + } + function serializeTemporaryIdMap(tempIdMap) { + const obj = Object.fromEntries(tempIdMap); + return JSON.stringify(obj); + } + function parseAllowedRepos() { + const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; + const set = new Set(); + if (allowedReposEnv) { + allowedReposEnv + .split(",") + .map(repo => repo.trim()) + .filter(repo => repo) + .forEach(repo => set.add(repo)); + } + return set; + } + function getDefaultTargetRepo() { + const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; + if (targetRepoSlug) { + return targetRepoSlug; + } + return `${context.repo.owner}/${context.repo.repo}`; + } + function validateRepo(repo, defaultRepo, allowedRepos) { + if (repo === defaultRepo) { + return { valid: true, error: null }; + } + if (allowedRepos.has(repo)) { + return { valid: true, error: null }; + } + return { + valid: false, + error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, + }; + } + function parseRepoSlug(repoSlug) { + const parts = repoSlug.split("/"); + if (parts.length !== 2 || !parts[0] || !parts[1]) { + return null; + } + return { owner: parts[0], repo: parts[1] }; + } + function addExpirationComment(bodyLines, envVarName, entityType) { + const expiresEnv = process.env[envVarName]; + if (expiresEnv) { + const expiresDays = parseInt(expiresEnv, 10); + if (!isNaN(expiresDays) && expiresDays > 0) { + const expirationDate = new Date(); + expirationDate.setDate(expirationDate.getDate() + expiresDays); + const expirationISO = expirationDate.toISOString(); + bodyLines.push(``); + core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); + } + } + } + function removeDuplicateTitleFromDescription(title, description) { + if (!title || typeof title !== "string") { + return description || ""; + } + if (!description || typeof description !== "string") { + return ""; + } + const trimmedTitle = title.trim(); + const trimmedDescription = description.trim(); + if (!trimmedTitle || !trimmedDescription) { + return trimmedDescription; + } + const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); + if (headerRegex.test(trimmedDescription)) { + return trimmedDescription.replace(headerRegex, "").trim(); + } + return trimmedDescription; + } async function main() { core.setOutput("issue_number", ""); core.setOutput("issue_url", ""); diff --git a/.github/workflows/cli-version-checker.lock.yml b/.github/workflows/cli-version-checker.lock.yml index c9a7bd0ed1a..f9590aeca25 100644 --- a/.github/workflows/cli-version-checker.lock.yml +++ b/.github/workflows/cli-version-checker.lock.yml @@ -6005,21 +6005,291 @@ jobs: with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - const { sanitizeLabelContent } = require("./sanitize_label_content.cjs"); - const { loadAgentOutput } = require("./load_agent_output.cjs"); - const { generateStagedPreview } = require("./staged_preview.cjs"); - const { generateFooter } = require("./generate_footer.cjs"); - const { getTrackerID } = require("./get_tracker_id.cjs"); - const { - generateTemporaryId, - isTemporaryId, - normalizeTemporaryId, - replaceTemporaryIdReferences, - serializeTemporaryIdMap, - } = require("./temporary_id.cjs"); - const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require("./repo_helpers.cjs"); - const { addExpirationComment } = require("./expiration_helpers.cjs"); - const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); + function sanitizeLabelContent(content) { + if (!content || typeof content !== "string") { + return ""; + } + let sanitized = content.trim(); + sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); + sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); + sanitized = sanitized.replace( + /(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, + (_m, p1, p2) => `${p1}\`@${p2}\`` + ); + sanitized = sanitized.replace(/[<>&'"]/g, ""); + return sanitized.trim(); + } + const fs = require("fs"); + const crypto = require("crypto"); + const MAX_LOG_CONTENT_LENGTH = 10000; + function truncateForLogging(content) { + if (content.length <= MAX_LOG_CONTENT_LENGTH) { + return content; + } + return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; + } + function loadAgentOutput() { + const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; + if (!agentOutputFile) { + core.info("No GH_AW_AGENT_OUTPUT environment variable found"); + return { success: false }; + } + let outputContent; + try { + outputContent = fs.readFileSync(agentOutputFile, "utf8"); + } catch (error) { + const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; + core.error(errorMessage); + return { success: false, error: errorMessage }; + } + if (outputContent.trim() === "") { + core.info("Agent output content is empty"); + return { success: false }; + } + core.info(`Agent output content length: ${outputContent.length}`); + let validatedOutput; + try { + validatedOutput = JSON.parse(outputContent); + } catch (error) { + const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; + core.error(errorMessage); + core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); + return { success: false, error: errorMessage }; + } + if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { + core.info("No valid items found in agent output"); + core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); + return { success: false }; + } + return { success: true, items: validatedOutput.items }; + } + async function generateStagedPreview(options) { + const { title, description, items, renderItem } = options; + let summaryContent = `## 🎭 Staged Mode: ${title} Preview\n\n`; + summaryContent += `${description}\n\n`; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + summaryContent += renderItem(item, i); + summaryContent += "---\n\n"; + } + try { + await core.summary.addRaw(summaryContent).write(); + core.info(summaryContent); + core.info(`📝 ${title} preview written to step summary`); + } catch (error) { + core.setFailed(error instanceof Error ? error : String(error)); + } + } + function generateXMLMarker(workflowName, runUrl) { + const engineId = process.env.GH_AW_ENGINE_ID || ""; + const engineVersion = process.env.GH_AW_ENGINE_VERSION || ""; + const engineModel = process.env.GH_AW_ENGINE_MODEL || ""; + const trackerId = process.env.GH_AW_TRACKER_ID || ""; + const parts = []; + parts.push(`agentic-workflow: ${workflowName}`); + if (trackerId) { + parts.push(`tracker-id: ${trackerId}`); + } + if (engineId) { + parts.push(`engine: ${engineId}`); + } + if (engineVersion) { + parts.push(`version: ${engineVersion}`); + } + if (engineModel) { + parts.push(`model: ${engineModel}`); + } + parts.push(`run: ${runUrl}`); + return ``; + } + function generateFooter( + workflowName, + runUrl, + workflowSource, + workflowSourceURL, + triggeringIssueNumber, + triggeringPRNumber, + triggeringDiscussionNumber + ) { + let footer = `\n\n> AI generated by [${workflowName}](${runUrl})`; + if (triggeringIssueNumber) { + footer += ` for #${triggeringIssueNumber}`; + } else if (triggeringPRNumber) { + footer += ` for #${triggeringPRNumber}`; + } else if (triggeringDiscussionNumber) { + footer += ` for discussion #${triggeringDiscussionNumber}`; + } + if (workflowSource && workflowSourceURL) { + footer += `\n>\n> To add this workflow in your repository, run \`gh aw add ${workflowSource}\`. See [usage guide](https://githubnext.github.io/gh-aw/tools/cli/).`; + } + footer += "\n\n" + generateXMLMarker(workflowName, runUrl); + footer += "\n"; + return footer; + } + function getTrackerID(format) { + const trackerID = process.env.GH_AW_TRACKER_ID || ""; + if (trackerID) { + core.info(`Tracker ID: ${trackerID}`); + return format === "markdown" ? `\n\n` : trackerID; + } + return ""; + } + const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; + function generateTemporaryId() { + return "aw_" + crypto.randomBytes(6).toString("hex"); + } + function isTemporaryId(value) { + if (typeof value === "string") { + return /^aw_[0-9a-f]{12}$/i.test(value); + } + return false; + } + function normalizeTemporaryId(tempId) { + return String(tempId).toLowerCase(); + } + function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { + return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { + const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); + if (resolved !== undefined) { + if (currentRepo && resolved.repo === currentRepo) { + return `#${resolved.number}`; + } + return `${resolved.repo}#${resolved.number}`; + } + return match; + }); + } + function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { + return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { + const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); + if (issueNumber !== undefined) { + return `#${issueNumber}`; + } + return match; + }); + } + function loadTemporaryIdMap() { + const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; + if (!mapJson || mapJson === "{}") { + return new Map(); + } + try { + const mapObject = JSON.parse(mapJson); + const result = new Map(); + for (const [key, value] of Object.entries(mapObject)) { + const normalizedKey = normalizeTemporaryId(key); + if (typeof value === "number") { + const contextRepo = `${context.repo.owner}/${context.repo.repo}`; + result.set(normalizedKey, { repo: contextRepo, number: value }); + } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { + result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); + } + } + return result; + } catch (error) { + if (typeof core !== "undefined") { + core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); + } + return new Map(); + } + } + function resolveIssueNumber(value, temporaryIdMap) { + if (value === undefined || value === null) { + return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; + } + const valueStr = String(value); + if (isTemporaryId(valueStr)) { + const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); + if (resolvedPair !== undefined) { + return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; + } + return { + resolved: null, + wasTemporaryId: true, + errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, + }; + } + const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); + if (isNaN(issueNumber) || issueNumber <= 0) { + return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; + } + const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; + return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; + } + function serializeTemporaryIdMap(tempIdMap) { + const obj = Object.fromEntries(tempIdMap); + return JSON.stringify(obj); + } + function parseAllowedRepos() { + const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; + const set = new Set(); + if (allowedReposEnv) { + allowedReposEnv + .split(",") + .map(repo => repo.trim()) + .filter(repo => repo) + .forEach(repo => set.add(repo)); + } + return set; + } + function getDefaultTargetRepo() { + const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; + if (targetRepoSlug) { + return targetRepoSlug; + } + return `${context.repo.owner}/${context.repo.repo}`; + } + function validateRepo(repo, defaultRepo, allowedRepos) { + if (repo === defaultRepo) { + return { valid: true, error: null }; + } + if (allowedRepos.has(repo)) { + return { valid: true, error: null }; + } + return { + valid: false, + error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, + }; + } + function parseRepoSlug(repoSlug) { + const parts = repoSlug.split("/"); + if (parts.length !== 2 || !parts[0] || !parts[1]) { + return null; + } + return { owner: parts[0], repo: parts[1] }; + } + function addExpirationComment(bodyLines, envVarName, entityType) { + const expiresEnv = process.env[envVarName]; + if (expiresEnv) { + const expiresDays = parseInt(expiresEnv, 10); + if (!isNaN(expiresDays) && expiresDays > 0) { + const expirationDate = new Date(); + expirationDate.setDate(expirationDate.getDate() + expiresDays); + const expirationISO = expirationDate.toISOString(); + bodyLines.push(``); + core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); + } + } + } + function removeDuplicateTitleFromDescription(title, description) { + if (!title || typeof title !== "string") { + return description || ""; + } + if (!description || typeof description !== "string") { + return ""; + } + const trimmedTitle = title.trim(); + const trimmedDescription = description.trim(); + if (!trimmedTitle || !trimmedDescription) { + return trimmedDescription; + } + const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); + if (headerRegex.test(trimmedDescription)) { + return trimmedDescription.replace(headerRegex, "").trim(); + } + return trimmedDescription; + } async function main() { core.setOutput("issue_number", ""); core.setOutput("issue_url", ""); diff --git a/.github/workflows/cloclo.lock.yml b/.github/workflows/cloclo.lock.yml index 6db5204950c..f2dd58bd537 100644 --- a/.github/workflows/cloclo.lock.yml +++ b/.github/workflows/cloclo.lock.yml @@ -7521,10 +7521,151 @@ jobs: script: | const fs = require("fs"); const crypto = require("crypto"); - const { updateActivationComment } = require("./update_activation_comment.cjs"); - const { getTrackerID } = require("./get_tracker_id.cjs"); - const { addExpirationComment } = require("./expiration_helpers.cjs"); - const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); + async function updateActivationComment(github, context, core, itemUrl, itemNumber, itemType = "pull_request") { + const itemLabel = itemType === "issue" ? "issue" : "pull request"; + const linkMessage = + itemType === "issue" + ? `\n\n✅ Issue created: [#${itemNumber}](${itemUrl})` + : `\n\n✅ Pull request created: [#${itemNumber}](${itemUrl})`; + await updateActivationCommentWithMessage(github, context, core, linkMessage, itemLabel); + } + async function updateActivationCommentWithCommit(github, context, core, commitSha, commitUrl) { + const shortSha = commitSha.substring(0, 7); + const message = `\n\n✅ Commit pushed: [\`${shortSha}\`](${commitUrl})`; + await updateActivationCommentWithMessage(github, context, core, message, "commit"); + } + async function updateActivationCommentWithMessage(github, context, core, message, label = "") { + const commentId = process.env.GH_AW_COMMENT_ID; + const commentRepo = process.env.GH_AW_COMMENT_REPO; + if (!commentId) { + core.info("No activation comment to update (GH_AW_COMMENT_ID not set)"); + return; + } + core.info(`Updating activation comment ${commentId}`); + let repoOwner = context.repo.owner; + let repoName = context.repo.repo; + if (commentRepo) { + const parts = commentRepo.split("/"); + if (parts.length === 2) { + repoOwner = parts[0]; + repoName = parts[1]; + } else { + core.warning(`Invalid comment repo format: ${commentRepo}, expected "owner/repo". Falling back to context.repo.`); + } + } + core.info(`Updating comment in ${repoOwner}/${repoName}`); + const isDiscussionComment = commentId.startsWith("DC_"); + try { + if (isDiscussionComment) { + const currentComment = await github.graphql( + ` + query($commentId: ID!) { + node(id: $commentId) { + ... on DiscussionComment { + body + } + } + }`, + { commentId: commentId } + ); + if (!currentComment?.node?.body) { + core.warning("Unable to fetch current comment body, comment may have been deleted or is inaccessible"); + return; + } + const currentBody = currentComment.node.body; + const updatedBody = currentBody + message; + const result = await github.graphql( + ` + mutation($commentId: ID!, $body: String!) { + updateDiscussionComment(input: { commentId: $commentId, body: $body }) { + comment { + id + url + } + } + }`, + { commentId: commentId, body: updatedBody } + ); + const comment = result.updateDiscussionComment.comment; + const successMessage = label + ? `Successfully updated discussion comment with ${label} link` + : "Successfully updated discussion comment"; + core.info(successMessage); + core.info(`Comment ID: ${comment.id}`); + core.info(`Comment URL: ${comment.url}`); + } else { + const currentComment = await github.request("GET /repos/{owner}/{repo}/issues/comments/{comment_id}", { + owner: repoOwner, + repo: repoName, + comment_id: parseInt(commentId, 10), + headers: { + Accept: "application/vnd.github+json", + }, + }); + if (!currentComment?.data?.body) { + core.warning("Unable to fetch current comment body, comment may have been deleted"); + return; + } + const currentBody = currentComment.data.body; + const updatedBody = currentBody + message; + const response = await github.request("PATCH /repos/{owner}/{repo}/issues/comments/{comment_id}", { + owner: repoOwner, + repo: repoName, + comment_id: parseInt(commentId, 10), + body: updatedBody, + headers: { + Accept: "application/vnd.github+json", + }, + }); + const successMessage = label ? `Successfully updated comment with ${label} link` : "Successfully updated comment"; + core.info(successMessage); + core.info(`Comment ID: ${response.data.id}`); + core.info(`Comment URL: ${response.data.html_url}`); + } + } catch (error) { + core.warning(`Failed to update activation comment: ${error instanceof Error ? error.message : String(error)}`); + } + } + function getTrackerID(format) { + const trackerID = process.env.GH_AW_TRACKER_ID || ""; + if (trackerID) { + core.info(`Tracker ID: ${trackerID}`); + return format === "markdown" ? `\n\n` : trackerID; + } + return ""; + } + function addExpirationComment(bodyLines, envVarName, entityType) { + const expiresEnv = process.env[envVarName]; + if (expiresEnv) { + const expiresDays = parseInt(expiresEnv, 10); + if (!isNaN(expiresDays) && expiresDays > 0) { + const expirationDate = new Date(); + expirationDate.setDate(expirationDate.getDate() + expiresDays); + const expirationISO = expirationDate.toISOString(); + bodyLines.push(``); + core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); + } + } + } + function removeDuplicateTitleFromDescription(title, description) { + if (!title || typeof title !== "string") { + return description || ""; + } + if (!description || typeof description !== "string") { + return ""; + } + const trimmedTitle = title.trim(); + const trimmedDescription = description.trim(); + if (!trimmedTitle || !trimmedDescription) { + return trimmedDescription; + } + const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); + if (headerRegex.test(trimmedDescription)) { + return trimmedDescription.replace(headerRegex, "").trim(); + } + return trimmedDescription; + } function generatePatchPreview(patchContent) { if (!patchContent || !patchContent.trim()) { return ""; diff --git a/.github/workflows/commit-changes-analyzer.lock.yml b/.github/workflows/commit-changes-analyzer.lock.yml index 8eabdb0feca..489153a2942 100644 --- a/.github/workflows/commit-changes-analyzer.lock.yml +++ b/.github/workflows/commit-changes-analyzer.lock.yml @@ -5927,13 +5927,400 @@ jobs: with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - const { loadAgentOutput } = require("./load_agent_output.cjs"); - const { getTrackerID } = require("./get_tracker_id.cjs"); - const { closeOlderDiscussions } = require("./close_older_discussions.cjs"); - const { replaceTemporaryIdReferences, loadTemporaryIdMap } = require("./temporary_id.cjs"); - const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require("./repo_helpers.cjs"); - const { addExpirationComment } = require("./expiration_helpers.cjs"); - const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); + const fs = require("fs"); + const crypto = require("crypto"); + const MAX_LOG_CONTENT_LENGTH = 10000; + function truncateForLogging(content) { + if (content.length <= MAX_LOG_CONTENT_LENGTH) { + return content; + } + return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; + } + function loadAgentOutput() { + const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; + if (!agentOutputFile) { + core.info("No GH_AW_AGENT_OUTPUT environment variable found"); + return { success: false }; + } + let outputContent; + try { + outputContent = fs.readFileSync(agentOutputFile, "utf8"); + } catch (error) { + const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; + core.error(errorMessage); + return { success: false, error: errorMessage }; + } + if (outputContent.trim() === "") { + core.info("Agent output content is empty"); + return { success: false }; + } + core.info(`Agent output content length: ${outputContent.length}`); + let validatedOutput; + try { + validatedOutput = JSON.parse(outputContent); + } catch (error) { + const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; + core.error(errorMessage); + core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); + return { success: false, error: errorMessage }; + } + if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { + core.info("No valid items found in agent output"); + core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); + return { success: false }; + } + return { success: true, items: validatedOutput.items }; + } + function getTrackerID(format) { + const trackerID = process.env.GH_AW_TRACKER_ID || ""; + if (trackerID) { + core.info(`Tracker ID: ${trackerID}`); + return format === "markdown" ? `\n\n` : trackerID; + } + return ""; + } + function getMessages() { + const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES; + if (!messagesEnv) { + return null; + } + try { + return JSON.parse(messagesEnv); + } catch (error) { + core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`); + return null; + } + } + function renderTemplate(template, context) { + return template.replace(/\{(\w+)\}/g, (match, key) => { + const value = context[key]; + return value !== undefined && value !== null ? String(value) : match; + }); + } + function toSnakeCase(obj) { + const result = {}; + for (const [key, value] of Object.entries(obj)) { + const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase(); + result[snakeKey] = value; + result[key] = value; + } + return result; + } + function getCloseOlderDiscussionMessage(ctx) { + const messages = getMessages(); + const templateContext = toSnakeCase(ctx); + const defaultMessage = `⚓ Avast! This discussion be marked as **outdated** by [{workflow_name}]({run_url}). + 🗺️ A newer treasure map awaits ye at **[Discussion #{new_discussion_number}]({new_discussion_url})**. + Fair winds, matey! 🏴‍☠️`; + return messages?.closeOlderDiscussion + ? renderTemplate(messages.closeOlderDiscussion, templateContext) + : renderTemplate(defaultMessage, templateContext); + } + const MAX_CLOSE_COUNT = 10; + const GRAPHQL_DELAY_MS = 500; + function delay(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); + } + async function searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, excludeNumber) { + let searchQuery = `repo:${owner}/${repo} is:open`; + if (titlePrefix) { + const escapedPrefix = titlePrefix.replace(/"/g, '\\"'); + searchQuery += ` in:title "${escapedPrefix}"`; + } + if (labels && labels.length > 0) { + for (const label of labels) { + const escapedLabel = label.replace(/"/g, '\\"'); + searchQuery += ` label:"${escapedLabel}"`; + } + } + const result = await github.graphql( + ` + query($searchTerms: String!, $first: Int!) { + search(query: $searchTerms, type: DISCUSSION, first: $first) { + nodes { + ... on Discussion { + id + number + title + url + category { + id + } + labels(first: 100) { + nodes { + name + } + } + closed + } + } + } + }`, + { searchTerms: searchQuery, first: 50 } + ); + if (!result || !result.search || !result.search.nodes) { + return []; + } + return result.search.nodes + .filter( + d => { + if (!d || d.number === excludeNumber || d.closed) { + return false; + } + if (titlePrefix && d.title && !d.title.startsWith(titlePrefix)) { + return false; + } + if (labels && labels.length > 0) { + const discussionLabels = d.labels?.nodes?.map(( l) => l.name) || []; + const hasAllLabels = labels.every(label => discussionLabels.includes(label)); + if (!hasAllLabels) { + return false; + } + } + if (categoryId && (!d.category || d.category.id !== categoryId)) { + return false; + } + return true; + } + ) + .map( + d => ({ + id: d.id, + number: d.number, + title: d.title, + url: d.url, + }) + ); + } + async function addDiscussionComment(github, discussionId, message) { + const result = await github.graphql( + ` + mutation($dId: ID!, $body: String!) { + addDiscussionComment(input: { discussionId: $dId, body: $body }) { + comment { + id + url + } + } + }`, + { dId: discussionId, body: message } + ); + return result.addDiscussionComment.comment; + } + async function closeDiscussionAsOutdated(github, discussionId) { + const result = await github.graphql( + ` + mutation($dId: ID!) { + closeDiscussion(input: { discussionId: $dId, reason: OUTDATED }) { + discussion { + id + url + } + } + }`, + { dId: discussionId } + ); + return result.closeDiscussion.discussion; + } + async function closeOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion, workflowName, runUrl) { + const searchCriteria = []; + if (titlePrefix) searchCriteria.push(`title prefix: "${titlePrefix}"`); + if (labels && labels.length > 0) searchCriteria.push(`labels: [${labels.join(", ")}]`); + core.info(`Searching for older discussions with ${searchCriteria.join(" and ")}`); + const olderDiscussions = await searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion.number); + if (olderDiscussions.length === 0) { + core.info("No older discussions found to close"); + return []; + } + core.info(`Found ${olderDiscussions.length} older discussion(s) to close`); + const discussionsToClose = olderDiscussions.slice(0, MAX_CLOSE_COUNT); + if (olderDiscussions.length > MAX_CLOSE_COUNT) { + core.warning(`Found ${olderDiscussions.length} older discussions, but only closing the first ${MAX_CLOSE_COUNT}`); + } + const closedDiscussions = []; + for (let i = 0; i < discussionsToClose.length; i++) { + const discussion = discussionsToClose[i]; + try { + const closingMessage = getCloseOlderDiscussionMessage({ + newDiscussionUrl: newDiscussion.url, + newDiscussionNumber: newDiscussion.number, + workflowName, + runUrl, + }); + core.info(`Adding closing comment to discussion #${discussion.number}`); + await addDiscussionComment(github, discussion.id, closingMessage); + core.info(`Closing discussion #${discussion.number} as outdated`); + await closeDiscussionAsOutdated(github, discussion.id); + closedDiscussions.push({ + number: discussion.number, + url: discussion.url, + }); + core.info(`✓ Closed discussion #${discussion.number}: ${discussion.url}`); + } catch (error) { + core.error(`✗ Failed to close discussion #${discussion.number}: ${error instanceof Error ? error.message : String(error)}`); + } + if (i < discussionsToClose.length - 1) { + await delay(GRAPHQL_DELAY_MS); + } + } + return closedDiscussions; + } + const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; + function generateTemporaryId() { + return "aw_" + crypto.randomBytes(6).toString("hex"); + } + function isTemporaryId(value) { + if (typeof value === "string") { + return /^aw_[0-9a-f]{12}$/i.test(value); + } + return false; + } + function normalizeTemporaryId(tempId) { + return String(tempId).toLowerCase(); + } + function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { + return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { + const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); + if (resolved !== undefined) { + if (currentRepo && resolved.repo === currentRepo) { + return `#${resolved.number}`; + } + return `${resolved.repo}#${resolved.number}`; + } + return match; + }); + } + function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { + return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { + const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); + if (issueNumber !== undefined) { + return `#${issueNumber}`; + } + return match; + }); + } + function loadTemporaryIdMap() { + const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; + if (!mapJson || mapJson === "{}") { + return new Map(); + } + try { + const mapObject = JSON.parse(mapJson); + const result = new Map(); + for (const [key, value] of Object.entries(mapObject)) { + const normalizedKey = normalizeTemporaryId(key); + if (typeof value === "number") { + const contextRepo = `${context.repo.owner}/${context.repo.repo}`; + result.set(normalizedKey, { repo: contextRepo, number: value }); + } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { + result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); + } + } + return result; + } catch (error) { + if (typeof core !== "undefined") { + core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); + } + return new Map(); + } + } + function resolveIssueNumber(value, temporaryIdMap) { + if (value === undefined || value === null) { + return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; + } + const valueStr = String(value); + if (isTemporaryId(valueStr)) { + const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); + if (resolvedPair !== undefined) { + return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; + } + return { + resolved: null, + wasTemporaryId: true, + errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, + }; + } + const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); + if (isNaN(issueNumber) || issueNumber <= 0) { + return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; + } + const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; + return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; + } + function serializeTemporaryIdMap(tempIdMap) { + const obj = Object.fromEntries(tempIdMap); + return JSON.stringify(obj); + } + function parseAllowedRepos() { + const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; + const set = new Set(); + if (allowedReposEnv) { + allowedReposEnv + .split(",") + .map(repo => repo.trim()) + .filter(repo => repo) + .forEach(repo => set.add(repo)); + } + return set; + } + function getDefaultTargetRepo() { + const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; + if (targetRepoSlug) { + return targetRepoSlug; + } + return `${context.repo.owner}/${context.repo.repo}`; + } + function validateRepo(repo, defaultRepo, allowedRepos) { + if (repo === defaultRepo) { + return { valid: true, error: null }; + } + if (allowedRepos.has(repo)) { + return { valid: true, error: null }; + } + return { + valid: false, + error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, + }; + } + function parseRepoSlug(repoSlug) { + const parts = repoSlug.split("/"); + if (parts.length !== 2 || !parts[0] || !parts[1]) { + return null; + } + return { owner: parts[0], repo: parts[1] }; + } + function addExpirationComment(bodyLines, envVarName, entityType) { + const expiresEnv = process.env[envVarName]; + if (expiresEnv) { + const expiresDays = parseInt(expiresEnv, 10); + if (!isNaN(expiresDays) && expiresDays > 0) { + const expirationDate = new Date(); + expirationDate.setDate(expirationDate.getDate() + expiresDays); + const expirationISO = expirationDate.toISOString(); + bodyLines.push(``); + core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); + } + } + } + function removeDuplicateTitleFromDescription(title, description) { + if (!title || typeof title !== "string") { + return description || ""; + } + if (!description || typeof description !== "string") { + return ""; + } + const trimmedTitle = title.trim(); + const trimmedDescription = description.trim(); + if (!trimmedTitle || !trimmedDescription) { + return trimmedDescription; + } + const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); + if (headerRegex.test(trimmedDescription)) { + return trimmedDescription.replace(headerRegex, "").trim(); + } + return trimmedDescription; + } async function fetchRepoDiscussionInfo(owner, repo) { const repositoryQuery = ` query($owner: String!, $repo: String!) { diff --git a/.github/workflows/copilot-agent-analysis.lock.yml b/.github/workflows/copilot-agent-analysis.lock.yml index b5e70d1d0a2..c194472b3ea 100644 --- a/.github/workflows/copilot-agent-analysis.lock.yml +++ b/.github/workflows/copilot-agent-analysis.lock.yml @@ -6681,13 +6681,400 @@ jobs: with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - const { loadAgentOutput } = require("./load_agent_output.cjs"); - const { getTrackerID } = require("./get_tracker_id.cjs"); - const { closeOlderDiscussions } = require("./close_older_discussions.cjs"); - const { replaceTemporaryIdReferences, loadTemporaryIdMap } = require("./temporary_id.cjs"); - const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require("./repo_helpers.cjs"); - const { addExpirationComment } = require("./expiration_helpers.cjs"); - const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); + const fs = require("fs"); + const crypto = require("crypto"); + const MAX_LOG_CONTENT_LENGTH = 10000; + function truncateForLogging(content) { + if (content.length <= MAX_LOG_CONTENT_LENGTH) { + return content; + } + return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; + } + function loadAgentOutput() { + const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; + if (!agentOutputFile) { + core.info("No GH_AW_AGENT_OUTPUT environment variable found"); + return { success: false }; + } + let outputContent; + try { + outputContent = fs.readFileSync(agentOutputFile, "utf8"); + } catch (error) { + const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; + core.error(errorMessage); + return { success: false, error: errorMessage }; + } + if (outputContent.trim() === "") { + core.info("Agent output content is empty"); + return { success: false }; + } + core.info(`Agent output content length: ${outputContent.length}`); + let validatedOutput; + try { + validatedOutput = JSON.parse(outputContent); + } catch (error) { + const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; + core.error(errorMessage); + core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); + return { success: false, error: errorMessage }; + } + if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { + core.info("No valid items found in agent output"); + core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); + return { success: false }; + } + return { success: true, items: validatedOutput.items }; + } + function getTrackerID(format) { + const trackerID = process.env.GH_AW_TRACKER_ID || ""; + if (trackerID) { + core.info(`Tracker ID: ${trackerID}`); + return format === "markdown" ? `\n\n` : trackerID; + } + return ""; + } + function getMessages() { + const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES; + if (!messagesEnv) { + return null; + } + try { + return JSON.parse(messagesEnv); + } catch (error) { + core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`); + return null; + } + } + function renderTemplate(template, context) { + return template.replace(/\{(\w+)\}/g, (match, key) => { + const value = context[key]; + return value !== undefined && value !== null ? String(value) : match; + }); + } + function toSnakeCase(obj) { + const result = {}; + for (const [key, value] of Object.entries(obj)) { + const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase(); + result[snakeKey] = value; + result[key] = value; + } + return result; + } + function getCloseOlderDiscussionMessage(ctx) { + const messages = getMessages(); + const templateContext = toSnakeCase(ctx); + const defaultMessage = `⚓ Avast! This discussion be marked as **outdated** by [{workflow_name}]({run_url}). + 🗺️ A newer treasure map awaits ye at **[Discussion #{new_discussion_number}]({new_discussion_url})**. + Fair winds, matey! 🏴‍☠️`; + return messages?.closeOlderDiscussion + ? renderTemplate(messages.closeOlderDiscussion, templateContext) + : renderTemplate(defaultMessage, templateContext); + } + const MAX_CLOSE_COUNT = 10; + const GRAPHQL_DELAY_MS = 500; + function delay(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); + } + async function searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, excludeNumber) { + let searchQuery = `repo:${owner}/${repo} is:open`; + if (titlePrefix) { + const escapedPrefix = titlePrefix.replace(/"/g, '\\"'); + searchQuery += ` in:title "${escapedPrefix}"`; + } + if (labels && labels.length > 0) { + for (const label of labels) { + const escapedLabel = label.replace(/"/g, '\\"'); + searchQuery += ` label:"${escapedLabel}"`; + } + } + const result = await github.graphql( + ` + query($searchTerms: String!, $first: Int!) { + search(query: $searchTerms, type: DISCUSSION, first: $first) { + nodes { + ... on Discussion { + id + number + title + url + category { + id + } + labels(first: 100) { + nodes { + name + } + } + closed + } + } + } + }`, + { searchTerms: searchQuery, first: 50 } + ); + if (!result || !result.search || !result.search.nodes) { + return []; + } + return result.search.nodes + .filter( + d => { + if (!d || d.number === excludeNumber || d.closed) { + return false; + } + if (titlePrefix && d.title && !d.title.startsWith(titlePrefix)) { + return false; + } + if (labels && labels.length > 0) { + const discussionLabels = d.labels?.nodes?.map(( l) => l.name) || []; + const hasAllLabels = labels.every(label => discussionLabels.includes(label)); + if (!hasAllLabels) { + return false; + } + } + if (categoryId && (!d.category || d.category.id !== categoryId)) { + return false; + } + return true; + } + ) + .map( + d => ({ + id: d.id, + number: d.number, + title: d.title, + url: d.url, + }) + ); + } + async function addDiscussionComment(github, discussionId, message) { + const result = await github.graphql( + ` + mutation($dId: ID!, $body: String!) { + addDiscussionComment(input: { discussionId: $dId, body: $body }) { + comment { + id + url + } + } + }`, + { dId: discussionId, body: message } + ); + return result.addDiscussionComment.comment; + } + async function closeDiscussionAsOutdated(github, discussionId) { + const result = await github.graphql( + ` + mutation($dId: ID!) { + closeDiscussion(input: { discussionId: $dId, reason: OUTDATED }) { + discussion { + id + url + } + } + }`, + { dId: discussionId } + ); + return result.closeDiscussion.discussion; + } + async function closeOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion, workflowName, runUrl) { + const searchCriteria = []; + if (titlePrefix) searchCriteria.push(`title prefix: "${titlePrefix}"`); + if (labels && labels.length > 0) searchCriteria.push(`labels: [${labels.join(", ")}]`); + core.info(`Searching for older discussions with ${searchCriteria.join(" and ")}`); + const olderDiscussions = await searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion.number); + if (olderDiscussions.length === 0) { + core.info("No older discussions found to close"); + return []; + } + core.info(`Found ${olderDiscussions.length} older discussion(s) to close`); + const discussionsToClose = olderDiscussions.slice(0, MAX_CLOSE_COUNT); + if (olderDiscussions.length > MAX_CLOSE_COUNT) { + core.warning(`Found ${olderDiscussions.length} older discussions, but only closing the first ${MAX_CLOSE_COUNT}`); + } + const closedDiscussions = []; + for (let i = 0; i < discussionsToClose.length; i++) { + const discussion = discussionsToClose[i]; + try { + const closingMessage = getCloseOlderDiscussionMessage({ + newDiscussionUrl: newDiscussion.url, + newDiscussionNumber: newDiscussion.number, + workflowName, + runUrl, + }); + core.info(`Adding closing comment to discussion #${discussion.number}`); + await addDiscussionComment(github, discussion.id, closingMessage); + core.info(`Closing discussion #${discussion.number} as outdated`); + await closeDiscussionAsOutdated(github, discussion.id); + closedDiscussions.push({ + number: discussion.number, + url: discussion.url, + }); + core.info(`✓ Closed discussion #${discussion.number}: ${discussion.url}`); + } catch (error) { + core.error(`✗ Failed to close discussion #${discussion.number}: ${error instanceof Error ? error.message : String(error)}`); + } + if (i < discussionsToClose.length - 1) { + await delay(GRAPHQL_DELAY_MS); + } + } + return closedDiscussions; + } + const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; + function generateTemporaryId() { + return "aw_" + crypto.randomBytes(6).toString("hex"); + } + function isTemporaryId(value) { + if (typeof value === "string") { + return /^aw_[0-9a-f]{12}$/i.test(value); + } + return false; + } + function normalizeTemporaryId(tempId) { + return String(tempId).toLowerCase(); + } + function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { + return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { + const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); + if (resolved !== undefined) { + if (currentRepo && resolved.repo === currentRepo) { + return `#${resolved.number}`; + } + return `${resolved.repo}#${resolved.number}`; + } + return match; + }); + } + function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { + return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { + const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); + if (issueNumber !== undefined) { + return `#${issueNumber}`; + } + return match; + }); + } + function loadTemporaryIdMap() { + const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; + if (!mapJson || mapJson === "{}") { + return new Map(); + } + try { + const mapObject = JSON.parse(mapJson); + const result = new Map(); + for (const [key, value] of Object.entries(mapObject)) { + const normalizedKey = normalizeTemporaryId(key); + if (typeof value === "number") { + const contextRepo = `${context.repo.owner}/${context.repo.repo}`; + result.set(normalizedKey, { repo: contextRepo, number: value }); + } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { + result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); + } + } + return result; + } catch (error) { + if (typeof core !== "undefined") { + core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); + } + return new Map(); + } + } + function resolveIssueNumber(value, temporaryIdMap) { + if (value === undefined || value === null) { + return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; + } + const valueStr = String(value); + if (isTemporaryId(valueStr)) { + const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); + if (resolvedPair !== undefined) { + return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; + } + return { + resolved: null, + wasTemporaryId: true, + errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, + }; + } + const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); + if (isNaN(issueNumber) || issueNumber <= 0) { + return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; + } + const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; + return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; + } + function serializeTemporaryIdMap(tempIdMap) { + const obj = Object.fromEntries(tempIdMap); + return JSON.stringify(obj); + } + function parseAllowedRepos() { + const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; + const set = new Set(); + if (allowedReposEnv) { + allowedReposEnv + .split(",") + .map(repo => repo.trim()) + .filter(repo => repo) + .forEach(repo => set.add(repo)); + } + return set; + } + function getDefaultTargetRepo() { + const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; + if (targetRepoSlug) { + return targetRepoSlug; + } + return `${context.repo.owner}/${context.repo.repo}`; + } + function validateRepo(repo, defaultRepo, allowedRepos) { + if (repo === defaultRepo) { + return { valid: true, error: null }; + } + if (allowedRepos.has(repo)) { + return { valid: true, error: null }; + } + return { + valid: false, + error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, + }; + } + function parseRepoSlug(repoSlug) { + const parts = repoSlug.split("/"); + if (parts.length !== 2 || !parts[0] || !parts[1]) { + return null; + } + return { owner: parts[0], repo: parts[1] }; + } + function addExpirationComment(bodyLines, envVarName, entityType) { + const expiresEnv = process.env[envVarName]; + if (expiresEnv) { + const expiresDays = parseInt(expiresEnv, 10); + if (!isNaN(expiresDays) && expiresDays > 0) { + const expirationDate = new Date(); + expirationDate.setDate(expirationDate.getDate() + expiresDays); + const expirationISO = expirationDate.toISOString(); + bodyLines.push(``); + core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); + } + } + } + function removeDuplicateTitleFromDescription(title, description) { + if (!title || typeof title !== "string") { + return description || ""; + } + if (!description || typeof description !== "string") { + return ""; + } + const trimmedTitle = title.trim(); + const trimmedDescription = description.trim(); + if (!trimmedTitle || !trimmedDescription) { + return trimmedDescription; + } + const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); + if (headerRegex.test(trimmedDescription)) { + return trimmedDescription.replace(headerRegex, "").trim(); + } + return trimmedDescription; + } async function fetchRepoDiscussionInfo(owner, repo) { const repositoryQuery = ` query($owner: String!, $repo: String!) { diff --git a/.github/workflows/copilot-pr-merged-report.lock.yml b/.github/workflows/copilot-pr-merged-report.lock.yml index 726c0624ef6..2bfa5424344 100644 --- a/.github/workflows/copilot-pr-merged-report.lock.yml +++ b/.github/workflows/copilot-pr-merged-report.lock.yml @@ -7680,13 +7680,400 @@ jobs: with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - const { loadAgentOutput } = require("./load_agent_output.cjs"); - const { getTrackerID } = require("./get_tracker_id.cjs"); - const { closeOlderDiscussions } = require("./close_older_discussions.cjs"); - const { replaceTemporaryIdReferences, loadTemporaryIdMap } = require("./temporary_id.cjs"); - const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require("./repo_helpers.cjs"); - const { addExpirationComment } = require("./expiration_helpers.cjs"); - const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); + const fs = require("fs"); + const crypto = require("crypto"); + const MAX_LOG_CONTENT_LENGTH = 10000; + function truncateForLogging(content) { + if (content.length <= MAX_LOG_CONTENT_LENGTH) { + return content; + } + return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; + } + function loadAgentOutput() { + const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; + if (!agentOutputFile) { + core.info("No GH_AW_AGENT_OUTPUT environment variable found"); + return { success: false }; + } + let outputContent; + try { + outputContent = fs.readFileSync(agentOutputFile, "utf8"); + } catch (error) { + const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; + core.error(errorMessage); + return { success: false, error: errorMessage }; + } + if (outputContent.trim() === "") { + core.info("Agent output content is empty"); + return { success: false }; + } + core.info(`Agent output content length: ${outputContent.length}`); + let validatedOutput; + try { + validatedOutput = JSON.parse(outputContent); + } catch (error) { + const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; + core.error(errorMessage); + core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); + return { success: false, error: errorMessage }; + } + if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { + core.info("No valid items found in agent output"); + core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); + return { success: false }; + } + return { success: true, items: validatedOutput.items }; + } + function getTrackerID(format) { + const trackerID = process.env.GH_AW_TRACKER_ID || ""; + if (trackerID) { + core.info(`Tracker ID: ${trackerID}`); + return format === "markdown" ? `\n\n` : trackerID; + } + return ""; + } + function getMessages() { + const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES; + if (!messagesEnv) { + return null; + } + try { + return JSON.parse(messagesEnv); + } catch (error) { + core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`); + return null; + } + } + function renderTemplate(template, context) { + return template.replace(/\{(\w+)\}/g, (match, key) => { + const value = context[key]; + return value !== undefined && value !== null ? String(value) : match; + }); + } + function toSnakeCase(obj) { + const result = {}; + for (const [key, value] of Object.entries(obj)) { + const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase(); + result[snakeKey] = value; + result[key] = value; + } + return result; + } + function getCloseOlderDiscussionMessage(ctx) { + const messages = getMessages(); + const templateContext = toSnakeCase(ctx); + const defaultMessage = `⚓ Avast! This discussion be marked as **outdated** by [{workflow_name}]({run_url}). + 🗺️ A newer treasure map awaits ye at **[Discussion #{new_discussion_number}]({new_discussion_url})**. + Fair winds, matey! 🏴‍☠️`; + return messages?.closeOlderDiscussion + ? renderTemplate(messages.closeOlderDiscussion, templateContext) + : renderTemplate(defaultMessage, templateContext); + } + const MAX_CLOSE_COUNT = 10; + const GRAPHQL_DELAY_MS = 500; + function delay(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); + } + async function searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, excludeNumber) { + let searchQuery = `repo:${owner}/${repo} is:open`; + if (titlePrefix) { + const escapedPrefix = titlePrefix.replace(/"/g, '\\"'); + searchQuery += ` in:title "${escapedPrefix}"`; + } + if (labels && labels.length > 0) { + for (const label of labels) { + const escapedLabel = label.replace(/"/g, '\\"'); + searchQuery += ` label:"${escapedLabel}"`; + } + } + const result = await github.graphql( + ` + query($searchTerms: String!, $first: Int!) { + search(query: $searchTerms, type: DISCUSSION, first: $first) { + nodes { + ... on Discussion { + id + number + title + url + category { + id + } + labels(first: 100) { + nodes { + name + } + } + closed + } + } + } + }`, + { searchTerms: searchQuery, first: 50 } + ); + if (!result || !result.search || !result.search.nodes) { + return []; + } + return result.search.nodes + .filter( + d => { + if (!d || d.number === excludeNumber || d.closed) { + return false; + } + if (titlePrefix && d.title && !d.title.startsWith(titlePrefix)) { + return false; + } + if (labels && labels.length > 0) { + const discussionLabels = d.labels?.nodes?.map(( l) => l.name) || []; + const hasAllLabels = labels.every(label => discussionLabels.includes(label)); + if (!hasAllLabels) { + return false; + } + } + if (categoryId && (!d.category || d.category.id !== categoryId)) { + return false; + } + return true; + } + ) + .map( + d => ({ + id: d.id, + number: d.number, + title: d.title, + url: d.url, + }) + ); + } + async function addDiscussionComment(github, discussionId, message) { + const result = await github.graphql( + ` + mutation($dId: ID!, $body: String!) { + addDiscussionComment(input: { discussionId: $dId, body: $body }) { + comment { + id + url + } + } + }`, + { dId: discussionId, body: message } + ); + return result.addDiscussionComment.comment; + } + async function closeDiscussionAsOutdated(github, discussionId) { + const result = await github.graphql( + ` + mutation($dId: ID!) { + closeDiscussion(input: { discussionId: $dId, reason: OUTDATED }) { + discussion { + id + url + } + } + }`, + { dId: discussionId } + ); + return result.closeDiscussion.discussion; + } + async function closeOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion, workflowName, runUrl) { + const searchCriteria = []; + if (titlePrefix) searchCriteria.push(`title prefix: "${titlePrefix}"`); + if (labels && labels.length > 0) searchCriteria.push(`labels: [${labels.join(", ")}]`); + core.info(`Searching for older discussions with ${searchCriteria.join(" and ")}`); + const olderDiscussions = await searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion.number); + if (olderDiscussions.length === 0) { + core.info("No older discussions found to close"); + return []; + } + core.info(`Found ${olderDiscussions.length} older discussion(s) to close`); + const discussionsToClose = olderDiscussions.slice(0, MAX_CLOSE_COUNT); + if (olderDiscussions.length > MAX_CLOSE_COUNT) { + core.warning(`Found ${olderDiscussions.length} older discussions, but only closing the first ${MAX_CLOSE_COUNT}`); + } + const closedDiscussions = []; + for (let i = 0; i < discussionsToClose.length; i++) { + const discussion = discussionsToClose[i]; + try { + const closingMessage = getCloseOlderDiscussionMessage({ + newDiscussionUrl: newDiscussion.url, + newDiscussionNumber: newDiscussion.number, + workflowName, + runUrl, + }); + core.info(`Adding closing comment to discussion #${discussion.number}`); + await addDiscussionComment(github, discussion.id, closingMessage); + core.info(`Closing discussion #${discussion.number} as outdated`); + await closeDiscussionAsOutdated(github, discussion.id); + closedDiscussions.push({ + number: discussion.number, + url: discussion.url, + }); + core.info(`✓ Closed discussion #${discussion.number}: ${discussion.url}`); + } catch (error) { + core.error(`✗ Failed to close discussion #${discussion.number}: ${error instanceof Error ? error.message : String(error)}`); + } + if (i < discussionsToClose.length - 1) { + await delay(GRAPHQL_DELAY_MS); + } + } + return closedDiscussions; + } + const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; + function generateTemporaryId() { + return "aw_" + crypto.randomBytes(6).toString("hex"); + } + function isTemporaryId(value) { + if (typeof value === "string") { + return /^aw_[0-9a-f]{12}$/i.test(value); + } + return false; + } + function normalizeTemporaryId(tempId) { + return String(tempId).toLowerCase(); + } + function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { + return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { + const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); + if (resolved !== undefined) { + if (currentRepo && resolved.repo === currentRepo) { + return `#${resolved.number}`; + } + return `${resolved.repo}#${resolved.number}`; + } + return match; + }); + } + function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { + return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { + const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); + if (issueNumber !== undefined) { + return `#${issueNumber}`; + } + return match; + }); + } + function loadTemporaryIdMap() { + const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; + if (!mapJson || mapJson === "{}") { + return new Map(); + } + try { + const mapObject = JSON.parse(mapJson); + const result = new Map(); + for (const [key, value] of Object.entries(mapObject)) { + const normalizedKey = normalizeTemporaryId(key); + if (typeof value === "number") { + const contextRepo = `${context.repo.owner}/${context.repo.repo}`; + result.set(normalizedKey, { repo: contextRepo, number: value }); + } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { + result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); + } + } + return result; + } catch (error) { + if (typeof core !== "undefined") { + core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); + } + return new Map(); + } + } + function resolveIssueNumber(value, temporaryIdMap) { + if (value === undefined || value === null) { + return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; + } + const valueStr = String(value); + if (isTemporaryId(valueStr)) { + const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); + if (resolvedPair !== undefined) { + return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; + } + return { + resolved: null, + wasTemporaryId: true, + errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, + }; + } + const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); + if (isNaN(issueNumber) || issueNumber <= 0) { + return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; + } + const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; + return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; + } + function serializeTemporaryIdMap(tempIdMap) { + const obj = Object.fromEntries(tempIdMap); + return JSON.stringify(obj); + } + function parseAllowedRepos() { + const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; + const set = new Set(); + if (allowedReposEnv) { + allowedReposEnv + .split(",") + .map(repo => repo.trim()) + .filter(repo => repo) + .forEach(repo => set.add(repo)); + } + return set; + } + function getDefaultTargetRepo() { + const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; + if (targetRepoSlug) { + return targetRepoSlug; + } + return `${context.repo.owner}/${context.repo.repo}`; + } + function validateRepo(repo, defaultRepo, allowedRepos) { + if (repo === defaultRepo) { + return { valid: true, error: null }; + } + if (allowedRepos.has(repo)) { + return { valid: true, error: null }; + } + return { + valid: false, + error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, + }; + } + function parseRepoSlug(repoSlug) { + const parts = repoSlug.split("/"); + if (parts.length !== 2 || !parts[0] || !parts[1]) { + return null; + } + return { owner: parts[0], repo: parts[1] }; + } + function addExpirationComment(bodyLines, envVarName, entityType) { + const expiresEnv = process.env[envVarName]; + if (expiresEnv) { + const expiresDays = parseInt(expiresEnv, 10); + if (!isNaN(expiresDays) && expiresDays > 0) { + const expirationDate = new Date(); + expirationDate.setDate(expirationDate.getDate() + expiresDays); + const expirationISO = expirationDate.toISOString(); + bodyLines.push(``); + core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); + } + } + } + function removeDuplicateTitleFromDescription(title, description) { + if (!title || typeof title !== "string") { + return description || ""; + } + if (!description || typeof description !== "string") { + return ""; + } + const trimmedTitle = title.trim(); + const trimmedDescription = description.trim(); + if (!trimmedTitle || !trimmedDescription) { + return trimmedDescription; + } + const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); + if (headerRegex.test(trimmedDescription)) { + return trimmedDescription.replace(headerRegex, "").trim(); + } + return trimmedDescription; + } async function fetchRepoDiscussionInfo(owner, repo) { const repositoryQuery = ` query($owner: String!, $repo: String!) { diff --git a/.github/workflows/copilot-pr-nlp-analysis.lock.yml b/.github/workflows/copilot-pr-nlp-analysis.lock.yml index c3a1c13d237..4c3b8e8b513 100644 --- a/.github/workflows/copilot-pr-nlp-analysis.lock.yml +++ b/.github/workflows/copilot-pr-nlp-analysis.lock.yml @@ -7787,13 +7787,400 @@ jobs: with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - const { loadAgentOutput } = require("./load_agent_output.cjs"); - const { getTrackerID } = require("./get_tracker_id.cjs"); - const { closeOlderDiscussions } = require("./close_older_discussions.cjs"); - const { replaceTemporaryIdReferences, loadTemporaryIdMap } = require("./temporary_id.cjs"); - const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require("./repo_helpers.cjs"); - const { addExpirationComment } = require("./expiration_helpers.cjs"); - const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); + const fs = require("fs"); + const crypto = require("crypto"); + const MAX_LOG_CONTENT_LENGTH = 10000; + function truncateForLogging(content) { + if (content.length <= MAX_LOG_CONTENT_LENGTH) { + return content; + } + return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; + } + function loadAgentOutput() { + const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; + if (!agentOutputFile) { + core.info("No GH_AW_AGENT_OUTPUT environment variable found"); + return { success: false }; + } + let outputContent; + try { + outputContent = fs.readFileSync(agentOutputFile, "utf8"); + } catch (error) { + const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; + core.error(errorMessage); + return { success: false, error: errorMessage }; + } + if (outputContent.trim() === "") { + core.info("Agent output content is empty"); + return { success: false }; + } + core.info(`Agent output content length: ${outputContent.length}`); + let validatedOutput; + try { + validatedOutput = JSON.parse(outputContent); + } catch (error) { + const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; + core.error(errorMessage); + core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); + return { success: false, error: errorMessage }; + } + if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { + core.info("No valid items found in agent output"); + core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); + return { success: false }; + } + return { success: true, items: validatedOutput.items }; + } + function getTrackerID(format) { + const trackerID = process.env.GH_AW_TRACKER_ID || ""; + if (trackerID) { + core.info(`Tracker ID: ${trackerID}`); + return format === "markdown" ? `\n\n` : trackerID; + } + return ""; + } + function getMessages() { + const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES; + if (!messagesEnv) { + return null; + } + try { + return JSON.parse(messagesEnv); + } catch (error) { + core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`); + return null; + } + } + function renderTemplate(template, context) { + return template.replace(/\{(\w+)\}/g, (match, key) => { + const value = context[key]; + return value !== undefined && value !== null ? String(value) : match; + }); + } + function toSnakeCase(obj) { + const result = {}; + for (const [key, value] of Object.entries(obj)) { + const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase(); + result[snakeKey] = value; + result[key] = value; + } + return result; + } + function getCloseOlderDiscussionMessage(ctx) { + const messages = getMessages(); + const templateContext = toSnakeCase(ctx); + const defaultMessage = `⚓ Avast! This discussion be marked as **outdated** by [{workflow_name}]({run_url}). + 🗺️ A newer treasure map awaits ye at **[Discussion #{new_discussion_number}]({new_discussion_url})**. + Fair winds, matey! 🏴‍☠️`; + return messages?.closeOlderDiscussion + ? renderTemplate(messages.closeOlderDiscussion, templateContext) + : renderTemplate(defaultMessage, templateContext); + } + const MAX_CLOSE_COUNT = 10; + const GRAPHQL_DELAY_MS = 500; + function delay(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); + } + async function searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, excludeNumber) { + let searchQuery = `repo:${owner}/${repo} is:open`; + if (titlePrefix) { + const escapedPrefix = titlePrefix.replace(/"/g, '\\"'); + searchQuery += ` in:title "${escapedPrefix}"`; + } + if (labels && labels.length > 0) { + for (const label of labels) { + const escapedLabel = label.replace(/"/g, '\\"'); + searchQuery += ` label:"${escapedLabel}"`; + } + } + const result = await github.graphql( + ` + query($searchTerms: String!, $first: Int!) { + search(query: $searchTerms, type: DISCUSSION, first: $first) { + nodes { + ... on Discussion { + id + number + title + url + category { + id + } + labels(first: 100) { + nodes { + name + } + } + closed + } + } + } + }`, + { searchTerms: searchQuery, first: 50 } + ); + if (!result || !result.search || !result.search.nodes) { + return []; + } + return result.search.nodes + .filter( + d => { + if (!d || d.number === excludeNumber || d.closed) { + return false; + } + if (titlePrefix && d.title && !d.title.startsWith(titlePrefix)) { + return false; + } + if (labels && labels.length > 0) { + const discussionLabels = d.labels?.nodes?.map(( l) => l.name) || []; + const hasAllLabels = labels.every(label => discussionLabels.includes(label)); + if (!hasAllLabels) { + return false; + } + } + if (categoryId && (!d.category || d.category.id !== categoryId)) { + return false; + } + return true; + } + ) + .map( + d => ({ + id: d.id, + number: d.number, + title: d.title, + url: d.url, + }) + ); + } + async function addDiscussionComment(github, discussionId, message) { + const result = await github.graphql( + ` + mutation($dId: ID!, $body: String!) { + addDiscussionComment(input: { discussionId: $dId, body: $body }) { + comment { + id + url + } + } + }`, + { dId: discussionId, body: message } + ); + return result.addDiscussionComment.comment; + } + async function closeDiscussionAsOutdated(github, discussionId) { + const result = await github.graphql( + ` + mutation($dId: ID!) { + closeDiscussion(input: { discussionId: $dId, reason: OUTDATED }) { + discussion { + id + url + } + } + }`, + { dId: discussionId } + ); + return result.closeDiscussion.discussion; + } + async function closeOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion, workflowName, runUrl) { + const searchCriteria = []; + if (titlePrefix) searchCriteria.push(`title prefix: "${titlePrefix}"`); + if (labels && labels.length > 0) searchCriteria.push(`labels: [${labels.join(", ")}]`); + core.info(`Searching for older discussions with ${searchCriteria.join(" and ")}`); + const olderDiscussions = await searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion.number); + if (olderDiscussions.length === 0) { + core.info("No older discussions found to close"); + return []; + } + core.info(`Found ${olderDiscussions.length} older discussion(s) to close`); + const discussionsToClose = olderDiscussions.slice(0, MAX_CLOSE_COUNT); + if (olderDiscussions.length > MAX_CLOSE_COUNT) { + core.warning(`Found ${olderDiscussions.length} older discussions, but only closing the first ${MAX_CLOSE_COUNT}`); + } + const closedDiscussions = []; + for (let i = 0; i < discussionsToClose.length; i++) { + const discussion = discussionsToClose[i]; + try { + const closingMessage = getCloseOlderDiscussionMessage({ + newDiscussionUrl: newDiscussion.url, + newDiscussionNumber: newDiscussion.number, + workflowName, + runUrl, + }); + core.info(`Adding closing comment to discussion #${discussion.number}`); + await addDiscussionComment(github, discussion.id, closingMessage); + core.info(`Closing discussion #${discussion.number} as outdated`); + await closeDiscussionAsOutdated(github, discussion.id); + closedDiscussions.push({ + number: discussion.number, + url: discussion.url, + }); + core.info(`✓ Closed discussion #${discussion.number}: ${discussion.url}`); + } catch (error) { + core.error(`✗ Failed to close discussion #${discussion.number}: ${error instanceof Error ? error.message : String(error)}`); + } + if (i < discussionsToClose.length - 1) { + await delay(GRAPHQL_DELAY_MS); + } + } + return closedDiscussions; + } + const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; + function generateTemporaryId() { + return "aw_" + crypto.randomBytes(6).toString("hex"); + } + function isTemporaryId(value) { + if (typeof value === "string") { + return /^aw_[0-9a-f]{12}$/i.test(value); + } + return false; + } + function normalizeTemporaryId(tempId) { + return String(tempId).toLowerCase(); + } + function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { + return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { + const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); + if (resolved !== undefined) { + if (currentRepo && resolved.repo === currentRepo) { + return `#${resolved.number}`; + } + return `${resolved.repo}#${resolved.number}`; + } + return match; + }); + } + function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { + return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { + const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); + if (issueNumber !== undefined) { + return `#${issueNumber}`; + } + return match; + }); + } + function loadTemporaryIdMap() { + const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; + if (!mapJson || mapJson === "{}") { + return new Map(); + } + try { + const mapObject = JSON.parse(mapJson); + const result = new Map(); + for (const [key, value] of Object.entries(mapObject)) { + const normalizedKey = normalizeTemporaryId(key); + if (typeof value === "number") { + const contextRepo = `${context.repo.owner}/${context.repo.repo}`; + result.set(normalizedKey, { repo: contextRepo, number: value }); + } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { + result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); + } + } + return result; + } catch (error) { + if (typeof core !== "undefined") { + core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); + } + return new Map(); + } + } + function resolveIssueNumber(value, temporaryIdMap) { + if (value === undefined || value === null) { + return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; + } + const valueStr = String(value); + if (isTemporaryId(valueStr)) { + const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); + if (resolvedPair !== undefined) { + return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; + } + return { + resolved: null, + wasTemporaryId: true, + errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, + }; + } + const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); + if (isNaN(issueNumber) || issueNumber <= 0) { + return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; + } + const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; + return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; + } + function serializeTemporaryIdMap(tempIdMap) { + const obj = Object.fromEntries(tempIdMap); + return JSON.stringify(obj); + } + function parseAllowedRepos() { + const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; + const set = new Set(); + if (allowedReposEnv) { + allowedReposEnv + .split(",") + .map(repo => repo.trim()) + .filter(repo => repo) + .forEach(repo => set.add(repo)); + } + return set; + } + function getDefaultTargetRepo() { + const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; + if (targetRepoSlug) { + return targetRepoSlug; + } + return `${context.repo.owner}/${context.repo.repo}`; + } + function validateRepo(repo, defaultRepo, allowedRepos) { + if (repo === defaultRepo) { + return { valid: true, error: null }; + } + if (allowedRepos.has(repo)) { + return { valid: true, error: null }; + } + return { + valid: false, + error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, + }; + } + function parseRepoSlug(repoSlug) { + const parts = repoSlug.split("/"); + if (parts.length !== 2 || !parts[0] || !parts[1]) { + return null; + } + return { owner: parts[0], repo: parts[1] }; + } + function addExpirationComment(bodyLines, envVarName, entityType) { + const expiresEnv = process.env[envVarName]; + if (expiresEnv) { + const expiresDays = parseInt(expiresEnv, 10); + if (!isNaN(expiresDays) && expiresDays > 0) { + const expirationDate = new Date(); + expirationDate.setDate(expirationDate.getDate() + expiresDays); + const expirationISO = expirationDate.toISOString(); + bodyLines.push(``); + core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); + } + } + } + function removeDuplicateTitleFromDescription(title, description) { + if (!title || typeof title !== "string") { + return description || ""; + } + if (!description || typeof description !== "string") { + return ""; + } + const trimmedTitle = title.trim(); + const trimmedDescription = description.trim(); + if (!trimmedTitle || !trimmedDescription) { + return trimmedDescription; + } + const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); + if (headerRegex.test(trimmedDescription)) { + return trimmedDescription.replace(headerRegex, "").trim(); + } + return trimmedDescription; + } async function fetchRepoDiscussionInfo(owner, repo) { const repositoryQuery = ` query($owner: String!, $repo: String!) { diff --git a/.github/workflows/copilot-pr-prompt-analysis.lock.yml b/.github/workflows/copilot-pr-prompt-analysis.lock.yml index 3b8ea450ff3..712787e79d6 100644 --- a/.github/workflows/copilot-pr-prompt-analysis.lock.yml +++ b/.github/workflows/copilot-pr-prompt-analysis.lock.yml @@ -6802,13 +6802,400 @@ jobs: with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - const { loadAgentOutput } = require("./load_agent_output.cjs"); - const { getTrackerID } = require("./get_tracker_id.cjs"); - const { closeOlderDiscussions } = require("./close_older_discussions.cjs"); - const { replaceTemporaryIdReferences, loadTemporaryIdMap } = require("./temporary_id.cjs"); - const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require("./repo_helpers.cjs"); - const { addExpirationComment } = require("./expiration_helpers.cjs"); - const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); + const fs = require("fs"); + const crypto = require("crypto"); + const MAX_LOG_CONTENT_LENGTH = 10000; + function truncateForLogging(content) { + if (content.length <= MAX_LOG_CONTENT_LENGTH) { + return content; + } + return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; + } + function loadAgentOutput() { + const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; + if (!agentOutputFile) { + core.info("No GH_AW_AGENT_OUTPUT environment variable found"); + return { success: false }; + } + let outputContent; + try { + outputContent = fs.readFileSync(agentOutputFile, "utf8"); + } catch (error) { + const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; + core.error(errorMessage); + return { success: false, error: errorMessage }; + } + if (outputContent.trim() === "") { + core.info("Agent output content is empty"); + return { success: false }; + } + core.info(`Agent output content length: ${outputContent.length}`); + let validatedOutput; + try { + validatedOutput = JSON.parse(outputContent); + } catch (error) { + const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; + core.error(errorMessage); + core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); + return { success: false, error: errorMessage }; + } + if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { + core.info("No valid items found in agent output"); + core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); + return { success: false }; + } + return { success: true, items: validatedOutput.items }; + } + function getTrackerID(format) { + const trackerID = process.env.GH_AW_TRACKER_ID || ""; + if (trackerID) { + core.info(`Tracker ID: ${trackerID}`); + return format === "markdown" ? `\n\n` : trackerID; + } + return ""; + } + function getMessages() { + const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES; + if (!messagesEnv) { + return null; + } + try { + return JSON.parse(messagesEnv); + } catch (error) { + core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`); + return null; + } + } + function renderTemplate(template, context) { + return template.replace(/\{(\w+)\}/g, (match, key) => { + const value = context[key]; + return value !== undefined && value !== null ? String(value) : match; + }); + } + function toSnakeCase(obj) { + const result = {}; + for (const [key, value] of Object.entries(obj)) { + const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase(); + result[snakeKey] = value; + result[key] = value; + } + return result; + } + function getCloseOlderDiscussionMessage(ctx) { + const messages = getMessages(); + const templateContext = toSnakeCase(ctx); + const defaultMessage = `⚓ Avast! This discussion be marked as **outdated** by [{workflow_name}]({run_url}). + 🗺️ A newer treasure map awaits ye at **[Discussion #{new_discussion_number}]({new_discussion_url})**. + Fair winds, matey! 🏴‍☠️`; + return messages?.closeOlderDiscussion + ? renderTemplate(messages.closeOlderDiscussion, templateContext) + : renderTemplate(defaultMessage, templateContext); + } + const MAX_CLOSE_COUNT = 10; + const GRAPHQL_DELAY_MS = 500; + function delay(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); + } + async function searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, excludeNumber) { + let searchQuery = `repo:${owner}/${repo} is:open`; + if (titlePrefix) { + const escapedPrefix = titlePrefix.replace(/"/g, '\\"'); + searchQuery += ` in:title "${escapedPrefix}"`; + } + if (labels && labels.length > 0) { + for (const label of labels) { + const escapedLabel = label.replace(/"/g, '\\"'); + searchQuery += ` label:"${escapedLabel}"`; + } + } + const result = await github.graphql( + ` + query($searchTerms: String!, $first: Int!) { + search(query: $searchTerms, type: DISCUSSION, first: $first) { + nodes { + ... on Discussion { + id + number + title + url + category { + id + } + labels(first: 100) { + nodes { + name + } + } + closed + } + } + } + }`, + { searchTerms: searchQuery, first: 50 } + ); + if (!result || !result.search || !result.search.nodes) { + return []; + } + return result.search.nodes + .filter( + d => { + if (!d || d.number === excludeNumber || d.closed) { + return false; + } + if (titlePrefix && d.title && !d.title.startsWith(titlePrefix)) { + return false; + } + if (labels && labels.length > 0) { + const discussionLabels = d.labels?.nodes?.map(( l) => l.name) || []; + const hasAllLabels = labels.every(label => discussionLabels.includes(label)); + if (!hasAllLabels) { + return false; + } + } + if (categoryId && (!d.category || d.category.id !== categoryId)) { + return false; + } + return true; + } + ) + .map( + d => ({ + id: d.id, + number: d.number, + title: d.title, + url: d.url, + }) + ); + } + async function addDiscussionComment(github, discussionId, message) { + const result = await github.graphql( + ` + mutation($dId: ID!, $body: String!) { + addDiscussionComment(input: { discussionId: $dId, body: $body }) { + comment { + id + url + } + } + }`, + { dId: discussionId, body: message } + ); + return result.addDiscussionComment.comment; + } + async function closeDiscussionAsOutdated(github, discussionId) { + const result = await github.graphql( + ` + mutation($dId: ID!) { + closeDiscussion(input: { discussionId: $dId, reason: OUTDATED }) { + discussion { + id + url + } + } + }`, + { dId: discussionId } + ); + return result.closeDiscussion.discussion; + } + async function closeOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion, workflowName, runUrl) { + const searchCriteria = []; + if (titlePrefix) searchCriteria.push(`title prefix: "${titlePrefix}"`); + if (labels && labels.length > 0) searchCriteria.push(`labels: [${labels.join(", ")}]`); + core.info(`Searching for older discussions with ${searchCriteria.join(" and ")}`); + const olderDiscussions = await searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion.number); + if (olderDiscussions.length === 0) { + core.info("No older discussions found to close"); + return []; + } + core.info(`Found ${olderDiscussions.length} older discussion(s) to close`); + const discussionsToClose = olderDiscussions.slice(0, MAX_CLOSE_COUNT); + if (olderDiscussions.length > MAX_CLOSE_COUNT) { + core.warning(`Found ${olderDiscussions.length} older discussions, but only closing the first ${MAX_CLOSE_COUNT}`); + } + const closedDiscussions = []; + for (let i = 0; i < discussionsToClose.length; i++) { + const discussion = discussionsToClose[i]; + try { + const closingMessage = getCloseOlderDiscussionMessage({ + newDiscussionUrl: newDiscussion.url, + newDiscussionNumber: newDiscussion.number, + workflowName, + runUrl, + }); + core.info(`Adding closing comment to discussion #${discussion.number}`); + await addDiscussionComment(github, discussion.id, closingMessage); + core.info(`Closing discussion #${discussion.number} as outdated`); + await closeDiscussionAsOutdated(github, discussion.id); + closedDiscussions.push({ + number: discussion.number, + url: discussion.url, + }); + core.info(`✓ Closed discussion #${discussion.number}: ${discussion.url}`); + } catch (error) { + core.error(`✗ Failed to close discussion #${discussion.number}: ${error instanceof Error ? error.message : String(error)}`); + } + if (i < discussionsToClose.length - 1) { + await delay(GRAPHQL_DELAY_MS); + } + } + return closedDiscussions; + } + const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; + function generateTemporaryId() { + return "aw_" + crypto.randomBytes(6).toString("hex"); + } + function isTemporaryId(value) { + if (typeof value === "string") { + return /^aw_[0-9a-f]{12}$/i.test(value); + } + return false; + } + function normalizeTemporaryId(tempId) { + return String(tempId).toLowerCase(); + } + function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { + return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { + const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); + if (resolved !== undefined) { + if (currentRepo && resolved.repo === currentRepo) { + return `#${resolved.number}`; + } + return `${resolved.repo}#${resolved.number}`; + } + return match; + }); + } + function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { + return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { + const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); + if (issueNumber !== undefined) { + return `#${issueNumber}`; + } + return match; + }); + } + function loadTemporaryIdMap() { + const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; + if (!mapJson || mapJson === "{}") { + return new Map(); + } + try { + const mapObject = JSON.parse(mapJson); + const result = new Map(); + for (const [key, value] of Object.entries(mapObject)) { + const normalizedKey = normalizeTemporaryId(key); + if (typeof value === "number") { + const contextRepo = `${context.repo.owner}/${context.repo.repo}`; + result.set(normalizedKey, { repo: contextRepo, number: value }); + } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { + result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); + } + } + return result; + } catch (error) { + if (typeof core !== "undefined") { + core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); + } + return new Map(); + } + } + function resolveIssueNumber(value, temporaryIdMap) { + if (value === undefined || value === null) { + return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; + } + const valueStr = String(value); + if (isTemporaryId(valueStr)) { + const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); + if (resolvedPair !== undefined) { + return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; + } + return { + resolved: null, + wasTemporaryId: true, + errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, + }; + } + const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); + if (isNaN(issueNumber) || issueNumber <= 0) { + return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; + } + const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; + return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; + } + function serializeTemporaryIdMap(tempIdMap) { + const obj = Object.fromEntries(tempIdMap); + return JSON.stringify(obj); + } + function parseAllowedRepos() { + const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; + const set = new Set(); + if (allowedReposEnv) { + allowedReposEnv + .split(",") + .map(repo => repo.trim()) + .filter(repo => repo) + .forEach(repo => set.add(repo)); + } + return set; + } + function getDefaultTargetRepo() { + const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; + if (targetRepoSlug) { + return targetRepoSlug; + } + return `${context.repo.owner}/${context.repo.repo}`; + } + function validateRepo(repo, defaultRepo, allowedRepos) { + if (repo === defaultRepo) { + return { valid: true, error: null }; + } + if (allowedRepos.has(repo)) { + return { valid: true, error: null }; + } + return { + valid: false, + error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, + }; + } + function parseRepoSlug(repoSlug) { + const parts = repoSlug.split("/"); + if (parts.length !== 2 || !parts[0] || !parts[1]) { + return null; + } + return { owner: parts[0], repo: parts[1] }; + } + function addExpirationComment(bodyLines, envVarName, entityType) { + const expiresEnv = process.env[envVarName]; + if (expiresEnv) { + const expiresDays = parseInt(expiresEnv, 10); + if (!isNaN(expiresDays) && expiresDays > 0) { + const expirationDate = new Date(); + expirationDate.setDate(expirationDate.getDate() + expiresDays); + const expirationISO = expirationDate.toISOString(); + bodyLines.push(``); + core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); + } + } + } + function removeDuplicateTitleFromDescription(title, description) { + if (!title || typeof title !== "string") { + return description || ""; + } + if (!description || typeof description !== "string") { + return ""; + } + const trimmedTitle = title.trim(); + const trimmedDescription = description.trim(); + if (!trimmedTitle || !trimmedDescription) { + return trimmedDescription; + } + const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); + if (headerRegex.test(trimmedDescription)) { + return trimmedDescription.replace(headerRegex, "").trim(); + } + return trimmedDescription; + } async function fetchRepoDiscussionInfo(owner, repo) { const repositoryQuery = ` query($owner: String!, $repo: String!) { diff --git a/.github/workflows/copilot-session-insights.lock.yml b/.github/workflows/copilot-session-insights.lock.yml index 0cb2e0d6d18..d727c85ec35 100644 --- a/.github/workflows/copilot-session-insights.lock.yml +++ b/.github/workflows/copilot-session-insights.lock.yml @@ -8099,13 +8099,400 @@ jobs: with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - const { loadAgentOutput } = require("./load_agent_output.cjs"); - const { getTrackerID } = require("./get_tracker_id.cjs"); - const { closeOlderDiscussions } = require("./close_older_discussions.cjs"); - const { replaceTemporaryIdReferences, loadTemporaryIdMap } = require("./temporary_id.cjs"); - const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require("./repo_helpers.cjs"); - const { addExpirationComment } = require("./expiration_helpers.cjs"); - const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); + const fs = require("fs"); + const crypto = require("crypto"); + const MAX_LOG_CONTENT_LENGTH = 10000; + function truncateForLogging(content) { + if (content.length <= MAX_LOG_CONTENT_LENGTH) { + return content; + } + return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; + } + function loadAgentOutput() { + const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; + if (!agentOutputFile) { + core.info("No GH_AW_AGENT_OUTPUT environment variable found"); + return { success: false }; + } + let outputContent; + try { + outputContent = fs.readFileSync(agentOutputFile, "utf8"); + } catch (error) { + const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; + core.error(errorMessage); + return { success: false, error: errorMessage }; + } + if (outputContent.trim() === "") { + core.info("Agent output content is empty"); + return { success: false }; + } + core.info(`Agent output content length: ${outputContent.length}`); + let validatedOutput; + try { + validatedOutput = JSON.parse(outputContent); + } catch (error) { + const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; + core.error(errorMessage); + core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); + return { success: false, error: errorMessage }; + } + if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { + core.info("No valid items found in agent output"); + core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); + return { success: false }; + } + return { success: true, items: validatedOutput.items }; + } + function getTrackerID(format) { + const trackerID = process.env.GH_AW_TRACKER_ID || ""; + if (trackerID) { + core.info(`Tracker ID: ${trackerID}`); + return format === "markdown" ? `\n\n` : trackerID; + } + return ""; + } + function getMessages() { + const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES; + if (!messagesEnv) { + return null; + } + try { + return JSON.parse(messagesEnv); + } catch (error) { + core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`); + return null; + } + } + function renderTemplate(template, context) { + return template.replace(/\{(\w+)\}/g, (match, key) => { + const value = context[key]; + return value !== undefined && value !== null ? String(value) : match; + }); + } + function toSnakeCase(obj) { + const result = {}; + for (const [key, value] of Object.entries(obj)) { + const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase(); + result[snakeKey] = value; + result[key] = value; + } + return result; + } + function getCloseOlderDiscussionMessage(ctx) { + const messages = getMessages(); + const templateContext = toSnakeCase(ctx); + const defaultMessage = `⚓ Avast! This discussion be marked as **outdated** by [{workflow_name}]({run_url}). + 🗺️ A newer treasure map awaits ye at **[Discussion #{new_discussion_number}]({new_discussion_url})**. + Fair winds, matey! 🏴‍☠️`; + return messages?.closeOlderDiscussion + ? renderTemplate(messages.closeOlderDiscussion, templateContext) + : renderTemplate(defaultMessage, templateContext); + } + const MAX_CLOSE_COUNT = 10; + const GRAPHQL_DELAY_MS = 500; + function delay(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); + } + async function searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, excludeNumber) { + let searchQuery = `repo:${owner}/${repo} is:open`; + if (titlePrefix) { + const escapedPrefix = titlePrefix.replace(/"/g, '\\"'); + searchQuery += ` in:title "${escapedPrefix}"`; + } + if (labels && labels.length > 0) { + for (const label of labels) { + const escapedLabel = label.replace(/"/g, '\\"'); + searchQuery += ` label:"${escapedLabel}"`; + } + } + const result = await github.graphql( + ` + query($searchTerms: String!, $first: Int!) { + search(query: $searchTerms, type: DISCUSSION, first: $first) { + nodes { + ... on Discussion { + id + number + title + url + category { + id + } + labels(first: 100) { + nodes { + name + } + } + closed + } + } + } + }`, + { searchTerms: searchQuery, first: 50 } + ); + if (!result || !result.search || !result.search.nodes) { + return []; + } + return result.search.nodes + .filter( + d => { + if (!d || d.number === excludeNumber || d.closed) { + return false; + } + if (titlePrefix && d.title && !d.title.startsWith(titlePrefix)) { + return false; + } + if (labels && labels.length > 0) { + const discussionLabels = d.labels?.nodes?.map(( l) => l.name) || []; + const hasAllLabels = labels.every(label => discussionLabels.includes(label)); + if (!hasAllLabels) { + return false; + } + } + if (categoryId && (!d.category || d.category.id !== categoryId)) { + return false; + } + return true; + } + ) + .map( + d => ({ + id: d.id, + number: d.number, + title: d.title, + url: d.url, + }) + ); + } + async function addDiscussionComment(github, discussionId, message) { + const result = await github.graphql( + ` + mutation($dId: ID!, $body: String!) { + addDiscussionComment(input: { discussionId: $dId, body: $body }) { + comment { + id + url + } + } + }`, + { dId: discussionId, body: message } + ); + return result.addDiscussionComment.comment; + } + async function closeDiscussionAsOutdated(github, discussionId) { + const result = await github.graphql( + ` + mutation($dId: ID!) { + closeDiscussion(input: { discussionId: $dId, reason: OUTDATED }) { + discussion { + id + url + } + } + }`, + { dId: discussionId } + ); + return result.closeDiscussion.discussion; + } + async function closeOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion, workflowName, runUrl) { + const searchCriteria = []; + if (titlePrefix) searchCriteria.push(`title prefix: "${titlePrefix}"`); + if (labels && labels.length > 0) searchCriteria.push(`labels: [${labels.join(", ")}]`); + core.info(`Searching for older discussions with ${searchCriteria.join(" and ")}`); + const olderDiscussions = await searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion.number); + if (olderDiscussions.length === 0) { + core.info("No older discussions found to close"); + return []; + } + core.info(`Found ${olderDiscussions.length} older discussion(s) to close`); + const discussionsToClose = olderDiscussions.slice(0, MAX_CLOSE_COUNT); + if (olderDiscussions.length > MAX_CLOSE_COUNT) { + core.warning(`Found ${olderDiscussions.length} older discussions, but only closing the first ${MAX_CLOSE_COUNT}`); + } + const closedDiscussions = []; + for (let i = 0; i < discussionsToClose.length; i++) { + const discussion = discussionsToClose[i]; + try { + const closingMessage = getCloseOlderDiscussionMessage({ + newDiscussionUrl: newDiscussion.url, + newDiscussionNumber: newDiscussion.number, + workflowName, + runUrl, + }); + core.info(`Adding closing comment to discussion #${discussion.number}`); + await addDiscussionComment(github, discussion.id, closingMessage); + core.info(`Closing discussion #${discussion.number} as outdated`); + await closeDiscussionAsOutdated(github, discussion.id); + closedDiscussions.push({ + number: discussion.number, + url: discussion.url, + }); + core.info(`✓ Closed discussion #${discussion.number}: ${discussion.url}`); + } catch (error) { + core.error(`✗ Failed to close discussion #${discussion.number}: ${error instanceof Error ? error.message : String(error)}`); + } + if (i < discussionsToClose.length - 1) { + await delay(GRAPHQL_DELAY_MS); + } + } + return closedDiscussions; + } + const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; + function generateTemporaryId() { + return "aw_" + crypto.randomBytes(6).toString("hex"); + } + function isTemporaryId(value) { + if (typeof value === "string") { + return /^aw_[0-9a-f]{12}$/i.test(value); + } + return false; + } + function normalizeTemporaryId(tempId) { + return String(tempId).toLowerCase(); + } + function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { + return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { + const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); + if (resolved !== undefined) { + if (currentRepo && resolved.repo === currentRepo) { + return `#${resolved.number}`; + } + return `${resolved.repo}#${resolved.number}`; + } + return match; + }); + } + function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { + return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { + const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); + if (issueNumber !== undefined) { + return `#${issueNumber}`; + } + return match; + }); + } + function loadTemporaryIdMap() { + const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; + if (!mapJson || mapJson === "{}") { + return new Map(); + } + try { + const mapObject = JSON.parse(mapJson); + const result = new Map(); + for (const [key, value] of Object.entries(mapObject)) { + const normalizedKey = normalizeTemporaryId(key); + if (typeof value === "number") { + const contextRepo = `${context.repo.owner}/${context.repo.repo}`; + result.set(normalizedKey, { repo: contextRepo, number: value }); + } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { + result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); + } + } + return result; + } catch (error) { + if (typeof core !== "undefined") { + core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); + } + return new Map(); + } + } + function resolveIssueNumber(value, temporaryIdMap) { + if (value === undefined || value === null) { + return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; + } + const valueStr = String(value); + if (isTemporaryId(valueStr)) { + const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); + if (resolvedPair !== undefined) { + return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; + } + return { + resolved: null, + wasTemporaryId: true, + errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, + }; + } + const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); + if (isNaN(issueNumber) || issueNumber <= 0) { + return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; + } + const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; + return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; + } + function serializeTemporaryIdMap(tempIdMap) { + const obj = Object.fromEntries(tempIdMap); + return JSON.stringify(obj); + } + function parseAllowedRepos() { + const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; + const set = new Set(); + if (allowedReposEnv) { + allowedReposEnv + .split(",") + .map(repo => repo.trim()) + .filter(repo => repo) + .forEach(repo => set.add(repo)); + } + return set; + } + function getDefaultTargetRepo() { + const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; + if (targetRepoSlug) { + return targetRepoSlug; + } + return `${context.repo.owner}/${context.repo.repo}`; + } + function validateRepo(repo, defaultRepo, allowedRepos) { + if (repo === defaultRepo) { + return { valid: true, error: null }; + } + if (allowedRepos.has(repo)) { + return { valid: true, error: null }; + } + return { + valid: false, + error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, + }; + } + function parseRepoSlug(repoSlug) { + const parts = repoSlug.split("/"); + if (parts.length !== 2 || !parts[0] || !parts[1]) { + return null; + } + return { owner: parts[0], repo: parts[1] }; + } + function addExpirationComment(bodyLines, envVarName, entityType) { + const expiresEnv = process.env[envVarName]; + if (expiresEnv) { + const expiresDays = parseInt(expiresEnv, 10); + if (!isNaN(expiresDays) && expiresDays > 0) { + const expirationDate = new Date(); + expirationDate.setDate(expirationDate.getDate() + expiresDays); + const expirationISO = expirationDate.toISOString(); + bodyLines.push(``); + core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); + } + } + } + function removeDuplicateTitleFromDescription(title, description) { + if (!title || typeof title !== "string") { + return description || ""; + } + if (!description || typeof description !== "string") { + return ""; + } + const trimmedTitle = title.trim(); + const trimmedDescription = description.trim(); + if (!trimmedTitle || !trimmedDescription) { + return trimmedDescription; + } + const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); + if (headerRegex.test(trimmedDescription)) { + return trimmedDescription.replace(headerRegex, "").trim(); + } + return trimmedDescription; + } async function fetchRepoDiscussionInfo(owner, repo) { const repositoryQuery = ` query($owner: String!, $repo: String!) { diff --git a/.github/workflows/daily-code-metrics.lock.yml b/.github/workflows/daily-code-metrics.lock.yml index 2b41f388068..097453a8c9f 100644 --- a/.github/workflows/daily-code-metrics.lock.yml +++ b/.github/workflows/daily-code-metrics.lock.yml @@ -7136,13 +7136,400 @@ jobs: with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - const { loadAgentOutput } = require("./load_agent_output.cjs"); - const { getTrackerID } = require("./get_tracker_id.cjs"); - const { closeOlderDiscussions } = require("./close_older_discussions.cjs"); - const { replaceTemporaryIdReferences, loadTemporaryIdMap } = require("./temporary_id.cjs"); - const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require("./repo_helpers.cjs"); - const { addExpirationComment } = require("./expiration_helpers.cjs"); - const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); + const fs = require("fs"); + const crypto = require("crypto"); + const MAX_LOG_CONTENT_LENGTH = 10000; + function truncateForLogging(content) { + if (content.length <= MAX_LOG_CONTENT_LENGTH) { + return content; + } + return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; + } + function loadAgentOutput() { + const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; + if (!agentOutputFile) { + core.info("No GH_AW_AGENT_OUTPUT environment variable found"); + return { success: false }; + } + let outputContent; + try { + outputContent = fs.readFileSync(agentOutputFile, "utf8"); + } catch (error) { + const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; + core.error(errorMessage); + return { success: false, error: errorMessage }; + } + if (outputContent.trim() === "") { + core.info("Agent output content is empty"); + return { success: false }; + } + core.info(`Agent output content length: ${outputContent.length}`); + let validatedOutput; + try { + validatedOutput = JSON.parse(outputContent); + } catch (error) { + const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; + core.error(errorMessage); + core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); + return { success: false, error: errorMessage }; + } + if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { + core.info("No valid items found in agent output"); + core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); + return { success: false }; + } + return { success: true, items: validatedOutput.items }; + } + function getTrackerID(format) { + const trackerID = process.env.GH_AW_TRACKER_ID || ""; + if (trackerID) { + core.info(`Tracker ID: ${trackerID}`); + return format === "markdown" ? `\n\n` : trackerID; + } + return ""; + } + function getMessages() { + const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES; + if (!messagesEnv) { + return null; + } + try { + return JSON.parse(messagesEnv); + } catch (error) { + core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`); + return null; + } + } + function renderTemplate(template, context) { + return template.replace(/\{(\w+)\}/g, (match, key) => { + const value = context[key]; + return value !== undefined && value !== null ? String(value) : match; + }); + } + function toSnakeCase(obj) { + const result = {}; + for (const [key, value] of Object.entries(obj)) { + const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase(); + result[snakeKey] = value; + result[key] = value; + } + return result; + } + function getCloseOlderDiscussionMessage(ctx) { + const messages = getMessages(); + const templateContext = toSnakeCase(ctx); + const defaultMessage = `⚓ Avast! This discussion be marked as **outdated** by [{workflow_name}]({run_url}). + 🗺️ A newer treasure map awaits ye at **[Discussion #{new_discussion_number}]({new_discussion_url})**. + Fair winds, matey! 🏴‍☠️`; + return messages?.closeOlderDiscussion + ? renderTemplate(messages.closeOlderDiscussion, templateContext) + : renderTemplate(defaultMessage, templateContext); + } + const MAX_CLOSE_COUNT = 10; + const GRAPHQL_DELAY_MS = 500; + function delay(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); + } + async function searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, excludeNumber) { + let searchQuery = `repo:${owner}/${repo} is:open`; + if (titlePrefix) { + const escapedPrefix = titlePrefix.replace(/"/g, '\\"'); + searchQuery += ` in:title "${escapedPrefix}"`; + } + if (labels && labels.length > 0) { + for (const label of labels) { + const escapedLabel = label.replace(/"/g, '\\"'); + searchQuery += ` label:"${escapedLabel}"`; + } + } + const result = await github.graphql( + ` + query($searchTerms: String!, $first: Int!) { + search(query: $searchTerms, type: DISCUSSION, first: $first) { + nodes { + ... on Discussion { + id + number + title + url + category { + id + } + labels(first: 100) { + nodes { + name + } + } + closed + } + } + } + }`, + { searchTerms: searchQuery, first: 50 } + ); + if (!result || !result.search || !result.search.nodes) { + return []; + } + return result.search.nodes + .filter( + d => { + if (!d || d.number === excludeNumber || d.closed) { + return false; + } + if (titlePrefix && d.title && !d.title.startsWith(titlePrefix)) { + return false; + } + if (labels && labels.length > 0) { + const discussionLabels = d.labels?.nodes?.map(( l) => l.name) || []; + const hasAllLabels = labels.every(label => discussionLabels.includes(label)); + if (!hasAllLabels) { + return false; + } + } + if (categoryId && (!d.category || d.category.id !== categoryId)) { + return false; + } + return true; + } + ) + .map( + d => ({ + id: d.id, + number: d.number, + title: d.title, + url: d.url, + }) + ); + } + async function addDiscussionComment(github, discussionId, message) { + const result = await github.graphql( + ` + mutation($dId: ID!, $body: String!) { + addDiscussionComment(input: { discussionId: $dId, body: $body }) { + comment { + id + url + } + } + }`, + { dId: discussionId, body: message } + ); + return result.addDiscussionComment.comment; + } + async function closeDiscussionAsOutdated(github, discussionId) { + const result = await github.graphql( + ` + mutation($dId: ID!) { + closeDiscussion(input: { discussionId: $dId, reason: OUTDATED }) { + discussion { + id + url + } + } + }`, + { dId: discussionId } + ); + return result.closeDiscussion.discussion; + } + async function closeOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion, workflowName, runUrl) { + const searchCriteria = []; + if (titlePrefix) searchCriteria.push(`title prefix: "${titlePrefix}"`); + if (labels && labels.length > 0) searchCriteria.push(`labels: [${labels.join(", ")}]`); + core.info(`Searching for older discussions with ${searchCriteria.join(" and ")}`); + const olderDiscussions = await searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion.number); + if (olderDiscussions.length === 0) { + core.info("No older discussions found to close"); + return []; + } + core.info(`Found ${olderDiscussions.length} older discussion(s) to close`); + const discussionsToClose = olderDiscussions.slice(0, MAX_CLOSE_COUNT); + if (olderDiscussions.length > MAX_CLOSE_COUNT) { + core.warning(`Found ${olderDiscussions.length} older discussions, but only closing the first ${MAX_CLOSE_COUNT}`); + } + const closedDiscussions = []; + for (let i = 0; i < discussionsToClose.length; i++) { + const discussion = discussionsToClose[i]; + try { + const closingMessage = getCloseOlderDiscussionMessage({ + newDiscussionUrl: newDiscussion.url, + newDiscussionNumber: newDiscussion.number, + workflowName, + runUrl, + }); + core.info(`Adding closing comment to discussion #${discussion.number}`); + await addDiscussionComment(github, discussion.id, closingMessage); + core.info(`Closing discussion #${discussion.number} as outdated`); + await closeDiscussionAsOutdated(github, discussion.id); + closedDiscussions.push({ + number: discussion.number, + url: discussion.url, + }); + core.info(`✓ Closed discussion #${discussion.number}: ${discussion.url}`); + } catch (error) { + core.error(`✗ Failed to close discussion #${discussion.number}: ${error instanceof Error ? error.message : String(error)}`); + } + if (i < discussionsToClose.length - 1) { + await delay(GRAPHQL_DELAY_MS); + } + } + return closedDiscussions; + } + const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; + function generateTemporaryId() { + return "aw_" + crypto.randomBytes(6).toString("hex"); + } + function isTemporaryId(value) { + if (typeof value === "string") { + return /^aw_[0-9a-f]{12}$/i.test(value); + } + return false; + } + function normalizeTemporaryId(tempId) { + return String(tempId).toLowerCase(); + } + function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { + return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { + const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); + if (resolved !== undefined) { + if (currentRepo && resolved.repo === currentRepo) { + return `#${resolved.number}`; + } + return `${resolved.repo}#${resolved.number}`; + } + return match; + }); + } + function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { + return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { + const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); + if (issueNumber !== undefined) { + return `#${issueNumber}`; + } + return match; + }); + } + function loadTemporaryIdMap() { + const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; + if (!mapJson || mapJson === "{}") { + return new Map(); + } + try { + const mapObject = JSON.parse(mapJson); + const result = new Map(); + for (const [key, value] of Object.entries(mapObject)) { + const normalizedKey = normalizeTemporaryId(key); + if (typeof value === "number") { + const contextRepo = `${context.repo.owner}/${context.repo.repo}`; + result.set(normalizedKey, { repo: contextRepo, number: value }); + } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { + result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); + } + } + return result; + } catch (error) { + if (typeof core !== "undefined") { + core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); + } + return new Map(); + } + } + function resolveIssueNumber(value, temporaryIdMap) { + if (value === undefined || value === null) { + return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; + } + const valueStr = String(value); + if (isTemporaryId(valueStr)) { + const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); + if (resolvedPair !== undefined) { + return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; + } + return { + resolved: null, + wasTemporaryId: true, + errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, + }; + } + const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); + if (isNaN(issueNumber) || issueNumber <= 0) { + return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; + } + const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; + return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; + } + function serializeTemporaryIdMap(tempIdMap) { + const obj = Object.fromEntries(tempIdMap); + return JSON.stringify(obj); + } + function parseAllowedRepos() { + const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; + const set = new Set(); + if (allowedReposEnv) { + allowedReposEnv + .split(",") + .map(repo => repo.trim()) + .filter(repo => repo) + .forEach(repo => set.add(repo)); + } + return set; + } + function getDefaultTargetRepo() { + const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; + if (targetRepoSlug) { + return targetRepoSlug; + } + return `${context.repo.owner}/${context.repo.repo}`; + } + function validateRepo(repo, defaultRepo, allowedRepos) { + if (repo === defaultRepo) { + return { valid: true, error: null }; + } + if (allowedRepos.has(repo)) { + return { valid: true, error: null }; + } + return { + valid: false, + error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, + }; + } + function parseRepoSlug(repoSlug) { + const parts = repoSlug.split("/"); + if (parts.length !== 2 || !parts[0] || !parts[1]) { + return null; + } + return { owner: parts[0], repo: parts[1] }; + } + function addExpirationComment(bodyLines, envVarName, entityType) { + const expiresEnv = process.env[envVarName]; + if (expiresEnv) { + const expiresDays = parseInt(expiresEnv, 10); + if (!isNaN(expiresDays) && expiresDays > 0) { + const expirationDate = new Date(); + expirationDate.setDate(expirationDate.getDate() + expiresDays); + const expirationISO = expirationDate.toISOString(); + bodyLines.push(``); + core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); + } + } + } + function removeDuplicateTitleFromDescription(title, description) { + if (!title || typeof title !== "string") { + return description || ""; + } + if (!description || typeof description !== "string") { + return ""; + } + const trimmedTitle = title.trim(); + const trimmedDescription = description.trim(); + if (!trimmedTitle || !trimmedDescription) { + return trimmedDescription; + } + const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); + if (headerRegex.test(trimmedDescription)) { + return trimmedDescription.replace(headerRegex, "").trim(); + } + return trimmedDescription; + } async function fetchRepoDiscussionInfo(owner, repo) { const repositoryQuery = ` query($owner: String!, $repo: String!) { diff --git a/.github/workflows/daily-copilot-token-report.lock.yml b/.github/workflows/daily-copilot-token-report.lock.yml index 00099a61a68..2ad6d034430 100644 --- a/.github/workflows/daily-copilot-token-report.lock.yml +++ b/.github/workflows/daily-copilot-token-report.lock.yml @@ -7956,13 +7956,400 @@ jobs: with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - const { loadAgentOutput } = require("./load_agent_output.cjs"); - const { getTrackerID } = require("./get_tracker_id.cjs"); - const { closeOlderDiscussions } = require("./close_older_discussions.cjs"); - const { replaceTemporaryIdReferences, loadTemporaryIdMap } = require("./temporary_id.cjs"); - const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require("./repo_helpers.cjs"); - const { addExpirationComment } = require("./expiration_helpers.cjs"); - const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); + const fs = require("fs"); + const crypto = require("crypto"); + const MAX_LOG_CONTENT_LENGTH = 10000; + function truncateForLogging(content) { + if (content.length <= MAX_LOG_CONTENT_LENGTH) { + return content; + } + return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; + } + function loadAgentOutput() { + const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; + if (!agentOutputFile) { + core.info("No GH_AW_AGENT_OUTPUT environment variable found"); + return { success: false }; + } + let outputContent; + try { + outputContent = fs.readFileSync(agentOutputFile, "utf8"); + } catch (error) { + const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; + core.error(errorMessage); + return { success: false, error: errorMessage }; + } + if (outputContent.trim() === "") { + core.info("Agent output content is empty"); + return { success: false }; + } + core.info(`Agent output content length: ${outputContent.length}`); + let validatedOutput; + try { + validatedOutput = JSON.parse(outputContent); + } catch (error) { + const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; + core.error(errorMessage); + core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); + return { success: false, error: errorMessage }; + } + if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { + core.info("No valid items found in agent output"); + core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); + return { success: false }; + } + return { success: true, items: validatedOutput.items }; + } + function getTrackerID(format) { + const trackerID = process.env.GH_AW_TRACKER_ID || ""; + if (trackerID) { + core.info(`Tracker ID: ${trackerID}`); + return format === "markdown" ? `\n\n` : trackerID; + } + return ""; + } + function getMessages() { + const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES; + if (!messagesEnv) { + return null; + } + try { + return JSON.parse(messagesEnv); + } catch (error) { + core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`); + return null; + } + } + function renderTemplate(template, context) { + return template.replace(/\{(\w+)\}/g, (match, key) => { + const value = context[key]; + return value !== undefined && value !== null ? String(value) : match; + }); + } + function toSnakeCase(obj) { + const result = {}; + for (const [key, value] of Object.entries(obj)) { + const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase(); + result[snakeKey] = value; + result[key] = value; + } + return result; + } + function getCloseOlderDiscussionMessage(ctx) { + const messages = getMessages(); + const templateContext = toSnakeCase(ctx); + const defaultMessage = `⚓ Avast! This discussion be marked as **outdated** by [{workflow_name}]({run_url}). + 🗺️ A newer treasure map awaits ye at **[Discussion #{new_discussion_number}]({new_discussion_url})**. + Fair winds, matey! 🏴‍☠️`; + return messages?.closeOlderDiscussion + ? renderTemplate(messages.closeOlderDiscussion, templateContext) + : renderTemplate(defaultMessage, templateContext); + } + const MAX_CLOSE_COUNT = 10; + const GRAPHQL_DELAY_MS = 500; + function delay(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); + } + async function searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, excludeNumber) { + let searchQuery = `repo:${owner}/${repo} is:open`; + if (titlePrefix) { + const escapedPrefix = titlePrefix.replace(/"/g, '\\"'); + searchQuery += ` in:title "${escapedPrefix}"`; + } + if (labels && labels.length > 0) { + for (const label of labels) { + const escapedLabel = label.replace(/"/g, '\\"'); + searchQuery += ` label:"${escapedLabel}"`; + } + } + const result = await github.graphql( + ` + query($searchTerms: String!, $first: Int!) { + search(query: $searchTerms, type: DISCUSSION, first: $first) { + nodes { + ... on Discussion { + id + number + title + url + category { + id + } + labels(first: 100) { + nodes { + name + } + } + closed + } + } + } + }`, + { searchTerms: searchQuery, first: 50 } + ); + if (!result || !result.search || !result.search.nodes) { + return []; + } + return result.search.nodes + .filter( + d => { + if (!d || d.number === excludeNumber || d.closed) { + return false; + } + if (titlePrefix && d.title && !d.title.startsWith(titlePrefix)) { + return false; + } + if (labels && labels.length > 0) { + const discussionLabels = d.labels?.nodes?.map(( l) => l.name) || []; + const hasAllLabels = labels.every(label => discussionLabels.includes(label)); + if (!hasAllLabels) { + return false; + } + } + if (categoryId && (!d.category || d.category.id !== categoryId)) { + return false; + } + return true; + } + ) + .map( + d => ({ + id: d.id, + number: d.number, + title: d.title, + url: d.url, + }) + ); + } + async function addDiscussionComment(github, discussionId, message) { + const result = await github.graphql( + ` + mutation($dId: ID!, $body: String!) { + addDiscussionComment(input: { discussionId: $dId, body: $body }) { + comment { + id + url + } + } + }`, + { dId: discussionId, body: message } + ); + return result.addDiscussionComment.comment; + } + async function closeDiscussionAsOutdated(github, discussionId) { + const result = await github.graphql( + ` + mutation($dId: ID!) { + closeDiscussion(input: { discussionId: $dId, reason: OUTDATED }) { + discussion { + id + url + } + } + }`, + { dId: discussionId } + ); + return result.closeDiscussion.discussion; + } + async function closeOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion, workflowName, runUrl) { + const searchCriteria = []; + if (titlePrefix) searchCriteria.push(`title prefix: "${titlePrefix}"`); + if (labels && labels.length > 0) searchCriteria.push(`labels: [${labels.join(", ")}]`); + core.info(`Searching for older discussions with ${searchCriteria.join(" and ")}`); + const olderDiscussions = await searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion.number); + if (olderDiscussions.length === 0) { + core.info("No older discussions found to close"); + return []; + } + core.info(`Found ${olderDiscussions.length} older discussion(s) to close`); + const discussionsToClose = olderDiscussions.slice(0, MAX_CLOSE_COUNT); + if (olderDiscussions.length > MAX_CLOSE_COUNT) { + core.warning(`Found ${olderDiscussions.length} older discussions, but only closing the first ${MAX_CLOSE_COUNT}`); + } + const closedDiscussions = []; + for (let i = 0; i < discussionsToClose.length; i++) { + const discussion = discussionsToClose[i]; + try { + const closingMessage = getCloseOlderDiscussionMessage({ + newDiscussionUrl: newDiscussion.url, + newDiscussionNumber: newDiscussion.number, + workflowName, + runUrl, + }); + core.info(`Adding closing comment to discussion #${discussion.number}`); + await addDiscussionComment(github, discussion.id, closingMessage); + core.info(`Closing discussion #${discussion.number} as outdated`); + await closeDiscussionAsOutdated(github, discussion.id); + closedDiscussions.push({ + number: discussion.number, + url: discussion.url, + }); + core.info(`✓ Closed discussion #${discussion.number}: ${discussion.url}`); + } catch (error) { + core.error(`✗ Failed to close discussion #${discussion.number}: ${error instanceof Error ? error.message : String(error)}`); + } + if (i < discussionsToClose.length - 1) { + await delay(GRAPHQL_DELAY_MS); + } + } + return closedDiscussions; + } + const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; + function generateTemporaryId() { + return "aw_" + crypto.randomBytes(6).toString("hex"); + } + function isTemporaryId(value) { + if (typeof value === "string") { + return /^aw_[0-9a-f]{12}$/i.test(value); + } + return false; + } + function normalizeTemporaryId(tempId) { + return String(tempId).toLowerCase(); + } + function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { + return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { + const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); + if (resolved !== undefined) { + if (currentRepo && resolved.repo === currentRepo) { + return `#${resolved.number}`; + } + return `${resolved.repo}#${resolved.number}`; + } + return match; + }); + } + function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { + return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { + const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); + if (issueNumber !== undefined) { + return `#${issueNumber}`; + } + return match; + }); + } + function loadTemporaryIdMap() { + const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; + if (!mapJson || mapJson === "{}") { + return new Map(); + } + try { + const mapObject = JSON.parse(mapJson); + const result = new Map(); + for (const [key, value] of Object.entries(mapObject)) { + const normalizedKey = normalizeTemporaryId(key); + if (typeof value === "number") { + const contextRepo = `${context.repo.owner}/${context.repo.repo}`; + result.set(normalizedKey, { repo: contextRepo, number: value }); + } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { + result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); + } + } + return result; + } catch (error) { + if (typeof core !== "undefined") { + core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); + } + return new Map(); + } + } + function resolveIssueNumber(value, temporaryIdMap) { + if (value === undefined || value === null) { + return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; + } + const valueStr = String(value); + if (isTemporaryId(valueStr)) { + const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); + if (resolvedPair !== undefined) { + return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; + } + return { + resolved: null, + wasTemporaryId: true, + errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, + }; + } + const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); + if (isNaN(issueNumber) || issueNumber <= 0) { + return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; + } + const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; + return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; + } + function serializeTemporaryIdMap(tempIdMap) { + const obj = Object.fromEntries(tempIdMap); + return JSON.stringify(obj); + } + function parseAllowedRepos() { + const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; + const set = new Set(); + if (allowedReposEnv) { + allowedReposEnv + .split(",") + .map(repo => repo.trim()) + .filter(repo => repo) + .forEach(repo => set.add(repo)); + } + return set; + } + function getDefaultTargetRepo() { + const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; + if (targetRepoSlug) { + return targetRepoSlug; + } + return `${context.repo.owner}/${context.repo.repo}`; + } + function validateRepo(repo, defaultRepo, allowedRepos) { + if (repo === defaultRepo) { + return { valid: true, error: null }; + } + if (allowedRepos.has(repo)) { + return { valid: true, error: null }; + } + return { + valid: false, + error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, + }; + } + function parseRepoSlug(repoSlug) { + const parts = repoSlug.split("/"); + if (parts.length !== 2 || !parts[0] || !parts[1]) { + return null; + } + return { owner: parts[0], repo: parts[1] }; + } + function addExpirationComment(bodyLines, envVarName, entityType) { + const expiresEnv = process.env[envVarName]; + if (expiresEnv) { + const expiresDays = parseInt(expiresEnv, 10); + if (!isNaN(expiresDays) && expiresDays > 0) { + const expirationDate = new Date(); + expirationDate.setDate(expirationDate.getDate() + expiresDays); + const expirationISO = expirationDate.toISOString(); + bodyLines.push(``); + core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); + } + } + } + function removeDuplicateTitleFromDescription(title, description) { + if (!title || typeof title !== "string") { + return description || ""; + } + if (!description || typeof description !== "string") { + return ""; + } + const trimmedTitle = title.trim(); + const trimmedDescription = description.trim(); + if (!trimmedTitle || !trimmedDescription) { + return trimmedDescription; + } + const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); + if (headerRegex.test(trimmedDescription)) { + return trimmedDescription.replace(headerRegex, "").trim(); + } + return trimmedDescription; + } async function fetchRepoDiscussionInfo(owner, repo) { const repositoryQuery = ` query($owner: String!, $repo: String!) { diff --git a/.github/workflows/daily-doc-updater.lock.yml b/.github/workflows/daily-doc-updater.lock.yml index 531dc99ccda..99423d81ca5 100644 --- a/.github/workflows/daily-doc-updater.lock.yml +++ b/.github/workflows/daily-doc-updater.lock.yml @@ -5774,10 +5774,151 @@ jobs: script: | const fs = require("fs"); const crypto = require("crypto"); - const { updateActivationComment } = require("./update_activation_comment.cjs"); - const { getTrackerID } = require("./get_tracker_id.cjs"); - const { addExpirationComment } = require("./expiration_helpers.cjs"); - const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); + async function updateActivationComment(github, context, core, itemUrl, itemNumber, itemType = "pull_request") { + const itemLabel = itemType === "issue" ? "issue" : "pull request"; + const linkMessage = + itemType === "issue" + ? `\n\n✅ Issue created: [#${itemNumber}](${itemUrl})` + : `\n\n✅ Pull request created: [#${itemNumber}](${itemUrl})`; + await updateActivationCommentWithMessage(github, context, core, linkMessage, itemLabel); + } + async function updateActivationCommentWithCommit(github, context, core, commitSha, commitUrl) { + const shortSha = commitSha.substring(0, 7); + const message = `\n\n✅ Commit pushed: [\`${shortSha}\`](${commitUrl})`; + await updateActivationCommentWithMessage(github, context, core, message, "commit"); + } + async function updateActivationCommentWithMessage(github, context, core, message, label = "") { + const commentId = process.env.GH_AW_COMMENT_ID; + const commentRepo = process.env.GH_AW_COMMENT_REPO; + if (!commentId) { + core.info("No activation comment to update (GH_AW_COMMENT_ID not set)"); + return; + } + core.info(`Updating activation comment ${commentId}`); + let repoOwner = context.repo.owner; + let repoName = context.repo.repo; + if (commentRepo) { + const parts = commentRepo.split("/"); + if (parts.length === 2) { + repoOwner = parts[0]; + repoName = parts[1]; + } else { + core.warning(`Invalid comment repo format: ${commentRepo}, expected "owner/repo". Falling back to context.repo.`); + } + } + core.info(`Updating comment in ${repoOwner}/${repoName}`); + const isDiscussionComment = commentId.startsWith("DC_"); + try { + if (isDiscussionComment) { + const currentComment = await github.graphql( + ` + query($commentId: ID!) { + node(id: $commentId) { + ... on DiscussionComment { + body + } + } + }`, + { commentId: commentId } + ); + if (!currentComment?.node?.body) { + core.warning("Unable to fetch current comment body, comment may have been deleted or is inaccessible"); + return; + } + const currentBody = currentComment.node.body; + const updatedBody = currentBody + message; + const result = await github.graphql( + ` + mutation($commentId: ID!, $body: String!) { + updateDiscussionComment(input: { commentId: $commentId, body: $body }) { + comment { + id + url + } + } + }`, + { commentId: commentId, body: updatedBody } + ); + const comment = result.updateDiscussionComment.comment; + const successMessage = label + ? `Successfully updated discussion comment with ${label} link` + : "Successfully updated discussion comment"; + core.info(successMessage); + core.info(`Comment ID: ${comment.id}`); + core.info(`Comment URL: ${comment.url}`); + } else { + const currentComment = await github.request("GET /repos/{owner}/{repo}/issues/comments/{comment_id}", { + owner: repoOwner, + repo: repoName, + comment_id: parseInt(commentId, 10), + headers: { + Accept: "application/vnd.github+json", + }, + }); + if (!currentComment?.data?.body) { + core.warning("Unable to fetch current comment body, comment may have been deleted"); + return; + } + const currentBody = currentComment.data.body; + const updatedBody = currentBody + message; + const response = await github.request("PATCH /repos/{owner}/{repo}/issues/comments/{comment_id}", { + owner: repoOwner, + repo: repoName, + comment_id: parseInt(commentId, 10), + body: updatedBody, + headers: { + Accept: "application/vnd.github+json", + }, + }); + const successMessage = label ? `Successfully updated comment with ${label} link` : "Successfully updated comment"; + core.info(successMessage); + core.info(`Comment ID: ${response.data.id}`); + core.info(`Comment URL: ${response.data.html_url}`); + } + } catch (error) { + core.warning(`Failed to update activation comment: ${error instanceof Error ? error.message : String(error)}`); + } + } + function getTrackerID(format) { + const trackerID = process.env.GH_AW_TRACKER_ID || ""; + if (trackerID) { + core.info(`Tracker ID: ${trackerID}`); + return format === "markdown" ? `\n\n` : trackerID; + } + return ""; + } + function addExpirationComment(bodyLines, envVarName, entityType) { + const expiresEnv = process.env[envVarName]; + if (expiresEnv) { + const expiresDays = parseInt(expiresEnv, 10); + if (!isNaN(expiresDays) && expiresDays > 0) { + const expirationDate = new Date(); + expirationDate.setDate(expirationDate.getDate() + expiresDays); + const expirationISO = expirationDate.toISOString(); + bodyLines.push(``); + core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); + } + } + } + function removeDuplicateTitleFromDescription(title, description) { + if (!title || typeof title !== "string") { + return description || ""; + } + if (!description || typeof description !== "string") { + return ""; + } + const trimmedTitle = title.trim(); + const trimmedDescription = description.trim(); + if (!trimmedTitle || !trimmedDescription) { + return trimmedDescription; + } + const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); + if (headerRegex.test(trimmedDescription)) { + return trimmedDescription.replace(headerRegex, "").trim(); + } + return trimmedDescription; + } function generatePatchPreview(patchContent) { if (!patchContent || !patchContent.trim()) { return ""; diff --git a/.github/workflows/daily-file-diet.lock.yml b/.github/workflows/daily-file-diet.lock.yml index a709e82c835..2da42d25cdc 100644 --- a/.github/workflows/daily-file-diet.lock.yml +++ b/.github/workflows/daily-file-diet.lock.yml @@ -6024,21 +6024,291 @@ jobs: with: github-token: ${{ steps.app-token.outputs.token }} script: | - const { sanitizeLabelContent } = require("./sanitize_label_content.cjs"); - const { loadAgentOutput } = require("./load_agent_output.cjs"); - const { generateStagedPreview } = require("./staged_preview.cjs"); - const { generateFooter } = require("./generate_footer.cjs"); - const { getTrackerID } = require("./get_tracker_id.cjs"); - const { - generateTemporaryId, - isTemporaryId, - normalizeTemporaryId, - replaceTemporaryIdReferences, - serializeTemporaryIdMap, - } = require("./temporary_id.cjs"); - const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require("./repo_helpers.cjs"); - const { addExpirationComment } = require("./expiration_helpers.cjs"); - const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); + function sanitizeLabelContent(content) { + if (!content || typeof content !== "string") { + return ""; + } + let sanitized = content.trim(); + sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); + sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); + sanitized = sanitized.replace( + /(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, + (_m, p1, p2) => `${p1}\`@${p2}\`` + ); + sanitized = sanitized.replace(/[<>&'"]/g, ""); + return sanitized.trim(); + } + const fs = require("fs"); + const crypto = require("crypto"); + const MAX_LOG_CONTENT_LENGTH = 10000; + function truncateForLogging(content) { + if (content.length <= MAX_LOG_CONTENT_LENGTH) { + return content; + } + return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; + } + function loadAgentOutput() { + const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; + if (!agentOutputFile) { + core.info("No GH_AW_AGENT_OUTPUT environment variable found"); + return { success: false }; + } + let outputContent; + try { + outputContent = fs.readFileSync(agentOutputFile, "utf8"); + } catch (error) { + const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; + core.error(errorMessage); + return { success: false, error: errorMessage }; + } + if (outputContent.trim() === "") { + core.info("Agent output content is empty"); + return { success: false }; + } + core.info(`Agent output content length: ${outputContent.length}`); + let validatedOutput; + try { + validatedOutput = JSON.parse(outputContent); + } catch (error) { + const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; + core.error(errorMessage); + core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); + return { success: false, error: errorMessage }; + } + if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { + core.info("No valid items found in agent output"); + core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); + return { success: false }; + } + return { success: true, items: validatedOutput.items }; + } + async function generateStagedPreview(options) { + const { title, description, items, renderItem } = options; + let summaryContent = `## 🎭 Staged Mode: ${title} Preview\n\n`; + summaryContent += `${description}\n\n`; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + summaryContent += renderItem(item, i); + summaryContent += "---\n\n"; + } + try { + await core.summary.addRaw(summaryContent).write(); + core.info(summaryContent); + core.info(`📝 ${title} preview written to step summary`); + } catch (error) { + core.setFailed(error instanceof Error ? error : String(error)); + } + } + function generateXMLMarker(workflowName, runUrl) { + const engineId = process.env.GH_AW_ENGINE_ID || ""; + const engineVersion = process.env.GH_AW_ENGINE_VERSION || ""; + const engineModel = process.env.GH_AW_ENGINE_MODEL || ""; + const trackerId = process.env.GH_AW_TRACKER_ID || ""; + const parts = []; + parts.push(`agentic-workflow: ${workflowName}`); + if (trackerId) { + parts.push(`tracker-id: ${trackerId}`); + } + if (engineId) { + parts.push(`engine: ${engineId}`); + } + if (engineVersion) { + parts.push(`version: ${engineVersion}`); + } + if (engineModel) { + parts.push(`model: ${engineModel}`); + } + parts.push(`run: ${runUrl}`); + return ``; + } + function generateFooter( + workflowName, + runUrl, + workflowSource, + workflowSourceURL, + triggeringIssueNumber, + triggeringPRNumber, + triggeringDiscussionNumber + ) { + let footer = `\n\n> AI generated by [${workflowName}](${runUrl})`; + if (triggeringIssueNumber) { + footer += ` for #${triggeringIssueNumber}`; + } else if (triggeringPRNumber) { + footer += ` for #${triggeringPRNumber}`; + } else if (triggeringDiscussionNumber) { + footer += ` for discussion #${triggeringDiscussionNumber}`; + } + if (workflowSource && workflowSourceURL) { + footer += `\n>\n> To add this workflow in your repository, run \`gh aw add ${workflowSource}\`. See [usage guide](https://githubnext.github.io/gh-aw/tools/cli/).`; + } + footer += "\n\n" + generateXMLMarker(workflowName, runUrl); + footer += "\n"; + return footer; + } + function getTrackerID(format) { + const trackerID = process.env.GH_AW_TRACKER_ID || ""; + if (trackerID) { + core.info(`Tracker ID: ${trackerID}`); + return format === "markdown" ? `\n\n` : trackerID; + } + return ""; + } + const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; + function generateTemporaryId() { + return "aw_" + crypto.randomBytes(6).toString("hex"); + } + function isTemporaryId(value) { + if (typeof value === "string") { + return /^aw_[0-9a-f]{12}$/i.test(value); + } + return false; + } + function normalizeTemporaryId(tempId) { + return String(tempId).toLowerCase(); + } + function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { + return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { + const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); + if (resolved !== undefined) { + if (currentRepo && resolved.repo === currentRepo) { + return `#${resolved.number}`; + } + return `${resolved.repo}#${resolved.number}`; + } + return match; + }); + } + function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { + return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { + const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); + if (issueNumber !== undefined) { + return `#${issueNumber}`; + } + return match; + }); + } + function loadTemporaryIdMap() { + const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; + if (!mapJson || mapJson === "{}") { + return new Map(); + } + try { + const mapObject = JSON.parse(mapJson); + const result = new Map(); + for (const [key, value] of Object.entries(mapObject)) { + const normalizedKey = normalizeTemporaryId(key); + if (typeof value === "number") { + const contextRepo = `${context.repo.owner}/${context.repo.repo}`; + result.set(normalizedKey, { repo: contextRepo, number: value }); + } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { + result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); + } + } + return result; + } catch (error) { + if (typeof core !== "undefined") { + core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); + } + return new Map(); + } + } + function resolveIssueNumber(value, temporaryIdMap) { + if (value === undefined || value === null) { + return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; + } + const valueStr = String(value); + if (isTemporaryId(valueStr)) { + const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); + if (resolvedPair !== undefined) { + return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; + } + return { + resolved: null, + wasTemporaryId: true, + errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, + }; + } + const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); + if (isNaN(issueNumber) || issueNumber <= 0) { + return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; + } + const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; + return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; + } + function serializeTemporaryIdMap(tempIdMap) { + const obj = Object.fromEntries(tempIdMap); + return JSON.stringify(obj); + } + function parseAllowedRepos() { + const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; + const set = new Set(); + if (allowedReposEnv) { + allowedReposEnv + .split(",") + .map(repo => repo.trim()) + .filter(repo => repo) + .forEach(repo => set.add(repo)); + } + return set; + } + function getDefaultTargetRepo() { + const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; + if (targetRepoSlug) { + return targetRepoSlug; + } + return `${context.repo.owner}/${context.repo.repo}`; + } + function validateRepo(repo, defaultRepo, allowedRepos) { + if (repo === defaultRepo) { + return { valid: true, error: null }; + } + if (allowedRepos.has(repo)) { + return { valid: true, error: null }; + } + return { + valid: false, + error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, + }; + } + function parseRepoSlug(repoSlug) { + const parts = repoSlug.split("/"); + if (parts.length !== 2 || !parts[0] || !parts[1]) { + return null; + } + return { owner: parts[0], repo: parts[1] }; + } + function addExpirationComment(bodyLines, envVarName, entityType) { + const expiresEnv = process.env[envVarName]; + if (expiresEnv) { + const expiresDays = parseInt(expiresEnv, 10); + if (!isNaN(expiresDays) && expiresDays > 0) { + const expirationDate = new Date(); + expirationDate.setDate(expirationDate.getDate() + expiresDays); + const expirationISO = expirationDate.toISOString(); + bodyLines.push(``); + core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); + } + } + } + function removeDuplicateTitleFromDescription(title, description) { + if (!title || typeof title !== "string") { + return description || ""; + } + if (!description || typeof description !== "string") { + return ""; + } + const trimmedTitle = title.trim(); + const trimmedDescription = description.trim(); + if (!trimmedTitle || !trimmedDescription) { + return trimmedDescription; + } + const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); + if (headerRegex.test(trimmedDescription)) { + return trimmedDescription.replace(headerRegex, "").trim(); + } + return trimmedDescription; + } async function main() { core.setOutput("issue_number", ""); core.setOutput("issue_url", ""); diff --git a/.github/workflows/daily-firewall-report.lock.yml b/.github/workflows/daily-firewall-report.lock.yml index 47048257b30..37bd63ed2a7 100644 --- a/.github/workflows/daily-firewall-report.lock.yml +++ b/.github/workflows/daily-firewall-report.lock.yml @@ -7251,13 +7251,400 @@ jobs: with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - const { loadAgentOutput } = require("./load_agent_output.cjs"); - const { getTrackerID } = require("./get_tracker_id.cjs"); - const { closeOlderDiscussions } = require("./close_older_discussions.cjs"); - const { replaceTemporaryIdReferences, loadTemporaryIdMap } = require("./temporary_id.cjs"); - const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require("./repo_helpers.cjs"); - const { addExpirationComment } = require("./expiration_helpers.cjs"); - const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); + const fs = require("fs"); + const crypto = require("crypto"); + const MAX_LOG_CONTENT_LENGTH = 10000; + function truncateForLogging(content) { + if (content.length <= MAX_LOG_CONTENT_LENGTH) { + return content; + } + return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; + } + function loadAgentOutput() { + const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; + if (!agentOutputFile) { + core.info("No GH_AW_AGENT_OUTPUT environment variable found"); + return { success: false }; + } + let outputContent; + try { + outputContent = fs.readFileSync(agentOutputFile, "utf8"); + } catch (error) { + const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; + core.error(errorMessage); + return { success: false, error: errorMessage }; + } + if (outputContent.trim() === "") { + core.info("Agent output content is empty"); + return { success: false }; + } + core.info(`Agent output content length: ${outputContent.length}`); + let validatedOutput; + try { + validatedOutput = JSON.parse(outputContent); + } catch (error) { + const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; + core.error(errorMessage); + core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); + return { success: false, error: errorMessage }; + } + if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { + core.info("No valid items found in agent output"); + core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); + return { success: false }; + } + return { success: true, items: validatedOutput.items }; + } + function getTrackerID(format) { + const trackerID = process.env.GH_AW_TRACKER_ID || ""; + if (trackerID) { + core.info(`Tracker ID: ${trackerID}`); + return format === "markdown" ? `\n\n` : trackerID; + } + return ""; + } + function getMessages() { + const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES; + if (!messagesEnv) { + return null; + } + try { + return JSON.parse(messagesEnv); + } catch (error) { + core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`); + return null; + } + } + function renderTemplate(template, context) { + return template.replace(/\{(\w+)\}/g, (match, key) => { + const value = context[key]; + return value !== undefined && value !== null ? String(value) : match; + }); + } + function toSnakeCase(obj) { + const result = {}; + for (const [key, value] of Object.entries(obj)) { + const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase(); + result[snakeKey] = value; + result[key] = value; + } + return result; + } + function getCloseOlderDiscussionMessage(ctx) { + const messages = getMessages(); + const templateContext = toSnakeCase(ctx); + const defaultMessage = `⚓ Avast! This discussion be marked as **outdated** by [{workflow_name}]({run_url}). + 🗺️ A newer treasure map awaits ye at **[Discussion #{new_discussion_number}]({new_discussion_url})**. + Fair winds, matey! 🏴‍☠️`; + return messages?.closeOlderDiscussion + ? renderTemplate(messages.closeOlderDiscussion, templateContext) + : renderTemplate(defaultMessage, templateContext); + } + const MAX_CLOSE_COUNT = 10; + const GRAPHQL_DELAY_MS = 500; + function delay(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); + } + async function searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, excludeNumber) { + let searchQuery = `repo:${owner}/${repo} is:open`; + if (titlePrefix) { + const escapedPrefix = titlePrefix.replace(/"/g, '\\"'); + searchQuery += ` in:title "${escapedPrefix}"`; + } + if (labels && labels.length > 0) { + for (const label of labels) { + const escapedLabel = label.replace(/"/g, '\\"'); + searchQuery += ` label:"${escapedLabel}"`; + } + } + const result = await github.graphql( + ` + query($searchTerms: String!, $first: Int!) { + search(query: $searchTerms, type: DISCUSSION, first: $first) { + nodes { + ... on Discussion { + id + number + title + url + category { + id + } + labels(first: 100) { + nodes { + name + } + } + closed + } + } + } + }`, + { searchTerms: searchQuery, first: 50 } + ); + if (!result || !result.search || !result.search.nodes) { + return []; + } + return result.search.nodes + .filter( + d => { + if (!d || d.number === excludeNumber || d.closed) { + return false; + } + if (titlePrefix && d.title && !d.title.startsWith(titlePrefix)) { + return false; + } + if (labels && labels.length > 0) { + const discussionLabels = d.labels?.nodes?.map(( l) => l.name) || []; + const hasAllLabels = labels.every(label => discussionLabels.includes(label)); + if (!hasAllLabels) { + return false; + } + } + if (categoryId && (!d.category || d.category.id !== categoryId)) { + return false; + } + return true; + } + ) + .map( + d => ({ + id: d.id, + number: d.number, + title: d.title, + url: d.url, + }) + ); + } + async function addDiscussionComment(github, discussionId, message) { + const result = await github.graphql( + ` + mutation($dId: ID!, $body: String!) { + addDiscussionComment(input: { discussionId: $dId, body: $body }) { + comment { + id + url + } + } + }`, + { dId: discussionId, body: message } + ); + return result.addDiscussionComment.comment; + } + async function closeDiscussionAsOutdated(github, discussionId) { + const result = await github.graphql( + ` + mutation($dId: ID!) { + closeDiscussion(input: { discussionId: $dId, reason: OUTDATED }) { + discussion { + id + url + } + } + }`, + { dId: discussionId } + ); + return result.closeDiscussion.discussion; + } + async function closeOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion, workflowName, runUrl) { + const searchCriteria = []; + if (titlePrefix) searchCriteria.push(`title prefix: "${titlePrefix}"`); + if (labels && labels.length > 0) searchCriteria.push(`labels: [${labels.join(", ")}]`); + core.info(`Searching for older discussions with ${searchCriteria.join(" and ")}`); + const olderDiscussions = await searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion.number); + if (olderDiscussions.length === 0) { + core.info("No older discussions found to close"); + return []; + } + core.info(`Found ${olderDiscussions.length} older discussion(s) to close`); + const discussionsToClose = olderDiscussions.slice(0, MAX_CLOSE_COUNT); + if (olderDiscussions.length > MAX_CLOSE_COUNT) { + core.warning(`Found ${olderDiscussions.length} older discussions, but only closing the first ${MAX_CLOSE_COUNT}`); + } + const closedDiscussions = []; + for (let i = 0; i < discussionsToClose.length; i++) { + const discussion = discussionsToClose[i]; + try { + const closingMessage = getCloseOlderDiscussionMessage({ + newDiscussionUrl: newDiscussion.url, + newDiscussionNumber: newDiscussion.number, + workflowName, + runUrl, + }); + core.info(`Adding closing comment to discussion #${discussion.number}`); + await addDiscussionComment(github, discussion.id, closingMessage); + core.info(`Closing discussion #${discussion.number} as outdated`); + await closeDiscussionAsOutdated(github, discussion.id); + closedDiscussions.push({ + number: discussion.number, + url: discussion.url, + }); + core.info(`✓ Closed discussion #${discussion.number}: ${discussion.url}`); + } catch (error) { + core.error(`✗ Failed to close discussion #${discussion.number}: ${error instanceof Error ? error.message : String(error)}`); + } + if (i < discussionsToClose.length - 1) { + await delay(GRAPHQL_DELAY_MS); + } + } + return closedDiscussions; + } + const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; + function generateTemporaryId() { + return "aw_" + crypto.randomBytes(6).toString("hex"); + } + function isTemporaryId(value) { + if (typeof value === "string") { + return /^aw_[0-9a-f]{12}$/i.test(value); + } + return false; + } + function normalizeTemporaryId(tempId) { + return String(tempId).toLowerCase(); + } + function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { + return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { + const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); + if (resolved !== undefined) { + if (currentRepo && resolved.repo === currentRepo) { + return `#${resolved.number}`; + } + return `${resolved.repo}#${resolved.number}`; + } + return match; + }); + } + function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { + return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { + const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); + if (issueNumber !== undefined) { + return `#${issueNumber}`; + } + return match; + }); + } + function loadTemporaryIdMap() { + const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; + if (!mapJson || mapJson === "{}") { + return new Map(); + } + try { + const mapObject = JSON.parse(mapJson); + const result = new Map(); + for (const [key, value] of Object.entries(mapObject)) { + const normalizedKey = normalizeTemporaryId(key); + if (typeof value === "number") { + const contextRepo = `${context.repo.owner}/${context.repo.repo}`; + result.set(normalizedKey, { repo: contextRepo, number: value }); + } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { + result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); + } + } + return result; + } catch (error) { + if (typeof core !== "undefined") { + core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); + } + return new Map(); + } + } + function resolveIssueNumber(value, temporaryIdMap) { + if (value === undefined || value === null) { + return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; + } + const valueStr = String(value); + if (isTemporaryId(valueStr)) { + const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); + if (resolvedPair !== undefined) { + return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; + } + return { + resolved: null, + wasTemporaryId: true, + errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, + }; + } + const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); + if (isNaN(issueNumber) || issueNumber <= 0) { + return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; + } + const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; + return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; + } + function serializeTemporaryIdMap(tempIdMap) { + const obj = Object.fromEntries(tempIdMap); + return JSON.stringify(obj); + } + function parseAllowedRepos() { + const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; + const set = new Set(); + if (allowedReposEnv) { + allowedReposEnv + .split(",") + .map(repo => repo.trim()) + .filter(repo => repo) + .forEach(repo => set.add(repo)); + } + return set; + } + function getDefaultTargetRepo() { + const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; + if (targetRepoSlug) { + return targetRepoSlug; + } + return `${context.repo.owner}/${context.repo.repo}`; + } + function validateRepo(repo, defaultRepo, allowedRepos) { + if (repo === defaultRepo) { + return { valid: true, error: null }; + } + if (allowedRepos.has(repo)) { + return { valid: true, error: null }; + } + return { + valid: false, + error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, + }; + } + function parseRepoSlug(repoSlug) { + const parts = repoSlug.split("/"); + if (parts.length !== 2 || !parts[0] || !parts[1]) { + return null; + } + return { owner: parts[0], repo: parts[1] }; + } + function addExpirationComment(bodyLines, envVarName, entityType) { + const expiresEnv = process.env[envVarName]; + if (expiresEnv) { + const expiresDays = parseInt(expiresEnv, 10); + if (!isNaN(expiresDays) && expiresDays > 0) { + const expirationDate = new Date(); + expirationDate.setDate(expirationDate.getDate() + expiresDays); + const expirationISO = expirationDate.toISOString(); + bodyLines.push(``); + core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); + } + } + } + function removeDuplicateTitleFromDescription(title, description) { + if (!title || typeof title !== "string") { + return description || ""; + } + if (!description || typeof description !== "string") { + return ""; + } + const trimmedTitle = title.trim(); + const trimmedDescription = description.trim(); + if (!trimmedTitle || !trimmedDescription) { + return trimmedDescription; + } + const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); + if (headerRegex.test(trimmedDescription)) { + return trimmedDescription.replace(headerRegex, "").trim(); + } + return trimmedDescription; + } async function fetchRepoDiscussionInfo(owner, repo) { const repositoryQuery = ` query($owner: String!, $repo: String!) { diff --git a/.github/workflows/daily-issues-report.lock.yml b/.github/workflows/daily-issues-report.lock.yml index 4d9f38f2a17..c2ccdf72b8a 100644 --- a/.github/workflows/daily-issues-report.lock.yml +++ b/.github/workflows/daily-issues-report.lock.yml @@ -7963,13 +7963,400 @@ jobs: with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - const { loadAgentOutput } = require("./load_agent_output.cjs"); - const { getTrackerID } = require("./get_tracker_id.cjs"); - const { closeOlderDiscussions } = require("./close_older_discussions.cjs"); - const { replaceTemporaryIdReferences, loadTemporaryIdMap } = require("./temporary_id.cjs"); - const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require("./repo_helpers.cjs"); - const { addExpirationComment } = require("./expiration_helpers.cjs"); - const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); + const fs = require("fs"); + const crypto = require("crypto"); + const MAX_LOG_CONTENT_LENGTH = 10000; + function truncateForLogging(content) { + if (content.length <= MAX_LOG_CONTENT_LENGTH) { + return content; + } + return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; + } + function loadAgentOutput() { + const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; + if (!agentOutputFile) { + core.info("No GH_AW_AGENT_OUTPUT environment variable found"); + return { success: false }; + } + let outputContent; + try { + outputContent = fs.readFileSync(agentOutputFile, "utf8"); + } catch (error) { + const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; + core.error(errorMessage); + return { success: false, error: errorMessage }; + } + if (outputContent.trim() === "") { + core.info("Agent output content is empty"); + return { success: false }; + } + core.info(`Agent output content length: ${outputContent.length}`); + let validatedOutput; + try { + validatedOutput = JSON.parse(outputContent); + } catch (error) { + const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; + core.error(errorMessage); + core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); + return { success: false, error: errorMessage }; + } + if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { + core.info("No valid items found in agent output"); + core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); + return { success: false }; + } + return { success: true, items: validatedOutput.items }; + } + function getTrackerID(format) { + const trackerID = process.env.GH_AW_TRACKER_ID || ""; + if (trackerID) { + core.info(`Tracker ID: ${trackerID}`); + return format === "markdown" ? `\n\n` : trackerID; + } + return ""; + } + function getMessages() { + const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES; + if (!messagesEnv) { + return null; + } + try { + return JSON.parse(messagesEnv); + } catch (error) { + core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`); + return null; + } + } + function renderTemplate(template, context) { + return template.replace(/\{(\w+)\}/g, (match, key) => { + const value = context[key]; + return value !== undefined && value !== null ? String(value) : match; + }); + } + function toSnakeCase(obj) { + const result = {}; + for (const [key, value] of Object.entries(obj)) { + const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase(); + result[snakeKey] = value; + result[key] = value; + } + return result; + } + function getCloseOlderDiscussionMessage(ctx) { + const messages = getMessages(); + const templateContext = toSnakeCase(ctx); + const defaultMessage = `⚓ Avast! This discussion be marked as **outdated** by [{workflow_name}]({run_url}). + 🗺️ A newer treasure map awaits ye at **[Discussion #{new_discussion_number}]({new_discussion_url})**. + Fair winds, matey! 🏴‍☠️`; + return messages?.closeOlderDiscussion + ? renderTemplate(messages.closeOlderDiscussion, templateContext) + : renderTemplate(defaultMessage, templateContext); + } + const MAX_CLOSE_COUNT = 10; + const GRAPHQL_DELAY_MS = 500; + function delay(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); + } + async function searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, excludeNumber) { + let searchQuery = `repo:${owner}/${repo} is:open`; + if (titlePrefix) { + const escapedPrefix = titlePrefix.replace(/"/g, '\\"'); + searchQuery += ` in:title "${escapedPrefix}"`; + } + if (labels && labels.length > 0) { + for (const label of labels) { + const escapedLabel = label.replace(/"/g, '\\"'); + searchQuery += ` label:"${escapedLabel}"`; + } + } + const result = await github.graphql( + ` + query($searchTerms: String!, $first: Int!) { + search(query: $searchTerms, type: DISCUSSION, first: $first) { + nodes { + ... on Discussion { + id + number + title + url + category { + id + } + labels(first: 100) { + nodes { + name + } + } + closed + } + } + } + }`, + { searchTerms: searchQuery, first: 50 } + ); + if (!result || !result.search || !result.search.nodes) { + return []; + } + return result.search.nodes + .filter( + d => { + if (!d || d.number === excludeNumber || d.closed) { + return false; + } + if (titlePrefix && d.title && !d.title.startsWith(titlePrefix)) { + return false; + } + if (labels && labels.length > 0) { + const discussionLabels = d.labels?.nodes?.map(( l) => l.name) || []; + const hasAllLabels = labels.every(label => discussionLabels.includes(label)); + if (!hasAllLabels) { + return false; + } + } + if (categoryId && (!d.category || d.category.id !== categoryId)) { + return false; + } + return true; + } + ) + .map( + d => ({ + id: d.id, + number: d.number, + title: d.title, + url: d.url, + }) + ); + } + async function addDiscussionComment(github, discussionId, message) { + const result = await github.graphql( + ` + mutation($dId: ID!, $body: String!) { + addDiscussionComment(input: { discussionId: $dId, body: $body }) { + comment { + id + url + } + } + }`, + { dId: discussionId, body: message } + ); + return result.addDiscussionComment.comment; + } + async function closeDiscussionAsOutdated(github, discussionId) { + const result = await github.graphql( + ` + mutation($dId: ID!) { + closeDiscussion(input: { discussionId: $dId, reason: OUTDATED }) { + discussion { + id + url + } + } + }`, + { dId: discussionId } + ); + return result.closeDiscussion.discussion; + } + async function closeOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion, workflowName, runUrl) { + const searchCriteria = []; + if (titlePrefix) searchCriteria.push(`title prefix: "${titlePrefix}"`); + if (labels && labels.length > 0) searchCriteria.push(`labels: [${labels.join(", ")}]`); + core.info(`Searching for older discussions with ${searchCriteria.join(" and ")}`); + const olderDiscussions = await searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion.number); + if (olderDiscussions.length === 0) { + core.info("No older discussions found to close"); + return []; + } + core.info(`Found ${olderDiscussions.length} older discussion(s) to close`); + const discussionsToClose = olderDiscussions.slice(0, MAX_CLOSE_COUNT); + if (olderDiscussions.length > MAX_CLOSE_COUNT) { + core.warning(`Found ${olderDiscussions.length} older discussions, but only closing the first ${MAX_CLOSE_COUNT}`); + } + const closedDiscussions = []; + for (let i = 0; i < discussionsToClose.length; i++) { + const discussion = discussionsToClose[i]; + try { + const closingMessage = getCloseOlderDiscussionMessage({ + newDiscussionUrl: newDiscussion.url, + newDiscussionNumber: newDiscussion.number, + workflowName, + runUrl, + }); + core.info(`Adding closing comment to discussion #${discussion.number}`); + await addDiscussionComment(github, discussion.id, closingMessage); + core.info(`Closing discussion #${discussion.number} as outdated`); + await closeDiscussionAsOutdated(github, discussion.id); + closedDiscussions.push({ + number: discussion.number, + url: discussion.url, + }); + core.info(`✓ Closed discussion #${discussion.number}: ${discussion.url}`); + } catch (error) { + core.error(`✗ Failed to close discussion #${discussion.number}: ${error instanceof Error ? error.message : String(error)}`); + } + if (i < discussionsToClose.length - 1) { + await delay(GRAPHQL_DELAY_MS); + } + } + return closedDiscussions; + } + const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; + function generateTemporaryId() { + return "aw_" + crypto.randomBytes(6).toString("hex"); + } + function isTemporaryId(value) { + if (typeof value === "string") { + return /^aw_[0-9a-f]{12}$/i.test(value); + } + return false; + } + function normalizeTemporaryId(tempId) { + return String(tempId).toLowerCase(); + } + function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { + return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { + const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); + if (resolved !== undefined) { + if (currentRepo && resolved.repo === currentRepo) { + return `#${resolved.number}`; + } + return `${resolved.repo}#${resolved.number}`; + } + return match; + }); + } + function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { + return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { + const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); + if (issueNumber !== undefined) { + return `#${issueNumber}`; + } + return match; + }); + } + function loadTemporaryIdMap() { + const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; + if (!mapJson || mapJson === "{}") { + return new Map(); + } + try { + const mapObject = JSON.parse(mapJson); + const result = new Map(); + for (const [key, value] of Object.entries(mapObject)) { + const normalizedKey = normalizeTemporaryId(key); + if (typeof value === "number") { + const contextRepo = `${context.repo.owner}/${context.repo.repo}`; + result.set(normalizedKey, { repo: contextRepo, number: value }); + } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { + result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); + } + } + return result; + } catch (error) { + if (typeof core !== "undefined") { + core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); + } + return new Map(); + } + } + function resolveIssueNumber(value, temporaryIdMap) { + if (value === undefined || value === null) { + return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; + } + const valueStr = String(value); + if (isTemporaryId(valueStr)) { + const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); + if (resolvedPair !== undefined) { + return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; + } + return { + resolved: null, + wasTemporaryId: true, + errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, + }; + } + const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); + if (isNaN(issueNumber) || issueNumber <= 0) { + return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; + } + const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; + return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; + } + function serializeTemporaryIdMap(tempIdMap) { + const obj = Object.fromEntries(tempIdMap); + return JSON.stringify(obj); + } + function parseAllowedRepos() { + const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; + const set = new Set(); + if (allowedReposEnv) { + allowedReposEnv + .split(",") + .map(repo => repo.trim()) + .filter(repo => repo) + .forEach(repo => set.add(repo)); + } + return set; + } + function getDefaultTargetRepo() { + const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; + if (targetRepoSlug) { + return targetRepoSlug; + } + return `${context.repo.owner}/${context.repo.repo}`; + } + function validateRepo(repo, defaultRepo, allowedRepos) { + if (repo === defaultRepo) { + return { valid: true, error: null }; + } + if (allowedRepos.has(repo)) { + return { valid: true, error: null }; + } + return { + valid: false, + error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, + }; + } + function parseRepoSlug(repoSlug) { + const parts = repoSlug.split("/"); + if (parts.length !== 2 || !parts[0] || !parts[1]) { + return null; + } + return { owner: parts[0], repo: parts[1] }; + } + function addExpirationComment(bodyLines, envVarName, entityType) { + const expiresEnv = process.env[envVarName]; + if (expiresEnv) { + const expiresDays = parseInt(expiresEnv, 10); + if (!isNaN(expiresDays) && expiresDays > 0) { + const expirationDate = new Date(); + expirationDate.setDate(expirationDate.getDate() + expiresDays); + const expirationISO = expirationDate.toISOString(); + bodyLines.push(``); + core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); + } + } + } + function removeDuplicateTitleFromDescription(title, description) { + if (!title || typeof title !== "string") { + return description || ""; + } + if (!description || typeof description !== "string") { + return ""; + } + const trimmedTitle = title.trim(); + const trimmedDescription = description.trim(); + if (!trimmedTitle || !trimmedDescription) { + return trimmedDescription; + } + const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); + if (headerRegex.test(trimmedDescription)) { + return trimmedDescription.replace(headerRegex, "").trim(); + } + return trimmedDescription; + } async function fetchRepoDiscussionInfo(owner, repo) { const repositoryQuery = ` query($owner: String!, $repo: String!) { diff --git a/.github/workflows/daily-multi-device-docs-tester.lock.yml b/.github/workflows/daily-multi-device-docs-tester.lock.yml index 89734c2e847..0a4c0c6a23d 100644 --- a/.github/workflows/daily-multi-device-docs-tester.lock.yml +++ b/.github/workflows/daily-multi-device-docs-tester.lock.yml @@ -5642,21 +5642,291 @@ jobs: with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - const { sanitizeLabelContent } = require("./sanitize_label_content.cjs"); - const { loadAgentOutput } = require("./load_agent_output.cjs"); - const { generateStagedPreview } = require("./staged_preview.cjs"); - const { generateFooter } = require("./generate_footer.cjs"); - const { getTrackerID } = require("./get_tracker_id.cjs"); - const { - generateTemporaryId, - isTemporaryId, - normalizeTemporaryId, - replaceTemporaryIdReferences, - serializeTemporaryIdMap, - } = require("./temporary_id.cjs"); - const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require("./repo_helpers.cjs"); - const { addExpirationComment } = require("./expiration_helpers.cjs"); - const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); + function sanitizeLabelContent(content) { + if (!content || typeof content !== "string") { + return ""; + } + let sanitized = content.trim(); + sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); + sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); + sanitized = sanitized.replace( + /(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, + (_m, p1, p2) => `${p1}\`@${p2}\`` + ); + sanitized = sanitized.replace(/[<>&'"]/g, ""); + return sanitized.trim(); + } + const fs = require("fs"); + const crypto = require("crypto"); + const MAX_LOG_CONTENT_LENGTH = 10000; + function truncateForLogging(content) { + if (content.length <= MAX_LOG_CONTENT_LENGTH) { + return content; + } + return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; + } + function loadAgentOutput() { + const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; + if (!agentOutputFile) { + core.info("No GH_AW_AGENT_OUTPUT environment variable found"); + return { success: false }; + } + let outputContent; + try { + outputContent = fs.readFileSync(agentOutputFile, "utf8"); + } catch (error) { + const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; + core.error(errorMessage); + return { success: false, error: errorMessage }; + } + if (outputContent.trim() === "") { + core.info("Agent output content is empty"); + return { success: false }; + } + core.info(`Agent output content length: ${outputContent.length}`); + let validatedOutput; + try { + validatedOutput = JSON.parse(outputContent); + } catch (error) { + const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; + core.error(errorMessage); + core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); + return { success: false, error: errorMessage }; + } + if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { + core.info("No valid items found in agent output"); + core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); + return { success: false }; + } + return { success: true, items: validatedOutput.items }; + } + async function generateStagedPreview(options) { + const { title, description, items, renderItem } = options; + let summaryContent = `## 🎭 Staged Mode: ${title} Preview\n\n`; + summaryContent += `${description}\n\n`; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + summaryContent += renderItem(item, i); + summaryContent += "---\n\n"; + } + try { + await core.summary.addRaw(summaryContent).write(); + core.info(summaryContent); + core.info(`📝 ${title} preview written to step summary`); + } catch (error) { + core.setFailed(error instanceof Error ? error : String(error)); + } + } + function generateXMLMarker(workflowName, runUrl) { + const engineId = process.env.GH_AW_ENGINE_ID || ""; + const engineVersion = process.env.GH_AW_ENGINE_VERSION || ""; + const engineModel = process.env.GH_AW_ENGINE_MODEL || ""; + const trackerId = process.env.GH_AW_TRACKER_ID || ""; + const parts = []; + parts.push(`agentic-workflow: ${workflowName}`); + if (trackerId) { + parts.push(`tracker-id: ${trackerId}`); + } + if (engineId) { + parts.push(`engine: ${engineId}`); + } + if (engineVersion) { + parts.push(`version: ${engineVersion}`); + } + if (engineModel) { + parts.push(`model: ${engineModel}`); + } + parts.push(`run: ${runUrl}`); + return ``; + } + function generateFooter( + workflowName, + runUrl, + workflowSource, + workflowSourceURL, + triggeringIssueNumber, + triggeringPRNumber, + triggeringDiscussionNumber + ) { + let footer = `\n\n> AI generated by [${workflowName}](${runUrl})`; + if (triggeringIssueNumber) { + footer += ` for #${triggeringIssueNumber}`; + } else if (triggeringPRNumber) { + footer += ` for #${triggeringPRNumber}`; + } else if (triggeringDiscussionNumber) { + footer += ` for discussion #${triggeringDiscussionNumber}`; + } + if (workflowSource && workflowSourceURL) { + footer += `\n>\n> To add this workflow in your repository, run \`gh aw add ${workflowSource}\`. See [usage guide](https://githubnext.github.io/gh-aw/tools/cli/).`; + } + footer += "\n\n" + generateXMLMarker(workflowName, runUrl); + footer += "\n"; + return footer; + } + function getTrackerID(format) { + const trackerID = process.env.GH_AW_TRACKER_ID || ""; + if (trackerID) { + core.info(`Tracker ID: ${trackerID}`); + return format === "markdown" ? `\n\n` : trackerID; + } + return ""; + } + const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; + function generateTemporaryId() { + return "aw_" + crypto.randomBytes(6).toString("hex"); + } + function isTemporaryId(value) { + if (typeof value === "string") { + return /^aw_[0-9a-f]{12}$/i.test(value); + } + return false; + } + function normalizeTemporaryId(tempId) { + return String(tempId).toLowerCase(); + } + function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { + return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { + const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); + if (resolved !== undefined) { + if (currentRepo && resolved.repo === currentRepo) { + return `#${resolved.number}`; + } + return `${resolved.repo}#${resolved.number}`; + } + return match; + }); + } + function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { + return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { + const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); + if (issueNumber !== undefined) { + return `#${issueNumber}`; + } + return match; + }); + } + function loadTemporaryIdMap() { + const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; + if (!mapJson || mapJson === "{}") { + return new Map(); + } + try { + const mapObject = JSON.parse(mapJson); + const result = new Map(); + for (const [key, value] of Object.entries(mapObject)) { + const normalizedKey = normalizeTemporaryId(key); + if (typeof value === "number") { + const contextRepo = `${context.repo.owner}/${context.repo.repo}`; + result.set(normalizedKey, { repo: contextRepo, number: value }); + } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { + result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); + } + } + return result; + } catch (error) { + if (typeof core !== "undefined") { + core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); + } + return new Map(); + } + } + function resolveIssueNumber(value, temporaryIdMap) { + if (value === undefined || value === null) { + return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; + } + const valueStr = String(value); + if (isTemporaryId(valueStr)) { + const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); + if (resolvedPair !== undefined) { + return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; + } + return { + resolved: null, + wasTemporaryId: true, + errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, + }; + } + const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); + if (isNaN(issueNumber) || issueNumber <= 0) { + return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; + } + const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; + return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; + } + function serializeTemporaryIdMap(tempIdMap) { + const obj = Object.fromEntries(tempIdMap); + return JSON.stringify(obj); + } + function parseAllowedRepos() { + const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; + const set = new Set(); + if (allowedReposEnv) { + allowedReposEnv + .split(",") + .map(repo => repo.trim()) + .filter(repo => repo) + .forEach(repo => set.add(repo)); + } + return set; + } + function getDefaultTargetRepo() { + const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; + if (targetRepoSlug) { + return targetRepoSlug; + } + return `${context.repo.owner}/${context.repo.repo}`; + } + function validateRepo(repo, defaultRepo, allowedRepos) { + if (repo === defaultRepo) { + return { valid: true, error: null }; + } + if (allowedRepos.has(repo)) { + return { valid: true, error: null }; + } + return { + valid: false, + error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, + }; + } + function parseRepoSlug(repoSlug) { + const parts = repoSlug.split("/"); + if (parts.length !== 2 || !parts[0] || !parts[1]) { + return null; + } + return { owner: parts[0], repo: parts[1] }; + } + function addExpirationComment(bodyLines, envVarName, entityType) { + const expiresEnv = process.env[envVarName]; + if (expiresEnv) { + const expiresDays = parseInt(expiresEnv, 10); + if (!isNaN(expiresDays) && expiresDays > 0) { + const expirationDate = new Date(); + expirationDate.setDate(expirationDate.getDate() + expiresDays); + const expirationISO = expirationDate.toISOString(); + bodyLines.push(``); + core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); + } + } + } + function removeDuplicateTitleFromDescription(title, description) { + if (!title || typeof title !== "string") { + return description || ""; + } + if (!description || typeof description !== "string") { + return ""; + } + const trimmedTitle = title.trim(); + const trimmedDescription = description.trim(); + if (!trimmedTitle || !trimmedDescription) { + return trimmedDescription; + } + const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); + if (headerRegex.test(trimmedDescription)) { + return trimmedDescription.replace(headerRegex, "").trim(); + } + return trimmedDescription; + } async function main() { core.setOutput("issue_number", ""); core.setOutput("issue_url", ""); diff --git a/.github/workflows/daily-news.lock.yml b/.github/workflows/daily-news.lock.yml index c91dc6e17a7..492e3255f5b 100644 --- a/.github/workflows/daily-news.lock.yml +++ b/.github/workflows/daily-news.lock.yml @@ -7715,13 +7715,400 @@ jobs: with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - const { loadAgentOutput } = require("./load_agent_output.cjs"); - const { getTrackerID } = require("./get_tracker_id.cjs"); - const { closeOlderDiscussions } = require("./close_older_discussions.cjs"); - const { replaceTemporaryIdReferences, loadTemporaryIdMap } = require("./temporary_id.cjs"); - const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require("./repo_helpers.cjs"); - const { addExpirationComment } = require("./expiration_helpers.cjs"); - const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); + const fs = require("fs"); + const crypto = require("crypto"); + const MAX_LOG_CONTENT_LENGTH = 10000; + function truncateForLogging(content) { + if (content.length <= MAX_LOG_CONTENT_LENGTH) { + return content; + } + return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; + } + function loadAgentOutput() { + const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; + if (!agentOutputFile) { + core.info("No GH_AW_AGENT_OUTPUT environment variable found"); + return { success: false }; + } + let outputContent; + try { + outputContent = fs.readFileSync(agentOutputFile, "utf8"); + } catch (error) { + const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; + core.error(errorMessage); + return { success: false, error: errorMessage }; + } + if (outputContent.trim() === "") { + core.info("Agent output content is empty"); + return { success: false }; + } + core.info(`Agent output content length: ${outputContent.length}`); + let validatedOutput; + try { + validatedOutput = JSON.parse(outputContent); + } catch (error) { + const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; + core.error(errorMessage); + core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); + return { success: false, error: errorMessage }; + } + if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { + core.info("No valid items found in agent output"); + core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); + return { success: false }; + } + return { success: true, items: validatedOutput.items }; + } + function getTrackerID(format) { + const trackerID = process.env.GH_AW_TRACKER_ID || ""; + if (trackerID) { + core.info(`Tracker ID: ${trackerID}`); + return format === "markdown" ? `\n\n` : trackerID; + } + return ""; + } + function getMessages() { + const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES; + if (!messagesEnv) { + return null; + } + try { + return JSON.parse(messagesEnv); + } catch (error) { + core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`); + return null; + } + } + function renderTemplate(template, context) { + return template.replace(/\{(\w+)\}/g, (match, key) => { + const value = context[key]; + return value !== undefined && value !== null ? String(value) : match; + }); + } + function toSnakeCase(obj) { + const result = {}; + for (const [key, value] of Object.entries(obj)) { + const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase(); + result[snakeKey] = value; + result[key] = value; + } + return result; + } + function getCloseOlderDiscussionMessage(ctx) { + const messages = getMessages(); + const templateContext = toSnakeCase(ctx); + const defaultMessage = `⚓ Avast! This discussion be marked as **outdated** by [{workflow_name}]({run_url}). + 🗺️ A newer treasure map awaits ye at **[Discussion #{new_discussion_number}]({new_discussion_url})**. + Fair winds, matey! 🏴‍☠️`; + return messages?.closeOlderDiscussion + ? renderTemplate(messages.closeOlderDiscussion, templateContext) + : renderTemplate(defaultMessage, templateContext); + } + const MAX_CLOSE_COUNT = 10; + const GRAPHQL_DELAY_MS = 500; + function delay(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); + } + async function searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, excludeNumber) { + let searchQuery = `repo:${owner}/${repo} is:open`; + if (titlePrefix) { + const escapedPrefix = titlePrefix.replace(/"/g, '\\"'); + searchQuery += ` in:title "${escapedPrefix}"`; + } + if (labels && labels.length > 0) { + for (const label of labels) { + const escapedLabel = label.replace(/"/g, '\\"'); + searchQuery += ` label:"${escapedLabel}"`; + } + } + const result = await github.graphql( + ` + query($searchTerms: String!, $first: Int!) { + search(query: $searchTerms, type: DISCUSSION, first: $first) { + nodes { + ... on Discussion { + id + number + title + url + category { + id + } + labels(first: 100) { + nodes { + name + } + } + closed + } + } + } + }`, + { searchTerms: searchQuery, first: 50 } + ); + if (!result || !result.search || !result.search.nodes) { + return []; + } + return result.search.nodes + .filter( + d => { + if (!d || d.number === excludeNumber || d.closed) { + return false; + } + if (titlePrefix && d.title && !d.title.startsWith(titlePrefix)) { + return false; + } + if (labels && labels.length > 0) { + const discussionLabels = d.labels?.nodes?.map(( l) => l.name) || []; + const hasAllLabels = labels.every(label => discussionLabels.includes(label)); + if (!hasAllLabels) { + return false; + } + } + if (categoryId && (!d.category || d.category.id !== categoryId)) { + return false; + } + return true; + } + ) + .map( + d => ({ + id: d.id, + number: d.number, + title: d.title, + url: d.url, + }) + ); + } + async function addDiscussionComment(github, discussionId, message) { + const result = await github.graphql( + ` + mutation($dId: ID!, $body: String!) { + addDiscussionComment(input: { discussionId: $dId, body: $body }) { + comment { + id + url + } + } + }`, + { dId: discussionId, body: message } + ); + return result.addDiscussionComment.comment; + } + async function closeDiscussionAsOutdated(github, discussionId) { + const result = await github.graphql( + ` + mutation($dId: ID!) { + closeDiscussion(input: { discussionId: $dId, reason: OUTDATED }) { + discussion { + id + url + } + } + }`, + { dId: discussionId } + ); + return result.closeDiscussion.discussion; + } + async function closeOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion, workflowName, runUrl) { + const searchCriteria = []; + if (titlePrefix) searchCriteria.push(`title prefix: "${titlePrefix}"`); + if (labels && labels.length > 0) searchCriteria.push(`labels: [${labels.join(", ")}]`); + core.info(`Searching for older discussions with ${searchCriteria.join(" and ")}`); + const olderDiscussions = await searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion.number); + if (olderDiscussions.length === 0) { + core.info("No older discussions found to close"); + return []; + } + core.info(`Found ${olderDiscussions.length} older discussion(s) to close`); + const discussionsToClose = olderDiscussions.slice(0, MAX_CLOSE_COUNT); + if (olderDiscussions.length > MAX_CLOSE_COUNT) { + core.warning(`Found ${olderDiscussions.length} older discussions, but only closing the first ${MAX_CLOSE_COUNT}`); + } + const closedDiscussions = []; + for (let i = 0; i < discussionsToClose.length; i++) { + const discussion = discussionsToClose[i]; + try { + const closingMessage = getCloseOlderDiscussionMessage({ + newDiscussionUrl: newDiscussion.url, + newDiscussionNumber: newDiscussion.number, + workflowName, + runUrl, + }); + core.info(`Adding closing comment to discussion #${discussion.number}`); + await addDiscussionComment(github, discussion.id, closingMessage); + core.info(`Closing discussion #${discussion.number} as outdated`); + await closeDiscussionAsOutdated(github, discussion.id); + closedDiscussions.push({ + number: discussion.number, + url: discussion.url, + }); + core.info(`✓ Closed discussion #${discussion.number}: ${discussion.url}`); + } catch (error) { + core.error(`✗ Failed to close discussion #${discussion.number}: ${error instanceof Error ? error.message : String(error)}`); + } + if (i < discussionsToClose.length - 1) { + await delay(GRAPHQL_DELAY_MS); + } + } + return closedDiscussions; + } + const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; + function generateTemporaryId() { + return "aw_" + crypto.randomBytes(6).toString("hex"); + } + function isTemporaryId(value) { + if (typeof value === "string") { + return /^aw_[0-9a-f]{12}$/i.test(value); + } + return false; + } + function normalizeTemporaryId(tempId) { + return String(tempId).toLowerCase(); + } + function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { + return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { + const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); + if (resolved !== undefined) { + if (currentRepo && resolved.repo === currentRepo) { + return `#${resolved.number}`; + } + return `${resolved.repo}#${resolved.number}`; + } + return match; + }); + } + function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { + return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { + const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); + if (issueNumber !== undefined) { + return `#${issueNumber}`; + } + return match; + }); + } + function loadTemporaryIdMap() { + const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; + if (!mapJson || mapJson === "{}") { + return new Map(); + } + try { + const mapObject = JSON.parse(mapJson); + const result = new Map(); + for (const [key, value] of Object.entries(mapObject)) { + const normalizedKey = normalizeTemporaryId(key); + if (typeof value === "number") { + const contextRepo = `${context.repo.owner}/${context.repo.repo}`; + result.set(normalizedKey, { repo: contextRepo, number: value }); + } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { + result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); + } + } + return result; + } catch (error) { + if (typeof core !== "undefined") { + core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); + } + return new Map(); + } + } + function resolveIssueNumber(value, temporaryIdMap) { + if (value === undefined || value === null) { + return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; + } + const valueStr = String(value); + if (isTemporaryId(valueStr)) { + const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); + if (resolvedPair !== undefined) { + return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; + } + return { + resolved: null, + wasTemporaryId: true, + errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, + }; + } + const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); + if (isNaN(issueNumber) || issueNumber <= 0) { + return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; + } + const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; + return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; + } + function serializeTemporaryIdMap(tempIdMap) { + const obj = Object.fromEntries(tempIdMap); + return JSON.stringify(obj); + } + function parseAllowedRepos() { + const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; + const set = new Set(); + if (allowedReposEnv) { + allowedReposEnv + .split(",") + .map(repo => repo.trim()) + .filter(repo => repo) + .forEach(repo => set.add(repo)); + } + return set; + } + function getDefaultTargetRepo() { + const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; + if (targetRepoSlug) { + return targetRepoSlug; + } + return `${context.repo.owner}/${context.repo.repo}`; + } + function validateRepo(repo, defaultRepo, allowedRepos) { + if (repo === defaultRepo) { + return { valid: true, error: null }; + } + if (allowedRepos.has(repo)) { + return { valid: true, error: null }; + } + return { + valid: false, + error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, + }; + } + function parseRepoSlug(repoSlug) { + const parts = repoSlug.split("/"); + if (parts.length !== 2 || !parts[0] || !parts[1]) { + return null; + } + return { owner: parts[0], repo: parts[1] }; + } + function addExpirationComment(bodyLines, envVarName, entityType) { + const expiresEnv = process.env[envVarName]; + if (expiresEnv) { + const expiresDays = parseInt(expiresEnv, 10); + if (!isNaN(expiresDays) && expiresDays > 0) { + const expirationDate = new Date(); + expirationDate.setDate(expirationDate.getDate() + expiresDays); + const expirationISO = expirationDate.toISOString(); + bodyLines.push(``); + core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); + } + } + } + function removeDuplicateTitleFromDescription(title, description) { + if (!title || typeof title !== "string") { + return description || ""; + } + if (!description || typeof description !== "string") { + return ""; + } + const trimmedTitle = title.trim(); + const trimmedDescription = description.trim(); + if (!trimmedTitle || !trimmedDescription) { + return trimmedDescription; + } + const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); + if (headerRegex.test(trimmedDescription)) { + return trimmedDescription.replace(headerRegex, "").trim(); + } + return trimmedDescription; + } async function fetchRepoDiscussionInfo(owner, repo) { const repositoryQuery = ` query($owner: String!, $repo: String!) { diff --git a/.github/workflows/daily-performance-summary.lock.yml b/.github/workflows/daily-performance-summary.lock.yml index 43537f4a2ff..c541ba752a5 100644 --- a/.github/workflows/daily-performance-summary.lock.yml +++ b/.github/workflows/daily-performance-summary.lock.yml @@ -9201,13 +9201,400 @@ jobs: with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - const { loadAgentOutput } = require("./load_agent_output.cjs"); - const { getTrackerID } = require("./get_tracker_id.cjs"); - const { closeOlderDiscussions } = require("./close_older_discussions.cjs"); - const { replaceTemporaryIdReferences, loadTemporaryIdMap } = require("./temporary_id.cjs"); - const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require("./repo_helpers.cjs"); - const { addExpirationComment } = require("./expiration_helpers.cjs"); - const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); + const fs = require("fs"); + const crypto = require("crypto"); + const MAX_LOG_CONTENT_LENGTH = 10000; + function truncateForLogging(content) { + if (content.length <= MAX_LOG_CONTENT_LENGTH) { + return content; + } + return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; + } + function loadAgentOutput() { + const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; + if (!agentOutputFile) { + core.info("No GH_AW_AGENT_OUTPUT environment variable found"); + return { success: false }; + } + let outputContent; + try { + outputContent = fs.readFileSync(agentOutputFile, "utf8"); + } catch (error) { + const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; + core.error(errorMessage); + return { success: false, error: errorMessage }; + } + if (outputContent.trim() === "") { + core.info("Agent output content is empty"); + return { success: false }; + } + core.info(`Agent output content length: ${outputContent.length}`); + let validatedOutput; + try { + validatedOutput = JSON.parse(outputContent); + } catch (error) { + const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; + core.error(errorMessage); + core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); + return { success: false, error: errorMessage }; + } + if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { + core.info("No valid items found in agent output"); + core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); + return { success: false }; + } + return { success: true, items: validatedOutput.items }; + } + function getTrackerID(format) { + const trackerID = process.env.GH_AW_TRACKER_ID || ""; + if (trackerID) { + core.info(`Tracker ID: ${trackerID}`); + return format === "markdown" ? `\n\n` : trackerID; + } + return ""; + } + function getMessages() { + const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES; + if (!messagesEnv) { + return null; + } + try { + return JSON.parse(messagesEnv); + } catch (error) { + core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`); + return null; + } + } + function renderTemplate(template, context) { + return template.replace(/\{(\w+)\}/g, (match, key) => { + const value = context[key]; + return value !== undefined && value !== null ? String(value) : match; + }); + } + function toSnakeCase(obj) { + const result = {}; + for (const [key, value] of Object.entries(obj)) { + const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase(); + result[snakeKey] = value; + result[key] = value; + } + return result; + } + function getCloseOlderDiscussionMessage(ctx) { + const messages = getMessages(); + const templateContext = toSnakeCase(ctx); + const defaultMessage = `⚓ Avast! This discussion be marked as **outdated** by [{workflow_name}]({run_url}). + 🗺️ A newer treasure map awaits ye at **[Discussion #{new_discussion_number}]({new_discussion_url})**. + Fair winds, matey! 🏴‍☠️`; + return messages?.closeOlderDiscussion + ? renderTemplate(messages.closeOlderDiscussion, templateContext) + : renderTemplate(defaultMessage, templateContext); + } + const MAX_CLOSE_COUNT = 10; + const GRAPHQL_DELAY_MS = 500; + function delay(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); + } + async function searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, excludeNumber) { + let searchQuery = `repo:${owner}/${repo} is:open`; + if (titlePrefix) { + const escapedPrefix = titlePrefix.replace(/"/g, '\\"'); + searchQuery += ` in:title "${escapedPrefix}"`; + } + if (labels && labels.length > 0) { + for (const label of labels) { + const escapedLabel = label.replace(/"/g, '\\"'); + searchQuery += ` label:"${escapedLabel}"`; + } + } + const result = await github.graphql( + ` + query($searchTerms: String!, $first: Int!) { + search(query: $searchTerms, type: DISCUSSION, first: $first) { + nodes { + ... on Discussion { + id + number + title + url + category { + id + } + labels(first: 100) { + nodes { + name + } + } + closed + } + } + } + }`, + { searchTerms: searchQuery, first: 50 } + ); + if (!result || !result.search || !result.search.nodes) { + return []; + } + return result.search.nodes + .filter( + d => { + if (!d || d.number === excludeNumber || d.closed) { + return false; + } + if (titlePrefix && d.title && !d.title.startsWith(titlePrefix)) { + return false; + } + if (labels && labels.length > 0) { + const discussionLabels = d.labels?.nodes?.map(( l) => l.name) || []; + const hasAllLabels = labels.every(label => discussionLabels.includes(label)); + if (!hasAllLabels) { + return false; + } + } + if (categoryId && (!d.category || d.category.id !== categoryId)) { + return false; + } + return true; + } + ) + .map( + d => ({ + id: d.id, + number: d.number, + title: d.title, + url: d.url, + }) + ); + } + async function addDiscussionComment(github, discussionId, message) { + const result = await github.graphql( + ` + mutation($dId: ID!, $body: String!) { + addDiscussionComment(input: { discussionId: $dId, body: $body }) { + comment { + id + url + } + } + }`, + { dId: discussionId, body: message } + ); + return result.addDiscussionComment.comment; + } + async function closeDiscussionAsOutdated(github, discussionId) { + const result = await github.graphql( + ` + mutation($dId: ID!) { + closeDiscussion(input: { discussionId: $dId, reason: OUTDATED }) { + discussion { + id + url + } + } + }`, + { dId: discussionId } + ); + return result.closeDiscussion.discussion; + } + async function closeOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion, workflowName, runUrl) { + const searchCriteria = []; + if (titlePrefix) searchCriteria.push(`title prefix: "${titlePrefix}"`); + if (labels && labels.length > 0) searchCriteria.push(`labels: [${labels.join(", ")}]`); + core.info(`Searching for older discussions with ${searchCriteria.join(" and ")}`); + const olderDiscussions = await searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion.number); + if (olderDiscussions.length === 0) { + core.info("No older discussions found to close"); + return []; + } + core.info(`Found ${olderDiscussions.length} older discussion(s) to close`); + const discussionsToClose = olderDiscussions.slice(0, MAX_CLOSE_COUNT); + if (olderDiscussions.length > MAX_CLOSE_COUNT) { + core.warning(`Found ${olderDiscussions.length} older discussions, but only closing the first ${MAX_CLOSE_COUNT}`); + } + const closedDiscussions = []; + for (let i = 0; i < discussionsToClose.length; i++) { + const discussion = discussionsToClose[i]; + try { + const closingMessage = getCloseOlderDiscussionMessage({ + newDiscussionUrl: newDiscussion.url, + newDiscussionNumber: newDiscussion.number, + workflowName, + runUrl, + }); + core.info(`Adding closing comment to discussion #${discussion.number}`); + await addDiscussionComment(github, discussion.id, closingMessage); + core.info(`Closing discussion #${discussion.number} as outdated`); + await closeDiscussionAsOutdated(github, discussion.id); + closedDiscussions.push({ + number: discussion.number, + url: discussion.url, + }); + core.info(`✓ Closed discussion #${discussion.number}: ${discussion.url}`); + } catch (error) { + core.error(`✗ Failed to close discussion #${discussion.number}: ${error instanceof Error ? error.message : String(error)}`); + } + if (i < discussionsToClose.length - 1) { + await delay(GRAPHQL_DELAY_MS); + } + } + return closedDiscussions; + } + const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; + function generateTemporaryId() { + return "aw_" + crypto.randomBytes(6).toString("hex"); + } + function isTemporaryId(value) { + if (typeof value === "string") { + return /^aw_[0-9a-f]{12}$/i.test(value); + } + return false; + } + function normalizeTemporaryId(tempId) { + return String(tempId).toLowerCase(); + } + function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { + return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { + const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); + if (resolved !== undefined) { + if (currentRepo && resolved.repo === currentRepo) { + return `#${resolved.number}`; + } + return `${resolved.repo}#${resolved.number}`; + } + return match; + }); + } + function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { + return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { + const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); + if (issueNumber !== undefined) { + return `#${issueNumber}`; + } + return match; + }); + } + function loadTemporaryIdMap() { + const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; + if (!mapJson || mapJson === "{}") { + return new Map(); + } + try { + const mapObject = JSON.parse(mapJson); + const result = new Map(); + for (const [key, value] of Object.entries(mapObject)) { + const normalizedKey = normalizeTemporaryId(key); + if (typeof value === "number") { + const contextRepo = `${context.repo.owner}/${context.repo.repo}`; + result.set(normalizedKey, { repo: contextRepo, number: value }); + } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { + result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); + } + } + return result; + } catch (error) { + if (typeof core !== "undefined") { + core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); + } + return new Map(); + } + } + function resolveIssueNumber(value, temporaryIdMap) { + if (value === undefined || value === null) { + return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; + } + const valueStr = String(value); + if (isTemporaryId(valueStr)) { + const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); + if (resolvedPair !== undefined) { + return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; + } + return { + resolved: null, + wasTemporaryId: true, + errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, + }; + } + const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); + if (isNaN(issueNumber) || issueNumber <= 0) { + return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; + } + const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; + return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; + } + function serializeTemporaryIdMap(tempIdMap) { + const obj = Object.fromEntries(tempIdMap); + return JSON.stringify(obj); + } + function parseAllowedRepos() { + const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; + const set = new Set(); + if (allowedReposEnv) { + allowedReposEnv + .split(",") + .map(repo => repo.trim()) + .filter(repo => repo) + .forEach(repo => set.add(repo)); + } + return set; + } + function getDefaultTargetRepo() { + const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; + if (targetRepoSlug) { + return targetRepoSlug; + } + return `${context.repo.owner}/${context.repo.repo}`; + } + function validateRepo(repo, defaultRepo, allowedRepos) { + if (repo === defaultRepo) { + return { valid: true, error: null }; + } + if (allowedRepos.has(repo)) { + return { valid: true, error: null }; + } + return { + valid: false, + error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, + }; + } + function parseRepoSlug(repoSlug) { + const parts = repoSlug.split("/"); + if (parts.length !== 2 || !parts[0] || !parts[1]) { + return null; + } + return { owner: parts[0], repo: parts[1] }; + } + function addExpirationComment(bodyLines, envVarName, entityType) { + const expiresEnv = process.env[envVarName]; + if (expiresEnv) { + const expiresDays = parseInt(expiresEnv, 10); + if (!isNaN(expiresDays) && expiresDays > 0) { + const expirationDate = new Date(); + expirationDate.setDate(expirationDate.getDate() + expiresDays); + const expirationISO = expirationDate.toISOString(); + bodyLines.push(``); + core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); + } + } + } + function removeDuplicateTitleFromDescription(title, description) { + if (!title || typeof title !== "string") { + return description || ""; + } + if (!description || typeof description !== "string") { + return ""; + } + const trimmedTitle = title.trim(); + const trimmedDescription = description.trim(); + if (!trimmedTitle || !trimmedDescription) { + return trimmedDescription; + } + const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); + if (headerRegex.test(trimmedDescription)) { + return trimmedDescription.replace(headerRegex, "").trim(); + } + return trimmedDescription; + } async function fetchRepoDiscussionInfo(owner, repo) { const repositoryQuery = ` query($owner: String!, $repo: String!) { diff --git a/.github/workflows/daily-repo-chronicle.lock.yml b/.github/workflows/daily-repo-chronicle.lock.yml index ff26e657ef2..7d7d34f4d9c 100644 --- a/.github/workflows/daily-repo-chronicle.lock.yml +++ b/.github/workflows/daily-repo-chronicle.lock.yml @@ -7389,13 +7389,400 @@ jobs: with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - const { loadAgentOutput } = require("./load_agent_output.cjs"); - const { getTrackerID } = require("./get_tracker_id.cjs"); - const { closeOlderDiscussions } = require("./close_older_discussions.cjs"); - const { replaceTemporaryIdReferences, loadTemporaryIdMap } = require("./temporary_id.cjs"); - const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require("./repo_helpers.cjs"); - const { addExpirationComment } = require("./expiration_helpers.cjs"); - const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); + const fs = require("fs"); + const crypto = require("crypto"); + const MAX_LOG_CONTENT_LENGTH = 10000; + function truncateForLogging(content) { + if (content.length <= MAX_LOG_CONTENT_LENGTH) { + return content; + } + return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; + } + function loadAgentOutput() { + const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; + if (!agentOutputFile) { + core.info("No GH_AW_AGENT_OUTPUT environment variable found"); + return { success: false }; + } + let outputContent; + try { + outputContent = fs.readFileSync(agentOutputFile, "utf8"); + } catch (error) { + const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; + core.error(errorMessage); + return { success: false, error: errorMessage }; + } + if (outputContent.trim() === "") { + core.info("Agent output content is empty"); + return { success: false }; + } + core.info(`Agent output content length: ${outputContent.length}`); + let validatedOutput; + try { + validatedOutput = JSON.parse(outputContent); + } catch (error) { + const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; + core.error(errorMessage); + core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); + return { success: false, error: errorMessage }; + } + if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { + core.info("No valid items found in agent output"); + core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); + return { success: false }; + } + return { success: true, items: validatedOutput.items }; + } + function getTrackerID(format) { + const trackerID = process.env.GH_AW_TRACKER_ID || ""; + if (trackerID) { + core.info(`Tracker ID: ${trackerID}`); + return format === "markdown" ? `\n\n` : trackerID; + } + return ""; + } + function getMessages() { + const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES; + if (!messagesEnv) { + return null; + } + try { + return JSON.parse(messagesEnv); + } catch (error) { + core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`); + return null; + } + } + function renderTemplate(template, context) { + return template.replace(/\{(\w+)\}/g, (match, key) => { + const value = context[key]; + return value !== undefined && value !== null ? String(value) : match; + }); + } + function toSnakeCase(obj) { + const result = {}; + for (const [key, value] of Object.entries(obj)) { + const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase(); + result[snakeKey] = value; + result[key] = value; + } + return result; + } + function getCloseOlderDiscussionMessage(ctx) { + const messages = getMessages(); + const templateContext = toSnakeCase(ctx); + const defaultMessage = `⚓ Avast! This discussion be marked as **outdated** by [{workflow_name}]({run_url}). + 🗺️ A newer treasure map awaits ye at **[Discussion #{new_discussion_number}]({new_discussion_url})**. + Fair winds, matey! 🏴‍☠️`; + return messages?.closeOlderDiscussion + ? renderTemplate(messages.closeOlderDiscussion, templateContext) + : renderTemplate(defaultMessage, templateContext); + } + const MAX_CLOSE_COUNT = 10; + const GRAPHQL_DELAY_MS = 500; + function delay(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); + } + async function searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, excludeNumber) { + let searchQuery = `repo:${owner}/${repo} is:open`; + if (titlePrefix) { + const escapedPrefix = titlePrefix.replace(/"/g, '\\"'); + searchQuery += ` in:title "${escapedPrefix}"`; + } + if (labels && labels.length > 0) { + for (const label of labels) { + const escapedLabel = label.replace(/"/g, '\\"'); + searchQuery += ` label:"${escapedLabel}"`; + } + } + const result = await github.graphql( + ` + query($searchTerms: String!, $first: Int!) { + search(query: $searchTerms, type: DISCUSSION, first: $first) { + nodes { + ... on Discussion { + id + number + title + url + category { + id + } + labels(first: 100) { + nodes { + name + } + } + closed + } + } + } + }`, + { searchTerms: searchQuery, first: 50 } + ); + if (!result || !result.search || !result.search.nodes) { + return []; + } + return result.search.nodes + .filter( + d => { + if (!d || d.number === excludeNumber || d.closed) { + return false; + } + if (titlePrefix && d.title && !d.title.startsWith(titlePrefix)) { + return false; + } + if (labels && labels.length > 0) { + const discussionLabels = d.labels?.nodes?.map(( l) => l.name) || []; + const hasAllLabels = labels.every(label => discussionLabels.includes(label)); + if (!hasAllLabels) { + return false; + } + } + if (categoryId && (!d.category || d.category.id !== categoryId)) { + return false; + } + return true; + } + ) + .map( + d => ({ + id: d.id, + number: d.number, + title: d.title, + url: d.url, + }) + ); + } + async function addDiscussionComment(github, discussionId, message) { + const result = await github.graphql( + ` + mutation($dId: ID!, $body: String!) { + addDiscussionComment(input: { discussionId: $dId, body: $body }) { + comment { + id + url + } + } + }`, + { dId: discussionId, body: message } + ); + return result.addDiscussionComment.comment; + } + async function closeDiscussionAsOutdated(github, discussionId) { + const result = await github.graphql( + ` + mutation($dId: ID!) { + closeDiscussion(input: { discussionId: $dId, reason: OUTDATED }) { + discussion { + id + url + } + } + }`, + { dId: discussionId } + ); + return result.closeDiscussion.discussion; + } + async function closeOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion, workflowName, runUrl) { + const searchCriteria = []; + if (titlePrefix) searchCriteria.push(`title prefix: "${titlePrefix}"`); + if (labels && labels.length > 0) searchCriteria.push(`labels: [${labels.join(", ")}]`); + core.info(`Searching for older discussions with ${searchCriteria.join(" and ")}`); + const olderDiscussions = await searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion.number); + if (olderDiscussions.length === 0) { + core.info("No older discussions found to close"); + return []; + } + core.info(`Found ${olderDiscussions.length} older discussion(s) to close`); + const discussionsToClose = olderDiscussions.slice(0, MAX_CLOSE_COUNT); + if (olderDiscussions.length > MAX_CLOSE_COUNT) { + core.warning(`Found ${olderDiscussions.length} older discussions, but only closing the first ${MAX_CLOSE_COUNT}`); + } + const closedDiscussions = []; + for (let i = 0; i < discussionsToClose.length; i++) { + const discussion = discussionsToClose[i]; + try { + const closingMessage = getCloseOlderDiscussionMessage({ + newDiscussionUrl: newDiscussion.url, + newDiscussionNumber: newDiscussion.number, + workflowName, + runUrl, + }); + core.info(`Adding closing comment to discussion #${discussion.number}`); + await addDiscussionComment(github, discussion.id, closingMessage); + core.info(`Closing discussion #${discussion.number} as outdated`); + await closeDiscussionAsOutdated(github, discussion.id); + closedDiscussions.push({ + number: discussion.number, + url: discussion.url, + }); + core.info(`✓ Closed discussion #${discussion.number}: ${discussion.url}`); + } catch (error) { + core.error(`✗ Failed to close discussion #${discussion.number}: ${error instanceof Error ? error.message : String(error)}`); + } + if (i < discussionsToClose.length - 1) { + await delay(GRAPHQL_DELAY_MS); + } + } + return closedDiscussions; + } + const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; + function generateTemporaryId() { + return "aw_" + crypto.randomBytes(6).toString("hex"); + } + function isTemporaryId(value) { + if (typeof value === "string") { + return /^aw_[0-9a-f]{12}$/i.test(value); + } + return false; + } + function normalizeTemporaryId(tempId) { + return String(tempId).toLowerCase(); + } + function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { + return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { + const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); + if (resolved !== undefined) { + if (currentRepo && resolved.repo === currentRepo) { + return `#${resolved.number}`; + } + return `${resolved.repo}#${resolved.number}`; + } + return match; + }); + } + function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { + return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { + const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); + if (issueNumber !== undefined) { + return `#${issueNumber}`; + } + return match; + }); + } + function loadTemporaryIdMap() { + const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; + if (!mapJson || mapJson === "{}") { + return new Map(); + } + try { + const mapObject = JSON.parse(mapJson); + const result = new Map(); + for (const [key, value] of Object.entries(mapObject)) { + const normalizedKey = normalizeTemporaryId(key); + if (typeof value === "number") { + const contextRepo = `${context.repo.owner}/${context.repo.repo}`; + result.set(normalizedKey, { repo: contextRepo, number: value }); + } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { + result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); + } + } + return result; + } catch (error) { + if (typeof core !== "undefined") { + core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); + } + return new Map(); + } + } + function resolveIssueNumber(value, temporaryIdMap) { + if (value === undefined || value === null) { + return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; + } + const valueStr = String(value); + if (isTemporaryId(valueStr)) { + const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); + if (resolvedPair !== undefined) { + return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; + } + return { + resolved: null, + wasTemporaryId: true, + errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, + }; + } + const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); + if (isNaN(issueNumber) || issueNumber <= 0) { + return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; + } + const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; + return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; + } + function serializeTemporaryIdMap(tempIdMap) { + const obj = Object.fromEntries(tempIdMap); + return JSON.stringify(obj); + } + function parseAllowedRepos() { + const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; + const set = new Set(); + if (allowedReposEnv) { + allowedReposEnv + .split(",") + .map(repo => repo.trim()) + .filter(repo => repo) + .forEach(repo => set.add(repo)); + } + return set; + } + function getDefaultTargetRepo() { + const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; + if (targetRepoSlug) { + return targetRepoSlug; + } + return `${context.repo.owner}/${context.repo.repo}`; + } + function validateRepo(repo, defaultRepo, allowedRepos) { + if (repo === defaultRepo) { + return { valid: true, error: null }; + } + if (allowedRepos.has(repo)) { + return { valid: true, error: null }; + } + return { + valid: false, + error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, + }; + } + function parseRepoSlug(repoSlug) { + const parts = repoSlug.split("/"); + if (parts.length !== 2 || !parts[0] || !parts[1]) { + return null; + } + return { owner: parts[0], repo: parts[1] }; + } + function addExpirationComment(bodyLines, envVarName, entityType) { + const expiresEnv = process.env[envVarName]; + if (expiresEnv) { + const expiresDays = parseInt(expiresEnv, 10); + if (!isNaN(expiresDays) && expiresDays > 0) { + const expirationDate = new Date(); + expirationDate.setDate(expirationDate.getDate() + expiresDays); + const expirationISO = expirationDate.toISOString(); + bodyLines.push(``); + core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); + } + } + } + function removeDuplicateTitleFromDescription(title, description) { + if (!title || typeof title !== "string") { + return description || ""; + } + if (!description || typeof description !== "string") { + return ""; + } + const trimmedTitle = title.trim(); + const trimmedDescription = description.trim(); + if (!trimmedTitle || !trimmedDescription) { + return trimmedDescription; + } + const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); + if (headerRegex.test(trimmedDescription)) { + return trimmedDescription.replace(headerRegex, "").trim(); + } + return trimmedDescription; + } async function fetchRepoDiscussionInfo(owner, repo) { const repositoryQuery = ` query($owner: String!, $repo: String!) { diff --git a/.github/workflows/daily-workflow-updater.lock.yml b/.github/workflows/daily-workflow-updater.lock.yml index aab8b94fbdf..e3877efde59 100644 --- a/.github/workflows/daily-workflow-updater.lock.yml +++ b/.github/workflows/daily-workflow-updater.lock.yml @@ -6204,10 +6204,151 @@ jobs: script: | const fs = require("fs"); const crypto = require("crypto"); - const { updateActivationComment } = require("./update_activation_comment.cjs"); - const { getTrackerID } = require("./get_tracker_id.cjs"); - const { addExpirationComment } = require("./expiration_helpers.cjs"); - const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); + async function updateActivationComment(github, context, core, itemUrl, itemNumber, itemType = "pull_request") { + const itemLabel = itemType === "issue" ? "issue" : "pull request"; + const linkMessage = + itemType === "issue" + ? `\n\n✅ Issue created: [#${itemNumber}](${itemUrl})` + : `\n\n✅ Pull request created: [#${itemNumber}](${itemUrl})`; + await updateActivationCommentWithMessage(github, context, core, linkMessage, itemLabel); + } + async function updateActivationCommentWithCommit(github, context, core, commitSha, commitUrl) { + const shortSha = commitSha.substring(0, 7); + const message = `\n\n✅ Commit pushed: [\`${shortSha}\`](${commitUrl})`; + await updateActivationCommentWithMessage(github, context, core, message, "commit"); + } + async function updateActivationCommentWithMessage(github, context, core, message, label = "") { + const commentId = process.env.GH_AW_COMMENT_ID; + const commentRepo = process.env.GH_AW_COMMENT_REPO; + if (!commentId) { + core.info("No activation comment to update (GH_AW_COMMENT_ID not set)"); + return; + } + core.info(`Updating activation comment ${commentId}`); + let repoOwner = context.repo.owner; + let repoName = context.repo.repo; + if (commentRepo) { + const parts = commentRepo.split("/"); + if (parts.length === 2) { + repoOwner = parts[0]; + repoName = parts[1]; + } else { + core.warning(`Invalid comment repo format: ${commentRepo}, expected "owner/repo". Falling back to context.repo.`); + } + } + core.info(`Updating comment in ${repoOwner}/${repoName}`); + const isDiscussionComment = commentId.startsWith("DC_"); + try { + if (isDiscussionComment) { + const currentComment = await github.graphql( + ` + query($commentId: ID!) { + node(id: $commentId) { + ... on DiscussionComment { + body + } + } + }`, + { commentId: commentId } + ); + if (!currentComment?.node?.body) { + core.warning("Unable to fetch current comment body, comment may have been deleted or is inaccessible"); + return; + } + const currentBody = currentComment.node.body; + const updatedBody = currentBody + message; + const result = await github.graphql( + ` + mutation($commentId: ID!, $body: String!) { + updateDiscussionComment(input: { commentId: $commentId, body: $body }) { + comment { + id + url + } + } + }`, + { commentId: commentId, body: updatedBody } + ); + const comment = result.updateDiscussionComment.comment; + const successMessage = label + ? `Successfully updated discussion comment with ${label} link` + : "Successfully updated discussion comment"; + core.info(successMessage); + core.info(`Comment ID: ${comment.id}`); + core.info(`Comment URL: ${comment.url}`); + } else { + const currentComment = await github.request("GET /repos/{owner}/{repo}/issues/comments/{comment_id}", { + owner: repoOwner, + repo: repoName, + comment_id: parseInt(commentId, 10), + headers: { + Accept: "application/vnd.github+json", + }, + }); + if (!currentComment?.data?.body) { + core.warning("Unable to fetch current comment body, comment may have been deleted"); + return; + } + const currentBody = currentComment.data.body; + const updatedBody = currentBody + message; + const response = await github.request("PATCH /repos/{owner}/{repo}/issues/comments/{comment_id}", { + owner: repoOwner, + repo: repoName, + comment_id: parseInt(commentId, 10), + body: updatedBody, + headers: { + Accept: "application/vnd.github+json", + }, + }); + const successMessage = label ? `Successfully updated comment with ${label} link` : "Successfully updated comment"; + core.info(successMessage); + core.info(`Comment ID: ${response.data.id}`); + core.info(`Comment URL: ${response.data.html_url}`); + } + } catch (error) { + core.warning(`Failed to update activation comment: ${error instanceof Error ? error.message : String(error)}`); + } + } + function getTrackerID(format) { + const trackerID = process.env.GH_AW_TRACKER_ID || ""; + if (trackerID) { + core.info(`Tracker ID: ${trackerID}`); + return format === "markdown" ? `\n\n` : trackerID; + } + return ""; + } + function addExpirationComment(bodyLines, envVarName, entityType) { + const expiresEnv = process.env[envVarName]; + if (expiresEnv) { + const expiresDays = parseInt(expiresEnv, 10); + if (!isNaN(expiresDays) && expiresDays > 0) { + const expirationDate = new Date(); + expirationDate.setDate(expirationDate.getDate() + expiresDays); + const expirationISO = expirationDate.toISOString(); + bodyLines.push(``); + core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); + } + } + } + function removeDuplicateTitleFromDescription(title, description) { + if (!title || typeof title !== "string") { + return description || ""; + } + if (!description || typeof description !== "string") { + return ""; + } + const trimmedTitle = title.trim(); + const trimmedDescription = description.trim(); + if (!trimmedTitle || !trimmedDescription) { + return trimmedDescription; + } + const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); + if (headerRegex.test(trimmedDescription)) { + return trimmedDescription.replace(headerRegex, "").trim(); + } + return trimmedDescription; + } function generatePatchPreview(patchContent) { if (!patchContent || !patchContent.trim()) { return ""; diff --git a/.github/workflows/deep-report.lock.yml b/.github/workflows/deep-report.lock.yml index 9bc6c396099..42bd9259460 100644 --- a/.github/workflows/deep-report.lock.yml +++ b/.github/workflows/deep-report.lock.yml @@ -6476,13 +6476,400 @@ jobs: with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - const { loadAgentOutput } = require("./load_agent_output.cjs"); - const { getTrackerID } = require("./get_tracker_id.cjs"); - const { closeOlderDiscussions } = require("./close_older_discussions.cjs"); - const { replaceTemporaryIdReferences, loadTemporaryIdMap } = require("./temporary_id.cjs"); - const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require("./repo_helpers.cjs"); - const { addExpirationComment } = require("./expiration_helpers.cjs"); - const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); + const fs = require("fs"); + const crypto = require("crypto"); + const MAX_LOG_CONTENT_LENGTH = 10000; + function truncateForLogging(content) { + if (content.length <= MAX_LOG_CONTENT_LENGTH) { + return content; + } + return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; + } + function loadAgentOutput() { + const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; + if (!agentOutputFile) { + core.info("No GH_AW_AGENT_OUTPUT environment variable found"); + return { success: false }; + } + let outputContent; + try { + outputContent = fs.readFileSync(agentOutputFile, "utf8"); + } catch (error) { + const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; + core.error(errorMessage); + return { success: false, error: errorMessage }; + } + if (outputContent.trim() === "") { + core.info("Agent output content is empty"); + return { success: false }; + } + core.info(`Agent output content length: ${outputContent.length}`); + let validatedOutput; + try { + validatedOutput = JSON.parse(outputContent); + } catch (error) { + const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; + core.error(errorMessage); + core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); + return { success: false, error: errorMessage }; + } + if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { + core.info("No valid items found in agent output"); + core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); + return { success: false }; + } + return { success: true, items: validatedOutput.items }; + } + function getTrackerID(format) { + const trackerID = process.env.GH_AW_TRACKER_ID || ""; + if (trackerID) { + core.info(`Tracker ID: ${trackerID}`); + return format === "markdown" ? `\n\n` : trackerID; + } + return ""; + } + function getMessages() { + const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES; + if (!messagesEnv) { + return null; + } + try { + return JSON.parse(messagesEnv); + } catch (error) { + core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`); + return null; + } + } + function renderTemplate(template, context) { + return template.replace(/\{(\w+)\}/g, (match, key) => { + const value = context[key]; + return value !== undefined && value !== null ? String(value) : match; + }); + } + function toSnakeCase(obj) { + const result = {}; + for (const [key, value] of Object.entries(obj)) { + const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase(); + result[snakeKey] = value; + result[key] = value; + } + return result; + } + function getCloseOlderDiscussionMessage(ctx) { + const messages = getMessages(); + const templateContext = toSnakeCase(ctx); + const defaultMessage = `⚓ Avast! This discussion be marked as **outdated** by [{workflow_name}]({run_url}). + 🗺️ A newer treasure map awaits ye at **[Discussion #{new_discussion_number}]({new_discussion_url})**. + Fair winds, matey! 🏴‍☠️`; + return messages?.closeOlderDiscussion + ? renderTemplate(messages.closeOlderDiscussion, templateContext) + : renderTemplate(defaultMessage, templateContext); + } + const MAX_CLOSE_COUNT = 10; + const GRAPHQL_DELAY_MS = 500; + function delay(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); + } + async function searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, excludeNumber) { + let searchQuery = `repo:${owner}/${repo} is:open`; + if (titlePrefix) { + const escapedPrefix = titlePrefix.replace(/"/g, '\\"'); + searchQuery += ` in:title "${escapedPrefix}"`; + } + if (labels && labels.length > 0) { + for (const label of labels) { + const escapedLabel = label.replace(/"/g, '\\"'); + searchQuery += ` label:"${escapedLabel}"`; + } + } + const result = await github.graphql( + ` + query($searchTerms: String!, $first: Int!) { + search(query: $searchTerms, type: DISCUSSION, first: $first) { + nodes { + ... on Discussion { + id + number + title + url + category { + id + } + labels(first: 100) { + nodes { + name + } + } + closed + } + } + } + }`, + { searchTerms: searchQuery, first: 50 } + ); + if (!result || !result.search || !result.search.nodes) { + return []; + } + return result.search.nodes + .filter( + d => { + if (!d || d.number === excludeNumber || d.closed) { + return false; + } + if (titlePrefix && d.title && !d.title.startsWith(titlePrefix)) { + return false; + } + if (labels && labels.length > 0) { + const discussionLabels = d.labels?.nodes?.map(( l) => l.name) || []; + const hasAllLabels = labels.every(label => discussionLabels.includes(label)); + if (!hasAllLabels) { + return false; + } + } + if (categoryId && (!d.category || d.category.id !== categoryId)) { + return false; + } + return true; + } + ) + .map( + d => ({ + id: d.id, + number: d.number, + title: d.title, + url: d.url, + }) + ); + } + async function addDiscussionComment(github, discussionId, message) { + const result = await github.graphql( + ` + mutation($dId: ID!, $body: String!) { + addDiscussionComment(input: { discussionId: $dId, body: $body }) { + comment { + id + url + } + } + }`, + { dId: discussionId, body: message } + ); + return result.addDiscussionComment.comment; + } + async function closeDiscussionAsOutdated(github, discussionId) { + const result = await github.graphql( + ` + mutation($dId: ID!) { + closeDiscussion(input: { discussionId: $dId, reason: OUTDATED }) { + discussion { + id + url + } + } + }`, + { dId: discussionId } + ); + return result.closeDiscussion.discussion; + } + async function closeOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion, workflowName, runUrl) { + const searchCriteria = []; + if (titlePrefix) searchCriteria.push(`title prefix: "${titlePrefix}"`); + if (labels && labels.length > 0) searchCriteria.push(`labels: [${labels.join(", ")}]`); + core.info(`Searching for older discussions with ${searchCriteria.join(" and ")}`); + const olderDiscussions = await searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion.number); + if (olderDiscussions.length === 0) { + core.info("No older discussions found to close"); + return []; + } + core.info(`Found ${olderDiscussions.length} older discussion(s) to close`); + const discussionsToClose = olderDiscussions.slice(0, MAX_CLOSE_COUNT); + if (olderDiscussions.length > MAX_CLOSE_COUNT) { + core.warning(`Found ${olderDiscussions.length} older discussions, but only closing the first ${MAX_CLOSE_COUNT}`); + } + const closedDiscussions = []; + for (let i = 0; i < discussionsToClose.length; i++) { + const discussion = discussionsToClose[i]; + try { + const closingMessage = getCloseOlderDiscussionMessage({ + newDiscussionUrl: newDiscussion.url, + newDiscussionNumber: newDiscussion.number, + workflowName, + runUrl, + }); + core.info(`Adding closing comment to discussion #${discussion.number}`); + await addDiscussionComment(github, discussion.id, closingMessage); + core.info(`Closing discussion #${discussion.number} as outdated`); + await closeDiscussionAsOutdated(github, discussion.id); + closedDiscussions.push({ + number: discussion.number, + url: discussion.url, + }); + core.info(`✓ Closed discussion #${discussion.number}: ${discussion.url}`); + } catch (error) { + core.error(`✗ Failed to close discussion #${discussion.number}: ${error instanceof Error ? error.message : String(error)}`); + } + if (i < discussionsToClose.length - 1) { + await delay(GRAPHQL_DELAY_MS); + } + } + return closedDiscussions; + } + const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; + function generateTemporaryId() { + return "aw_" + crypto.randomBytes(6).toString("hex"); + } + function isTemporaryId(value) { + if (typeof value === "string") { + return /^aw_[0-9a-f]{12}$/i.test(value); + } + return false; + } + function normalizeTemporaryId(tempId) { + return String(tempId).toLowerCase(); + } + function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { + return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { + const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); + if (resolved !== undefined) { + if (currentRepo && resolved.repo === currentRepo) { + return `#${resolved.number}`; + } + return `${resolved.repo}#${resolved.number}`; + } + return match; + }); + } + function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { + return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { + const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); + if (issueNumber !== undefined) { + return `#${issueNumber}`; + } + return match; + }); + } + function loadTemporaryIdMap() { + const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; + if (!mapJson || mapJson === "{}") { + return new Map(); + } + try { + const mapObject = JSON.parse(mapJson); + const result = new Map(); + for (const [key, value] of Object.entries(mapObject)) { + const normalizedKey = normalizeTemporaryId(key); + if (typeof value === "number") { + const contextRepo = `${context.repo.owner}/${context.repo.repo}`; + result.set(normalizedKey, { repo: contextRepo, number: value }); + } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { + result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); + } + } + return result; + } catch (error) { + if (typeof core !== "undefined") { + core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); + } + return new Map(); + } + } + function resolveIssueNumber(value, temporaryIdMap) { + if (value === undefined || value === null) { + return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; + } + const valueStr = String(value); + if (isTemporaryId(valueStr)) { + const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); + if (resolvedPair !== undefined) { + return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; + } + return { + resolved: null, + wasTemporaryId: true, + errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, + }; + } + const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); + if (isNaN(issueNumber) || issueNumber <= 0) { + return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; + } + const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; + return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; + } + function serializeTemporaryIdMap(tempIdMap) { + const obj = Object.fromEntries(tempIdMap); + return JSON.stringify(obj); + } + function parseAllowedRepos() { + const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; + const set = new Set(); + if (allowedReposEnv) { + allowedReposEnv + .split(",") + .map(repo => repo.trim()) + .filter(repo => repo) + .forEach(repo => set.add(repo)); + } + return set; + } + function getDefaultTargetRepo() { + const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; + if (targetRepoSlug) { + return targetRepoSlug; + } + return `${context.repo.owner}/${context.repo.repo}`; + } + function validateRepo(repo, defaultRepo, allowedRepos) { + if (repo === defaultRepo) { + return { valid: true, error: null }; + } + if (allowedRepos.has(repo)) { + return { valid: true, error: null }; + } + return { + valid: false, + error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, + }; + } + function parseRepoSlug(repoSlug) { + const parts = repoSlug.split("/"); + if (parts.length !== 2 || !parts[0] || !parts[1]) { + return null; + } + return { owner: parts[0], repo: parts[1] }; + } + function addExpirationComment(bodyLines, envVarName, entityType) { + const expiresEnv = process.env[envVarName]; + if (expiresEnv) { + const expiresDays = parseInt(expiresEnv, 10); + if (!isNaN(expiresDays) && expiresDays > 0) { + const expirationDate = new Date(); + expirationDate.setDate(expirationDate.getDate() + expiresDays); + const expirationISO = expirationDate.toISOString(); + bodyLines.push(``); + core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); + } + } + } + function removeDuplicateTitleFromDescription(title, description) { + if (!title || typeof title !== "string") { + return description || ""; + } + if (!description || typeof description !== "string") { + return ""; + } + const trimmedTitle = title.trim(); + const trimmedDescription = description.trim(); + if (!trimmedTitle || !trimmedDescription) { + return trimmedDescription; + } + const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); + if (headerRegex.test(trimmedDescription)) { + return trimmedDescription.replace(headerRegex, "").trim(); + } + return trimmedDescription; + } async function fetchRepoDiscussionInfo(owner, repo) { const repositoryQuery = ` query($owner: String!, $repo: String!) { diff --git a/.github/workflows/dependabot-go-checker.lock.yml b/.github/workflows/dependabot-go-checker.lock.yml index 40aa25ea380..a2b68bf0324 100644 --- a/.github/workflows/dependabot-go-checker.lock.yml +++ b/.github/workflows/dependabot-go-checker.lock.yml @@ -6846,21 +6846,291 @@ jobs: with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - const { sanitizeLabelContent } = require("./sanitize_label_content.cjs"); - const { loadAgentOutput } = require("./load_agent_output.cjs"); - const { generateStagedPreview } = require("./staged_preview.cjs"); - const { generateFooter } = require("./generate_footer.cjs"); - const { getTrackerID } = require("./get_tracker_id.cjs"); - const { - generateTemporaryId, - isTemporaryId, - normalizeTemporaryId, - replaceTemporaryIdReferences, - serializeTemporaryIdMap, - } = require("./temporary_id.cjs"); - const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require("./repo_helpers.cjs"); - const { addExpirationComment } = require("./expiration_helpers.cjs"); - const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); + function sanitizeLabelContent(content) { + if (!content || typeof content !== "string") { + return ""; + } + let sanitized = content.trim(); + sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); + sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); + sanitized = sanitized.replace( + /(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, + (_m, p1, p2) => `${p1}\`@${p2}\`` + ); + sanitized = sanitized.replace(/[<>&'"]/g, ""); + return sanitized.trim(); + } + const fs = require("fs"); + const crypto = require("crypto"); + const MAX_LOG_CONTENT_LENGTH = 10000; + function truncateForLogging(content) { + if (content.length <= MAX_LOG_CONTENT_LENGTH) { + return content; + } + return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; + } + function loadAgentOutput() { + const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; + if (!agentOutputFile) { + core.info("No GH_AW_AGENT_OUTPUT environment variable found"); + return { success: false }; + } + let outputContent; + try { + outputContent = fs.readFileSync(agentOutputFile, "utf8"); + } catch (error) { + const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; + core.error(errorMessage); + return { success: false, error: errorMessage }; + } + if (outputContent.trim() === "") { + core.info("Agent output content is empty"); + return { success: false }; + } + core.info(`Agent output content length: ${outputContent.length}`); + let validatedOutput; + try { + validatedOutput = JSON.parse(outputContent); + } catch (error) { + const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; + core.error(errorMessage); + core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); + return { success: false, error: errorMessage }; + } + if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { + core.info("No valid items found in agent output"); + core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); + return { success: false }; + } + return { success: true, items: validatedOutput.items }; + } + async function generateStagedPreview(options) { + const { title, description, items, renderItem } = options; + let summaryContent = `## 🎭 Staged Mode: ${title} Preview\n\n`; + summaryContent += `${description}\n\n`; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + summaryContent += renderItem(item, i); + summaryContent += "---\n\n"; + } + try { + await core.summary.addRaw(summaryContent).write(); + core.info(summaryContent); + core.info(`📝 ${title} preview written to step summary`); + } catch (error) { + core.setFailed(error instanceof Error ? error : String(error)); + } + } + function generateXMLMarker(workflowName, runUrl) { + const engineId = process.env.GH_AW_ENGINE_ID || ""; + const engineVersion = process.env.GH_AW_ENGINE_VERSION || ""; + const engineModel = process.env.GH_AW_ENGINE_MODEL || ""; + const trackerId = process.env.GH_AW_TRACKER_ID || ""; + const parts = []; + parts.push(`agentic-workflow: ${workflowName}`); + if (trackerId) { + parts.push(`tracker-id: ${trackerId}`); + } + if (engineId) { + parts.push(`engine: ${engineId}`); + } + if (engineVersion) { + parts.push(`version: ${engineVersion}`); + } + if (engineModel) { + parts.push(`model: ${engineModel}`); + } + parts.push(`run: ${runUrl}`); + return ``; + } + function generateFooter( + workflowName, + runUrl, + workflowSource, + workflowSourceURL, + triggeringIssueNumber, + triggeringPRNumber, + triggeringDiscussionNumber + ) { + let footer = `\n\n> AI generated by [${workflowName}](${runUrl})`; + if (triggeringIssueNumber) { + footer += ` for #${triggeringIssueNumber}`; + } else if (triggeringPRNumber) { + footer += ` for #${triggeringPRNumber}`; + } else if (triggeringDiscussionNumber) { + footer += ` for discussion #${triggeringDiscussionNumber}`; + } + if (workflowSource && workflowSourceURL) { + footer += `\n>\n> To add this workflow in your repository, run \`gh aw add ${workflowSource}\`. See [usage guide](https://githubnext.github.io/gh-aw/tools/cli/).`; + } + footer += "\n\n" + generateXMLMarker(workflowName, runUrl); + footer += "\n"; + return footer; + } + function getTrackerID(format) { + const trackerID = process.env.GH_AW_TRACKER_ID || ""; + if (trackerID) { + core.info(`Tracker ID: ${trackerID}`); + return format === "markdown" ? `\n\n` : trackerID; + } + return ""; + } + const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; + function generateTemporaryId() { + return "aw_" + crypto.randomBytes(6).toString("hex"); + } + function isTemporaryId(value) { + if (typeof value === "string") { + return /^aw_[0-9a-f]{12}$/i.test(value); + } + return false; + } + function normalizeTemporaryId(tempId) { + return String(tempId).toLowerCase(); + } + function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { + return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { + const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); + if (resolved !== undefined) { + if (currentRepo && resolved.repo === currentRepo) { + return `#${resolved.number}`; + } + return `${resolved.repo}#${resolved.number}`; + } + return match; + }); + } + function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { + return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { + const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); + if (issueNumber !== undefined) { + return `#${issueNumber}`; + } + return match; + }); + } + function loadTemporaryIdMap() { + const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; + if (!mapJson || mapJson === "{}") { + return new Map(); + } + try { + const mapObject = JSON.parse(mapJson); + const result = new Map(); + for (const [key, value] of Object.entries(mapObject)) { + const normalizedKey = normalizeTemporaryId(key); + if (typeof value === "number") { + const contextRepo = `${context.repo.owner}/${context.repo.repo}`; + result.set(normalizedKey, { repo: contextRepo, number: value }); + } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { + result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); + } + } + return result; + } catch (error) { + if (typeof core !== "undefined") { + core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); + } + return new Map(); + } + } + function resolveIssueNumber(value, temporaryIdMap) { + if (value === undefined || value === null) { + return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; + } + const valueStr = String(value); + if (isTemporaryId(valueStr)) { + const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); + if (resolvedPair !== undefined) { + return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; + } + return { + resolved: null, + wasTemporaryId: true, + errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, + }; + } + const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); + if (isNaN(issueNumber) || issueNumber <= 0) { + return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; + } + const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; + return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; + } + function serializeTemporaryIdMap(tempIdMap) { + const obj = Object.fromEntries(tempIdMap); + return JSON.stringify(obj); + } + function parseAllowedRepos() { + const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; + const set = new Set(); + if (allowedReposEnv) { + allowedReposEnv + .split(",") + .map(repo => repo.trim()) + .filter(repo => repo) + .forEach(repo => set.add(repo)); + } + return set; + } + function getDefaultTargetRepo() { + const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; + if (targetRepoSlug) { + return targetRepoSlug; + } + return `${context.repo.owner}/${context.repo.repo}`; + } + function validateRepo(repo, defaultRepo, allowedRepos) { + if (repo === defaultRepo) { + return { valid: true, error: null }; + } + if (allowedRepos.has(repo)) { + return { valid: true, error: null }; + } + return { + valid: false, + error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, + }; + } + function parseRepoSlug(repoSlug) { + const parts = repoSlug.split("/"); + if (parts.length !== 2 || !parts[0] || !parts[1]) { + return null; + } + return { owner: parts[0], repo: parts[1] }; + } + function addExpirationComment(bodyLines, envVarName, entityType) { + const expiresEnv = process.env[envVarName]; + if (expiresEnv) { + const expiresDays = parseInt(expiresEnv, 10); + if (!isNaN(expiresDays) && expiresDays > 0) { + const expirationDate = new Date(); + expirationDate.setDate(expirationDate.getDate() + expiresDays); + const expirationISO = expirationDate.toISOString(); + bodyLines.push(``); + core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); + } + } + } + function removeDuplicateTitleFromDescription(title, description) { + if (!title || typeof title !== "string") { + return description || ""; + } + if (!description || typeof description !== "string") { + return ""; + } + const trimmedTitle = title.trim(); + const trimmedDescription = description.trim(); + if (!trimmedTitle || !trimmedDescription) { + return trimmedDescription; + } + const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); + if (headerRegex.test(trimmedDescription)) { + return trimmedDescription.replace(headerRegex, "").trim(); + } + return trimmedDescription; + } async function main() { core.setOutput("issue_number", ""); core.setOutput("issue_url", ""); diff --git a/.github/workflows/dev.lock.yml b/.github/workflows/dev.lock.yml index 126668a2012..c759495732b 100644 --- a/.github/workflows/dev.lock.yml +++ b/.github/workflows/dev.lock.yml @@ -6987,10 +6987,151 @@ jobs: script: | const fs = require("fs"); const crypto = require("crypto"); - const { updateActivationComment } = require("./update_activation_comment.cjs"); - const { getTrackerID } = require("./get_tracker_id.cjs"); - const { addExpirationComment } = require("./expiration_helpers.cjs"); - const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); + async function updateActivationComment(github, context, core, itemUrl, itemNumber, itemType = "pull_request") { + const itemLabel = itemType === "issue" ? "issue" : "pull request"; + const linkMessage = + itemType === "issue" + ? `\n\n✅ Issue created: [#${itemNumber}](${itemUrl})` + : `\n\n✅ Pull request created: [#${itemNumber}](${itemUrl})`; + await updateActivationCommentWithMessage(github, context, core, linkMessage, itemLabel); + } + async function updateActivationCommentWithCommit(github, context, core, commitSha, commitUrl) { + const shortSha = commitSha.substring(0, 7); + const message = `\n\n✅ Commit pushed: [\`${shortSha}\`](${commitUrl})`; + await updateActivationCommentWithMessage(github, context, core, message, "commit"); + } + async function updateActivationCommentWithMessage(github, context, core, message, label = "") { + const commentId = process.env.GH_AW_COMMENT_ID; + const commentRepo = process.env.GH_AW_COMMENT_REPO; + if (!commentId) { + core.info("No activation comment to update (GH_AW_COMMENT_ID not set)"); + return; + } + core.info(`Updating activation comment ${commentId}`); + let repoOwner = context.repo.owner; + let repoName = context.repo.repo; + if (commentRepo) { + const parts = commentRepo.split("/"); + if (parts.length === 2) { + repoOwner = parts[0]; + repoName = parts[1]; + } else { + core.warning(`Invalid comment repo format: ${commentRepo}, expected "owner/repo". Falling back to context.repo.`); + } + } + core.info(`Updating comment in ${repoOwner}/${repoName}`); + const isDiscussionComment = commentId.startsWith("DC_"); + try { + if (isDiscussionComment) { + const currentComment = await github.graphql( + ` + query($commentId: ID!) { + node(id: $commentId) { + ... on DiscussionComment { + body + } + } + }`, + { commentId: commentId } + ); + if (!currentComment?.node?.body) { + core.warning("Unable to fetch current comment body, comment may have been deleted or is inaccessible"); + return; + } + const currentBody = currentComment.node.body; + const updatedBody = currentBody + message; + const result = await github.graphql( + ` + mutation($commentId: ID!, $body: String!) { + updateDiscussionComment(input: { commentId: $commentId, body: $body }) { + comment { + id + url + } + } + }`, + { commentId: commentId, body: updatedBody } + ); + const comment = result.updateDiscussionComment.comment; + const successMessage = label + ? `Successfully updated discussion comment with ${label} link` + : "Successfully updated discussion comment"; + core.info(successMessage); + core.info(`Comment ID: ${comment.id}`); + core.info(`Comment URL: ${comment.url}`); + } else { + const currentComment = await github.request("GET /repos/{owner}/{repo}/issues/comments/{comment_id}", { + owner: repoOwner, + repo: repoName, + comment_id: parseInt(commentId, 10), + headers: { + Accept: "application/vnd.github+json", + }, + }); + if (!currentComment?.data?.body) { + core.warning("Unable to fetch current comment body, comment may have been deleted"); + return; + } + const currentBody = currentComment.data.body; + const updatedBody = currentBody + message; + const response = await github.request("PATCH /repos/{owner}/{repo}/issues/comments/{comment_id}", { + owner: repoOwner, + repo: repoName, + comment_id: parseInt(commentId, 10), + body: updatedBody, + headers: { + Accept: "application/vnd.github+json", + }, + }); + const successMessage = label ? `Successfully updated comment with ${label} link` : "Successfully updated comment"; + core.info(successMessage); + core.info(`Comment ID: ${response.data.id}`); + core.info(`Comment URL: ${response.data.html_url}`); + } + } catch (error) { + core.warning(`Failed to update activation comment: ${error instanceof Error ? error.message : String(error)}`); + } + } + function getTrackerID(format) { + const trackerID = process.env.GH_AW_TRACKER_ID || ""; + if (trackerID) { + core.info(`Tracker ID: ${trackerID}`); + return format === "markdown" ? `\n\n` : trackerID; + } + return ""; + } + function addExpirationComment(bodyLines, envVarName, entityType) { + const expiresEnv = process.env[envVarName]; + if (expiresEnv) { + const expiresDays = parseInt(expiresEnv, 10); + if (!isNaN(expiresDays) && expiresDays > 0) { + const expirationDate = new Date(); + expirationDate.setDate(expirationDate.getDate() + expiresDays); + const expirationISO = expirationDate.toISOString(); + bodyLines.push(``); + core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); + } + } + } + function removeDuplicateTitleFromDescription(title, description) { + if (!title || typeof title !== "string") { + return description || ""; + } + if (!description || typeof description !== "string") { + return ""; + } + const trimmedTitle = title.trim(); + const trimmedDescription = description.trim(); + if (!trimmedTitle || !trimmedDescription) { + return trimmedDescription; + } + const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); + if (headerRegex.test(trimmedDescription)) { + return trimmedDescription.replace(headerRegex, "").trim(); + } + return trimmedDescription; + } function generatePatchPreview(patchContent) { if (!patchContent || !patchContent.trim()) { return ""; diff --git a/.github/workflows/developer-docs-consolidator.lock.yml b/.github/workflows/developer-docs-consolidator.lock.yml index 4bb97a9d104..5b3d241d6dc 100644 --- a/.github/workflows/developer-docs-consolidator.lock.yml +++ b/.github/workflows/developer-docs-consolidator.lock.yml @@ -6891,13 +6891,400 @@ jobs: with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - const { loadAgentOutput } = require("./load_agent_output.cjs"); - const { getTrackerID } = require("./get_tracker_id.cjs"); - const { closeOlderDiscussions } = require("./close_older_discussions.cjs"); - const { replaceTemporaryIdReferences, loadTemporaryIdMap } = require("./temporary_id.cjs"); - const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require("./repo_helpers.cjs"); - const { addExpirationComment } = require("./expiration_helpers.cjs"); - const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); + const fs = require("fs"); + const crypto = require("crypto"); + const MAX_LOG_CONTENT_LENGTH = 10000; + function truncateForLogging(content) { + if (content.length <= MAX_LOG_CONTENT_LENGTH) { + return content; + } + return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; + } + function loadAgentOutput() { + const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; + if (!agentOutputFile) { + core.info("No GH_AW_AGENT_OUTPUT environment variable found"); + return { success: false }; + } + let outputContent; + try { + outputContent = fs.readFileSync(agentOutputFile, "utf8"); + } catch (error) { + const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; + core.error(errorMessage); + return { success: false, error: errorMessage }; + } + if (outputContent.trim() === "") { + core.info("Agent output content is empty"); + return { success: false }; + } + core.info(`Agent output content length: ${outputContent.length}`); + let validatedOutput; + try { + validatedOutput = JSON.parse(outputContent); + } catch (error) { + const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; + core.error(errorMessage); + core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); + return { success: false, error: errorMessage }; + } + if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { + core.info("No valid items found in agent output"); + core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); + return { success: false }; + } + return { success: true, items: validatedOutput.items }; + } + function getTrackerID(format) { + const trackerID = process.env.GH_AW_TRACKER_ID || ""; + if (trackerID) { + core.info(`Tracker ID: ${trackerID}`); + return format === "markdown" ? `\n\n` : trackerID; + } + return ""; + } + function getMessages() { + const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES; + if (!messagesEnv) { + return null; + } + try { + return JSON.parse(messagesEnv); + } catch (error) { + core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`); + return null; + } + } + function renderTemplate(template, context) { + return template.replace(/\{(\w+)\}/g, (match, key) => { + const value = context[key]; + return value !== undefined && value !== null ? String(value) : match; + }); + } + function toSnakeCase(obj) { + const result = {}; + for (const [key, value] of Object.entries(obj)) { + const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase(); + result[snakeKey] = value; + result[key] = value; + } + return result; + } + function getCloseOlderDiscussionMessage(ctx) { + const messages = getMessages(); + const templateContext = toSnakeCase(ctx); + const defaultMessage = `⚓ Avast! This discussion be marked as **outdated** by [{workflow_name}]({run_url}). + 🗺️ A newer treasure map awaits ye at **[Discussion #{new_discussion_number}]({new_discussion_url})**. + Fair winds, matey! 🏴‍☠️`; + return messages?.closeOlderDiscussion + ? renderTemplate(messages.closeOlderDiscussion, templateContext) + : renderTemplate(defaultMessage, templateContext); + } + const MAX_CLOSE_COUNT = 10; + const GRAPHQL_DELAY_MS = 500; + function delay(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); + } + async function searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, excludeNumber) { + let searchQuery = `repo:${owner}/${repo} is:open`; + if (titlePrefix) { + const escapedPrefix = titlePrefix.replace(/"/g, '\\"'); + searchQuery += ` in:title "${escapedPrefix}"`; + } + if (labels && labels.length > 0) { + for (const label of labels) { + const escapedLabel = label.replace(/"/g, '\\"'); + searchQuery += ` label:"${escapedLabel}"`; + } + } + const result = await github.graphql( + ` + query($searchTerms: String!, $first: Int!) { + search(query: $searchTerms, type: DISCUSSION, first: $first) { + nodes { + ... on Discussion { + id + number + title + url + category { + id + } + labels(first: 100) { + nodes { + name + } + } + closed + } + } + } + }`, + { searchTerms: searchQuery, first: 50 } + ); + if (!result || !result.search || !result.search.nodes) { + return []; + } + return result.search.nodes + .filter( + d => { + if (!d || d.number === excludeNumber || d.closed) { + return false; + } + if (titlePrefix && d.title && !d.title.startsWith(titlePrefix)) { + return false; + } + if (labels && labels.length > 0) { + const discussionLabels = d.labels?.nodes?.map(( l) => l.name) || []; + const hasAllLabels = labels.every(label => discussionLabels.includes(label)); + if (!hasAllLabels) { + return false; + } + } + if (categoryId && (!d.category || d.category.id !== categoryId)) { + return false; + } + return true; + } + ) + .map( + d => ({ + id: d.id, + number: d.number, + title: d.title, + url: d.url, + }) + ); + } + async function addDiscussionComment(github, discussionId, message) { + const result = await github.graphql( + ` + mutation($dId: ID!, $body: String!) { + addDiscussionComment(input: { discussionId: $dId, body: $body }) { + comment { + id + url + } + } + }`, + { dId: discussionId, body: message } + ); + return result.addDiscussionComment.comment; + } + async function closeDiscussionAsOutdated(github, discussionId) { + const result = await github.graphql( + ` + mutation($dId: ID!) { + closeDiscussion(input: { discussionId: $dId, reason: OUTDATED }) { + discussion { + id + url + } + } + }`, + { dId: discussionId } + ); + return result.closeDiscussion.discussion; + } + async function closeOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion, workflowName, runUrl) { + const searchCriteria = []; + if (titlePrefix) searchCriteria.push(`title prefix: "${titlePrefix}"`); + if (labels && labels.length > 0) searchCriteria.push(`labels: [${labels.join(", ")}]`); + core.info(`Searching for older discussions with ${searchCriteria.join(" and ")}`); + const olderDiscussions = await searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion.number); + if (olderDiscussions.length === 0) { + core.info("No older discussions found to close"); + return []; + } + core.info(`Found ${olderDiscussions.length} older discussion(s) to close`); + const discussionsToClose = olderDiscussions.slice(0, MAX_CLOSE_COUNT); + if (olderDiscussions.length > MAX_CLOSE_COUNT) { + core.warning(`Found ${olderDiscussions.length} older discussions, but only closing the first ${MAX_CLOSE_COUNT}`); + } + const closedDiscussions = []; + for (let i = 0; i < discussionsToClose.length; i++) { + const discussion = discussionsToClose[i]; + try { + const closingMessage = getCloseOlderDiscussionMessage({ + newDiscussionUrl: newDiscussion.url, + newDiscussionNumber: newDiscussion.number, + workflowName, + runUrl, + }); + core.info(`Adding closing comment to discussion #${discussion.number}`); + await addDiscussionComment(github, discussion.id, closingMessage); + core.info(`Closing discussion #${discussion.number} as outdated`); + await closeDiscussionAsOutdated(github, discussion.id); + closedDiscussions.push({ + number: discussion.number, + url: discussion.url, + }); + core.info(`✓ Closed discussion #${discussion.number}: ${discussion.url}`); + } catch (error) { + core.error(`✗ Failed to close discussion #${discussion.number}: ${error instanceof Error ? error.message : String(error)}`); + } + if (i < discussionsToClose.length - 1) { + await delay(GRAPHQL_DELAY_MS); + } + } + return closedDiscussions; + } + const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; + function generateTemporaryId() { + return "aw_" + crypto.randomBytes(6).toString("hex"); + } + function isTemporaryId(value) { + if (typeof value === "string") { + return /^aw_[0-9a-f]{12}$/i.test(value); + } + return false; + } + function normalizeTemporaryId(tempId) { + return String(tempId).toLowerCase(); + } + function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { + return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { + const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); + if (resolved !== undefined) { + if (currentRepo && resolved.repo === currentRepo) { + return `#${resolved.number}`; + } + return `${resolved.repo}#${resolved.number}`; + } + return match; + }); + } + function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { + return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { + const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); + if (issueNumber !== undefined) { + return `#${issueNumber}`; + } + return match; + }); + } + function loadTemporaryIdMap() { + const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; + if (!mapJson || mapJson === "{}") { + return new Map(); + } + try { + const mapObject = JSON.parse(mapJson); + const result = new Map(); + for (const [key, value] of Object.entries(mapObject)) { + const normalizedKey = normalizeTemporaryId(key); + if (typeof value === "number") { + const contextRepo = `${context.repo.owner}/${context.repo.repo}`; + result.set(normalizedKey, { repo: contextRepo, number: value }); + } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { + result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); + } + } + return result; + } catch (error) { + if (typeof core !== "undefined") { + core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); + } + return new Map(); + } + } + function resolveIssueNumber(value, temporaryIdMap) { + if (value === undefined || value === null) { + return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; + } + const valueStr = String(value); + if (isTemporaryId(valueStr)) { + const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); + if (resolvedPair !== undefined) { + return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; + } + return { + resolved: null, + wasTemporaryId: true, + errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, + }; + } + const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); + if (isNaN(issueNumber) || issueNumber <= 0) { + return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; + } + const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; + return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; + } + function serializeTemporaryIdMap(tempIdMap) { + const obj = Object.fromEntries(tempIdMap); + return JSON.stringify(obj); + } + function parseAllowedRepos() { + const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; + const set = new Set(); + if (allowedReposEnv) { + allowedReposEnv + .split(",") + .map(repo => repo.trim()) + .filter(repo => repo) + .forEach(repo => set.add(repo)); + } + return set; + } + function getDefaultTargetRepo() { + const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; + if (targetRepoSlug) { + return targetRepoSlug; + } + return `${context.repo.owner}/${context.repo.repo}`; + } + function validateRepo(repo, defaultRepo, allowedRepos) { + if (repo === defaultRepo) { + return { valid: true, error: null }; + } + if (allowedRepos.has(repo)) { + return { valid: true, error: null }; + } + return { + valid: false, + error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, + }; + } + function parseRepoSlug(repoSlug) { + const parts = repoSlug.split("/"); + if (parts.length !== 2 || !parts[0] || !parts[1]) { + return null; + } + return { owner: parts[0], repo: parts[1] }; + } + function addExpirationComment(bodyLines, envVarName, entityType) { + const expiresEnv = process.env[envVarName]; + if (expiresEnv) { + const expiresDays = parseInt(expiresEnv, 10); + if (!isNaN(expiresDays) && expiresDays > 0) { + const expirationDate = new Date(); + expirationDate.setDate(expirationDate.getDate() + expiresDays); + const expirationISO = expirationDate.toISOString(); + bodyLines.push(``); + core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); + } + } + } + function removeDuplicateTitleFromDescription(title, description) { + if (!title || typeof title !== "string") { + return description || ""; + } + if (!description || typeof description !== "string") { + return ""; + } + const trimmedTitle = title.trim(); + const trimmedDescription = description.trim(); + if (!trimmedTitle || !trimmedDescription) { + return trimmedDescription; + } + const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); + if (headerRegex.test(trimmedDescription)) { + return trimmedDescription.replace(headerRegex, "").trim(); + } + return trimmedDescription; + } async function fetchRepoDiscussionInfo(owner, repo) { const repositoryQuery = ` query($owner: String!, $repo: String!) { @@ -7259,10 +7646,151 @@ jobs: script: | const fs = require("fs"); const crypto = require("crypto"); - const { updateActivationComment } = require("./update_activation_comment.cjs"); - const { getTrackerID } = require("./get_tracker_id.cjs"); - const { addExpirationComment } = require("./expiration_helpers.cjs"); - const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); + async function updateActivationComment(github, context, core, itemUrl, itemNumber, itemType = "pull_request") { + const itemLabel = itemType === "issue" ? "issue" : "pull request"; + const linkMessage = + itemType === "issue" + ? `\n\n✅ Issue created: [#${itemNumber}](${itemUrl})` + : `\n\n✅ Pull request created: [#${itemNumber}](${itemUrl})`; + await updateActivationCommentWithMessage(github, context, core, linkMessage, itemLabel); + } + async function updateActivationCommentWithCommit(github, context, core, commitSha, commitUrl) { + const shortSha = commitSha.substring(0, 7); + const message = `\n\n✅ Commit pushed: [\`${shortSha}\`](${commitUrl})`; + await updateActivationCommentWithMessage(github, context, core, message, "commit"); + } + async function updateActivationCommentWithMessage(github, context, core, message, label = "") { + const commentId = process.env.GH_AW_COMMENT_ID; + const commentRepo = process.env.GH_AW_COMMENT_REPO; + if (!commentId) { + core.info("No activation comment to update (GH_AW_COMMENT_ID not set)"); + return; + } + core.info(`Updating activation comment ${commentId}`); + let repoOwner = context.repo.owner; + let repoName = context.repo.repo; + if (commentRepo) { + const parts = commentRepo.split("/"); + if (parts.length === 2) { + repoOwner = parts[0]; + repoName = parts[1]; + } else { + core.warning(`Invalid comment repo format: ${commentRepo}, expected "owner/repo". Falling back to context.repo.`); + } + } + core.info(`Updating comment in ${repoOwner}/${repoName}`); + const isDiscussionComment = commentId.startsWith("DC_"); + try { + if (isDiscussionComment) { + const currentComment = await github.graphql( + ` + query($commentId: ID!) { + node(id: $commentId) { + ... on DiscussionComment { + body + } + } + }`, + { commentId: commentId } + ); + if (!currentComment?.node?.body) { + core.warning("Unable to fetch current comment body, comment may have been deleted or is inaccessible"); + return; + } + const currentBody = currentComment.node.body; + const updatedBody = currentBody + message; + const result = await github.graphql( + ` + mutation($commentId: ID!, $body: String!) { + updateDiscussionComment(input: { commentId: $commentId, body: $body }) { + comment { + id + url + } + } + }`, + { commentId: commentId, body: updatedBody } + ); + const comment = result.updateDiscussionComment.comment; + const successMessage = label + ? `Successfully updated discussion comment with ${label} link` + : "Successfully updated discussion comment"; + core.info(successMessage); + core.info(`Comment ID: ${comment.id}`); + core.info(`Comment URL: ${comment.url}`); + } else { + const currentComment = await github.request("GET /repos/{owner}/{repo}/issues/comments/{comment_id}", { + owner: repoOwner, + repo: repoName, + comment_id: parseInt(commentId, 10), + headers: { + Accept: "application/vnd.github+json", + }, + }); + if (!currentComment?.data?.body) { + core.warning("Unable to fetch current comment body, comment may have been deleted"); + return; + } + const currentBody = currentComment.data.body; + const updatedBody = currentBody + message; + const response = await github.request("PATCH /repos/{owner}/{repo}/issues/comments/{comment_id}", { + owner: repoOwner, + repo: repoName, + comment_id: parseInt(commentId, 10), + body: updatedBody, + headers: { + Accept: "application/vnd.github+json", + }, + }); + const successMessage = label ? `Successfully updated comment with ${label} link` : "Successfully updated comment"; + core.info(successMessage); + core.info(`Comment ID: ${response.data.id}`); + core.info(`Comment URL: ${response.data.html_url}`); + } + } catch (error) { + core.warning(`Failed to update activation comment: ${error instanceof Error ? error.message : String(error)}`); + } + } + function getTrackerID(format) { + const trackerID = process.env.GH_AW_TRACKER_ID || ""; + if (trackerID) { + core.info(`Tracker ID: ${trackerID}`); + return format === "markdown" ? `\n\n` : trackerID; + } + return ""; + } + function addExpirationComment(bodyLines, envVarName, entityType) { + const expiresEnv = process.env[envVarName]; + if (expiresEnv) { + const expiresDays = parseInt(expiresEnv, 10); + if (!isNaN(expiresDays) && expiresDays > 0) { + const expirationDate = new Date(); + expirationDate.setDate(expirationDate.getDate() + expiresDays); + const expirationISO = expirationDate.toISOString(); + bodyLines.push(``); + core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); + } + } + } + function removeDuplicateTitleFromDescription(title, description) { + if (!title || typeof title !== "string") { + return description || ""; + } + if (!description || typeof description !== "string") { + return ""; + } + const trimmedTitle = title.trim(); + const trimmedDescription = description.trim(); + if (!trimmedTitle || !trimmedDescription) { + return trimmedDescription; + } + const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); + if (headerRegex.test(trimmedDescription)) { + return trimmedDescription.replace(headerRegex, "").trim(); + } + return trimmedDescription; + } function generatePatchPreview(patchContent) { if (!patchContent || !patchContent.trim()) { return ""; diff --git a/.github/workflows/dictation-prompt.lock.yml b/.github/workflows/dictation-prompt.lock.yml index 8853cae3005..e2dd58c44f9 100644 --- a/.github/workflows/dictation-prompt.lock.yml +++ b/.github/workflows/dictation-prompt.lock.yml @@ -6147,10 +6147,151 @@ jobs: script: | const fs = require("fs"); const crypto = require("crypto"); - const { updateActivationComment } = require("./update_activation_comment.cjs"); - const { getTrackerID } = require("./get_tracker_id.cjs"); - const { addExpirationComment } = require("./expiration_helpers.cjs"); - const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); + async function updateActivationComment(github, context, core, itemUrl, itemNumber, itemType = "pull_request") { + const itemLabel = itemType === "issue" ? "issue" : "pull request"; + const linkMessage = + itemType === "issue" + ? `\n\n✅ Issue created: [#${itemNumber}](${itemUrl})` + : `\n\n✅ Pull request created: [#${itemNumber}](${itemUrl})`; + await updateActivationCommentWithMessage(github, context, core, linkMessage, itemLabel); + } + async function updateActivationCommentWithCommit(github, context, core, commitSha, commitUrl) { + const shortSha = commitSha.substring(0, 7); + const message = `\n\n✅ Commit pushed: [\`${shortSha}\`](${commitUrl})`; + await updateActivationCommentWithMessage(github, context, core, message, "commit"); + } + async function updateActivationCommentWithMessage(github, context, core, message, label = "") { + const commentId = process.env.GH_AW_COMMENT_ID; + const commentRepo = process.env.GH_AW_COMMENT_REPO; + if (!commentId) { + core.info("No activation comment to update (GH_AW_COMMENT_ID not set)"); + return; + } + core.info(`Updating activation comment ${commentId}`); + let repoOwner = context.repo.owner; + let repoName = context.repo.repo; + if (commentRepo) { + const parts = commentRepo.split("/"); + if (parts.length === 2) { + repoOwner = parts[0]; + repoName = parts[1]; + } else { + core.warning(`Invalid comment repo format: ${commentRepo}, expected "owner/repo". Falling back to context.repo.`); + } + } + core.info(`Updating comment in ${repoOwner}/${repoName}`); + const isDiscussionComment = commentId.startsWith("DC_"); + try { + if (isDiscussionComment) { + const currentComment = await github.graphql( + ` + query($commentId: ID!) { + node(id: $commentId) { + ... on DiscussionComment { + body + } + } + }`, + { commentId: commentId } + ); + if (!currentComment?.node?.body) { + core.warning("Unable to fetch current comment body, comment may have been deleted or is inaccessible"); + return; + } + const currentBody = currentComment.node.body; + const updatedBody = currentBody + message; + const result = await github.graphql( + ` + mutation($commentId: ID!, $body: String!) { + updateDiscussionComment(input: { commentId: $commentId, body: $body }) { + comment { + id + url + } + } + }`, + { commentId: commentId, body: updatedBody } + ); + const comment = result.updateDiscussionComment.comment; + const successMessage = label + ? `Successfully updated discussion comment with ${label} link` + : "Successfully updated discussion comment"; + core.info(successMessage); + core.info(`Comment ID: ${comment.id}`); + core.info(`Comment URL: ${comment.url}`); + } else { + const currentComment = await github.request("GET /repos/{owner}/{repo}/issues/comments/{comment_id}", { + owner: repoOwner, + repo: repoName, + comment_id: parseInt(commentId, 10), + headers: { + Accept: "application/vnd.github+json", + }, + }); + if (!currentComment?.data?.body) { + core.warning("Unable to fetch current comment body, comment may have been deleted"); + return; + } + const currentBody = currentComment.data.body; + const updatedBody = currentBody + message; + const response = await github.request("PATCH /repos/{owner}/{repo}/issues/comments/{comment_id}", { + owner: repoOwner, + repo: repoName, + comment_id: parseInt(commentId, 10), + body: updatedBody, + headers: { + Accept: "application/vnd.github+json", + }, + }); + const successMessage = label ? `Successfully updated comment with ${label} link` : "Successfully updated comment"; + core.info(successMessage); + core.info(`Comment ID: ${response.data.id}`); + core.info(`Comment URL: ${response.data.html_url}`); + } + } catch (error) { + core.warning(`Failed to update activation comment: ${error instanceof Error ? error.message : String(error)}`); + } + } + function getTrackerID(format) { + const trackerID = process.env.GH_AW_TRACKER_ID || ""; + if (trackerID) { + core.info(`Tracker ID: ${trackerID}`); + return format === "markdown" ? `\n\n` : trackerID; + } + return ""; + } + function addExpirationComment(bodyLines, envVarName, entityType) { + const expiresEnv = process.env[envVarName]; + if (expiresEnv) { + const expiresDays = parseInt(expiresEnv, 10); + if (!isNaN(expiresDays) && expiresDays > 0) { + const expirationDate = new Date(); + expirationDate.setDate(expirationDate.getDate() + expiresDays); + const expirationISO = expirationDate.toISOString(); + bodyLines.push(``); + core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); + } + } + } + function removeDuplicateTitleFromDescription(title, description) { + if (!title || typeof title !== "string") { + return description || ""; + } + if (!description || typeof description !== "string") { + return ""; + } + const trimmedTitle = title.trim(); + const trimmedDescription = description.trim(); + if (!trimmedTitle || !trimmedDescription) { + return trimmedDescription; + } + const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); + if (headerRegex.test(trimmedDescription)) { + return trimmedDescription.replace(headerRegex, "").trim(); + } + return trimmedDescription; + } function generatePatchPreview(patchContent) { if (!patchContent || !patchContent.trim()) { return ""; diff --git a/.github/workflows/docs-noob-tester.lock.yml b/.github/workflows/docs-noob-tester.lock.yml index 455cebc87d5..67483ef060c 100644 --- a/.github/workflows/docs-noob-tester.lock.yml +++ b/.github/workflows/docs-noob-tester.lock.yml @@ -6251,13 +6251,400 @@ jobs: with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - const { loadAgentOutput } = require("./load_agent_output.cjs"); - const { getTrackerID } = require("./get_tracker_id.cjs"); - const { closeOlderDiscussions } = require("./close_older_discussions.cjs"); - const { replaceTemporaryIdReferences, loadTemporaryIdMap } = require("./temporary_id.cjs"); - const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require("./repo_helpers.cjs"); - const { addExpirationComment } = require("./expiration_helpers.cjs"); - const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); + const fs = require("fs"); + const crypto = require("crypto"); + const MAX_LOG_CONTENT_LENGTH = 10000; + function truncateForLogging(content) { + if (content.length <= MAX_LOG_CONTENT_LENGTH) { + return content; + } + return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; + } + function loadAgentOutput() { + const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; + if (!agentOutputFile) { + core.info("No GH_AW_AGENT_OUTPUT environment variable found"); + return { success: false }; + } + let outputContent; + try { + outputContent = fs.readFileSync(agentOutputFile, "utf8"); + } catch (error) { + const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; + core.error(errorMessage); + return { success: false, error: errorMessage }; + } + if (outputContent.trim() === "") { + core.info("Agent output content is empty"); + return { success: false }; + } + core.info(`Agent output content length: ${outputContent.length}`); + let validatedOutput; + try { + validatedOutput = JSON.parse(outputContent); + } catch (error) { + const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; + core.error(errorMessage); + core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); + return { success: false, error: errorMessage }; + } + if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { + core.info("No valid items found in agent output"); + core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); + return { success: false }; + } + return { success: true, items: validatedOutput.items }; + } + function getTrackerID(format) { + const trackerID = process.env.GH_AW_TRACKER_ID || ""; + if (trackerID) { + core.info(`Tracker ID: ${trackerID}`); + return format === "markdown" ? `\n\n` : trackerID; + } + return ""; + } + function getMessages() { + const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES; + if (!messagesEnv) { + return null; + } + try { + return JSON.parse(messagesEnv); + } catch (error) { + core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`); + return null; + } + } + function renderTemplate(template, context) { + return template.replace(/\{(\w+)\}/g, (match, key) => { + const value = context[key]; + return value !== undefined && value !== null ? String(value) : match; + }); + } + function toSnakeCase(obj) { + const result = {}; + for (const [key, value] of Object.entries(obj)) { + const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase(); + result[snakeKey] = value; + result[key] = value; + } + return result; + } + function getCloseOlderDiscussionMessage(ctx) { + const messages = getMessages(); + const templateContext = toSnakeCase(ctx); + const defaultMessage = `⚓ Avast! This discussion be marked as **outdated** by [{workflow_name}]({run_url}). + 🗺️ A newer treasure map awaits ye at **[Discussion #{new_discussion_number}]({new_discussion_url})**. + Fair winds, matey! 🏴‍☠️`; + return messages?.closeOlderDiscussion + ? renderTemplate(messages.closeOlderDiscussion, templateContext) + : renderTemplate(defaultMessage, templateContext); + } + const MAX_CLOSE_COUNT = 10; + const GRAPHQL_DELAY_MS = 500; + function delay(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); + } + async function searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, excludeNumber) { + let searchQuery = `repo:${owner}/${repo} is:open`; + if (titlePrefix) { + const escapedPrefix = titlePrefix.replace(/"/g, '\\"'); + searchQuery += ` in:title "${escapedPrefix}"`; + } + if (labels && labels.length > 0) { + for (const label of labels) { + const escapedLabel = label.replace(/"/g, '\\"'); + searchQuery += ` label:"${escapedLabel}"`; + } + } + const result = await github.graphql( + ` + query($searchTerms: String!, $first: Int!) { + search(query: $searchTerms, type: DISCUSSION, first: $first) { + nodes { + ... on Discussion { + id + number + title + url + category { + id + } + labels(first: 100) { + nodes { + name + } + } + closed + } + } + } + }`, + { searchTerms: searchQuery, first: 50 } + ); + if (!result || !result.search || !result.search.nodes) { + return []; + } + return result.search.nodes + .filter( + d => { + if (!d || d.number === excludeNumber || d.closed) { + return false; + } + if (titlePrefix && d.title && !d.title.startsWith(titlePrefix)) { + return false; + } + if (labels && labels.length > 0) { + const discussionLabels = d.labels?.nodes?.map(( l) => l.name) || []; + const hasAllLabels = labels.every(label => discussionLabels.includes(label)); + if (!hasAllLabels) { + return false; + } + } + if (categoryId && (!d.category || d.category.id !== categoryId)) { + return false; + } + return true; + } + ) + .map( + d => ({ + id: d.id, + number: d.number, + title: d.title, + url: d.url, + }) + ); + } + async function addDiscussionComment(github, discussionId, message) { + const result = await github.graphql( + ` + mutation($dId: ID!, $body: String!) { + addDiscussionComment(input: { discussionId: $dId, body: $body }) { + comment { + id + url + } + } + }`, + { dId: discussionId, body: message } + ); + return result.addDiscussionComment.comment; + } + async function closeDiscussionAsOutdated(github, discussionId) { + const result = await github.graphql( + ` + mutation($dId: ID!) { + closeDiscussion(input: { discussionId: $dId, reason: OUTDATED }) { + discussion { + id + url + } + } + }`, + { dId: discussionId } + ); + return result.closeDiscussion.discussion; + } + async function closeOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion, workflowName, runUrl) { + const searchCriteria = []; + if (titlePrefix) searchCriteria.push(`title prefix: "${titlePrefix}"`); + if (labels && labels.length > 0) searchCriteria.push(`labels: [${labels.join(", ")}]`); + core.info(`Searching for older discussions with ${searchCriteria.join(" and ")}`); + const olderDiscussions = await searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion.number); + if (olderDiscussions.length === 0) { + core.info("No older discussions found to close"); + return []; + } + core.info(`Found ${olderDiscussions.length} older discussion(s) to close`); + const discussionsToClose = olderDiscussions.slice(0, MAX_CLOSE_COUNT); + if (olderDiscussions.length > MAX_CLOSE_COUNT) { + core.warning(`Found ${olderDiscussions.length} older discussions, but only closing the first ${MAX_CLOSE_COUNT}`); + } + const closedDiscussions = []; + for (let i = 0; i < discussionsToClose.length; i++) { + const discussion = discussionsToClose[i]; + try { + const closingMessage = getCloseOlderDiscussionMessage({ + newDiscussionUrl: newDiscussion.url, + newDiscussionNumber: newDiscussion.number, + workflowName, + runUrl, + }); + core.info(`Adding closing comment to discussion #${discussion.number}`); + await addDiscussionComment(github, discussion.id, closingMessage); + core.info(`Closing discussion #${discussion.number} as outdated`); + await closeDiscussionAsOutdated(github, discussion.id); + closedDiscussions.push({ + number: discussion.number, + url: discussion.url, + }); + core.info(`✓ Closed discussion #${discussion.number}: ${discussion.url}`); + } catch (error) { + core.error(`✗ Failed to close discussion #${discussion.number}: ${error instanceof Error ? error.message : String(error)}`); + } + if (i < discussionsToClose.length - 1) { + await delay(GRAPHQL_DELAY_MS); + } + } + return closedDiscussions; + } + const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; + function generateTemporaryId() { + return "aw_" + crypto.randomBytes(6).toString("hex"); + } + function isTemporaryId(value) { + if (typeof value === "string") { + return /^aw_[0-9a-f]{12}$/i.test(value); + } + return false; + } + function normalizeTemporaryId(tempId) { + return String(tempId).toLowerCase(); + } + function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { + return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { + const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); + if (resolved !== undefined) { + if (currentRepo && resolved.repo === currentRepo) { + return `#${resolved.number}`; + } + return `${resolved.repo}#${resolved.number}`; + } + return match; + }); + } + function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { + return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { + const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); + if (issueNumber !== undefined) { + return `#${issueNumber}`; + } + return match; + }); + } + function loadTemporaryIdMap() { + const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; + if (!mapJson || mapJson === "{}") { + return new Map(); + } + try { + const mapObject = JSON.parse(mapJson); + const result = new Map(); + for (const [key, value] of Object.entries(mapObject)) { + const normalizedKey = normalizeTemporaryId(key); + if (typeof value === "number") { + const contextRepo = `${context.repo.owner}/${context.repo.repo}`; + result.set(normalizedKey, { repo: contextRepo, number: value }); + } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { + result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); + } + } + return result; + } catch (error) { + if (typeof core !== "undefined") { + core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); + } + return new Map(); + } + } + function resolveIssueNumber(value, temporaryIdMap) { + if (value === undefined || value === null) { + return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; + } + const valueStr = String(value); + if (isTemporaryId(valueStr)) { + const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); + if (resolvedPair !== undefined) { + return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; + } + return { + resolved: null, + wasTemporaryId: true, + errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, + }; + } + const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); + if (isNaN(issueNumber) || issueNumber <= 0) { + return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; + } + const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; + return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; + } + function serializeTemporaryIdMap(tempIdMap) { + const obj = Object.fromEntries(tempIdMap); + return JSON.stringify(obj); + } + function parseAllowedRepos() { + const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; + const set = new Set(); + if (allowedReposEnv) { + allowedReposEnv + .split(",") + .map(repo => repo.trim()) + .filter(repo => repo) + .forEach(repo => set.add(repo)); + } + return set; + } + function getDefaultTargetRepo() { + const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; + if (targetRepoSlug) { + return targetRepoSlug; + } + return `${context.repo.owner}/${context.repo.repo}`; + } + function validateRepo(repo, defaultRepo, allowedRepos) { + if (repo === defaultRepo) { + return { valid: true, error: null }; + } + if (allowedRepos.has(repo)) { + return { valid: true, error: null }; + } + return { + valid: false, + error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, + }; + } + function parseRepoSlug(repoSlug) { + const parts = repoSlug.split("/"); + if (parts.length !== 2 || !parts[0] || !parts[1]) { + return null; + } + return { owner: parts[0], repo: parts[1] }; + } + function addExpirationComment(bodyLines, envVarName, entityType) { + const expiresEnv = process.env[envVarName]; + if (expiresEnv) { + const expiresDays = parseInt(expiresEnv, 10); + if (!isNaN(expiresDays) && expiresDays > 0) { + const expirationDate = new Date(); + expirationDate.setDate(expirationDate.getDate() + expiresDays); + const expirationISO = expirationDate.toISOString(); + bodyLines.push(``); + core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); + } + } + } + function removeDuplicateTitleFromDescription(title, description) { + if (!title || typeof title !== "string") { + return description || ""; + } + if (!description || typeof description !== "string") { + return ""; + } + const trimmedTitle = title.trim(); + const trimmedDescription = description.trim(); + if (!trimmedTitle || !trimmedDescription) { + return trimmedDescription; + } + const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); + if (headerRegex.test(trimmedDescription)) { + return trimmedDescription.replace(headerRegex, "").trim(); + } + return trimmedDescription; + } async function fetchRepoDiscussionInfo(owner, repo) { const repositoryQuery = ` query($owner: String!, $repo: String!) { diff --git a/.github/workflows/duplicate-code-detector.lock.yml b/.github/workflows/duplicate-code-detector.lock.yml index 9070a4927e9..7e157c27d18 100644 --- a/.github/workflows/duplicate-code-detector.lock.yml +++ b/.github/workflows/duplicate-code-detector.lock.yml @@ -5822,21 +5822,291 @@ jobs: with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - const { sanitizeLabelContent } = require("./sanitize_label_content.cjs"); - const { loadAgentOutput } = require("./load_agent_output.cjs"); - const { generateStagedPreview } = require("./staged_preview.cjs"); - const { generateFooter } = require("./generate_footer.cjs"); - const { getTrackerID } = require("./get_tracker_id.cjs"); - const { - generateTemporaryId, - isTemporaryId, - normalizeTemporaryId, - replaceTemporaryIdReferences, - serializeTemporaryIdMap, - } = require("./temporary_id.cjs"); - const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require("./repo_helpers.cjs"); - const { addExpirationComment } = require("./expiration_helpers.cjs"); - const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); + function sanitizeLabelContent(content) { + if (!content || typeof content !== "string") { + return ""; + } + let sanitized = content.trim(); + sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); + sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); + sanitized = sanitized.replace( + /(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, + (_m, p1, p2) => `${p1}\`@${p2}\`` + ); + sanitized = sanitized.replace(/[<>&'"]/g, ""); + return sanitized.trim(); + } + const fs = require("fs"); + const crypto = require("crypto"); + const MAX_LOG_CONTENT_LENGTH = 10000; + function truncateForLogging(content) { + if (content.length <= MAX_LOG_CONTENT_LENGTH) { + return content; + } + return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; + } + function loadAgentOutput() { + const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; + if (!agentOutputFile) { + core.info("No GH_AW_AGENT_OUTPUT environment variable found"); + return { success: false }; + } + let outputContent; + try { + outputContent = fs.readFileSync(agentOutputFile, "utf8"); + } catch (error) { + const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; + core.error(errorMessage); + return { success: false, error: errorMessage }; + } + if (outputContent.trim() === "") { + core.info("Agent output content is empty"); + return { success: false }; + } + core.info(`Agent output content length: ${outputContent.length}`); + let validatedOutput; + try { + validatedOutput = JSON.parse(outputContent); + } catch (error) { + const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; + core.error(errorMessage); + core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); + return { success: false, error: errorMessage }; + } + if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { + core.info("No valid items found in agent output"); + core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); + return { success: false }; + } + return { success: true, items: validatedOutput.items }; + } + async function generateStagedPreview(options) { + const { title, description, items, renderItem } = options; + let summaryContent = `## 🎭 Staged Mode: ${title} Preview\n\n`; + summaryContent += `${description}\n\n`; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + summaryContent += renderItem(item, i); + summaryContent += "---\n\n"; + } + try { + await core.summary.addRaw(summaryContent).write(); + core.info(summaryContent); + core.info(`📝 ${title} preview written to step summary`); + } catch (error) { + core.setFailed(error instanceof Error ? error : String(error)); + } + } + function generateXMLMarker(workflowName, runUrl) { + const engineId = process.env.GH_AW_ENGINE_ID || ""; + const engineVersion = process.env.GH_AW_ENGINE_VERSION || ""; + const engineModel = process.env.GH_AW_ENGINE_MODEL || ""; + const trackerId = process.env.GH_AW_TRACKER_ID || ""; + const parts = []; + parts.push(`agentic-workflow: ${workflowName}`); + if (trackerId) { + parts.push(`tracker-id: ${trackerId}`); + } + if (engineId) { + parts.push(`engine: ${engineId}`); + } + if (engineVersion) { + parts.push(`version: ${engineVersion}`); + } + if (engineModel) { + parts.push(`model: ${engineModel}`); + } + parts.push(`run: ${runUrl}`); + return ``; + } + function generateFooter( + workflowName, + runUrl, + workflowSource, + workflowSourceURL, + triggeringIssueNumber, + triggeringPRNumber, + triggeringDiscussionNumber + ) { + let footer = `\n\n> AI generated by [${workflowName}](${runUrl})`; + if (triggeringIssueNumber) { + footer += ` for #${triggeringIssueNumber}`; + } else if (triggeringPRNumber) { + footer += ` for #${triggeringPRNumber}`; + } else if (triggeringDiscussionNumber) { + footer += ` for discussion #${triggeringDiscussionNumber}`; + } + if (workflowSource && workflowSourceURL) { + footer += `\n>\n> To add this workflow in your repository, run \`gh aw add ${workflowSource}\`. See [usage guide](https://githubnext.github.io/gh-aw/tools/cli/).`; + } + footer += "\n\n" + generateXMLMarker(workflowName, runUrl); + footer += "\n"; + return footer; + } + function getTrackerID(format) { + const trackerID = process.env.GH_AW_TRACKER_ID || ""; + if (trackerID) { + core.info(`Tracker ID: ${trackerID}`); + return format === "markdown" ? `\n\n` : trackerID; + } + return ""; + } + const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; + function generateTemporaryId() { + return "aw_" + crypto.randomBytes(6).toString("hex"); + } + function isTemporaryId(value) { + if (typeof value === "string") { + return /^aw_[0-9a-f]{12}$/i.test(value); + } + return false; + } + function normalizeTemporaryId(tempId) { + return String(tempId).toLowerCase(); + } + function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { + return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { + const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); + if (resolved !== undefined) { + if (currentRepo && resolved.repo === currentRepo) { + return `#${resolved.number}`; + } + return `${resolved.repo}#${resolved.number}`; + } + return match; + }); + } + function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { + return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { + const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); + if (issueNumber !== undefined) { + return `#${issueNumber}`; + } + return match; + }); + } + function loadTemporaryIdMap() { + const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; + if (!mapJson || mapJson === "{}") { + return new Map(); + } + try { + const mapObject = JSON.parse(mapJson); + const result = new Map(); + for (const [key, value] of Object.entries(mapObject)) { + const normalizedKey = normalizeTemporaryId(key); + if (typeof value === "number") { + const contextRepo = `${context.repo.owner}/${context.repo.repo}`; + result.set(normalizedKey, { repo: contextRepo, number: value }); + } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { + result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); + } + } + return result; + } catch (error) { + if (typeof core !== "undefined") { + core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); + } + return new Map(); + } + } + function resolveIssueNumber(value, temporaryIdMap) { + if (value === undefined || value === null) { + return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; + } + const valueStr = String(value); + if (isTemporaryId(valueStr)) { + const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); + if (resolvedPair !== undefined) { + return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; + } + return { + resolved: null, + wasTemporaryId: true, + errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, + }; + } + const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); + if (isNaN(issueNumber) || issueNumber <= 0) { + return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; + } + const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; + return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; + } + function serializeTemporaryIdMap(tempIdMap) { + const obj = Object.fromEntries(tempIdMap); + return JSON.stringify(obj); + } + function parseAllowedRepos() { + const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; + const set = new Set(); + if (allowedReposEnv) { + allowedReposEnv + .split(",") + .map(repo => repo.trim()) + .filter(repo => repo) + .forEach(repo => set.add(repo)); + } + return set; + } + function getDefaultTargetRepo() { + const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; + if (targetRepoSlug) { + return targetRepoSlug; + } + return `${context.repo.owner}/${context.repo.repo}`; + } + function validateRepo(repo, defaultRepo, allowedRepos) { + if (repo === defaultRepo) { + return { valid: true, error: null }; + } + if (allowedRepos.has(repo)) { + return { valid: true, error: null }; + } + return { + valid: false, + error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, + }; + } + function parseRepoSlug(repoSlug) { + const parts = repoSlug.split("/"); + if (parts.length !== 2 || !parts[0] || !parts[1]) { + return null; + } + return { owner: parts[0], repo: parts[1] }; + } + function addExpirationComment(bodyLines, envVarName, entityType) { + const expiresEnv = process.env[envVarName]; + if (expiresEnv) { + const expiresDays = parseInt(expiresEnv, 10); + if (!isNaN(expiresDays) && expiresDays > 0) { + const expirationDate = new Date(); + expirationDate.setDate(expirationDate.getDate() + expiresDays); + const expirationISO = expirationDate.toISOString(); + bodyLines.push(``); + core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); + } + } + } + function removeDuplicateTitleFromDescription(title, description) { + if (!title || typeof title !== "string") { + return description || ""; + } + if (!description || typeof description !== "string") { + return ""; + } + const trimmedTitle = title.trim(); + const trimmedDescription = description.trim(); + if (!trimmedTitle || !trimmedDescription) { + return trimmedDescription; + } + const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); + if (headerRegex.test(trimmedDescription)) { + return trimmedDescription.replace(headerRegex, "").trim(); + } + return trimmedDescription; + } async function main() { core.setOutput("issue_number", ""); core.setOutput("issue_url", ""); diff --git a/.github/workflows/example-workflow-analyzer.lock.yml b/.github/workflows/example-workflow-analyzer.lock.yml index 0bfe02d2a2a..929c613e292 100644 --- a/.github/workflows/example-workflow-analyzer.lock.yml +++ b/.github/workflows/example-workflow-analyzer.lock.yml @@ -5437,13 +5437,400 @@ jobs: with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - const { loadAgentOutput } = require("./load_agent_output.cjs"); - const { getTrackerID } = require("./get_tracker_id.cjs"); - const { closeOlderDiscussions } = require("./close_older_discussions.cjs"); - const { replaceTemporaryIdReferences, loadTemporaryIdMap } = require("./temporary_id.cjs"); - const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require("./repo_helpers.cjs"); - const { addExpirationComment } = require("./expiration_helpers.cjs"); - const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); + const fs = require("fs"); + const crypto = require("crypto"); + const MAX_LOG_CONTENT_LENGTH = 10000; + function truncateForLogging(content) { + if (content.length <= MAX_LOG_CONTENT_LENGTH) { + return content; + } + return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; + } + function loadAgentOutput() { + const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; + if (!agentOutputFile) { + core.info("No GH_AW_AGENT_OUTPUT environment variable found"); + return { success: false }; + } + let outputContent; + try { + outputContent = fs.readFileSync(agentOutputFile, "utf8"); + } catch (error) { + const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; + core.error(errorMessage); + return { success: false, error: errorMessage }; + } + if (outputContent.trim() === "") { + core.info("Agent output content is empty"); + return { success: false }; + } + core.info(`Agent output content length: ${outputContent.length}`); + let validatedOutput; + try { + validatedOutput = JSON.parse(outputContent); + } catch (error) { + const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; + core.error(errorMessage); + core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); + return { success: false, error: errorMessage }; + } + if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { + core.info("No valid items found in agent output"); + core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); + return { success: false }; + } + return { success: true, items: validatedOutput.items }; + } + function getTrackerID(format) { + const trackerID = process.env.GH_AW_TRACKER_ID || ""; + if (trackerID) { + core.info(`Tracker ID: ${trackerID}`); + return format === "markdown" ? `\n\n` : trackerID; + } + return ""; + } + function getMessages() { + const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES; + if (!messagesEnv) { + return null; + } + try { + return JSON.parse(messagesEnv); + } catch (error) { + core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`); + return null; + } + } + function renderTemplate(template, context) { + return template.replace(/\{(\w+)\}/g, (match, key) => { + const value = context[key]; + return value !== undefined && value !== null ? String(value) : match; + }); + } + function toSnakeCase(obj) { + const result = {}; + for (const [key, value] of Object.entries(obj)) { + const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase(); + result[snakeKey] = value; + result[key] = value; + } + return result; + } + function getCloseOlderDiscussionMessage(ctx) { + const messages = getMessages(); + const templateContext = toSnakeCase(ctx); + const defaultMessage = `⚓ Avast! This discussion be marked as **outdated** by [{workflow_name}]({run_url}). + 🗺️ A newer treasure map awaits ye at **[Discussion #{new_discussion_number}]({new_discussion_url})**. + Fair winds, matey! 🏴‍☠️`; + return messages?.closeOlderDiscussion + ? renderTemplate(messages.closeOlderDiscussion, templateContext) + : renderTemplate(defaultMessage, templateContext); + } + const MAX_CLOSE_COUNT = 10; + const GRAPHQL_DELAY_MS = 500; + function delay(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); + } + async function searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, excludeNumber) { + let searchQuery = `repo:${owner}/${repo} is:open`; + if (titlePrefix) { + const escapedPrefix = titlePrefix.replace(/"/g, '\\"'); + searchQuery += ` in:title "${escapedPrefix}"`; + } + if (labels && labels.length > 0) { + for (const label of labels) { + const escapedLabel = label.replace(/"/g, '\\"'); + searchQuery += ` label:"${escapedLabel}"`; + } + } + const result = await github.graphql( + ` + query($searchTerms: String!, $first: Int!) { + search(query: $searchTerms, type: DISCUSSION, first: $first) { + nodes { + ... on Discussion { + id + number + title + url + category { + id + } + labels(first: 100) { + nodes { + name + } + } + closed + } + } + } + }`, + { searchTerms: searchQuery, first: 50 } + ); + if (!result || !result.search || !result.search.nodes) { + return []; + } + return result.search.nodes + .filter( + d => { + if (!d || d.number === excludeNumber || d.closed) { + return false; + } + if (titlePrefix && d.title && !d.title.startsWith(titlePrefix)) { + return false; + } + if (labels && labels.length > 0) { + const discussionLabels = d.labels?.nodes?.map(( l) => l.name) || []; + const hasAllLabels = labels.every(label => discussionLabels.includes(label)); + if (!hasAllLabels) { + return false; + } + } + if (categoryId && (!d.category || d.category.id !== categoryId)) { + return false; + } + return true; + } + ) + .map( + d => ({ + id: d.id, + number: d.number, + title: d.title, + url: d.url, + }) + ); + } + async function addDiscussionComment(github, discussionId, message) { + const result = await github.graphql( + ` + mutation($dId: ID!, $body: String!) { + addDiscussionComment(input: { discussionId: $dId, body: $body }) { + comment { + id + url + } + } + }`, + { dId: discussionId, body: message } + ); + return result.addDiscussionComment.comment; + } + async function closeDiscussionAsOutdated(github, discussionId) { + const result = await github.graphql( + ` + mutation($dId: ID!) { + closeDiscussion(input: { discussionId: $dId, reason: OUTDATED }) { + discussion { + id + url + } + } + }`, + { dId: discussionId } + ); + return result.closeDiscussion.discussion; + } + async function closeOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion, workflowName, runUrl) { + const searchCriteria = []; + if (titlePrefix) searchCriteria.push(`title prefix: "${titlePrefix}"`); + if (labels && labels.length > 0) searchCriteria.push(`labels: [${labels.join(", ")}]`); + core.info(`Searching for older discussions with ${searchCriteria.join(" and ")}`); + const olderDiscussions = await searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion.number); + if (olderDiscussions.length === 0) { + core.info("No older discussions found to close"); + return []; + } + core.info(`Found ${olderDiscussions.length} older discussion(s) to close`); + const discussionsToClose = olderDiscussions.slice(0, MAX_CLOSE_COUNT); + if (olderDiscussions.length > MAX_CLOSE_COUNT) { + core.warning(`Found ${olderDiscussions.length} older discussions, but only closing the first ${MAX_CLOSE_COUNT}`); + } + const closedDiscussions = []; + for (let i = 0; i < discussionsToClose.length; i++) { + const discussion = discussionsToClose[i]; + try { + const closingMessage = getCloseOlderDiscussionMessage({ + newDiscussionUrl: newDiscussion.url, + newDiscussionNumber: newDiscussion.number, + workflowName, + runUrl, + }); + core.info(`Adding closing comment to discussion #${discussion.number}`); + await addDiscussionComment(github, discussion.id, closingMessage); + core.info(`Closing discussion #${discussion.number} as outdated`); + await closeDiscussionAsOutdated(github, discussion.id); + closedDiscussions.push({ + number: discussion.number, + url: discussion.url, + }); + core.info(`✓ Closed discussion #${discussion.number}: ${discussion.url}`); + } catch (error) { + core.error(`✗ Failed to close discussion #${discussion.number}: ${error instanceof Error ? error.message : String(error)}`); + } + if (i < discussionsToClose.length - 1) { + await delay(GRAPHQL_DELAY_MS); + } + } + return closedDiscussions; + } + const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; + function generateTemporaryId() { + return "aw_" + crypto.randomBytes(6).toString("hex"); + } + function isTemporaryId(value) { + if (typeof value === "string") { + return /^aw_[0-9a-f]{12}$/i.test(value); + } + return false; + } + function normalizeTemporaryId(tempId) { + return String(tempId).toLowerCase(); + } + function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { + return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { + const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); + if (resolved !== undefined) { + if (currentRepo && resolved.repo === currentRepo) { + return `#${resolved.number}`; + } + return `${resolved.repo}#${resolved.number}`; + } + return match; + }); + } + function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { + return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { + const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); + if (issueNumber !== undefined) { + return `#${issueNumber}`; + } + return match; + }); + } + function loadTemporaryIdMap() { + const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; + if (!mapJson || mapJson === "{}") { + return new Map(); + } + try { + const mapObject = JSON.parse(mapJson); + const result = new Map(); + for (const [key, value] of Object.entries(mapObject)) { + const normalizedKey = normalizeTemporaryId(key); + if (typeof value === "number") { + const contextRepo = `${context.repo.owner}/${context.repo.repo}`; + result.set(normalizedKey, { repo: contextRepo, number: value }); + } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { + result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); + } + } + return result; + } catch (error) { + if (typeof core !== "undefined") { + core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); + } + return new Map(); + } + } + function resolveIssueNumber(value, temporaryIdMap) { + if (value === undefined || value === null) { + return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; + } + const valueStr = String(value); + if (isTemporaryId(valueStr)) { + const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); + if (resolvedPair !== undefined) { + return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; + } + return { + resolved: null, + wasTemporaryId: true, + errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, + }; + } + const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); + if (isNaN(issueNumber) || issueNumber <= 0) { + return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; + } + const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; + return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; + } + function serializeTemporaryIdMap(tempIdMap) { + const obj = Object.fromEntries(tempIdMap); + return JSON.stringify(obj); + } + function parseAllowedRepos() { + const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; + const set = new Set(); + if (allowedReposEnv) { + allowedReposEnv + .split(",") + .map(repo => repo.trim()) + .filter(repo => repo) + .forEach(repo => set.add(repo)); + } + return set; + } + function getDefaultTargetRepo() { + const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; + if (targetRepoSlug) { + return targetRepoSlug; + } + return `${context.repo.owner}/${context.repo.repo}`; + } + function validateRepo(repo, defaultRepo, allowedRepos) { + if (repo === defaultRepo) { + return { valid: true, error: null }; + } + if (allowedRepos.has(repo)) { + return { valid: true, error: null }; + } + return { + valid: false, + error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, + }; + } + function parseRepoSlug(repoSlug) { + const parts = repoSlug.split("/"); + if (parts.length !== 2 || !parts[0] || !parts[1]) { + return null; + } + return { owner: parts[0], repo: parts[1] }; + } + function addExpirationComment(bodyLines, envVarName, entityType) { + const expiresEnv = process.env[envVarName]; + if (expiresEnv) { + const expiresDays = parseInt(expiresEnv, 10); + if (!isNaN(expiresDays) && expiresDays > 0) { + const expirationDate = new Date(); + expirationDate.setDate(expirationDate.getDate() + expiresDays); + const expirationISO = expirationDate.toISOString(); + bodyLines.push(``); + core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); + } + } + } + function removeDuplicateTitleFromDescription(title, description) { + if (!title || typeof title !== "string") { + return description || ""; + } + if (!description || typeof description !== "string") { + return ""; + } + const trimmedTitle = title.trim(); + const trimmedDescription = description.trim(); + if (!trimmedTitle || !trimmedDescription) { + return trimmedDescription; + } + const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); + if (headerRegex.test(trimmedDescription)) { + return trimmedDescription.replace(headerRegex, "").trim(); + } + return trimmedDescription; + } async function fetchRepoDiscussionInfo(owner, repo) { const repositoryQuery = ` query($owner: String!, $repo: String!) { diff --git a/.github/workflows/github-mcp-structural-analysis.lock.yml b/.github/workflows/github-mcp-structural-analysis.lock.yml index 083bef5d61d..eb0c38af879 100644 --- a/.github/workflows/github-mcp-structural-analysis.lock.yml +++ b/.github/workflows/github-mcp-structural-analysis.lock.yml @@ -6817,13 +6817,400 @@ jobs: with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - const { loadAgentOutput } = require("./load_agent_output.cjs"); - const { getTrackerID } = require("./get_tracker_id.cjs"); - const { closeOlderDiscussions } = require("./close_older_discussions.cjs"); - const { replaceTemporaryIdReferences, loadTemporaryIdMap } = require("./temporary_id.cjs"); - const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require("./repo_helpers.cjs"); - const { addExpirationComment } = require("./expiration_helpers.cjs"); - const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); + const fs = require("fs"); + const crypto = require("crypto"); + const MAX_LOG_CONTENT_LENGTH = 10000; + function truncateForLogging(content) { + if (content.length <= MAX_LOG_CONTENT_LENGTH) { + return content; + } + return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; + } + function loadAgentOutput() { + const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; + if (!agentOutputFile) { + core.info("No GH_AW_AGENT_OUTPUT environment variable found"); + return { success: false }; + } + let outputContent; + try { + outputContent = fs.readFileSync(agentOutputFile, "utf8"); + } catch (error) { + const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; + core.error(errorMessage); + return { success: false, error: errorMessage }; + } + if (outputContent.trim() === "") { + core.info("Agent output content is empty"); + return { success: false }; + } + core.info(`Agent output content length: ${outputContent.length}`); + let validatedOutput; + try { + validatedOutput = JSON.parse(outputContent); + } catch (error) { + const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; + core.error(errorMessage); + core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); + return { success: false, error: errorMessage }; + } + if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { + core.info("No valid items found in agent output"); + core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); + return { success: false }; + } + return { success: true, items: validatedOutput.items }; + } + function getTrackerID(format) { + const trackerID = process.env.GH_AW_TRACKER_ID || ""; + if (trackerID) { + core.info(`Tracker ID: ${trackerID}`); + return format === "markdown" ? `\n\n` : trackerID; + } + return ""; + } + function getMessages() { + const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES; + if (!messagesEnv) { + return null; + } + try { + return JSON.parse(messagesEnv); + } catch (error) { + core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`); + return null; + } + } + function renderTemplate(template, context) { + return template.replace(/\{(\w+)\}/g, (match, key) => { + const value = context[key]; + return value !== undefined && value !== null ? String(value) : match; + }); + } + function toSnakeCase(obj) { + const result = {}; + for (const [key, value] of Object.entries(obj)) { + const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase(); + result[snakeKey] = value; + result[key] = value; + } + return result; + } + function getCloseOlderDiscussionMessage(ctx) { + const messages = getMessages(); + const templateContext = toSnakeCase(ctx); + const defaultMessage = `⚓ Avast! This discussion be marked as **outdated** by [{workflow_name}]({run_url}). + 🗺️ A newer treasure map awaits ye at **[Discussion #{new_discussion_number}]({new_discussion_url})**. + Fair winds, matey! 🏴‍☠️`; + return messages?.closeOlderDiscussion + ? renderTemplate(messages.closeOlderDiscussion, templateContext) + : renderTemplate(defaultMessage, templateContext); + } + const MAX_CLOSE_COUNT = 10; + const GRAPHQL_DELAY_MS = 500; + function delay(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); + } + async function searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, excludeNumber) { + let searchQuery = `repo:${owner}/${repo} is:open`; + if (titlePrefix) { + const escapedPrefix = titlePrefix.replace(/"/g, '\\"'); + searchQuery += ` in:title "${escapedPrefix}"`; + } + if (labels && labels.length > 0) { + for (const label of labels) { + const escapedLabel = label.replace(/"/g, '\\"'); + searchQuery += ` label:"${escapedLabel}"`; + } + } + const result = await github.graphql( + ` + query($searchTerms: String!, $first: Int!) { + search(query: $searchTerms, type: DISCUSSION, first: $first) { + nodes { + ... on Discussion { + id + number + title + url + category { + id + } + labels(first: 100) { + nodes { + name + } + } + closed + } + } + } + }`, + { searchTerms: searchQuery, first: 50 } + ); + if (!result || !result.search || !result.search.nodes) { + return []; + } + return result.search.nodes + .filter( + d => { + if (!d || d.number === excludeNumber || d.closed) { + return false; + } + if (titlePrefix && d.title && !d.title.startsWith(titlePrefix)) { + return false; + } + if (labels && labels.length > 0) { + const discussionLabels = d.labels?.nodes?.map(( l) => l.name) || []; + const hasAllLabels = labels.every(label => discussionLabels.includes(label)); + if (!hasAllLabels) { + return false; + } + } + if (categoryId && (!d.category || d.category.id !== categoryId)) { + return false; + } + return true; + } + ) + .map( + d => ({ + id: d.id, + number: d.number, + title: d.title, + url: d.url, + }) + ); + } + async function addDiscussionComment(github, discussionId, message) { + const result = await github.graphql( + ` + mutation($dId: ID!, $body: String!) { + addDiscussionComment(input: { discussionId: $dId, body: $body }) { + comment { + id + url + } + } + }`, + { dId: discussionId, body: message } + ); + return result.addDiscussionComment.comment; + } + async function closeDiscussionAsOutdated(github, discussionId) { + const result = await github.graphql( + ` + mutation($dId: ID!) { + closeDiscussion(input: { discussionId: $dId, reason: OUTDATED }) { + discussion { + id + url + } + } + }`, + { dId: discussionId } + ); + return result.closeDiscussion.discussion; + } + async function closeOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion, workflowName, runUrl) { + const searchCriteria = []; + if (titlePrefix) searchCriteria.push(`title prefix: "${titlePrefix}"`); + if (labels && labels.length > 0) searchCriteria.push(`labels: [${labels.join(", ")}]`); + core.info(`Searching for older discussions with ${searchCriteria.join(" and ")}`); + const olderDiscussions = await searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion.number); + if (olderDiscussions.length === 0) { + core.info("No older discussions found to close"); + return []; + } + core.info(`Found ${olderDiscussions.length} older discussion(s) to close`); + const discussionsToClose = olderDiscussions.slice(0, MAX_CLOSE_COUNT); + if (olderDiscussions.length > MAX_CLOSE_COUNT) { + core.warning(`Found ${olderDiscussions.length} older discussions, but only closing the first ${MAX_CLOSE_COUNT}`); + } + const closedDiscussions = []; + for (let i = 0; i < discussionsToClose.length; i++) { + const discussion = discussionsToClose[i]; + try { + const closingMessage = getCloseOlderDiscussionMessage({ + newDiscussionUrl: newDiscussion.url, + newDiscussionNumber: newDiscussion.number, + workflowName, + runUrl, + }); + core.info(`Adding closing comment to discussion #${discussion.number}`); + await addDiscussionComment(github, discussion.id, closingMessage); + core.info(`Closing discussion #${discussion.number} as outdated`); + await closeDiscussionAsOutdated(github, discussion.id); + closedDiscussions.push({ + number: discussion.number, + url: discussion.url, + }); + core.info(`✓ Closed discussion #${discussion.number}: ${discussion.url}`); + } catch (error) { + core.error(`✗ Failed to close discussion #${discussion.number}: ${error instanceof Error ? error.message : String(error)}`); + } + if (i < discussionsToClose.length - 1) { + await delay(GRAPHQL_DELAY_MS); + } + } + return closedDiscussions; + } + const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; + function generateTemporaryId() { + return "aw_" + crypto.randomBytes(6).toString("hex"); + } + function isTemporaryId(value) { + if (typeof value === "string") { + return /^aw_[0-9a-f]{12}$/i.test(value); + } + return false; + } + function normalizeTemporaryId(tempId) { + return String(tempId).toLowerCase(); + } + function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { + return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { + const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); + if (resolved !== undefined) { + if (currentRepo && resolved.repo === currentRepo) { + return `#${resolved.number}`; + } + return `${resolved.repo}#${resolved.number}`; + } + return match; + }); + } + function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { + return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { + const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); + if (issueNumber !== undefined) { + return `#${issueNumber}`; + } + return match; + }); + } + function loadTemporaryIdMap() { + const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; + if (!mapJson || mapJson === "{}") { + return new Map(); + } + try { + const mapObject = JSON.parse(mapJson); + const result = new Map(); + for (const [key, value] of Object.entries(mapObject)) { + const normalizedKey = normalizeTemporaryId(key); + if (typeof value === "number") { + const contextRepo = `${context.repo.owner}/${context.repo.repo}`; + result.set(normalizedKey, { repo: contextRepo, number: value }); + } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { + result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); + } + } + return result; + } catch (error) { + if (typeof core !== "undefined") { + core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); + } + return new Map(); + } + } + function resolveIssueNumber(value, temporaryIdMap) { + if (value === undefined || value === null) { + return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; + } + const valueStr = String(value); + if (isTemporaryId(valueStr)) { + const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); + if (resolvedPair !== undefined) { + return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; + } + return { + resolved: null, + wasTemporaryId: true, + errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, + }; + } + const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); + if (isNaN(issueNumber) || issueNumber <= 0) { + return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; + } + const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; + return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; + } + function serializeTemporaryIdMap(tempIdMap) { + const obj = Object.fromEntries(tempIdMap); + return JSON.stringify(obj); + } + function parseAllowedRepos() { + const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; + const set = new Set(); + if (allowedReposEnv) { + allowedReposEnv + .split(",") + .map(repo => repo.trim()) + .filter(repo => repo) + .forEach(repo => set.add(repo)); + } + return set; + } + function getDefaultTargetRepo() { + const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; + if (targetRepoSlug) { + return targetRepoSlug; + } + return `${context.repo.owner}/${context.repo.repo}`; + } + function validateRepo(repo, defaultRepo, allowedRepos) { + if (repo === defaultRepo) { + return { valid: true, error: null }; + } + if (allowedRepos.has(repo)) { + return { valid: true, error: null }; + } + return { + valid: false, + error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, + }; + } + function parseRepoSlug(repoSlug) { + const parts = repoSlug.split("/"); + if (parts.length !== 2 || !parts[0] || !parts[1]) { + return null; + } + return { owner: parts[0], repo: parts[1] }; + } + function addExpirationComment(bodyLines, envVarName, entityType) { + const expiresEnv = process.env[envVarName]; + if (expiresEnv) { + const expiresDays = parseInt(expiresEnv, 10); + if (!isNaN(expiresDays) && expiresDays > 0) { + const expirationDate = new Date(); + expirationDate.setDate(expirationDate.getDate() + expiresDays); + const expirationISO = expirationDate.toISOString(); + bodyLines.push(``); + core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); + } + } + } + function removeDuplicateTitleFromDescription(title, description) { + if (!title || typeof title !== "string") { + return description || ""; + } + if (!description || typeof description !== "string") { + return ""; + } + const trimmedTitle = title.trim(); + const trimmedDescription = description.trim(); + if (!trimmedTitle || !trimmedDescription) { + return trimmedDescription; + } + const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); + if (headerRegex.test(trimmedDescription)) { + return trimmedDescription.replace(headerRegex, "").trim(); + } + return trimmedDescription; + } async function fetchRepoDiscussionInfo(owner, repo) { const repositoryQuery = ` query($owner: String!, $repo: String!) { diff --git a/.github/workflows/github-mcp-tools-report.lock.yml b/.github/workflows/github-mcp-tools-report.lock.yml index 036afba235a..a20e7ad7905 100644 --- a/.github/workflows/github-mcp-tools-report.lock.yml +++ b/.github/workflows/github-mcp-tools-report.lock.yml @@ -6594,13 +6594,400 @@ jobs: with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - const { loadAgentOutput } = require("./load_agent_output.cjs"); - const { getTrackerID } = require("./get_tracker_id.cjs"); - const { closeOlderDiscussions } = require("./close_older_discussions.cjs"); - const { replaceTemporaryIdReferences, loadTemporaryIdMap } = require("./temporary_id.cjs"); - const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require("./repo_helpers.cjs"); - const { addExpirationComment } = require("./expiration_helpers.cjs"); - const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); + const fs = require("fs"); + const crypto = require("crypto"); + const MAX_LOG_CONTENT_LENGTH = 10000; + function truncateForLogging(content) { + if (content.length <= MAX_LOG_CONTENT_LENGTH) { + return content; + } + return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; + } + function loadAgentOutput() { + const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; + if (!agentOutputFile) { + core.info("No GH_AW_AGENT_OUTPUT environment variable found"); + return { success: false }; + } + let outputContent; + try { + outputContent = fs.readFileSync(agentOutputFile, "utf8"); + } catch (error) { + const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; + core.error(errorMessage); + return { success: false, error: errorMessage }; + } + if (outputContent.trim() === "") { + core.info("Agent output content is empty"); + return { success: false }; + } + core.info(`Agent output content length: ${outputContent.length}`); + let validatedOutput; + try { + validatedOutput = JSON.parse(outputContent); + } catch (error) { + const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; + core.error(errorMessage); + core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); + return { success: false, error: errorMessage }; + } + if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { + core.info("No valid items found in agent output"); + core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); + return { success: false }; + } + return { success: true, items: validatedOutput.items }; + } + function getTrackerID(format) { + const trackerID = process.env.GH_AW_TRACKER_ID || ""; + if (trackerID) { + core.info(`Tracker ID: ${trackerID}`); + return format === "markdown" ? `\n\n` : trackerID; + } + return ""; + } + function getMessages() { + const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES; + if (!messagesEnv) { + return null; + } + try { + return JSON.parse(messagesEnv); + } catch (error) { + core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`); + return null; + } + } + function renderTemplate(template, context) { + return template.replace(/\{(\w+)\}/g, (match, key) => { + const value = context[key]; + return value !== undefined && value !== null ? String(value) : match; + }); + } + function toSnakeCase(obj) { + const result = {}; + for (const [key, value] of Object.entries(obj)) { + const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase(); + result[snakeKey] = value; + result[key] = value; + } + return result; + } + function getCloseOlderDiscussionMessage(ctx) { + const messages = getMessages(); + const templateContext = toSnakeCase(ctx); + const defaultMessage = `⚓ Avast! This discussion be marked as **outdated** by [{workflow_name}]({run_url}). + 🗺️ A newer treasure map awaits ye at **[Discussion #{new_discussion_number}]({new_discussion_url})**. + Fair winds, matey! 🏴‍☠️`; + return messages?.closeOlderDiscussion + ? renderTemplate(messages.closeOlderDiscussion, templateContext) + : renderTemplate(defaultMessage, templateContext); + } + const MAX_CLOSE_COUNT = 10; + const GRAPHQL_DELAY_MS = 500; + function delay(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); + } + async function searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, excludeNumber) { + let searchQuery = `repo:${owner}/${repo} is:open`; + if (titlePrefix) { + const escapedPrefix = titlePrefix.replace(/"/g, '\\"'); + searchQuery += ` in:title "${escapedPrefix}"`; + } + if (labels && labels.length > 0) { + for (const label of labels) { + const escapedLabel = label.replace(/"/g, '\\"'); + searchQuery += ` label:"${escapedLabel}"`; + } + } + const result = await github.graphql( + ` + query($searchTerms: String!, $first: Int!) { + search(query: $searchTerms, type: DISCUSSION, first: $first) { + nodes { + ... on Discussion { + id + number + title + url + category { + id + } + labels(first: 100) { + nodes { + name + } + } + closed + } + } + } + }`, + { searchTerms: searchQuery, first: 50 } + ); + if (!result || !result.search || !result.search.nodes) { + return []; + } + return result.search.nodes + .filter( + d => { + if (!d || d.number === excludeNumber || d.closed) { + return false; + } + if (titlePrefix && d.title && !d.title.startsWith(titlePrefix)) { + return false; + } + if (labels && labels.length > 0) { + const discussionLabels = d.labels?.nodes?.map(( l) => l.name) || []; + const hasAllLabels = labels.every(label => discussionLabels.includes(label)); + if (!hasAllLabels) { + return false; + } + } + if (categoryId && (!d.category || d.category.id !== categoryId)) { + return false; + } + return true; + } + ) + .map( + d => ({ + id: d.id, + number: d.number, + title: d.title, + url: d.url, + }) + ); + } + async function addDiscussionComment(github, discussionId, message) { + const result = await github.graphql( + ` + mutation($dId: ID!, $body: String!) { + addDiscussionComment(input: { discussionId: $dId, body: $body }) { + comment { + id + url + } + } + }`, + { dId: discussionId, body: message } + ); + return result.addDiscussionComment.comment; + } + async function closeDiscussionAsOutdated(github, discussionId) { + const result = await github.graphql( + ` + mutation($dId: ID!) { + closeDiscussion(input: { discussionId: $dId, reason: OUTDATED }) { + discussion { + id + url + } + } + }`, + { dId: discussionId } + ); + return result.closeDiscussion.discussion; + } + async function closeOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion, workflowName, runUrl) { + const searchCriteria = []; + if (titlePrefix) searchCriteria.push(`title prefix: "${titlePrefix}"`); + if (labels && labels.length > 0) searchCriteria.push(`labels: [${labels.join(", ")}]`); + core.info(`Searching for older discussions with ${searchCriteria.join(" and ")}`); + const olderDiscussions = await searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion.number); + if (olderDiscussions.length === 0) { + core.info("No older discussions found to close"); + return []; + } + core.info(`Found ${olderDiscussions.length} older discussion(s) to close`); + const discussionsToClose = olderDiscussions.slice(0, MAX_CLOSE_COUNT); + if (olderDiscussions.length > MAX_CLOSE_COUNT) { + core.warning(`Found ${olderDiscussions.length} older discussions, but only closing the first ${MAX_CLOSE_COUNT}`); + } + const closedDiscussions = []; + for (let i = 0; i < discussionsToClose.length; i++) { + const discussion = discussionsToClose[i]; + try { + const closingMessage = getCloseOlderDiscussionMessage({ + newDiscussionUrl: newDiscussion.url, + newDiscussionNumber: newDiscussion.number, + workflowName, + runUrl, + }); + core.info(`Adding closing comment to discussion #${discussion.number}`); + await addDiscussionComment(github, discussion.id, closingMessage); + core.info(`Closing discussion #${discussion.number} as outdated`); + await closeDiscussionAsOutdated(github, discussion.id); + closedDiscussions.push({ + number: discussion.number, + url: discussion.url, + }); + core.info(`✓ Closed discussion #${discussion.number}: ${discussion.url}`); + } catch (error) { + core.error(`✗ Failed to close discussion #${discussion.number}: ${error instanceof Error ? error.message : String(error)}`); + } + if (i < discussionsToClose.length - 1) { + await delay(GRAPHQL_DELAY_MS); + } + } + return closedDiscussions; + } + const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; + function generateTemporaryId() { + return "aw_" + crypto.randomBytes(6).toString("hex"); + } + function isTemporaryId(value) { + if (typeof value === "string") { + return /^aw_[0-9a-f]{12}$/i.test(value); + } + return false; + } + function normalizeTemporaryId(tempId) { + return String(tempId).toLowerCase(); + } + function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { + return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { + const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); + if (resolved !== undefined) { + if (currentRepo && resolved.repo === currentRepo) { + return `#${resolved.number}`; + } + return `${resolved.repo}#${resolved.number}`; + } + return match; + }); + } + function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { + return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { + const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); + if (issueNumber !== undefined) { + return `#${issueNumber}`; + } + return match; + }); + } + function loadTemporaryIdMap() { + const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; + if (!mapJson || mapJson === "{}") { + return new Map(); + } + try { + const mapObject = JSON.parse(mapJson); + const result = new Map(); + for (const [key, value] of Object.entries(mapObject)) { + const normalizedKey = normalizeTemporaryId(key); + if (typeof value === "number") { + const contextRepo = `${context.repo.owner}/${context.repo.repo}`; + result.set(normalizedKey, { repo: contextRepo, number: value }); + } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { + result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); + } + } + return result; + } catch (error) { + if (typeof core !== "undefined") { + core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); + } + return new Map(); + } + } + function resolveIssueNumber(value, temporaryIdMap) { + if (value === undefined || value === null) { + return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; + } + const valueStr = String(value); + if (isTemporaryId(valueStr)) { + const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); + if (resolvedPair !== undefined) { + return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; + } + return { + resolved: null, + wasTemporaryId: true, + errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, + }; + } + const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); + if (isNaN(issueNumber) || issueNumber <= 0) { + return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; + } + const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; + return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; + } + function serializeTemporaryIdMap(tempIdMap) { + const obj = Object.fromEntries(tempIdMap); + return JSON.stringify(obj); + } + function parseAllowedRepos() { + const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; + const set = new Set(); + if (allowedReposEnv) { + allowedReposEnv + .split(",") + .map(repo => repo.trim()) + .filter(repo => repo) + .forEach(repo => set.add(repo)); + } + return set; + } + function getDefaultTargetRepo() { + const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; + if (targetRepoSlug) { + return targetRepoSlug; + } + return `${context.repo.owner}/${context.repo.repo}`; + } + function validateRepo(repo, defaultRepo, allowedRepos) { + if (repo === defaultRepo) { + return { valid: true, error: null }; + } + if (allowedRepos.has(repo)) { + return { valid: true, error: null }; + } + return { + valid: false, + error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, + }; + } + function parseRepoSlug(repoSlug) { + const parts = repoSlug.split("/"); + if (parts.length !== 2 || !parts[0] || !parts[1]) { + return null; + } + return { owner: parts[0], repo: parts[1] }; + } + function addExpirationComment(bodyLines, envVarName, entityType) { + const expiresEnv = process.env[envVarName]; + if (expiresEnv) { + const expiresDays = parseInt(expiresEnv, 10); + if (!isNaN(expiresDays) && expiresDays > 0) { + const expirationDate = new Date(); + expirationDate.setDate(expirationDate.getDate() + expiresDays); + const expirationISO = expirationDate.toISOString(); + bodyLines.push(``); + core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); + } + } + } + function removeDuplicateTitleFromDescription(title, description) { + if (!title || typeof title !== "string") { + return description || ""; + } + if (!description || typeof description !== "string") { + return ""; + } + const trimmedTitle = title.trim(); + const trimmedDescription = description.trim(); + if (!trimmedTitle || !trimmedDescription) { + return trimmedDescription; + } + const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); + if (headerRegex.test(trimmedDescription)) { + return trimmedDescription.replace(headerRegex, "").trim(); + } + return trimmedDescription; + } async function fetchRepoDiscussionInfo(owner, repo) { const repositoryQuery = ` query($owner: String!, $repo: String!) { @@ -6962,10 +7349,151 @@ jobs: script: | const fs = require("fs"); const crypto = require("crypto"); - const { updateActivationComment } = require("./update_activation_comment.cjs"); - const { getTrackerID } = require("./get_tracker_id.cjs"); - const { addExpirationComment } = require("./expiration_helpers.cjs"); - const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); + async function updateActivationComment(github, context, core, itemUrl, itemNumber, itemType = "pull_request") { + const itemLabel = itemType === "issue" ? "issue" : "pull request"; + const linkMessage = + itemType === "issue" + ? `\n\n✅ Issue created: [#${itemNumber}](${itemUrl})` + : `\n\n✅ Pull request created: [#${itemNumber}](${itemUrl})`; + await updateActivationCommentWithMessage(github, context, core, linkMessage, itemLabel); + } + async function updateActivationCommentWithCommit(github, context, core, commitSha, commitUrl) { + const shortSha = commitSha.substring(0, 7); + const message = `\n\n✅ Commit pushed: [\`${shortSha}\`](${commitUrl})`; + await updateActivationCommentWithMessage(github, context, core, message, "commit"); + } + async function updateActivationCommentWithMessage(github, context, core, message, label = "") { + const commentId = process.env.GH_AW_COMMENT_ID; + const commentRepo = process.env.GH_AW_COMMENT_REPO; + if (!commentId) { + core.info("No activation comment to update (GH_AW_COMMENT_ID not set)"); + return; + } + core.info(`Updating activation comment ${commentId}`); + let repoOwner = context.repo.owner; + let repoName = context.repo.repo; + if (commentRepo) { + const parts = commentRepo.split("/"); + if (parts.length === 2) { + repoOwner = parts[0]; + repoName = parts[1]; + } else { + core.warning(`Invalid comment repo format: ${commentRepo}, expected "owner/repo". Falling back to context.repo.`); + } + } + core.info(`Updating comment in ${repoOwner}/${repoName}`); + const isDiscussionComment = commentId.startsWith("DC_"); + try { + if (isDiscussionComment) { + const currentComment = await github.graphql( + ` + query($commentId: ID!) { + node(id: $commentId) { + ... on DiscussionComment { + body + } + } + }`, + { commentId: commentId } + ); + if (!currentComment?.node?.body) { + core.warning("Unable to fetch current comment body, comment may have been deleted or is inaccessible"); + return; + } + const currentBody = currentComment.node.body; + const updatedBody = currentBody + message; + const result = await github.graphql( + ` + mutation($commentId: ID!, $body: String!) { + updateDiscussionComment(input: { commentId: $commentId, body: $body }) { + comment { + id + url + } + } + }`, + { commentId: commentId, body: updatedBody } + ); + const comment = result.updateDiscussionComment.comment; + const successMessage = label + ? `Successfully updated discussion comment with ${label} link` + : "Successfully updated discussion comment"; + core.info(successMessage); + core.info(`Comment ID: ${comment.id}`); + core.info(`Comment URL: ${comment.url}`); + } else { + const currentComment = await github.request("GET /repos/{owner}/{repo}/issues/comments/{comment_id}", { + owner: repoOwner, + repo: repoName, + comment_id: parseInt(commentId, 10), + headers: { + Accept: "application/vnd.github+json", + }, + }); + if (!currentComment?.data?.body) { + core.warning("Unable to fetch current comment body, comment may have been deleted"); + return; + } + const currentBody = currentComment.data.body; + const updatedBody = currentBody + message; + const response = await github.request("PATCH /repos/{owner}/{repo}/issues/comments/{comment_id}", { + owner: repoOwner, + repo: repoName, + comment_id: parseInt(commentId, 10), + body: updatedBody, + headers: { + Accept: "application/vnd.github+json", + }, + }); + const successMessage = label ? `Successfully updated comment with ${label} link` : "Successfully updated comment"; + core.info(successMessage); + core.info(`Comment ID: ${response.data.id}`); + core.info(`Comment URL: ${response.data.html_url}`); + } + } catch (error) { + core.warning(`Failed to update activation comment: ${error instanceof Error ? error.message : String(error)}`); + } + } + function getTrackerID(format) { + const trackerID = process.env.GH_AW_TRACKER_ID || ""; + if (trackerID) { + core.info(`Tracker ID: ${trackerID}`); + return format === "markdown" ? `\n\n` : trackerID; + } + return ""; + } + function addExpirationComment(bodyLines, envVarName, entityType) { + const expiresEnv = process.env[envVarName]; + if (expiresEnv) { + const expiresDays = parseInt(expiresEnv, 10); + if (!isNaN(expiresDays) && expiresDays > 0) { + const expirationDate = new Date(); + expirationDate.setDate(expirationDate.getDate() + expiresDays); + const expirationISO = expirationDate.toISOString(); + bodyLines.push(``); + core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); + } + } + } + function removeDuplicateTitleFromDescription(title, description) { + if (!title || typeof title !== "string") { + return description || ""; + } + if (!description || typeof description !== "string") { + return ""; + } + const trimmedTitle = title.trim(); + const trimmedDescription = description.trim(); + if (!trimmedTitle || !trimmedDescription) { + return trimmedDescription; + } + const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); + if (headerRegex.test(trimmedDescription)) { + return trimmedDescription.replace(headerRegex, "").trim(); + } + return trimmedDescription; + } function generatePatchPreview(patchContent) { if (!patchContent || !patchContent.trim()) { return ""; diff --git a/.github/workflows/glossary-maintainer.lock.yml b/.github/workflows/glossary-maintainer.lock.yml index 799ca5e091d..7e0f75d5079 100644 --- a/.github/workflows/glossary-maintainer.lock.yml +++ b/.github/workflows/glossary-maintainer.lock.yml @@ -7312,10 +7312,151 @@ jobs: script: | const fs = require("fs"); const crypto = require("crypto"); - const { updateActivationComment } = require("./update_activation_comment.cjs"); - const { getTrackerID } = require("./get_tracker_id.cjs"); - const { addExpirationComment } = require("./expiration_helpers.cjs"); - const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); + async function updateActivationComment(github, context, core, itemUrl, itemNumber, itemType = "pull_request") { + const itemLabel = itemType === "issue" ? "issue" : "pull request"; + const linkMessage = + itemType === "issue" + ? `\n\n✅ Issue created: [#${itemNumber}](${itemUrl})` + : `\n\n✅ Pull request created: [#${itemNumber}](${itemUrl})`; + await updateActivationCommentWithMessage(github, context, core, linkMessage, itemLabel); + } + async function updateActivationCommentWithCommit(github, context, core, commitSha, commitUrl) { + const shortSha = commitSha.substring(0, 7); + const message = `\n\n✅ Commit pushed: [\`${shortSha}\`](${commitUrl})`; + await updateActivationCommentWithMessage(github, context, core, message, "commit"); + } + async function updateActivationCommentWithMessage(github, context, core, message, label = "") { + const commentId = process.env.GH_AW_COMMENT_ID; + const commentRepo = process.env.GH_AW_COMMENT_REPO; + if (!commentId) { + core.info("No activation comment to update (GH_AW_COMMENT_ID not set)"); + return; + } + core.info(`Updating activation comment ${commentId}`); + let repoOwner = context.repo.owner; + let repoName = context.repo.repo; + if (commentRepo) { + const parts = commentRepo.split("/"); + if (parts.length === 2) { + repoOwner = parts[0]; + repoName = parts[1]; + } else { + core.warning(`Invalid comment repo format: ${commentRepo}, expected "owner/repo". Falling back to context.repo.`); + } + } + core.info(`Updating comment in ${repoOwner}/${repoName}`); + const isDiscussionComment = commentId.startsWith("DC_"); + try { + if (isDiscussionComment) { + const currentComment = await github.graphql( + ` + query($commentId: ID!) { + node(id: $commentId) { + ... on DiscussionComment { + body + } + } + }`, + { commentId: commentId } + ); + if (!currentComment?.node?.body) { + core.warning("Unable to fetch current comment body, comment may have been deleted or is inaccessible"); + return; + } + const currentBody = currentComment.node.body; + const updatedBody = currentBody + message; + const result = await github.graphql( + ` + mutation($commentId: ID!, $body: String!) { + updateDiscussionComment(input: { commentId: $commentId, body: $body }) { + comment { + id + url + } + } + }`, + { commentId: commentId, body: updatedBody } + ); + const comment = result.updateDiscussionComment.comment; + const successMessage = label + ? `Successfully updated discussion comment with ${label} link` + : "Successfully updated discussion comment"; + core.info(successMessage); + core.info(`Comment ID: ${comment.id}`); + core.info(`Comment URL: ${comment.url}`); + } else { + const currentComment = await github.request("GET /repos/{owner}/{repo}/issues/comments/{comment_id}", { + owner: repoOwner, + repo: repoName, + comment_id: parseInt(commentId, 10), + headers: { + Accept: "application/vnd.github+json", + }, + }); + if (!currentComment?.data?.body) { + core.warning("Unable to fetch current comment body, comment may have been deleted"); + return; + } + const currentBody = currentComment.data.body; + const updatedBody = currentBody + message; + const response = await github.request("PATCH /repos/{owner}/{repo}/issues/comments/{comment_id}", { + owner: repoOwner, + repo: repoName, + comment_id: parseInt(commentId, 10), + body: updatedBody, + headers: { + Accept: "application/vnd.github+json", + }, + }); + const successMessage = label ? `Successfully updated comment with ${label} link` : "Successfully updated comment"; + core.info(successMessage); + core.info(`Comment ID: ${response.data.id}`); + core.info(`Comment URL: ${response.data.html_url}`); + } + } catch (error) { + core.warning(`Failed to update activation comment: ${error instanceof Error ? error.message : String(error)}`); + } + } + function getTrackerID(format) { + const trackerID = process.env.GH_AW_TRACKER_ID || ""; + if (trackerID) { + core.info(`Tracker ID: ${trackerID}`); + return format === "markdown" ? `\n\n` : trackerID; + } + return ""; + } + function addExpirationComment(bodyLines, envVarName, entityType) { + const expiresEnv = process.env[envVarName]; + if (expiresEnv) { + const expiresDays = parseInt(expiresEnv, 10); + if (!isNaN(expiresDays) && expiresDays > 0) { + const expirationDate = new Date(); + expirationDate.setDate(expirationDate.getDate() + expiresDays); + const expirationISO = expirationDate.toISOString(); + bodyLines.push(``); + core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); + } + } + } + function removeDuplicateTitleFromDescription(title, description) { + if (!title || typeof title !== "string") { + return description || ""; + } + if (!description || typeof description !== "string") { + return ""; + } + const trimmedTitle = title.trim(); + const trimmedDescription = description.trim(); + if (!trimmedTitle || !trimmedDescription) { + return trimmedDescription; + } + const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); + if (headerRegex.test(trimmedDescription)) { + return trimmedDescription.replace(headerRegex, "").trim(); + } + return trimmedDescription; + } function generatePatchPreview(patchContent) { if (!patchContent || !patchContent.trim()) { return ""; diff --git a/.github/workflows/go-fan.lock.yml b/.github/workflows/go-fan.lock.yml index 622d270d3a2..3f5f3880f37 100644 --- a/.github/workflows/go-fan.lock.yml +++ b/.github/workflows/go-fan.lock.yml @@ -6162,13 +6162,400 @@ jobs: with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - const { loadAgentOutput } = require("./load_agent_output.cjs"); - const { getTrackerID } = require("./get_tracker_id.cjs"); - const { closeOlderDiscussions } = require("./close_older_discussions.cjs"); - const { replaceTemporaryIdReferences, loadTemporaryIdMap } = require("./temporary_id.cjs"); - const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require("./repo_helpers.cjs"); - const { addExpirationComment } = require("./expiration_helpers.cjs"); - const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); + const fs = require("fs"); + const crypto = require("crypto"); + const MAX_LOG_CONTENT_LENGTH = 10000; + function truncateForLogging(content) { + if (content.length <= MAX_LOG_CONTENT_LENGTH) { + return content; + } + return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; + } + function loadAgentOutput() { + const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; + if (!agentOutputFile) { + core.info("No GH_AW_AGENT_OUTPUT environment variable found"); + return { success: false }; + } + let outputContent; + try { + outputContent = fs.readFileSync(agentOutputFile, "utf8"); + } catch (error) { + const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; + core.error(errorMessage); + return { success: false, error: errorMessage }; + } + if (outputContent.trim() === "") { + core.info("Agent output content is empty"); + return { success: false }; + } + core.info(`Agent output content length: ${outputContent.length}`); + let validatedOutput; + try { + validatedOutput = JSON.parse(outputContent); + } catch (error) { + const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; + core.error(errorMessage); + core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); + return { success: false, error: errorMessage }; + } + if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { + core.info("No valid items found in agent output"); + core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); + return { success: false }; + } + return { success: true, items: validatedOutput.items }; + } + function getTrackerID(format) { + const trackerID = process.env.GH_AW_TRACKER_ID || ""; + if (trackerID) { + core.info(`Tracker ID: ${trackerID}`); + return format === "markdown" ? `\n\n` : trackerID; + } + return ""; + } + function getMessages() { + const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES; + if (!messagesEnv) { + return null; + } + try { + return JSON.parse(messagesEnv); + } catch (error) { + core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`); + return null; + } + } + function renderTemplate(template, context) { + return template.replace(/\{(\w+)\}/g, (match, key) => { + const value = context[key]; + return value !== undefined && value !== null ? String(value) : match; + }); + } + function toSnakeCase(obj) { + const result = {}; + for (const [key, value] of Object.entries(obj)) { + const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase(); + result[snakeKey] = value; + result[key] = value; + } + return result; + } + function getCloseOlderDiscussionMessage(ctx) { + const messages = getMessages(); + const templateContext = toSnakeCase(ctx); + const defaultMessage = `⚓ Avast! This discussion be marked as **outdated** by [{workflow_name}]({run_url}). + 🗺️ A newer treasure map awaits ye at **[Discussion #{new_discussion_number}]({new_discussion_url})**. + Fair winds, matey! 🏴‍☠️`; + return messages?.closeOlderDiscussion + ? renderTemplate(messages.closeOlderDiscussion, templateContext) + : renderTemplate(defaultMessage, templateContext); + } + const MAX_CLOSE_COUNT = 10; + const GRAPHQL_DELAY_MS = 500; + function delay(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); + } + async function searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, excludeNumber) { + let searchQuery = `repo:${owner}/${repo} is:open`; + if (titlePrefix) { + const escapedPrefix = titlePrefix.replace(/"/g, '\\"'); + searchQuery += ` in:title "${escapedPrefix}"`; + } + if (labels && labels.length > 0) { + for (const label of labels) { + const escapedLabel = label.replace(/"/g, '\\"'); + searchQuery += ` label:"${escapedLabel}"`; + } + } + const result = await github.graphql( + ` + query($searchTerms: String!, $first: Int!) { + search(query: $searchTerms, type: DISCUSSION, first: $first) { + nodes { + ... on Discussion { + id + number + title + url + category { + id + } + labels(first: 100) { + nodes { + name + } + } + closed + } + } + } + }`, + { searchTerms: searchQuery, first: 50 } + ); + if (!result || !result.search || !result.search.nodes) { + return []; + } + return result.search.nodes + .filter( + d => { + if (!d || d.number === excludeNumber || d.closed) { + return false; + } + if (titlePrefix && d.title && !d.title.startsWith(titlePrefix)) { + return false; + } + if (labels && labels.length > 0) { + const discussionLabels = d.labels?.nodes?.map(( l) => l.name) || []; + const hasAllLabels = labels.every(label => discussionLabels.includes(label)); + if (!hasAllLabels) { + return false; + } + } + if (categoryId && (!d.category || d.category.id !== categoryId)) { + return false; + } + return true; + } + ) + .map( + d => ({ + id: d.id, + number: d.number, + title: d.title, + url: d.url, + }) + ); + } + async function addDiscussionComment(github, discussionId, message) { + const result = await github.graphql( + ` + mutation($dId: ID!, $body: String!) { + addDiscussionComment(input: { discussionId: $dId, body: $body }) { + comment { + id + url + } + } + }`, + { dId: discussionId, body: message } + ); + return result.addDiscussionComment.comment; + } + async function closeDiscussionAsOutdated(github, discussionId) { + const result = await github.graphql( + ` + mutation($dId: ID!) { + closeDiscussion(input: { discussionId: $dId, reason: OUTDATED }) { + discussion { + id + url + } + } + }`, + { dId: discussionId } + ); + return result.closeDiscussion.discussion; + } + async function closeOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion, workflowName, runUrl) { + const searchCriteria = []; + if (titlePrefix) searchCriteria.push(`title prefix: "${titlePrefix}"`); + if (labels && labels.length > 0) searchCriteria.push(`labels: [${labels.join(", ")}]`); + core.info(`Searching for older discussions with ${searchCriteria.join(" and ")}`); + const olderDiscussions = await searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion.number); + if (olderDiscussions.length === 0) { + core.info("No older discussions found to close"); + return []; + } + core.info(`Found ${olderDiscussions.length} older discussion(s) to close`); + const discussionsToClose = olderDiscussions.slice(0, MAX_CLOSE_COUNT); + if (olderDiscussions.length > MAX_CLOSE_COUNT) { + core.warning(`Found ${olderDiscussions.length} older discussions, but only closing the first ${MAX_CLOSE_COUNT}`); + } + const closedDiscussions = []; + for (let i = 0; i < discussionsToClose.length; i++) { + const discussion = discussionsToClose[i]; + try { + const closingMessage = getCloseOlderDiscussionMessage({ + newDiscussionUrl: newDiscussion.url, + newDiscussionNumber: newDiscussion.number, + workflowName, + runUrl, + }); + core.info(`Adding closing comment to discussion #${discussion.number}`); + await addDiscussionComment(github, discussion.id, closingMessage); + core.info(`Closing discussion #${discussion.number} as outdated`); + await closeDiscussionAsOutdated(github, discussion.id); + closedDiscussions.push({ + number: discussion.number, + url: discussion.url, + }); + core.info(`✓ Closed discussion #${discussion.number}: ${discussion.url}`); + } catch (error) { + core.error(`✗ Failed to close discussion #${discussion.number}: ${error instanceof Error ? error.message : String(error)}`); + } + if (i < discussionsToClose.length - 1) { + await delay(GRAPHQL_DELAY_MS); + } + } + return closedDiscussions; + } + const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; + function generateTemporaryId() { + return "aw_" + crypto.randomBytes(6).toString("hex"); + } + function isTemporaryId(value) { + if (typeof value === "string") { + return /^aw_[0-9a-f]{12}$/i.test(value); + } + return false; + } + function normalizeTemporaryId(tempId) { + return String(tempId).toLowerCase(); + } + function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { + return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { + const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); + if (resolved !== undefined) { + if (currentRepo && resolved.repo === currentRepo) { + return `#${resolved.number}`; + } + return `${resolved.repo}#${resolved.number}`; + } + return match; + }); + } + function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { + return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { + const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); + if (issueNumber !== undefined) { + return `#${issueNumber}`; + } + return match; + }); + } + function loadTemporaryIdMap() { + const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; + if (!mapJson || mapJson === "{}") { + return new Map(); + } + try { + const mapObject = JSON.parse(mapJson); + const result = new Map(); + for (const [key, value] of Object.entries(mapObject)) { + const normalizedKey = normalizeTemporaryId(key); + if (typeof value === "number") { + const contextRepo = `${context.repo.owner}/${context.repo.repo}`; + result.set(normalizedKey, { repo: contextRepo, number: value }); + } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { + result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); + } + } + return result; + } catch (error) { + if (typeof core !== "undefined") { + core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); + } + return new Map(); + } + } + function resolveIssueNumber(value, temporaryIdMap) { + if (value === undefined || value === null) { + return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; + } + const valueStr = String(value); + if (isTemporaryId(valueStr)) { + const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); + if (resolvedPair !== undefined) { + return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; + } + return { + resolved: null, + wasTemporaryId: true, + errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, + }; + } + const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); + if (isNaN(issueNumber) || issueNumber <= 0) { + return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; + } + const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; + return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; + } + function serializeTemporaryIdMap(tempIdMap) { + const obj = Object.fromEntries(tempIdMap); + return JSON.stringify(obj); + } + function parseAllowedRepos() { + const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; + const set = new Set(); + if (allowedReposEnv) { + allowedReposEnv + .split(",") + .map(repo => repo.trim()) + .filter(repo => repo) + .forEach(repo => set.add(repo)); + } + return set; + } + function getDefaultTargetRepo() { + const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; + if (targetRepoSlug) { + return targetRepoSlug; + } + return `${context.repo.owner}/${context.repo.repo}`; + } + function validateRepo(repo, defaultRepo, allowedRepos) { + if (repo === defaultRepo) { + return { valid: true, error: null }; + } + if (allowedRepos.has(repo)) { + return { valid: true, error: null }; + } + return { + valid: false, + error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, + }; + } + function parseRepoSlug(repoSlug) { + const parts = repoSlug.split("/"); + if (parts.length !== 2 || !parts[0] || !parts[1]) { + return null; + } + return { owner: parts[0], repo: parts[1] }; + } + function addExpirationComment(bodyLines, envVarName, entityType) { + const expiresEnv = process.env[envVarName]; + if (expiresEnv) { + const expiresDays = parseInt(expiresEnv, 10); + if (!isNaN(expiresDays) && expiresDays > 0) { + const expirationDate = new Date(); + expirationDate.setDate(expirationDate.getDate() + expiresDays); + const expirationISO = expirationDate.toISOString(); + bodyLines.push(``); + core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); + } + } + } + function removeDuplicateTitleFromDescription(title, description) { + if (!title || typeof title !== "string") { + return description || ""; + } + if (!description || typeof description !== "string") { + return ""; + } + const trimmedTitle = title.trim(); + const trimmedDescription = description.trim(); + if (!trimmedTitle || !trimmedDescription) { + return trimmedDescription; + } + const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); + if (headerRegex.test(trimmedDescription)) { + return trimmedDescription.replace(headerRegex, "").trim(); + } + return trimmedDescription; + } async function fetchRepoDiscussionInfo(owner, repo) { const repositoryQuery = ` query($owner: String!, $repo: String!) { diff --git a/.github/workflows/go-logger.lock.yml b/.github/workflows/go-logger.lock.yml index 01971d44e3f..5dd05ac0673 100644 --- a/.github/workflows/go-logger.lock.yml +++ b/.github/workflows/go-logger.lock.yml @@ -5933,10 +5933,151 @@ jobs: script: | const fs = require("fs"); const crypto = require("crypto"); - const { updateActivationComment } = require("./update_activation_comment.cjs"); - const { getTrackerID } = require("./get_tracker_id.cjs"); - const { addExpirationComment } = require("./expiration_helpers.cjs"); - const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); + async function updateActivationComment(github, context, core, itemUrl, itemNumber, itemType = "pull_request") { + const itemLabel = itemType === "issue" ? "issue" : "pull request"; + const linkMessage = + itemType === "issue" + ? `\n\n✅ Issue created: [#${itemNumber}](${itemUrl})` + : `\n\n✅ Pull request created: [#${itemNumber}](${itemUrl})`; + await updateActivationCommentWithMessage(github, context, core, linkMessage, itemLabel); + } + async function updateActivationCommentWithCommit(github, context, core, commitSha, commitUrl) { + const shortSha = commitSha.substring(0, 7); + const message = `\n\n✅ Commit pushed: [\`${shortSha}\`](${commitUrl})`; + await updateActivationCommentWithMessage(github, context, core, message, "commit"); + } + async function updateActivationCommentWithMessage(github, context, core, message, label = "") { + const commentId = process.env.GH_AW_COMMENT_ID; + const commentRepo = process.env.GH_AW_COMMENT_REPO; + if (!commentId) { + core.info("No activation comment to update (GH_AW_COMMENT_ID not set)"); + return; + } + core.info(`Updating activation comment ${commentId}`); + let repoOwner = context.repo.owner; + let repoName = context.repo.repo; + if (commentRepo) { + const parts = commentRepo.split("/"); + if (parts.length === 2) { + repoOwner = parts[0]; + repoName = parts[1]; + } else { + core.warning(`Invalid comment repo format: ${commentRepo}, expected "owner/repo". Falling back to context.repo.`); + } + } + core.info(`Updating comment in ${repoOwner}/${repoName}`); + const isDiscussionComment = commentId.startsWith("DC_"); + try { + if (isDiscussionComment) { + const currentComment = await github.graphql( + ` + query($commentId: ID!) { + node(id: $commentId) { + ... on DiscussionComment { + body + } + } + }`, + { commentId: commentId } + ); + if (!currentComment?.node?.body) { + core.warning("Unable to fetch current comment body, comment may have been deleted or is inaccessible"); + return; + } + const currentBody = currentComment.node.body; + const updatedBody = currentBody + message; + const result = await github.graphql( + ` + mutation($commentId: ID!, $body: String!) { + updateDiscussionComment(input: { commentId: $commentId, body: $body }) { + comment { + id + url + } + } + }`, + { commentId: commentId, body: updatedBody } + ); + const comment = result.updateDiscussionComment.comment; + const successMessage = label + ? `Successfully updated discussion comment with ${label} link` + : "Successfully updated discussion comment"; + core.info(successMessage); + core.info(`Comment ID: ${comment.id}`); + core.info(`Comment URL: ${comment.url}`); + } else { + const currentComment = await github.request("GET /repos/{owner}/{repo}/issues/comments/{comment_id}", { + owner: repoOwner, + repo: repoName, + comment_id: parseInt(commentId, 10), + headers: { + Accept: "application/vnd.github+json", + }, + }); + if (!currentComment?.data?.body) { + core.warning("Unable to fetch current comment body, comment may have been deleted"); + return; + } + const currentBody = currentComment.data.body; + const updatedBody = currentBody + message; + const response = await github.request("PATCH /repos/{owner}/{repo}/issues/comments/{comment_id}", { + owner: repoOwner, + repo: repoName, + comment_id: parseInt(commentId, 10), + body: updatedBody, + headers: { + Accept: "application/vnd.github+json", + }, + }); + const successMessage = label ? `Successfully updated comment with ${label} link` : "Successfully updated comment"; + core.info(successMessage); + core.info(`Comment ID: ${response.data.id}`); + core.info(`Comment URL: ${response.data.html_url}`); + } + } catch (error) { + core.warning(`Failed to update activation comment: ${error instanceof Error ? error.message : String(error)}`); + } + } + function getTrackerID(format) { + const trackerID = process.env.GH_AW_TRACKER_ID || ""; + if (trackerID) { + core.info(`Tracker ID: ${trackerID}`); + return format === "markdown" ? `\n\n` : trackerID; + } + return ""; + } + function addExpirationComment(bodyLines, envVarName, entityType) { + const expiresEnv = process.env[envVarName]; + if (expiresEnv) { + const expiresDays = parseInt(expiresEnv, 10); + if (!isNaN(expiresDays) && expiresDays > 0) { + const expirationDate = new Date(); + expirationDate.setDate(expirationDate.getDate() + expiresDays); + const expirationISO = expirationDate.toISOString(); + bodyLines.push(``); + core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); + } + } + } + function removeDuplicateTitleFromDescription(title, description) { + if (!title || typeof title !== "string") { + return description || ""; + } + if (!description || typeof description !== "string") { + return ""; + } + const trimmedTitle = title.trim(); + const trimmedDescription = description.trim(); + if (!trimmedTitle || !trimmedDescription) { + return trimmedDescription; + } + const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); + if (headerRegex.test(trimmedDescription)) { + return trimmedDescription.replace(headerRegex, "").trim(); + } + return trimmedDescription; + } function generatePatchPreview(patchContent) { if (!patchContent || !patchContent.trim()) { return ""; diff --git a/.github/workflows/go-pattern-detector.lock.yml b/.github/workflows/go-pattern-detector.lock.yml index 43448681713..9d016fcabbc 100644 --- a/.github/workflows/go-pattern-detector.lock.yml +++ b/.github/workflows/go-pattern-detector.lock.yml @@ -5669,21 +5669,291 @@ jobs: with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - const { sanitizeLabelContent } = require("./sanitize_label_content.cjs"); - const { loadAgentOutput } = require("./load_agent_output.cjs"); - const { generateStagedPreview } = require("./staged_preview.cjs"); - const { generateFooter } = require("./generate_footer.cjs"); - const { getTrackerID } = require("./get_tracker_id.cjs"); - const { - generateTemporaryId, - isTemporaryId, - normalizeTemporaryId, - replaceTemporaryIdReferences, - serializeTemporaryIdMap, - } = require("./temporary_id.cjs"); - const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require("./repo_helpers.cjs"); - const { addExpirationComment } = require("./expiration_helpers.cjs"); - const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); + function sanitizeLabelContent(content) { + if (!content || typeof content !== "string") { + return ""; + } + let sanitized = content.trim(); + sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); + sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); + sanitized = sanitized.replace( + /(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, + (_m, p1, p2) => `${p1}\`@${p2}\`` + ); + sanitized = sanitized.replace(/[<>&'"]/g, ""); + return sanitized.trim(); + } + const fs = require("fs"); + const crypto = require("crypto"); + const MAX_LOG_CONTENT_LENGTH = 10000; + function truncateForLogging(content) { + if (content.length <= MAX_LOG_CONTENT_LENGTH) { + return content; + } + return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; + } + function loadAgentOutput() { + const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; + if (!agentOutputFile) { + core.info("No GH_AW_AGENT_OUTPUT environment variable found"); + return { success: false }; + } + let outputContent; + try { + outputContent = fs.readFileSync(agentOutputFile, "utf8"); + } catch (error) { + const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; + core.error(errorMessage); + return { success: false, error: errorMessage }; + } + if (outputContent.trim() === "") { + core.info("Agent output content is empty"); + return { success: false }; + } + core.info(`Agent output content length: ${outputContent.length}`); + let validatedOutput; + try { + validatedOutput = JSON.parse(outputContent); + } catch (error) { + const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; + core.error(errorMessage); + core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); + return { success: false, error: errorMessage }; + } + if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { + core.info("No valid items found in agent output"); + core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); + return { success: false }; + } + return { success: true, items: validatedOutput.items }; + } + async function generateStagedPreview(options) { + const { title, description, items, renderItem } = options; + let summaryContent = `## 🎭 Staged Mode: ${title} Preview\n\n`; + summaryContent += `${description}\n\n`; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + summaryContent += renderItem(item, i); + summaryContent += "---\n\n"; + } + try { + await core.summary.addRaw(summaryContent).write(); + core.info(summaryContent); + core.info(`📝 ${title} preview written to step summary`); + } catch (error) { + core.setFailed(error instanceof Error ? error : String(error)); + } + } + function generateXMLMarker(workflowName, runUrl) { + const engineId = process.env.GH_AW_ENGINE_ID || ""; + const engineVersion = process.env.GH_AW_ENGINE_VERSION || ""; + const engineModel = process.env.GH_AW_ENGINE_MODEL || ""; + const trackerId = process.env.GH_AW_TRACKER_ID || ""; + const parts = []; + parts.push(`agentic-workflow: ${workflowName}`); + if (trackerId) { + parts.push(`tracker-id: ${trackerId}`); + } + if (engineId) { + parts.push(`engine: ${engineId}`); + } + if (engineVersion) { + parts.push(`version: ${engineVersion}`); + } + if (engineModel) { + parts.push(`model: ${engineModel}`); + } + parts.push(`run: ${runUrl}`); + return ``; + } + function generateFooter( + workflowName, + runUrl, + workflowSource, + workflowSourceURL, + triggeringIssueNumber, + triggeringPRNumber, + triggeringDiscussionNumber + ) { + let footer = `\n\n> AI generated by [${workflowName}](${runUrl})`; + if (triggeringIssueNumber) { + footer += ` for #${triggeringIssueNumber}`; + } else if (triggeringPRNumber) { + footer += ` for #${triggeringPRNumber}`; + } else if (triggeringDiscussionNumber) { + footer += ` for discussion #${triggeringDiscussionNumber}`; + } + if (workflowSource && workflowSourceURL) { + footer += `\n>\n> To add this workflow in your repository, run \`gh aw add ${workflowSource}\`. See [usage guide](https://githubnext.github.io/gh-aw/tools/cli/).`; + } + footer += "\n\n" + generateXMLMarker(workflowName, runUrl); + footer += "\n"; + return footer; + } + function getTrackerID(format) { + const trackerID = process.env.GH_AW_TRACKER_ID || ""; + if (trackerID) { + core.info(`Tracker ID: ${trackerID}`); + return format === "markdown" ? `\n\n` : trackerID; + } + return ""; + } + const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; + function generateTemporaryId() { + return "aw_" + crypto.randomBytes(6).toString("hex"); + } + function isTemporaryId(value) { + if (typeof value === "string") { + return /^aw_[0-9a-f]{12}$/i.test(value); + } + return false; + } + function normalizeTemporaryId(tempId) { + return String(tempId).toLowerCase(); + } + function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { + return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { + const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); + if (resolved !== undefined) { + if (currentRepo && resolved.repo === currentRepo) { + return `#${resolved.number}`; + } + return `${resolved.repo}#${resolved.number}`; + } + return match; + }); + } + function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { + return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { + const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); + if (issueNumber !== undefined) { + return `#${issueNumber}`; + } + return match; + }); + } + function loadTemporaryIdMap() { + const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; + if (!mapJson || mapJson === "{}") { + return new Map(); + } + try { + const mapObject = JSON.parse(mapJson); + const result = new Map(); + for (const [key, value] of Object.entries(mapObject)) { + const normalizedKey = normalizeTemporaryId(key); + if (typeof value === "number") { + const contextRepo = `${context.repo.owner}/${context.repo.repo}`; + result.set(normalizedKey, { repo: contextRepo, number: value }); + } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { + result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); + } + } + return result; + } catch (error) { + if (typeof core !== "undefined") { + core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); + } + return new Map(); + } + } + function resolveIssueNumber(value, temporaryIdMap) { + if (value === undefined || value === null) { + return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; + } + const valueStr = String(value); + if (isTemporaryId(valueStr)) { + const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); + if (resolvedPair !== undefined) { + return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; + } + return { + resolved: null, + wasTemporaryId: true, + errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, + }; + } + const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); + if (isNaN(issueNumber) || issueNumber <= 0) { + return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; + } + const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; + return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; + } + function serializeTemporaryIdMap(tempIdMap) { + const obj = Object.fromEntries(tempIdMap); + return JSON.stringify(obj); + } + function parseAllowedRepos() { + const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; + const set = new Set(); + if (allowedReposEnv) { + allowedReposEnv + .split(",") + .map(repo => repo.trim()) + .filter(repo => repo) + .forEach(repo => set.add(repo)); + } + return set; + } + function getDefaultTargetRepo() { + const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; + if (targetRepoSlug) { + return targetRepoSlug; + } + return `${context.repo.owner}/${context.repo.repo}`; + } + function validateRepo(repo, defaultRepo, allowedRepos) { + if (repo === defaultRepo) { + return { valid: true, error: null }; + } + if (allowedRepos.has(repo)) { + return { valid: true, error: null }; + } + return { + valid: false, + error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, + }; + } + function parseRepoSlug(repoSlug) { + const parts = repoSlug.split("/"); + if (parts.length !== 2 || !parts[0] || !parts[1]) { + return null; + } + return { owner: parts[0], repo: parts[1] }; + } + function addExpirationComment(bodyLines, envVarName, entityType) { + const expiresEnv = process.env[envVarName]; + if (expiresEnv) { + const expiresDays = parseInt(expiresEnv, 10); + if (!isNaN(expiresDays) && expiresDays > 0) { + const expirationDate = new Date(); + expirationDate.setDate(expirationDate.getDate() + expiresDays); + const expirationISO = expirationDate.toISOString(); + bodyLines.push(``); + core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); + } + } + } + function removeDuplicateTitleFromDescription(title, description) { + if (!title || typeof title !== "string") { + return description || ""; + } + if (!description || typeof description !== "string") { + return ""; + } + const trimmedTitle = title.trim(); + const trimmedDescription = description.trim(); + if (!trimmedTitle || !trimmedDescription) { + return trimmedDescription; + } + const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); + if (headerRegex.test(trimmedDescription)) { + return trimmedDescription.replace(headerRegex, "").trim(); + } + return trimmedDescription; + } async function main() { core.setOutput("issue_number", ""); core.setOutput("issue_url", ""); diff --git a/.github/workflows/instructions-janitor.lock.yml b/.github/workflows/instructions-janitor.lock.yml index 7a7fbf0f93b..f0c147849f3 100644 --- a/.github/workflows/instructions-janitor.lock.yml +++ b/.github/workflows/instructions-janitor.lock.yml @@ -5698,10 +5698,151 @@ jobs: script: | const fs = require("fs"); const crypto = require("crypto"); - const { updateActivationComment } = require("./update_activation_comment.cjs"); - const { getTrackerID } = require("./get_tracker_id.cjs"); - const { addExpirationComment } = require("./expiration_helpers.cjs"); - const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); + async function updateActivationComment(github, context, core, itemUrl, itemNumber, itemType = "pull_request") { + const itemLabel = itemType === "issue" ? "issue" : "pull request"; + const linkMessage = + itemType === "issue" + ? `\n\n✅ Issue created: [#${itemNumber}](${itemUrl})` + : `\n\n✅ Pull request created: [#${itemNumber}](${itemUrl})`; + await updateActivationCommentWithMessage(github, context, core, linkMessage, itemLabel); + } + async function updateActivationCommentWithCommit(github, context, core, commitSha, commitUrl) { + const shortSha = commitSha.substring(0, 7); + const message = `\n\n✅ Commit pushed: [\`${shortSha}\`](${commitUrl})`; + await updateActivationCommentWithMessage(github, context, core, message, "commit"); + } + async function updateActivationCommentWithMessage(github, context, core, message, label = "") { + const commentId = process.env.GH_AW_COMMENT_ID; + const commentRepo = process.env.GH_AW_COMMENT_REPO; + if (!commentId) { + core.info("No activation comment to update (GH_AW_COMMENT_ID not set)"); + return; + } + core.info(`Updating activation comment ${commentId}`); + let repoOwner = context.repo.owner; + let repoName = context.repo.repo; + if (commentRepo) { + const parts = commentRepo.split("/"); + if (parts.length === 2) { + repoOwner = parts[0]; + repoName = parts[1]; + } else { + core.warning(`Invalid comment repo format: ${commentRepo}, expected "owner/repo". Falling back to context.repo.`); + } + } + core.info(`Updating comment in ${repoOwner}/${repoName}`); + const isDiscussionComment = commentId.startsWith("DC_"); + try { + if (isDiscussionComment) { + const currentComment = await github.graphql( + ` + query($commentId: ID!) { + node(id: $commentId) { + ... on DiscussionComment { + body + } + } + }`, + { commentId: commentId } + ); + if (!currentComment?.node?.body) { + core.warning("Unable to fetch current comment body, comment may have been deleted or is inaccessible"); + return; + } + const currentBody = currentComment.node.body; + const updatedBody = currentBody + message; + const result = await github.graphql( + ` + mutation($commentId: ID!, $body: String!) { + updateDiscussionComment(input: { commentId: $commentId, body: $body }) { + comment { + id + url + } + } + }`, + { commentId: commentId, body: updatedBody } + ); + const comment = result.updateDiscussionComment.comment; + const successMessage = label + ? `Successfully updated discussion comment with ${label} link` + : "Successfully updated discussion comment"; + core.info(successMessage); + core.info(`Comment ID: ${comment.id}`); + core.info(`Comment URL: ${comment.url}`); + } else { + const currentComment = await github.request("GET /repos/{owner}/{repo}/issues/comments/{comment_id}", { + owner: repoOwner, + repo: repoName, + comment_id: parseInt(commentId, 10), + headers: { + Accept: "application/vnd.github+json", + }, + }); + if (!currentComment?.data?.body) { + core.warning("Unable to fetch current comment body, comment may have been deleted"); + return; + } + const currentBody = currentComment.data.body; + const updatedBody = currentBody + message; + const response = await github.request("PATCH /repos/{owner}/{repo}/issues/comments/{comment_id}", { + owner: repoOwner, + repo: repoName, + comment_id: parseInt(commentId, 10), + body: updatedBody, + headers: { + Accept: "application/vnd.github+json", + }, + }); + const successMessage = label ? `Successfully updated comment with ${label} link` : "Successfully updated comment"; + core.info(successMessage); + core.info(`Comment ID: ${response.data.id}`); + core.info(`Comment URL: ${response.data.html_url}`); + } + } catch (error) { + core.warning(`Failed to update activation comment: ${error instanceof Error ? error.message : String(error)}`); + } + } + function getTrackerID(format) { + const trackerID = process.env.GH_AW_TRACKER_ID || ""; + if (trackerID) { + core.info(`Tracker ID: ${trackerID}`); + return format === "markdown" ? `\n\n` : trackerID; + } + return ""; + } + function addExpirationComment(bodyLines, envVarName, entityType) { + const expiresEnv = process.env[envVarName]; + if (expiresEnv) { + const expiresDays = parseInt(expiresEnv, 10); + if (!isNaN(expiresDays) && expiresDays > 0) { + const expirationDate = new Date(); + expirationDate.setDate(expirationDate.getDate() + expiresDays); + const expirationISO = expirationDate.toISOString(); + bodyLines.push(``); + core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); + } + } + } + function removeDuplicateTitleFromDescription(title, description) { + if (!title || typeof title !== "string") { + return description || ""; + } + if (!description || typeof description !== "string") { + return ""; + } + const trimmedTitle = title.trim(); + const trimmedDescription = description.trim(); + if (!trimmedTitle || !trimmedDescription) { + return trimmedDescription; + } + const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); + if (headerRegex.test(trimmedDescription)) { + return trimmedDescription.replace(headerRegex, "").trim(); + } + return trimmedDescription; + } function generatePatchPreview(patchContent) { if (!patchContent || !patchContent.trim()) { return ""; diff --git a/.github/workflows/issue-arborist.lock.yml b/.github/workflows/issue-arborist.lock.yml index c62e355a504..1ccf5a77290 100644 --- a/.github/workflows/issue-arborist.lock.yml +++ b/.github/workflows/issue-arborist.lock.yml @@ -5830,13 +5830,400 @@ jobs: with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - const { loadAgentOutput } = require("./load_agent_output.cjs"); - const { getTrackerID } = require("./get_tracker_id.cjs"); - const { closeOlderDiscussions } = require("./close_older_discussions.cjs"); - const { replaceTemporaryIdReferences, loadTemporaryIdMap } = require("./temporary_id.cjs"); - const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require("./repo_helpers.cjs"); - const { addExpirationComment } = require("./expiration_helpers.cjs"); - const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); + const fs = require("fs"); + const crypto = require("crypto"); + const MAX_LOG_CONTENT_LENGTH = 10000; + function truncateForLogging(content) { + if (content.length <= MAX_LOG_CONTENT_LENGTH) { + return content; + } + return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; + } + function loadAgentOutput() { + const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; + if (!agentOutputFile) { + core.info("No GH_AW_AGENT_OUTPUT environment variable found"); + return { success: false }; + } + let outputContent; + try { + outputContent = fs.readFileSync(agentOutputFile, "utf8"); + } catch (error) { + const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; + core.error(errorMessage); + return { success: false, error: errorMessage }; + } + if (outputContent.trim() === "") { + core.info("Agent output content is empty"); + return { success: false }; + } + core.info(`Agent output content length: ${outputContent.length}`); + let validatedOutput; + try { + validatedOutput = JSON.parse(outputContent); + } catch (error) { + const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; + core.error(errorMessage); + core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); + return { success: false, error: errorMessage }; + } + if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { + core.info("No valid items found in agent output"); + core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); + return { success: false }; + } + return { success: true, items: validatedOutput.items }; + } + function getTrackerID(format) { + const trackerID = process.env.GH_AW_TRACKER_ID || ""; + if (trackerID) { + core.info(`Tracker ID: ${trackerID}`); + return format === "markdown" ? `\n\n` : trackerID; + } + return ""; + } + function getMessages() { + const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES; + if (!messagesEnv) { + return null; + } + try { + return JSON.parse(messagesEnv); + } catch (error) { + core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`); + return null; + } + } + function renderTemplate(template, context) { + return template.replace(/\{(\w+)\}/g, (match, key) => { + const value = context[key]; + return value !== undefined && value !== null ? String(value) : match; + }); + } + function toSnakeCase(obj) { + const result = {}; + for (const [key, value] of Object.entries(obj)) { + const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase(); + result[snakeKey] = value; + result[key] = value; + } + return result; + } + function getCloseOlderDiscussionMessage(ctx) { + const messages = getMessages(); + const templateContext = toSnakeCase(ctx); + const defaultMessage = `⚓ Avast! This discussion be marked as **outdated** by [{workflow_name}]({run_url}). + 🗺️ A newer treasure map awaits ye at **[Discussion #{new_discussion_number}]({new_discussion_url})**. + Fair winds, matey! 🏴‍☠️`; + return messages?.closeOlderDiscussion + ? renderTemplate(messages.closeOlderDiscussion, templateContext) + : renderTemplate(defaultMessage, templateContext); + } + const MAX_CLOSE_COUNT = 10; + const GRAPHQL_DELAY_MS = 500; + function delay(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); + } + async function searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, excludeNumber) { + let searchQuery = `repo:${owner}/${repo} is:open`; + if (titlePrefix) { + const escapedPrefix = titlePrefix.replace(/"/g, '\\"'); + searchQuery += ` in:title "${escapedPrefix}"`; + } + if (labels && labels.length > 0) { + for (const label of labels) { + const escapedLabel = label.replace(/"/g, '\\"'); + searchQuery += ` label:"${escapedLabel}"`; + } + } + const result = await github.graphql( + ` + query($searchTerms: String!, $first: Int!) { + search(query: $searchTerms, type: DISCUSSION, first: $first) { + nodes { + ... on Discussion { + id + number + title + url + category { + id + } + labels(first: 100) { + nodes { + name + } + } + closed + } + } + } + }`, + { searchTerms: searchQuery, first: 50 } + ); + if (!result || !result.search || !result.search.nodes) { + return []; + } + return result.search.nodes + .filter( + d => { + if (!d || d.number === excludeNumber || d.closed) { + return false; + } + if (titlePrefix && d.title && !d.title.startsWith(titlePrefix)) { + return false; + } + if (labels && labels.length > 0) { + const discussionLabels = d.labels?.nodes?.map(( l) => l.name) || []; + const hasAllLabels = labels.every(label => discussionLabels.includes(label)); + if (!hasAllLabels) { + return false; + } + } + if (categoryId && (!d.category || d.category.id !== categoryId)) { + return false; + } + return true; + } + ) + .map( + d => ({ + id: d.id, + number: d.number, + title: d.title, + url: d.url, + }) + ); + } + async function addDiscussionComment(github, discussionId, message) { + const result = await github.graphql( + ` + mutation($dId: ID!, $body: String!) { + addDiscussionComment(input: { discussionId: $dId, body: $body }) { + comment { + id + url + } + } + }`, + { dId: discussionId, body: message } + ); + return result.addDiscussionComment.comment; + } + async function closeDiscussionAsOutdated(github, discussionId) { + const result = await github.graphql( + ` + mutation($dId: ID!) { + closeDiscussion(input: { discussionId: $dId, reason: OUTDATED }) { + discussion { + id + url + } + } + }`, + { dId: discussionId } + ); + return result.closeDiscussion.discussion; + } + async function closeOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion, workflowName, runUrl) { + const searchCriteria = []; + if (titlePrefix) searchCriteria.push(`title prefix: "${titlePrefix}"`); + if (labels && labels.length > 0) searchCriteria.push(`labels: [${labels.join(", ")}]`); + core.info(`Searching for older discussions with ${searchCriteria.join(" and ")}`); + const olderDiscussions = await searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion.number); + if (olderDiscussions.length === 0) { + core.info("No older discussions found to close"); + return []; + } + core.info(`Found ${olderDiscussions.length} older discussion(s) to close`); + const discussionsToClose = olderDiscussions.slice(0, MAX_CLOSE_COUNT); + if (olderDiscussions.length > MAX_CLOSE_COUNT) { + core.warning(`Found ${olderDiscussions.length} older discussions, but only closing the first ${MAX_CLOSE_COUNT}`); + } + const closedDiscussions = []; + for (let i = 0; i < discussionsToClose.length; i++) { + const discussion = discussionsToClose[i]; + try { + const closingMessage = getCloseOlderDiscussionMessage({ + newDiscussionUrl: newDiscussion.url, + newDiscussionNumber: newDiscussion.number, + workflowName, + runUrl, + }); + core.info(`Adding closing comment to discussion #${discussion.number}`); + await addDiscussionComment(github, discussion.id, closingMessage); + core.info(`Closing discussion #${discussion.number} as outdated`); + await closeDiscussionAsOutdated(github, discussion.id); + closedDiscussions.push({ + number: discussion.number, + url: discussion.url, + }); + core.info(`✓ Closed discussion #${discussion.number}: ${discussion.url}`); + } catch (error) { + core.error(`✗ Failed to close discussion #${discussion.number}: ${error instanceof Error ? error.message : String(error)}`); + } + if (i < discussionsToClose.length - 1) { + await delay(GRAPHQL_DELAY_MS); + } + } + return closedDiscussions; + } + const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; + function generateTemporaryId() { + return "aw_" + crypto.randomBytes(6).toString("hex"); + } + function isTemporaryId(value) { + if (typeof value === "string") { + return /^aw_[0-9a-f]{12}$/i.test(value); + } + return false; + } + function normalizeTemporaryId(tempId) { + return String(tempId).toLowerCase(); + } + function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { + return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { + const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); + if (resolved !== undefined) { + if (currentRepo && resolved.repo === currentRepo) { + return `#${resolved.number}`; + } + return `${resolved.repo}#${resolved.number}`; + } + return match; + }); + } + function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { + return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { + const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); + if (issueNumber !== undefined) { + return `#${issueNumber}`; + } + return match; + }); + } + function loadTemporaryIdMap() { + const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; + if (!mapJson || mapJson === "{}") { + return new Map(); + } + try { + const mapObject = JSON.parse(mapJson); + const result = new Map(); + for (const [key, value] of Object.entries(mapObject)) { + const normalizedKey = normalizeTemporaryId(key); + if (typeof value === "number") { + const contextRepo = `${context.repo.owner}/${context.repo.repo}`; + result.set(normalizedKey, { repo: contextRepo, number: value }); + } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { + result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); + } + } + return result; + } catch (error) { + if (typeof core !== "undefined") { + core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); + } + return new Map(); + } + } + function resolveIssueNumber(value, temporaryIdMap) { + if (value === undefined || value === null) { + return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; + } + const valueStr = String(value); + if (isTemporaryId(valueStr)) { + const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); + if (resolvedPair !== undefined) { + return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; + } + return { + resolved: null, + wasTemporaryId: true, + errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, + }; + } + const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); + if (isNaN(issueNumber) || issueNumber <= 0) { + return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; + } + const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; + return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; + } + function serializeTemporaryIdMap(tempIdMap) { + const obj = Object.fromEntries(tempIdMap); + return JSON.stringify(obj); + } + function parseAllowedRepos() { + const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; + const set = new Set(); + if (allowedReposEnv) { + allowedReposEnv + .split(",") + .map(repo => repo.trim()) + .filter(repo => repo) + .forEach(repo => set.add(repo)); + } + return set; + } + function getDefaultTargetRepo() { + const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; + if (targetRepoSlug) { + return targetRepoSlug; + } + return `${context.repo.owner}/${context.repo.repo}`; + } + function validateRepo(repo, defaultRepo, allowedRepos) { + if (repo === defaultRepo) { + return { valid: true, error: null }; + } + if (allowedRepos.has(repo)) { + return { valid: true, error: null }; + } + return { + valid: false, + error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, + }; + } + function parseRepoSlug(repoSlug) { + const parts = repoSlug.split("/"); + if (parts.length !== 2 || !parts[0] || !parts[1]) { + return null; + } + return { owner: parts[0], repo: parts[1] }; + } + function addExpirationComment(bodyLines, envVarName, entityType) { + const expiresEnv = process.env[envVarName]; + if (expiresEnv) { + const expiresDays = parseInt(expiresEnv, 10); + if (!isNaN(expiresDays) && expiresDays > 0) { + const expirationDate = new Date(); + expirationDate.setDate(expirationDate.getDate() + expiresDays); + const expirationISO = expirationDate.toISOString(); + bodyLines.push(``); + core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); + } + } + } + function removeDuplicateTitleFromDescription(title, description) { + if (!title || typeof title !== "string") { + return description || ""; + } + if (!description || typeof description !== "string") { + return ""; + } + const trimmedTitle = title.trim(); + const trimmedDescription = description.trim(); + if (!trimmedTitle || !trimmedDescription) { + return trimmedDescription; + } + const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); + if (headerRegex.test(trimmedDescription)) { + return trimmedDescription.replace(headerRegex, "").trim(); + } + return trimmedDescription; + } async function fetchRepoDiscussionInfo(owner, repo) { const repositoryQuery = ` query($owner: String!, $repo: String!) { diff --git a/.github/workflows/layout-spec-maintainer.lock.yml b/.github/workflows/layout-spec-maintainer.lock.yml index bb02017b46d..f4fdd703742 100644 --- a/.github/workflows/layout-spec-maintainer.lock.yml +++ b/.github/workflows/layout-spec-maintainer.lock.yml @@ -6442,10 +6442,151 @@ jobs: script: | const fs = require("fs"); const crypto = require("crypto"); - const { updateActivationComment } = require("./update_activation_comment.cjs"); - const { getTrackerID } = require("./get_tracker_id.cjs"); - const { addExpirationComment } = require("./expiration_helpers.cjs"); - const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); + async function updateActivationComment(github, context, core, itemUrl, itemNumber, itemType = "pull_request") { + const itemLabel = itemType === "issue" ? "issue" : "pull request"; + const linkMessage = + itemType === "issue" + ? `\n\n✅ Issue created: [#${itemNumber}](${itemUrl})` + : `\n\n✅ Pull request created: [#${itemNumber}](${itemUrl})`; + await updateActivationCommentWithMessage(github, context, core, linkMessage, itemLabel); + } + async function updateActivationCommentWithCommit(github, context, core, commitSha, commitUrl) { + const shortSha = commitSha.substring(0, 7); + const message = `\n\n✅ Commit pushed: [\`${shortSha}\`](${commitUrl})`; + await updateActivationCommentWithMessage(github, context, core, message, "commit"); + } + async function updateActivationCommentWithMessage(github, context, core, message, label = "") { + const commentId = process.env.GH_AW_COMMENT_ID; + const commentRepo = process.env.GH_AW_COMMENT_REPO; + if (!commentId) { + core.info("No activation comment to update (GH_AW_COMMENT_ID not set)"); + return; + } + core.info(`Updating activation comment ${commentId}`); + let repoOwner = context.repo.owner; + let repoName = context.repo.repo; + if (commentRepo) { + const parts = commentRepo.split("/"); + if (parts.length === 2) { + repoOwner = parts[0]; + repoName = parts[1]; + } else { + core.warning(`Invalid comment repo format: ${commentRepo}, expected "owner/repo". Falling back to context.repo.`); + } + } + core.info(`Updating comment in ${repoOwner}/${repoName}`); + const isDiscussionComment = commentId.startsWith("DC_"); + try { + if (isDiscussionComment) { + const currentComment = await github.graphql( + ` + query($commentId: ID!) { + node(id: $commentId) { + ... on DiscussionComment { + body + } + } + }`, + { commentId: commentId } + ); + if (!currentComment?.node?.body) { + core.warning("Unable to fetch current comment body, comment may have been deleted or is inaccessible"); + return; + } + const currentBody = currentComment.node.body; + const updatedBody = currentBody + message; + const result = await github.graphql( + ` + mutation($commentId: ID!, $body: String!) { + updateDiscussionComment(input: { commentId: $commentId, body: $body }) { + comment { + id + url + } + } + }`, + { commentId: commentId, body: updatedBody } + ); + const comment = result.updateDiscussionComment.comment; + const successMessage = label + ? `Successfully updated discussion comment with ${label} link` + : "Successfully updated discussion comment"; + core.info(successMessage); + core.info(`Comment ID: ${comment.id}`); + core.info(`Comment URL: ${comment.url}`); + } else { + const currentComment = await github.request("GET /repos/{owner}/{repo}/issues/comments/{comment_id}", { + owner: repoOwner, + repo: repoName, + comment_id: parseInt(commentId, 10), + headers: { + Accept: "application/vnd.github+json", + }, + }); + if (!currentComment?.data?.body) { + core.warning("Unable to fetch current comment body, comment may have been deleted"); + return; + } + const currentBody = currentComment.data.body; + const updatedBody = currentBody + message; + const response = await github.request("PATCH /repos/{owner}/{repo}/issues/comments/{comment_id}", { + owner: repoOwner, + repo: repoName, + comment_id: parseInt(commentId, 10), + body: updatedBody, + headers: { + Accept: "application/vnd.github+json", + }, + }); + const successMessage = label ? `Successfully updated comment with ${label} link` : "Successfully updated comment"; + core.info(successMessage); + core.info(`Comment ID: ${response.data.id}`); + core.info(`Comment URL: ${response.data.html_url}`); + } + } catch (error) { + core.warning(`Failed to update activation comment: ${error instanceof Error ? error.message : String(error)}`); + } + } + function getTrackerID(format) { + const trackerID = process.env.GH_AW_TRACKER_ID || ""; + if (trackerID) { + core.info(`Tracker ID: ${trackerID}`); + return format === "markdown" ? `\n\n` : trackerID; + } + return ""; + } + function addExpirationComment(bodyLines, envVarName, entityType) { + const expiresEnv = process.env[envVarName]; + if (expiresEnv) { + const expiresDays = parseInt(expiresEnv, 10); + if (!isNaN(expiresDays) && expiresDays > 0) { + const expirationDate = new Date(); + expirationDate.setDate(expirationDate.getDate() + expiresDays); + const expirationISO = expirationDate.toISOString(); + bodyLines.push(``); + core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); + } + } + } + function removeDuplicateTitleFromDescription(title, description) { + if (!title || typeof title !== "string") { + return description || ""; + } + if (!description || typeof description !== "string") { + return ""; + } + const trimmedTitle = title.trim(); + const trimmedDescription = description.trim(); + if (!trimmedTitle || !trimmedDescription) { + return trimmedDescription; + } + const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); + if (headerRegex.test(trimmedDescription)) { + return trimmedDescription.replace(headerRegex, "").trim(); + } + return trimmedDescription; + } function generatePatchPreview(patchContent) { if (!patchContent || !patchContent.trim()) { return ""; diff --git a/.github/workflows/lockfile-stats.lock.yml b/.github/workflows/lockfile-stats.lock.yml index 846c163b15c..81eab4b3cea 100644 --- a/.github/workflows/lockfile-stats.lock.yml +++ b/.github/workflows/lockfile-stats.lock.yml @@ -6168,13 +6168,400 @@ jobs: with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - const { loadAgentOutput } = require("./load_agent_output.cjs"); - const { getTrackerID } = require("./get_tracker_id.cjs"); - const { closeOlderDiscussions } = require("./close_older_discussions.cjs"); - const { replaceTemporaryIdReferences, loadTemporaryIdMap } = require("./temporary_id.cjs"); - const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require("./repo_helpers.cjs"); - const { addExpirationComment } = require("./expiration_helpers.cjs"); - const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); + const fs = require("fs"); + const crypto = require("crypto"); + const MAX_LOG_CONTENT_LENGTH = 10000; + function truncateForLogging(content) { + if (content.length <= MAX_LOG_CONTENT_LENGTH) { + return content; + } + return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; + } + function loadAgentOutput() { + const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; + if (!agentOutputFile) { + core.info("No GH_AW_AGENT_OUTPUT environment variable found"); + return { success: false }; + } + let outputContent; + try { + outputContent = fs.readFileSync(agentOutputFile, "utf8"); + } catch (error) { + const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; + core.error(errorMessage); + return { success: false, error: errorMessage }; + } + if (outputContent.trim() === "") { + core.info("Agent output content is empty"); + return { success: false }; + } + core.info(`Agent output content length: ${outputContent.length}`); + let validatedOutput; + try { + validatedOutput = JSON.parse(outputContent); + } catch (error) { + const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; + core.error(errorMessage); + core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); + return { success: false, error: errorMessage }; + } + if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { + core.info("No valid items found in agent output"); + core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); + return { success: false }; + } + return { success: true, items: validatedOutput.items }; + } + function getTrackerID(format) { + const trackerID = process.env.GH_AW_TRACKER_ID || ""; + if (trackerID) { + core.info(`Tracker ID: ${trackerID}`); + return format === "markdown" ? `\n\n` : trackerID; + } + return ""; + } + function getMessages() { + const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES; + if (!messagesEnv) { + return null; + } + try { + return JSON.parse(messagesEnv); + } catch (error) { + core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`); + return null; + } + } + function renderTemplate(template, context) { + return template.replace(/\{(\w+)\}/g, (match, key) => { + const value = context[key]; + return value !== undefined && value !== null ? String(value) : match; + }); + } + function toSnakeCase(obj) { + const result = {}; + for (const [key, value] of Object.entries(obj)) { + const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase(); + result[snakeKey] = value; + result[key] = value; + } + return result; + } + function getCloseOlderDiscussionMessage(ctx) { + const messages = getMessages(); + const templateContext = toSnakeCase(ctx); + const defaultMessage = `⚓ Avast! This discussion be marked as **outdated** by [{workflow_name}]({run_url}). + 🗺️ A newer treasure map awaits ye at **[Discussion #{new_discussion_number}]({new_discussion_url})**. + Fair winds, matey! 🏴‍☠️`; + return messages?.closeOlderDiscussion + ? renderTemplate(messages.closeOlderDiscussion, templateContext) + : renderTemplate(defaultMessage, templateContext); + } + const MAX_CLOSE_COUNT = 10; + const GRAPHQL_DELAY_MS = 500; + function delay(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); + } + async function searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, excludeNumber) { + let searchQuery = `repo:${owner}/${repo} is:open`; + if (titlePrefix) { + const escapedPrefix = titlePrefix.replace(/"/g, '\\"'); + searchQuery += ` in:title "${escapedPrefix}"`; + } + if (labels && labels.length > 0) { + for (const label of labels) { + const escapedLabel = label.replace(/"/g, '\\"'); + searchQuery += ` label:"${escapedLabel}"`; + } + } + const result = await github.graphql( + ` + query($searchTerms: String!, $first: Int!) { + search(query: $searchTerms, type: DISCUSSION, first: $first) { + nodes { + ... on Discussion { + id + number + title + url + category { + id + } + labels(first: 100) { + nodes { + name + } + } + closed + } + } + } + }`, + { searchTerms: searchQuery, first: 50 } + ); + if (!result || !result.search || !result.search.nodes) { + return []; + } + return result.search.nodes + .filter( + d => { + if (!d || d.number === excludeNumber || d.closed) { + return false; + } + if (titlePrefix && d.title && !d.title.startsWith(titlePrefix)) { + return false; + } + if (labels && labels.length > 0) { + const discussionLabels = d.labels?.nodes?.map(( l) => l.name) || []; + const hasAllLabels = labels.every(label => discussionLabels.includes(label)); + if (!hasAllLabels) { + return false; + } + } + if (categoryId && (!d.category || d.category.id !== categoryId)) { + return false; + } + return true; + } + ) + .map( + d => ({ + id: d.id, + number: d.number, + title: d.title, + url: d.url, + }) + ); + } + async function addDiscussionComment(github, discussionId, message) { + const result = await github.graphql( + ` + mutation($dId: ID!, $body: String!) { + addDiscussionComment(input: { discussionId: $dId, body: $body }) { + comment { + id + url + } + } + }`, + { dId: discussionId, body: message } + ); + return result.addDiscussionComment.comment; + } + async function closeDiscussionAsOutdated(github, discussionId) { + const result = await github.graphql( + ` + mutation($dId: ID!) { + closeDiscussion(input: { discussionId: $dId, reason: OUTDATED }) { + discussion { + id + url + } + } + }`, + { dId: discussionId } + ); + return result.closeDiscussion.discussion; + } + async function closeOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion, workflowName, runUrl) { + const searchCriteria = []; + if (titlePrefix) searchCriteria.push(`title prefix: "${titlePrefix}"`); + if (labels && labels.length > 0) searchCriteria.push(`labels: [${labels.join(", ")}]`); + core.info(`Searching for older discussions with ${searchCriteria.join(" and ")}`); + const olderDiscussions = await searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion.number); + if (olderDiscussions.length === 0) { + core.info("No older discussions found to close"); + return []; + } + core.info(`Found ${olderDiscussions.length} older discussion(s) to close`); + const discussionsToClose = olderDiscussions.slice(0, MAX_CLOSE_COUNT); + if (olderDiscussions.length > MAX_CLOSE_COUNT) { + core.warning(`Found ${olderDiscussions.length} older discussions, but only closing the first ${MAX_CLOSE_COUNT}`); + } + const closedDiscussions = []; + for (let i = 0; i < discussionsToClose.length; i++) { + const discussion = discussionsToClose[i]; + try { + const closingMessage = getCloseOlderDiscussionMessage({ + newDiscussionUrl: newDiscussion.url, + newDiscussionNumber: newDiscussion.number, + workflowName, + runUrl, + }); + core.info(`Adding closing comment to discussion #${discussion.number}`); + await addDiscussionComment(github, discussion.id, closingMessage); + core.info(`Closing discussion #${discussion.number} as outdated`); + await closeDiscussionAsOutdated(github, discussion.id); + closedDiscussions.push({ + number: discussion.number, + url: discussion.url, + }); + core.info(`✓ Closed discussion #${discussion.number}: ${discussion.url}`); + } catch (error) { + core.error(`✗ Failed to close discussion #${discussion.number}: ${error instanceof Error ? error.message : String(error)}`); + } + if (i < discussionsToClose.length - 1) { + await delay(GRAPHQL_DELAY_MS); + } + } + return closedDiscussions; + } + const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; + function generateTemporaryId() { + return "aw_" + crypto.randomBytes(6).toString("hex"); + } + function isTemporaryId(value) { + if (typeof value === "string") { + return /^aw_[0-9a-f]{12}$/i.test(value); + } + return false; + } + function normalizeTemporaryId(tempId) { + return String(tempId).toLowerCase(); + } + function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { + return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { + const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); + if (resolved !== undefined) { + if (currentRepo && resolved.repo === currentRepo) { + return `#${resolved.number}`; + } + return `${resolved.repo}#${resolved.number}`; + } + return match; + }); + } + function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { + return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { + const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); + if (issueNumber !== undefined) { + return `#${issueNumber}`; + } + return match; + }); + } + function loadTemporaryIdMap() { + const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; + if (!mapJson || mapJson === "{}") { + return new Map(); + } + try { + const mapObject = JSON.parse(mapJson); + const result = new Map(); + for (const [key, value] of Object.entries(mapObject)) { + const normalizedKey = normalizeTemporaryId(key); + if (typeof value === "number") { + const contextRepo = `${context.repo.owner}/${context.repo.repo}`; + result.set(normalizedKey, { repo: contextRepo, number: value }); + } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { + result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); + } + } + return result; + } catch (error) { + if (typeof core !== "undefined") { + core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); + } + return new Map(); + } + } + function resolveIssueNumber(value, temporaryIdMap) { + if (value === undefined || value === null) { + return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; + } + const valueStr = String(value); + if (isTemporaryId(valueStr)) { + const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); + if (resolvedPair !== undefined) { + return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; + } + return { + resolved: null, + wasTemporaryId: true, + errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, + }; + } + const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); + if (isNaN(issueNumber) || issueNumber <= 0) { + return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; + } + const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; + return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; + } + function serializeTemporaryIdMap(tempIdMap) { + const obj = Object.fromEntries(tempIdMap); + return JSON.stringify(obj); + } + function parseAllowedRepos() { + const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; + const set = new Set(); + if (allowedReposEnv) { + allowedReposEnv + .split(",") + .map(repo => repo.trim()) + .filter(repo => repo) + .forEach(repo => set.add(repo)); + } + return set; + } + function getDefaultTargetRepo() { + const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; + if (targetRepoSlug) { + return targetRepoSlug; + } + return `${context.repo.owner}/${context.repo.repo}`; + } + function validateRepo(repo, defaultRepo, allowedRepos) { + if (repo === defaultRepo) { + return { valid: true, error: null }; + } + if (allowedRepos.has(repo)) { + return { valid: true, error: null }; + } + return { + valid: false, + error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, + }; + } + function parseRepoSlug(repoSlug) { + const parts = repoSlug.split("/"); + if (parts.length !== 2 || !parts[0] || !parts[1]) { + return null; + } + return { owner: parts[0], repo: parts[1] }; + } + function addExpirationComment(bodyLines, envVarName, entityType) { + const expiresEnv = process.env[envVarName]; + if (expiresEnv) { + const expiresDays = parseInt(expiresEnv, 10); + if (!isNaN(expiresDays) && expiresDays > 0) { + const expirationDate = new Date(); + expirationDate.setDate(expirationDate.getDate() + expiresDays); + const expirationISO = expirationDate.toISOString(); + bodyLines.push(``); + core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); + } + } + } + function removeDuplicateTitleFromDescription(title, description) { + if (!title || typeof title !== "string") { + return description || ""; + } + if (!description || typeof description !== "string") { + return ""; + } + const trimmedTitle = title.trim(); + const trimmedDescription = description.trim(); + if (!trimmedTitle || !trimmedDescription) { + return trimmedDescription; + } + const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); + if (headerRegex.test(trimmedDescription)) { + return trimmedDescription.replace(headerRegex, "").trim(); + } + return trimmedDescription; + } async function fetchRepoDiscussionInfo(owner, repo) { const repositoryQuery = ` query($owner: String!, $repo: String!) { diff --git a/.github/workflows/mcp-inspector.lock.yml b/.github/workflows/mcp-inspector.lock.yml index 33cbeeba0c0..f17cd98d860 100644 --- a/.github/workflows/mcp-inspector.lock.yml +++ b/.github/workflows/mcp-inspector.lock.yml @@ -6784,13 +6784,400 @@ jobs: with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - const { loadAgentOutput } = require("./load_agent_output.cjs"); - const { getTrackerID } = require("./get_tracker_id.cjs"); - const { closeOlderDiscussions } = require("./close_older_discussions.cjs"); - const { replaceTemporaryIdReferences, loadTemporaryIdMap } = require("./temporary_id.cjs"); - const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require("./repo_helpers.cjs"); - const { addExpirationComment } = require("./expiration_helpers.cjs"); - const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); + const fs = require("fs"); + const crypto = require("crypto"); + const MAX_LOG_CONTENT_LENGTH = 10000; + function truncateForLogging(content) { + if (content.length <= MAX_LOG_CONTENT_LENGTH) { + return content; + } + return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; + } + function loadAgentOutput() { + const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; + if (!agentOutputFile) { + core.info("No GH_AW_AGENT_OUTPUT environment variable found"); + return { success: false }; + } + let outputContent; + try { + outputContent = fs.readFileSync(agentOutputFile, "utf8"); + } catch (error) { + const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; + core.error(errorMessage); + return { success: false, error: errorMessage }; + } + if (outputContent.trim() === "") { + core.info("Agent output content is empty"); + return { success: false }; + } + core.info(`Agent output content length: ${outputContent.length}`); + let validatedOutput; + try { + validatedOutput = JSON.parse(outputContent); + } catch (error) { + const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; + core.error(errorMessage); + core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); + return { success: false, error: errorMessage }; + } + if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { + core.info("No valid items found in agent output"); + core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); + return { success: false }; + } + return { success: true, items: validatedOutput.items }; + } + function getTrackerID(format) { + const trackerID = process.env.GH_AW_TRACKER_ID || ""; + if (trackerID) { + core.info(`Tracker ID: ${trackerID}`); + return format === "markdown" ? `\n\n` : trackerID; + } + return ""; + } + function getMessages() { + const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES; + if (!messagesEnv) { + return null; + } + try { + return JSON.parse(messagesEnv); + } catch (error) { + core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`); + return null; + } + } + function renderTemplate(template, context) { + return template.replace(/\{(\w+)\}/g, (match, key) => { + const value = context[key]; + return value !== undefined && value !== null ? String(value) : match; + }); + } + function toSnakeCase(obj) { + const result = {}; + for (const [key, value] of Object.entries(obj)) { + const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase(); + result[snakeKey] = value; + result[key] = value; + } + return result; + } + function getCloseOlderDiscussionMessage(ctx) { + const messages = getMessages(); + const templateContext = toSnakeCase(ctx); + const defaultMessage = `⚓ Avast! This discussion be marked as **outdated** by [{workflow_name}]({run_url}). + 🗺️ A newer treasure map awaits ye at **[Discussion #{new_discussion_number}]({new_discussion_url})**. + Fair winds, matey! 🏴‍☠️`; + return messages?.closeOlderDiscussion + ? renderTemplate(messages.closeOlderDiscussion, templateContext) + : renderTemplate(defaultMessage, templateContext); + } + const MAX_CLOSE_COUNT = 10; + const GRAPHQL_DELAY_MS = 500; + function delay(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); + } + async function searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, excludeNumber) { + let searchQuery = `repo:${owner}/${repo} is:open`; + if (titlePrefix) { + const escapedPrefix = titlePrefix.replace(/"/g, '\\"'); + searchQuery += ` in:title "${escapedPrefix}"`; + } + if (labels && labels.length > 0) { + for (const label of labels) { + const escapedLabel = label.replace(/"/g, '\\"'); + searchQuery += ` label:"${escapedLabel}"`; + } + } + const result = await github.graphql( + ` + query($searchTerms: String!, $first: Int!) { + search(query: $searchTerms, type: DISCUSSION, first: $first) { + nodes { + ... on Discussion { + id + number + title + url + category { + id + } + labels(first: 100) { + nodes { + name + } + } + closed + } + } + } + }`, + { searchTerms: searchQuery, first: 50 } + ); + if (!result || !result.search || !result.search.nodes) { + return []; + } + return result.search.nodes + .filter( + d => { + if (!d || d.number === excludeNumber || d.closed) { + return false; + } + if (titlePrefix && d.title && !d.title.startsWith(titlePrefix)) { + return false; + } + if (labels && labels.length > 0) { + const discussionLabels = d.labels?.nodes?.map(( l) => l.name) || []; + const hasAllLabels = labels.every(label => discussionLabels.includes(label)); + if (!hasAllLabels) { + return false; + } + } + if (categoryId && (!d.category || d.category.id !== categoryId)) { + return false; + } + return true; + } + ) + .map( + d => ({ + id: d.id, + number: d.number, + title: d.title, + url: d.url, + }) + ); + } + async function addDiscussionComment(github, discussionId, message) { + const result = await github.graphql( + ` + mutation($dId: ID!, $body: String!) { + addDiscussionComment(input: { discussionId: $dId, body: $body }) { + comment { + id + url + } + } + }`, + { dId: discussionId, body: message } + ); + return result.addDiscussionComment.comment; + } + async function closeDiscussionAsOutdated(github, discussionId) { + const result = await github.graphql( + ` + mutation($dId: ID!) { + closeDiscussion(input: { discussionId: $dId, reason: OUTDATED }) { + discussion { + id + url + } + } + }`, + { dId: discussionId } + ); + return result.closeDiscussion.discussion; + } + async function closeOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion, workflowName, runUrl) { + const searchCriteria = []; + if (titlePrefix) searchCriteria.push(`title prefix: "${titlePrefix}"`); + if (labels && labels.length > 0) searchCriteria.push(`labels: [${labels.join(", ")}]`); + core.info(`Searching for older discussions with ${searchCriteria.join(" and ")}`); + const olderDiscussions = await searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion.number); + if (olderDiscussions.length === 0) { + core.info("No older discussions found to close"); + return []; + } + core.info(`Found ${olderDiscussions.length} older discussion(s) to close`); + const discussionsToClose = olderDiscussions.slice(0, MAX_CLOSE_COUNT); + if (olderDiscussions.length > MAX_CLOSE_COUNT) { + core.warning(`Found ${olderDiscussions.length} older discussions, but only closing the first ${MAX_CLOSE_COUNT}`); + } + const closedDiscussions = []; + for (let i = 0; i < discussionsToClose.length; i++) { + const discussion = discussionsToClose[i]; + try { + const closingMessage = getCloseOlderDiscussionMessage({ + newDiscussionUrl: newDiscussion.url, + newDiscussionNumber: newDiscussion.number, + workflowName, + runUrl, + }); + core.info(`Adding closing comment to discussion #${discussion.number}`); + await addDiscussionComment(github, discussion.id, closingMessage); + core.info(`Closing discussion #${discussion.number} as outdated`); + await closeDiscussionAsOutdated(github, discussion.id); + closedDiscussions.push({ + number: discussion.number, + url: discussion.url, + }); + core.info(`✓ Closed discussion #${discussion.number}: ${discussion.url}`); + } catch (error) { + core.error(`✗ Failed to close discussion #${discussion.number}: ${error instanceof Error ? error.message : String(error)}`); + } + if (i < discussionsToClose.length - 1) { + await delay(GRAPHQL_DELAY_MS); + } + } + return closedDiscussions; + } + const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; + function generateTemporaryId() { + return "aw_" + crypto.randomBytes(6).toString("hex"); + } + function isTemporaryId(value) { + if (typeof value === "string") { + return /^aw_[0-9a-f]{12}$/i.test(value); + } + return false; + } + function normalizeTemporaryId(tempId) { + return String(tempId).toLowerCase(); + } + function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { + return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { + const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); + if (resolved !== undefined) { + if (currentRepo && resolved.repo === currentRepo) { + return `#${resolved.number}`; + } + return `${resolved.repo}#${resolved.number}`; + } + return match; + }); + } + function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { + return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { + const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); + if (issueNumber !== undefined) { + return `#${issueNumber}`; + } + return match; + }); + } + function loadTemporaryIdMap() { + const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; + if (!mapJson || mapJson === "{}") { + return new Map(); + } + try { + const mapObject = JSON.parse(mapJson); + const result = new Map(); + for (const [key, value] of Object.entries(mapObject)) { + const normalizedKey = normalizeTemporaryId(key); + if (typeof value === "number") { + const contextRepo = `${context.repo.owner}/${context.repo.repo}`; + result.set(normalizedKey, { repo: contextRepo, number: value }); + } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { + result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); + } + } + return result; + } catch (error) { + if (typeof core !== "undefined") { + core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); + } + return new Map(); + } + } + function resolveIssueNumber(value, temporaryIdMap) { + if (value === undefined || value === null) { + return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; + } + const valueStr = String(value); + if (isTemporaryId(valueStr)) { + const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); + if (resolvedPair !== undefined) { + return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; + } + return { + resolved: null, + wasTemporaryId: true, + errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, + }; + } + const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); + if (isNaN(issueNumber) || issueNumber <= 0) { + return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; + } + const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; + return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; + } + function serializeTemporaryIdMap(tempIdMap) { + const obj = Object.fromEntries(tempIdMap); + return JSON.stringify(obj); + } + function parseAllowedRepos() { + const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; + const set = new Set(); + if (allowedReposEnv) { + allowedReposEnv + .split(",") + .map(repo => repo.trim()) + .filter(repo => repo) + .forEach(repo => set.add(repo)); + } + return set; + } + function getDefaultTargetRepo() { + const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; + if (targetRepoSlug) { + return targetRepoSlug; + } + return `${context.repo.owner}/${context.repo.repo}`; + } + function validateRepo(repo, defaultRepo, allowedRepos) { + if (repo === defaultRepo) { + return { valid: true, error: null }; + } + if (allowedRepos.has(repo)) { + return { valid: true, error: null }; + } + return { + valid: false, + error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, + }; + } + function parseRepoSlug(repoSlug) { + const parts = repoSlug.split("/"); + if (parts.length !== 2 || !parts[0] || !parts[1]) { + return null; + } + return { owner: parts[0], repo: parts[1] }; + } + function addExpirationComment(bodyLines, envVarName, entityType) { + const expiresEnv = process.env[envVarName]; + if (expiresEnv) { + const expiresDays = parseInt(expiresEnv, 10); + if (!isNaN(expiresDays) && expiresDays > 0) { + const expirationDate = new Date(); + expirationDate.setDate(expirationDate.getDate() + expiresDays); + const expirationISO = expirationDate.toISOString(); + bodyLines.push(``); + core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); + } + } + } + function removeDuplicateTitleFromDescription(title, description) { + if (!title || typeof title !== "string") { + return description || ""; + } + if (!description || typeof description !== "string") { + return ""; + } + const trimmedTitle = title.trim(); + const trimmedDescription = description.trim(); + if (!trimmedTitle || !trimmedDescription) { + return trimmedDescription; + } + const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); + if (headerRegex.test(trimmedDescription)) { + return trimmedDescription.replace(headerRegex, "").trim(); + } + return trimmedDescription; + } async function fetchRepoDiscussionInfo(owner, repo) { const repositoryQuery = ` query($owner: String!, $repo: String!) { diff --git a/.github/workflows/org-health-report.lock.yml b/.github/workflows/org-health-report.lock.yml index 266da6b8fa5..2c241ce7395 100644 --- a/.github/workflows/org-health-report.lock.yml +++ b/.github/workflows/org-health-report.lock.yml @@ -7649,13 +7649,400 @@ jobs: with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - const { loadAgentOutput } = require("./load_agent_output.cjs"); - const { getTrackerID } = require("./get_tracker_id.cjs"); - const { closeOlderDiscussions } = require("./close_older_discussions.cjs"); - const { replaceTemporaryIdReferences, loadTemporaryIdMap } = require("./temporary_id.cjs"); - const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require("./repo_helpers.cjs"); - const { addExpirationComment } = require("./expiration_helpers.cjs"); - const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); + const fs = require("fs"); + const crypto = require("crypto"); + const MAX_LOG_CONTENT_LENGTH = 10000; + function truncateForLogging(content) { + if (content.length <= MAX_LOG_CONTENT_LENGTH) { + return content; + } + return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; + } + function loadAgentOutput() { + const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; + if (!agentOutputFile) { + core.info("No GH_AW_AGENT_OUTPUT environment variable found"); + return { success: false }; + } + let outputContent; + try { + outputContent = fs.readFileSync(agentOutputFile, "utf8"); + } catch (error) { + const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; + core.error(errorMessage); + return { success: false, error: errorMessage }; + } + if (outputContent.trim() === "") { + core.info("Agent output content is empty"); + return { success: false }; + } + core.info(`Agent output content length: ${outputContent.length}`); + let validatedOutput; + try { + validatedOutput = JSON.parse(outputContent); + } catch (error) { + const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; + core.error(errorMessage); + core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); + return { success: false, error: errorMessage }; + } + if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { + core.info("No valid items found in agent output"); + core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); + return { success: false }; + } + return { success: true, items: validatedOutput.items }; + } + function getTrackerID(format) { + const trackerID = process.env.GH_AW_TRACKER_ID || ""; + if (trackerID) { + core.info(`Tracker ID: ${trackerID}`); + return format === "markdown" ? `\n\n` : trackerID; + } + return ""; + } + function getMessages() { + const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES; + if (!messagesEnv) { + return null; + } + try { + return JSON.parse(messagesEnv); + } catch (error) { + core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`); + return null; + } + } + function renderTemplate(template, context) { + return template.replace(/\{(\w+)\}/g, (match, key) => { + const value = context[key]; + return value !== undefined && value !== null ? String(value) : match; + }); + } + function toSnakeCase(obj) { + const result = {}; + for (const [key, value] of Object.entries(obj)) { + const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase(); + result[snakeKey] = value; + result[key] = value; + } + return result; + } + function getCloseOlderDiscussionMessage(ctx) { + const messages = getMessages(); + const templateContext = toSnakeCase(ctx); + const defaultMessage = `⚓ Avast! This discussion be marked as **outdated** by [{workflow_name}]({run_url}). + 🗺️ A newer treasure map awaits ye at **[Discussion #{new_discussion_number}]({new_discussion_url})**. + Fair winds, matey! 🏴‍☠️`; + return messages?.closeOlderDiscussion + ? renderTemplate(messages.closeOlderDiscussion, templateContext) + : renderTemplate(defaultMessage, templateContext); + } + const MAX_CLOSE_COUNT = 10; + const GRAPHQL_DELAY_MS = 500; + function delay(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); + } + async function searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, excludeNumber) { + let searchQuery = `repo:${owner}/${repo} is:open`; + if (titlePrefix) { + const escapedPrefix = titlePrefix.replace(/"/g, '\\"'); + searchQuery += ` in:title "${escapedPrefix}"`; + } + if (labels && labels.length > 0) { + for (const label of labels) { + const escapedLabel = label.replace(/"/g, '\\"'); + searchQuery += ` label:"${escapedLabel}"`; + } + } + const result = await github.graphql( + ` + query($searchTerms: String!, $first: Int!) { + search(query: $searchTerms, type: DISCUSSION, first: $first) { + nodes { + ... on Discussion { + id + number + title + url + category { + id + } + labels(first: 100) { + nodes { + name + } + } + closed + } + } + } + }`, + { searchTerms: searchQuery, first: 50 } + ); + if (!result || !result.search || !result.search.nodes) { + return []; + } + return result.search.nodes + .filter( + d => { + if (!d || d.number === excludeNumber || d.closed) { + return false; + } + if (titlePrefix && d.title && !d.title.startsWith(titlePrefix)) { + return false; + } + if (labels && labels.length > 0) { + const discussionLabels = d.labels?.nodes?.map(( l) => l.name) || []; + const hasAllLabels = labels.every(label => discussionLabels.includes(label)); + if (!hasAllLabels) { + return false; + } + } + if (categoryId && (!d.category || d.category.id !== categoryId)) { + return false; + } + return true; + } + ) + .map( + d => ({ + id: d.id, + number: d.number, + title: d.title, + url: d.url, + }) + ); + } + async function addDiscussionComment(github, discussionId, message) { + const result = await github.graphql( + ` + mutation($dId: ID!, $body: String!) { + addDiscussionComment(input: { discussionId: $dId, body: $body }) { + comment { + id + url + } + } + }`, + { dId: discussionId, body: message } + ); + return result.addDiscussionComment.comment; + } + async function closeDiscussionAsOutdated(github, discussionId) { + const result = await github.graphql( + ` + mutation($dId: ID!) { + closeDiscussion(input: { discussionId: $dId, reason: OUTDATED }) { + discussion { + id + url + } + } + }`, + { dId: discussionId } + ); + return result.closeDiscussion.discussion; + } + async function closeOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion, workflowName, runUrl) { + const searchCriteria = []; + if (titlePrefix) searchCriteria.push(`title prefix: "${titlePrefix}"`); + if (labels && labels.length > 0) searchCriteria.push(`labels: [${labels.join(", ")}]`); + core.info(`Searching for older discussions with ${searchCriteria.join(" and ")}`); + const olderDiscussions = await searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion.number); + if (olderDiscussions.length === 0) { + core.info("No older discussions found to close"); + return []; + } + core.info(`Found ${olderDiscussions.length} older discussion(s) to close`); + const discussionsToClose = olderDiscussions.slice(0, MAX_CLOSE_COUNT); + if (olderDiscussions.length > MAX_CLOSE_COUNT) { + core.warning(`Found ${olderDiscussions.length} older discussions, but only closing the first ${MAX_CLOSE_COUNT}`); + } + const closedDiscussions = []; + for (let i = 0; i < discussionsToClose.length; i++) { + const discussion = discussionsToClose[i]; + try { + const closingMessage = getCloseOlderDiscussionMessage({ + newDiscussionUrl: newDiscussion.url, + newDiscussionNumber: newDiscussion.number, + workflowName, + runUrl, + }); + core.info(`Adding closing comment to discussion #${discussion.number}`); + await addDiscussionComment(github, discussion.id, closingMessage); + core.info(`Closing discussion #${discussion.number} as outdated`); + await closeDiscussionAsOutdated(github, discussion.id); + closedDiscussions.push({ + number: discussion.number, + url: discussion.url, + }); + core.info(`✓ Closed discussion #${discussion.number}: ${discussion.url}`); + } catch (error) { + core.error(`✗ Failed to close discussion #${discussion.number}: ${error instanceof Error ? error.message : String(error)}`); + } + if (i < discussionsToClose.length - 1) { + await delay(GRAPHQL_DELAY_MS); + } + } + return closedDiscussions; + } + const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; + function generateTemporaryId() { + return "aw_" + crypto.randomBytes(6).toString("hex"); + } + function isTemporaryId(value) { + if (typeof value === "string") { + return /^aw_[0-9a-f]{12}$/i.test(value); + } + return false; + } + function normalizeTemporaryId(tempId) { + return String(tempId).toLowerCase(); + } + function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { + return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { + const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); + if (resolved !== undefined) { + if (currentRepo && resolved.repo === currentRepo) { + return `#${resolved.number}`; + } + return `${resolved.repo}#${resolved.number}`; + } + return match; + }); + } + function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { + return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { + const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); + if (issueNumber !== undefined) { + return `#${issueNumber}`; + } + return match; + }); + } + function loadTemporaryIdMap() { + const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; + if (!mapJson || mapJson === "{}") { + return new Map(); + } + try { + const mapObject = JSON.parse(mapJson); + const result = new Map(); + for (const [key, value] of Object.entries(mapObject)) { + const normalizedKey = normalizeTemporaryId(key); + if (typeof value === "number") { + const contextRepo = `${context.repo.owner}/${context.repo.repo}`; + result.set(normalizedKey, { repo: contextRepo, number: value }); + } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { + result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); + } + } + return result; + } catch (error) { + if (typeof core !== "undefined") { + core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); + } + return new Map(); + } + } + function resolveIssueNumber(value, temporaryIdMap) { + if (value === undefined || value === null) { + return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; + } + const valueStr = String(value); + if (isTemporaryId(valueStr)) { + const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); + if (resolvedPair !== undefined) { + return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; + } + return { + resolved: null, + wasTemporaryId: true, + errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, + }; + } + const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); + if (isNaN(issueNumber) || issueNumber <= 0) { + return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; + } + const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; + return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; + } + function serializeTemporaryIdMap(tempIdMap) { + const obj = Object.fromEntries(tempIdMap); + return JSON.stringify(obj); + } + function parseAllowedRepos() { + const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; + const set = new Set(); + if (allowedReposEnv) { + allowedReposEnv + .split(",") + .map(repo => repo.trim()) + .filter(repo => repo) + .forEach(repo => set.add(repo)); + } + return set; + } + function getDefaultTargetRepo() { + const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; + if (targetRepoSlug) { + return targetRepoSlug; + } + return `${context.repo.owner}/${context.repo.repo}`; + } + function validateRepo(repo, defaultRepo, allowedRepos) { + if (repo === defaultRepo) { + return { valid: true, error: null }; + } + if (allowedRepos.has(repo)) { + return { valid: true, error: null }; + } + return { + valid: false, + error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, + }; + } + function parseRepoSlug(repoSlug) { + const parts = repoSlug.split("/"); + if (parts.length !== 2 || !parts[0] || !parts[1]) { + return null; + } + return { owner: parts[0], repo: parts[1] }; + } + function addExpirationComment(bodyLines, envVarName, entityType) { + const expiresEnv = process.env[envVarName]; + if (expiresEnv) { + const expiresDays = parseInt(expiresEnv, 10); + if (!isNaN(expiresDays) && expiresDays > 0) { + const expirationDate = new Date(); + expirationDate.setDate(expirationDate.getDate() + expiresDays); + const expirationISO = expirationDate.toISOString(); + bodyLines.push(``); + core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); + } + } + } + function removeDuplicateTitleFromDescription(title, description) { + if (!title || typeof title !== "string") { + return description || ""; + } + if (!description || typeof description !== "string") { + return ""; + } + const trimmedTitle = title.trim(); + const trimmedDescription = description.trim(); + if (!trimmedTitle || !trimmedDescription) { + return trimmedDescription; + } + const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); + if (headerRegex.test(trimmedDescription)) { + return trimmedDescription.replace(headerRegex, "").trim(); + } + return trimmedDescription; + } async function fetchRepoDiscussionInfo(owner, repo) { const repositoryQuery = ` query($owner: String!, $repo: String!) { diff --git a/.github/workflows/plan.lock.yml b/.github/workflows/plan.lock.yml index 36688fcd3ae..e57b333e4ca 100644 --- a/.github/workflows/plan.lock.yml +++ b/.github/workflows/plan.lock.yml @@ -7449,21 +7449,291 @@ jobs: with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - const { sanitizeLabelContent } = require("./sanitize_label_content.cjs"); - const { loadAgentOutput } = require("./load_agent_output.cjs"); - const { generateStagedPreview } = require("./staged_preview.cjs"); - const { generateFooter } = require("./generate_footer.cjs"); - const { getTrackerID } = require("./get_tracker_id.cjs"); - const { - generateTemporaryId, - isTemporaryId, - normalizeTemporaryId, - replaceTemporaryIdReferences, - serializeTemporaryIdMap, - } = require("./temporary_id.cjs"); - const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require("./repo_helpers.cjs"); - const { addExpirationComment } = require("./expiration_helpers.cjs"); - const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); + function sanitizeLabelContent(content) { + if (!content || typeof content !== "string") { + return ""; + } + let sanitized = content.trim(); + sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); + sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); + sanitized = sanitized.replace( + /(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, + (_m, p1, p2) => `${p1}\`@${p2}\`` + ); + sanitized = sanitized.replace(/[<>&'"]/g, ""); + return sanitized.trim(); + } + const fs = require("fs"); + const crypto = require("crypto"); + const MAX_LOG_CONTENT_LENGTH = 10000; + function truncateForLogging(content) { + if (content.length <= MAX_LOG_CONTENT_LENGTH) { + return content; + } + return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; + } + function loadAgentOutput() { + const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; + if (!agentOutputFile) { + core.info("No GH_AW_AGENT_OUTPUT environment variable found"); + return { success: false }; + } + let outputContent; + try { + outputContent = fs.readFileSync(agentOutputFile, "utf8"); + } catch (error) { + const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; + core.error(errorMessage); + return { success: false, error: errorMessage }; + } + if (outputContent.trim() === "") { + core.info("Agent output content is empty"); + return { success: false }; + } + core.info(`Agent output content length: ${outputContent.length}`); + let validatedOutput; + try { + validatedOutput = JSON.parse(outputContent); + } catch (error) { + const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; + core.error(errorMessage); + core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); + return { success: false, error: errorMessage }; + } + if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { + core.info("No valid items found in agent output"); + core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); + return { success: false }; + } + return { success: true, items: validatedOutput.items }; + } + async function generateStagedPreview(options) { + const { title, description, items, renderItem } = options; + let summaryContent = `## 🎭 Staged Mode: ${title} Preview\n\n`; + summaryContent += `${description}\n\n`; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + summaryContent += renderItem(item, i); + summaryContent += "---\n\n"; + } + try { + await core.summary.addRaw(summaryContent).write(); + core.info(summaryContent); + core.info(`📝 ${title} preview written to step summary`); + } catch (error) { + core.setFailed(error instanceof Error ? error : String(error)); + } + } + function generateXMLMarker(workflowName, runUrl) { + const engineId = process.env.GH_AW_ENGINE_ID || ""; + const engineVersion = process.env.GH_AW_ENGINE_VERSION || ""; + const engineModel = process.env.GH_AW_ENGINE_MODEL || ""; + const trackerId = process.env.GH_AW_TRACKER_ID || ""; + const parts = []; + parts.push(`agentic-workflow: ${workflowName}`); + if (trackerId) { + parts.push(`tracker-id: ${trackerId}`); + } + if (engineId) { + parts.push(`engine: ${engineId}`); + } + if (engineVersion) { + parts.push(`version: ${engineVersion}`); + } + if (engineModel) { + parts.push(`model: ${engineModel}`); + } + parts.push(`run: ${runUrl}`); + return ``; + } + function generateFooter( + workflowName, + runUrl, + workflowSource, + workflowSourceURL, + triggeringIssueNumber, + triggeringPRNumber, + triggeringDiscussionNumber + ) { + let footer = `\n\n> AI generated by [${workflowName}](${runUrl})`; + if (triggeringIssueNumber) { + footer += ` for #${triggeringIssueNumber}`; + } else if (triggeringPRNumber) { + footer += ` for #${triggeringPRNumber}`; + } else if (triggeringDiscussionNumber) { + footer += ` for discussion #${triggeringDiscussionNumber}`; + } + if (workflowSource && workflowSourceURL) { + footer += `\n>\n> To add this workflow in your repository, run \`gh aw add ${workflowSource}\`. See [usage guide](https://githubnext.github.io/gh-aw/tools/cli/).`; + } + footer += "\n\n" + generateXMLMarker(workflowName, runUrl); + footer += "\n"; + return footer; + } + function getTrackerID(format) { + const trackerID = process.env.GH_AW_TRACKER_ID || ""; + if (trackerID) { + core.info(`Tracker ID: ${trackerID}`); + return format === "markdown" ? `\n\n` : trackerID; + } + return ""; + } + const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; + function generateTemporaryId() { + return "aw_" + crypto.randomBytes(6).toString("hex"); + } + function isTemporaryId(value) { + if (typeof value === "string") { + return /^aw_[0-9a-f]{12}$/i.test(value); + } + return false; + } + function normalizeTemporaryId(tempId) { + return String(tempId).toLowerCase(); + } + function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { + return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { + const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); + if (resolved !== undefined) { + if (currentRepo && resolved.repo === currentRepo) { + return `#${resolved.number}`; + } + return `${resolved.repo}#${resolved.number}`; + } + return match; + }); + } + function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { + return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { + const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); + if (issueNumber !== undefined) { + return `#${issueNumber}`; + } + return match; + }); + } + function loadTemporaryIdMap() { + const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; + if (!mapJson || mapJson === "{}") { + return new Map(); + } + try { + const mapObject = JSON.parse(mapJson); + const result = new Map(); + for (const [key, value] of Object.entries(mapObject)) { + const normalizedKey = normalizeTemporaryId(key); + if (typeof value === "number") { + const contextRepo = `${context.repo.owner}/${context.repo.repo}`; + result.set(normalizedKey, { repo: contextRepo, number: value }); + } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { + result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); + } + } + return result; + } catch (error) { + if (typeof core !== "undefined") { + core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); + } + return new Map(); + } + } + function resolveIssueNumber(value, temporaryIdMap) { + if (value === undefined || value === null) { + return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; + } + const valueStr = String(value); + if (isTemporaryId(valueStr)) { + const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); + if (resolvedPair !== undefined) { + return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; + } + return { + resolved: null, + wasTemporaryId: true, + errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, + }; + } + const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); + if (isNaN(issueNumber) || issueNumber <= 0) { + return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; + } + const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; + return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; + } + function serializeTemporaryIdMap(tempIdMap) { + const obj = Object.fromEntries(tempIdMap); + return JSON.stringify(obj); + } + function parseAllowedRepos() { + const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; + const set = new Set(); + if (allowedReposEnv) { + allowedReposEnv + .split(",") + .map(repo => repo.trim()) + .filter(repo => repo) + .forEach(repo => set.add(repo)); + } + return set; + } + function getDefaultTargetRepo() { + const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; + if (targetRepoSlug) { + return targetRepoSlug; + } + return `${context.repo.owner}/${context.repo.repo}`; + } + function validateRepo(repo, defaultRepo, allowedRepos) { + if (repo === defaultRepo) { + return { valid: true, error: null }; + } + if (allowedRepos.has(repo)) { + return { valid: true, error: null }; + } + return { + valid: false, + error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, + }; + } + function parseRepoSlug(repoSlug) { + const parts = repoSlug.split("/"); + if (parts.length !== 2 || !parts[0] || !parts[1]) { + return null; + } + return { owner: parts[0], repo: parts[1] }; + } + function addExpirationComment(bodyLines, envVarName, entityType) { + const expiresEnv = process.env[envVarName]; + if (expiresEnv) { + const expiresDays = parseInt(expiresEnv, 10); + if (!isNaN(expiresDays) && expiresDays > 0) { + const expirationDate = new Date(); + expirationDate.setDate(expirationDate.getDate() + expiresDays); + const expirationISO = expirationDate.toISOString(); + bodyLines.push(``); + core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); + } + } + } + function removeDuplicateTitleFromDescription(title, description) { + if (!title || typeof title !== "string") { + return description || ""; + } + if (!description || typeof description !== "string") { + return ""; + } + const trimmedTitle = title.trim(); + const trimmedDescription = description.trim(); + if (!trimmedTitle || !trimmedDescription) { + return trimmedDescription; + } + const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); + if (headerRegex.test(trimmedDescription)) { + return trimmedDescription.replace(headerRegex, "").trim(); + } + return trimmedDescription; + } async function main() { core.setOutput("issue_number", ""); core.setOutput("issue_url", ""); diff --git a/.github/workflows/poem-bot.lock.yml b/.github/workflows/poem-bot.lock.yml index 75f30aae256..8b4378013e7 100644 --- a/.github/workflows/poem-bot.lock.yml +++ b/.github/workflows/poem-bot.lock.yml @@ -8984,13 +8984,400 @@ jobs: with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - const { loadAgentOutput } = require("./load_agent_output.cjs"); - const { getTrackerID } = require("./get_tracker_id.cjs"); - const { closeOlderDiscussions } = require("./close_older_discussions.cjs"); - const { replaceTemporaryIdReferences, loadTemporaryIdMap } = require("./temporary_id.cjs"); - const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require("./repo_helpers.cjs"); - const { addExpirationComment } = require("./expiration_helpers.cjs"); - const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); + const fs = require("fs"); + const crypto = require("crypto"); + const MAX_LOG_CONTENT_LENGTH = 10000; + function truncateForLogging(content) { + if (content.length <= MAX_LOG_CONTENT_LENGTH) { + return content; + } + return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; + } + function loadAgentOutput() { + const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; + if (!agentOutputFile) { + core.info("No GH_AW_AGENT_OUTPUT environment variable found"); + return { success: false }; + } + let outputContent; + try { + outputContent = fs.readFileSync(agentOutputFile, "utf8"); + } catch (error) { + const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; + core.error(errorMessage); + return { success: false, error: errorMessage }; + } + if (outputContent.trim() === "") { + core.info("Agent output content is empty"); + return { success: false }; + } + core.info(`Agent output content length: ${outputContent.length}`); + let validatedOutput; + try { + validatedOutput = JSON.parse(outputContent); + } catch (error) { + const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; + core.error(errorMessage); + core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); + return { success: false, error: errorMessage }; + } + if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { + core.info("No valid items found in agent output"); + core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); + return { success: false }; + } + return { success: true, items: validatedOutput.items }; + } + function getTrackerID(format) { + const trackerID = process.env.GH_AW_TRACKER_ID || ""; + if (trackerID) { + core.info(`Tracker ID: ${trackerID}`); + return format === "markdown" ? `\n\n` : trackerID; + } + return ""; + } + function getMessages() { + const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES; + if (!messagesEnv) { + return null; + } + try { + return JSON.parse(messagesEnv); + } catch (error) { + core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`); + return null; + } + } + function renderTemplate(template, context) { + return template.replace(/\{(\w+)\}/g, (match, key) => { + const value = context[key]; + return value !== undefined && value !== null ? String(value) : match; + }); + } + function toSnakeCase(obj) { + const result = {}; + for (const [key, value] of Object.entries(obj)) { + const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase(); + result[snakeKey] = value; + result[key] = value; + } + return result; + } + function getCloseOlderDiscussionMessage(ctx) { + const messages = getMessages(); + const templateContext = toSnakeCase(ctx); + const defaultMessage = `⚓ Avast! This discussion be marked as **outdated** by [{workflow_name}]({run_url}). + 🗺️ A newer treasure map awaits ye at **[Discussion #{new_discussion_number}]({new_discussion_url})**. + Fair winds, matey! 🏴‍☠️`; + return messages?.closeOlderDiscussion + ? renderTemplate(messages.closeOlderDiscussion, templateContext) + : renderTemplate(defaultMessage, templateContext); + } + const MAX_CLOSE_COUNT = 10; + const GRAPHQL_DELAY_MS = 500; + function delay(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); + } + async function searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, excludeNumber) { + let searchQuery = `repo:${owner}/${repo} is:open`; + if (titlePrefix) { + const escapedPrefix = titlePrefix.replace(/"/g, '\\"'); + searchQuery += ` in:title "${escapedPrefix}"`; + } + if (labels && labels.length > 0) { + for (const label of labels) { + const escapedLabel = label.replace(/"/g, '\\"'); + searchQuery += ` label:"${escapedLabel}"`; + } + } + const result = await github.graphql( + ` + query($searchTerms: String!, $first: Int!) { + search(query: $searchTerms, type: DISCUSSION, first: $first) { + nodes { + ... on Discussion { + id + number + title + url + category { + id + } + labels(first: 100) { + nodes { + name + } + } + closed + } + } + } + }`, + { searchTerms: searchQuery, first: 50 } + ); + if (!result || !result.search || !result.search.nodes) { + return []; + } + return result.search.nodes + .filter( + d => { + if (!d || d.number === excludeNumber || d.closed) { + return false; + } + if (titlePrefix && d.title && !d.title.startsWith(titlePrefix)) { + return false; + } + if (labels && labels.length > 0) { + const discussionLabels = d.labels?.nodes?.map(( l) => l.name) || []; + const hasAllLabels = labels.every(label => discussionLabels.includes(label)); + if (!hasAllLabels) { + return false; + } + } + if (categoryId && (!d.category || d.category.id !== categoryId)) { + return false; + } + return true; + } + ) + .map( + d => ({ + id: d.id, + number: d.number, + title: d.title, + url: d.url, + }) + ); + } + async function addDiscussionComment(github, discussionId, message) { + const result = await github.graphql( + ` + mutation($dId: ID!, $body: String!) { + addDiscussionComment(input: { discussionId: $dId, body: $body }) { + comment { + id + url + } + } + }`, + { dId: discussionId, body: message } + ); + return result.addDiscussionComment.comment; + } + async function closeDiscussionAsOutdated(github, discussionId) { + const result = await github.graphql( + ` + mutation($dId: ID!) { + closeDiscussion(input: { discussionId: $dId, reason: OUTDATED }) { + discussion { + id + url + } + } + }`, + { dId: discussionId } + ); + return result.closeDiscussion.discussion; + } + async function closeOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion, workflowName, runUrl) { + const searchCriteria = []; + if (titlePrefix) searchCriteria.push(`title prefix: "${titlePrefix}"`); + if (labels && labels.length > 0) searchCriteria.push(`labels: [${labels.join(", ")}]`); + core.info(`Searching for older discussions with ${searchCriteria.join(" and ")}`); + const olderDiscussions = await searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion.number); + if (olderDiscussions.length === 0) { + core.info("No older discussions found to close"); + return []; + } + core.info(`Found ${olderDiscussions.length} older discussion(s) to close`); + const discussionsToClose = olderDiscussions.slice(0, MAX_CLOSE_COUNT); + if (olderDiscussions.length > MAX_CLOSE_COUNT) { + core.warning(`Found ${olderDiscussions.length} older discussions, but only closing the first ${MAX_CLOSE_COUNT}`); + } + const closedDiscussions = []; + for (let i = 0; i < discussionsToClose.length; i++) { + const discussion = discussionsToClose[i]; + try { + const closingMessage = getCloseOlderDiscussionMessage({ + newDiscussionUrl: newDiscussion.url, + newDiscussionNumber: newDiscussion.number, + workflowName, + runUrl, + }); + core.info(`Adding closing comment to discussion #${discussion.number}`); + await addDiscussionComment(github, discussion.id, closingMessage); + core.info(`Closing discussion #${discussion.number} as outdated`); + await closeDiscussionAsOutdated(github, discussion.id); + closedDiscussions.push({ + number: discussion.number, + url: discussion.url, + }); + core.info(`✓ Closed discussion #${discussion.number}: ${discussion.url}`); + } catch (error) { + core.error(`✗ Failed to close discussion #${discussion.number}: ${error instanceof Error ? error.message : String(error)}`); + } + if (i < discussionsToClose.length - 1) { + await delay(GRAPHQL_DELAY_MS); + } + } + return closedDiscussions; + } + const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; + function generateTemporaryId() { + return "aw_" + crypto.randomBytes(6).toString("hex"); + } + function isTemporaryId(value) { + if (typeof value === "string") { + return /^aw_[0-9a-f]{12}$/i.test(value); + } + return false; + } + function normalizeTemporaryId(tempId) { + return String(tempId).toLowerCase(); + } + function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { + return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { + const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); + if (resolved !== undefined) { + if (currentRepo && resolved.repo === currentRepo) { + return `#${resolved.number}`; + } + return `${resolved.repo}#${resolved.number}`; + } + return match; + }); + } + function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { + return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { + const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); + if (issueNumber !== undefined) { + return `#${issueNumber}`; + } + return match; + }); + } + function loadTemporaryIdMap() { + const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; + if (!mapJson || mapJson === "{}") { + return new Map(); + } + try { + const mapObject = JSON.parse(mapJson); + const result = new Map(); + for (const [key, value] of Object.entries(mapObject)) { + const normalizedKey = normalizeTemporaryId(key); + if (typeof value === "number") { + const contextRepo = `${context.repo.owner}/${context.repo.repo}`; + result.set(normalizedKey, { repo: contextRepo, number: value }); + } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { + result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); + } + } + return result; + } catch (error) { + if (typeof core !== "undefined") { + core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); + } + return new Map(); + } + } + function resolveIssueNumber(value, temporaryIdMap) { + if (value === undefined || value === null) { + return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; + } + const valueStr = String(value); + if (isTemporaryId(valueStr)) { + const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); + if (resolvedPair !== undefined) { + return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; + } + return { + resolved: null, + wasTemporaryId: true, + errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, + }; + } + const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); + if (isNaN(issueNumber) || issueNumber <= 0) { + return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; + } + const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; + return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; + } + function serializeTemporaryIdMap(tempIdMap) { + const obj = Object.fromEntries(tempIdMap); + return JSON.stringify(obj); + } + function parseAllowedRepos() { + const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; + const set = new Set(); + if (allowedReposEnv) { + allowedReposEnv + .split(",") + .map(repo => repo.trim()) + .filter(repo => repo) + .forEach(repo => set.add(repo)); + } + return set; + } + function getDefaultTargetRepo() { + const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; + if (targetRepoSlug) { + return targetRepoSlug; + } + return `${context.repo.owner}/${context.repo.repo}`; + } + function validateRepo(repo, defaultRepo, allowedRepos) { + if (repo === defaultRepo) { + return { valid: true, error: null }; + } + if (allowedRepos.has(repo)) { + return { valid: true, error: null }; + } + return { + valid: false, + error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, + }; + } + function parseRepoSlug(repoSlug) { + const parts = repoSlug.split("/"); + if (parts.length !== 2 || !parts[0] || !parts[1]) { + return null; + } + return { owner: parts[0], repo: parts[1] }; + } + function addExpirationComment(bodyLines, envVarName, entityType) { + const expiresEnv = process.env[envVarName]; + if (expiresEnv) { + const expiresDays = parseInt(expiresEnv, 10); + if (!isNaN(expiresDays) && expiresDays > 0) { + const expirationDate = new Date(); + expirationDate.setDate(expirationDate.getDate() + expiresDays); + const expirationISO = expirationDate.toISOString(); + bodyLines.push(``); + core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); + } + } + } + function removeDuplicateTitleFromDescription(title, description) { + if (!title || typeof title !== "string") { + return description || ""; + } + if (!description || typeof description !== "string") { + return ""; + } + const trimmedTitle = title.trim(); + const trimmedDescription = description.trim(); + if (!trimmedTitle || !trimmedDescription) { + return trimmedDescription; + } + const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); + if (headerRegex.test(trimmedDescription)) { + return trimmedDescription.replace(headerRegex, "").trim(); + } + return trimmedDescription; + } async function fetchRepoDiscussionInfo(owner, repo) { const repositoryQuery = ` query($owner: String!, $repo: String!) { @@ -9259,82 +9646,352 @@ jobs: throw error; } } - if (createdDiscussions.length > 0) { - let summaryContent = "\n\n## GitHub Discussions\n"; - for (const discussion of createdDiscussions) { - const repoLabel = discussion._repo !== defaultTargetRepo ? ` (${discussion._repo})` : ""; - summaryContent += `- Discussion #${discussion.number}${repoLabel}: [${discussion.title}](${discussion.url})\n`; - } - if (closedDiscussionsSummary.length > 0) { - summaryContent += "\n### Closed Older Discussions\n"; - for (const closed of closedDiscussionsSummary) { - summaryContent += `- Discussion #${closed.number}: [View](${closed.url}) (marked as outdated)\n`; - } + if (createdDiscussions.length > 0) { + let summaryContent = "\n\n## GitHub Discussions\n"; + for (const discussion of createdDiscussions) { + const repoLabel = discussion._repo !== defaultTargetRepo ? ` (${discussion._repo})` : ""; + summaryContent += `- Discussion #${discussion.number}${repoLabel}: [${discussion.title}](${discussion.url})\n`; + } + if (closedDiscussionsSummary.length > 0) { + summaryContent += "\n### Closed Older Discussions\n"; + for (const closed of closedDiscussionsSummary) { + summaryContent += `- Discussion #${closed.number}: [View](${closed.url}) (marked as outdated)\n`; + } + } + await core.summary.addRaw(summaryContent).write(); + } + core.info(`Successfully created ${createdDiscussions.length} discussion(s)`); + } + await main(); + + create_issue: + needs: + - agent + - detection + if: > + (((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'create_issue'))) && + (needs.detection.outputs.success == 'true') + runs-on: ubuntu-slim + permissions: + contents: read + issues: write + timeout-minutes: 10 + outputs: + issue_number: ${{ steps.create_issue.outputs.issue_number }} + issue_url: ${{ steps.create_issue.outputs.issue_url }} + temporary_id_map: ${{ steps.create_issue.outputs.temporary_id_map }} + steps: + - name: Download agent output artifact + continue-on-error: true + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 + with: + name: agent_output.json + path: /tmp/gh-aw/safeoutputs/ + - name: Setup agent output environment variable + run: | + mkdir -p /tmp/gh-aw/safeoutputs/ + find "/tmp/gh-aw/safeoutputs/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" + - name: Create Output Issue + id: create_issue + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} + GH_AW_ISSUE_TITLE_PREFIX: "[🎭 POEM-BOT] " + GH_AW_ISSUE_LABELS: "poetry,automation,ai-generated" + GH_AW_WORKFLOW_NAME: "Poem Bot - A Creative Agentic Workflow" + GH_AW_ENGINE_ID: "copilot" + GH_AW_ENGINE_MODEL: "gpt-5" + GH_AW_SAFE_OUTPUTS_STAGED: "true" + GH_AW_SAFE_OUTPUT_MESSAGES: "{\"footer\":\"\\u003e 🪶 *Verses penned by [{workflow_name}]({run_url})*\",\"runStarted\":\"🎭 Hear ye! The muse stirs! [{workflow_name}]({run_url}) takes quill in hand for this {event_type}...\",\"runSuccess\":\"🪶 The poem is writ! [{workflow_name}]({run_url}) has composed verses most fair. Applause! 👏\",\"runFailure\":\"🎭 Alas! [{workflow_name}]({run_url}) {status}. The muse has fled, leaving verses unsung...\"}" + with: + github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + script: | + function sanitizeLabelContent(content) { + if (!content || typeof content !== "string") { + return ""; + } + let sanitized = content.trim(); + sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); + sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); + sanitized = sanitized.replace( + /(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, + (_m, p1, p2) => `${p1}\`@${p2}\`` + ); + sanitized = sanitized.replace(/[<>&'"]/g, ""); + return sanitized.trim(); + } + const fs = require("fs"); + const crypto = require("crypto"); + const MAX_LOG_CONTENT_LENGTH = 10000; + function truncateForLogging(content) { + if (content.length <= MAX_LOG_CONTENT_LENGTH) { + return content; + } + return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; + } + function loadAgentOutput() { + const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; + if (!agentOutputFile) { + core.info("No GH_AW_AGENT_OUTPUT environment variable found"); + return { success: false }; + } + let outputContent; + try { + outputContent = fs.readFileSync(agentOutputFile, "utf8"); + } catch (error) { + const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; + core.error(errorMessage); + return { success: false, error: errorMessage }; + } + if (outputContent.trim() === "") { + core.info("Agent output content is empty"); + return { success: false }; + } + core.info(`Agent output content length: ${outputContent.length}`); + let validatedOutput; + try { + validatedOutput = JSON.parse(outputContent); + } catch (error) { + const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; + core.error(errorMessage); + core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); + return { success: false, error: errorMessage }; + } + if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { + core.info("No valid items found in agent output"); + core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); + return { success: false }; + } + return { success: true, items: validatedOutput.items }; + } + async function generateStagedPreview(options) { + const { title, description, items, renderItem } = options; + let summaryContent = `## 🎭 Staged Mode: ${title} Preview\n\n`; + summaryContent += `${description}\n\n`; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + summaryContent += renderItem(item, i); + summaryContent += "---\n\n"; + } + try { + await core.summary.addRaw(summaryContent).write(); + core.info(summaryContent); + core.info(`📝 ${title} preview written to step summary`); + } catch (error) { + core.setFailed(error instanceof Error ? error : String(error)); + } + } + function generateXMLMarker(workflowName, runUrl) { + const engineId = process.env.GH_AW_ENGINE_ID || ""; + const engineVersion = process.env.GH_AW_ENGINE_VERSION || ""; + const engineModel = process.env.GH_AW_ENGINE_MODEL || ""; + const trackerId = process.env.GH_AW_TRACKER_ID || ""; + const parts = []; + parts.push(`agentic-workflow: ${workflowName}`); + if (trackerId) { + parts.push(`tracker-id: ${trackerId}`); + } + if (engineId) { + parts.push(`engine: ${engineId}`); + } + if (engineVersion) { + parts.push(`version: ${engineVersion}`); + } + if (engineModel) { + parts.push(`model: ${engineModel}`); + } + parts.push(`run: ${runUrl}`); + return ``; + } + function generateFooter( + workflowName, + runUrl, + workflowSource, + workflowSourceURL, + triggeringIssueNumber, + triggeringPRNumber, + triggeringDiscussionNumber + ) { + let footer = `\n\n> AI generated by [${workflowName}](${runUrl})`; + if (triggeringIssueNumber) { + footer += ` for #${triggeringIssueNumber}`; + } else if (triggeringPRNumber) { + footer += ` for #${triggeringPRNumber}`; + } else if (triggeringDiscussionNumber) { + footer += ` for discussion #${triggeringDiscussionNumber}`; + } + if (workflowSource && workflowSourceURL) { + footer += `\n>\n> To add this workflow in your repository, run \`gh aw add ${workflowSource}\`. See [usage guide](https://githubnext.github.io/gh-aw/tools/cli/).`; + } + footer += "\n\n" + generateXMLMarker(workflowName, runUrl); + footer += "\n"; + return footer; + } + function getTrackerID(format) { + const trackerID = process.env.GH_AW_TRACKER_ID || ""; + if (trackerID) { + core.info(`Tracker ID: ${trackerID}`); + return format === "markdown" ? `\n\n` : trackerID; + } + return ""; + } + const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; + function generateTemporaryId() { + return "aw_" + crypto.randomBytes(6).toString("hex"); + } + function isTemporaryId(value) { + if (typeof value === "string") { + return /^aw_[0-9a-f]{12}$/i.test(value); + } + return false; + } + function normalizeTemporaryId(tempId) { + return String(tempId).toLowerCase(); + } + function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { + return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { + const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); + if (resolved !== undefined) { + if (currentRepo && resolved.repo === currentRepo) { + return `#${resolved.number}`; + } + return `${resolved.repo}#${resolved.number}`; + } + return match; + }); + } + function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { + return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { + const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); + if (issueNumber !== undefined) { + return `#${issueNumber}`; + } + return match; + }); + } + function loadTemporaryIdMap() { + const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; + if (!mapJson || mapJson === "{}") { + return new Map(); + } + try { + const mapObject = JSON.parse(mapJson); + const result = new Map(); + for (const [key, value] of Object.entries(mapObject)) { + const normalizedKey = normalizeTemporaryId(key); + if (typeof value === "number") { + const contextRepo = `${context.repo.owner}/${context.repo.repo}`; + result.set(normalizedKey, { repo: contextRepo, number: value }); + } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { + result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); + } + } + return result; + } catch (error) { + if (typeof core !== "undefined") { + core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); + } + return new Map(); + } + } + function resolveIssueNumber(value, temporaryIdMap) { + if (value === undefined || value === null) { + return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; + } + const valueStr = String(value); + if (isTemporaryId(valueStr)) { + const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); + if (resolvedPair !== undefined) { + return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; + } + return { + resolved: null, + wasTemporaryId: true, + errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, + }; + } + const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); + if (isNaN(issueNumber) || issueNumber <= 0) { + return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; + } + const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; + return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; + } + function serializeTemporaryIdMap(tempIdMap) { + const obj = Object.fromEntries(tempIdMap); + return JSON.stringify(obj); + } + function parseAllowedRepos() { + const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; + const set = new Set(); + if (allowedReposEnv) { + allowedReposEnv + .split(",") + .map(repo => repo.trim()) + .filter(repo => repo) + .forEach(repo => set.add(repo)); + } + return set; + } + function getDefaultTargetRepo() { + const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; + if (targetRepoSlug) { + return targetRepoSlug; + } + return `${context.repo.owner}/${context.repo.repo}`; + } + function validateRepo(repo, defaultRepo, allowedRepos) { + if (repo === defaultRepo) { + return { valid: true, error: null }; + } + if (allowedRepos.has(repo)) { + return { valid: true, error: null }; + } + return { + valid: false, + error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, + }; + } + function parseRepoSlug(repoSlug) { + const parts = repoSlug.split("/"); + if (parts.length !== 2 || !parts[0] || !parts[1]) { + return null; + } + return { owner: parts[0], repo: parts[1] }; + } + function addExpirationComment(bodyLines, envVarName, entityType) { + const expiresEnv = process.env[envVarName]; + if (expiresEnv) { + const expiresDays = parseInt(expiresEnv, 10); + if (!isNaN(expiresDays) && expiresDays > 0) { + const expirationDate = new Date(); + expirationDate.setDate(expirationDate.getDate() + expiresDays); + const expirationISO = expirationDate.toISOString(); + bodyLines.push(``); + core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); } - await core.summary.addRaw(summaryContent).write(); } - core.info(`Successfully created ${createdDiscussions.length} discussion(s)`); } - await main(); - - create_issue: - needs: - - agent - - detection - if: > - (((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'create_issue'))) && - (needs.detection.outputs.success == 'true') - runs-on: ubuntu-slim - permissions: - contents: read - issues: write - timeout-minutes: 10 - outputs: - issue_number: ${{ steps.create_issue.outputs.issue_number }} - issue_url: ${{ steps.create_issue.outputs.issue_url }} - temporary_id_map: ${{ steps.create_issue.outputs.temporary_id_map }} - steps: - - name: Download agent output artifact - continue-on-error: true - uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 - with: - name: agent_output.json - path: /tmp/gh-aw/safeoutputs/ - - name: Setup agent output environment variable - run: | - mkdir -p /tmp/gh-aw/safeoutputs/ - find "/tmp/gh-aw/safeoutputs/" -type f -print - echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" - - name: Create Output Issue - id: create_issue - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_ISSUE_TITLE_PREFIX: "[🎭 POEM-BOT] " - GH_AW_ISSUE_LABELS: "poetry,automation,ai-generated" - GH_AW_WORKFLOW_NAME: "Poem Bot - A Creative Agentic Workflow" - GH_AW_ENGINE_ID: "copilot" - GH_AW_ENGINE_MODEL: "gpt-5" - GH_AW_SAFE_OUTPUTS_STAGED: "true" - GH_AW_SAFE_OUTPUT_MESSAGES: "{\"footer\":\"\\u003e 🪶 *Verses penned by [{workflow_name}]({run_url})*\",\"runStarted\":\"🎭 Hear ye! The muse stirs! [{workflow_name}]({run_url}) takes quill in hand for this {event_type}...\",\"runSuccess\":\"🪶 The poem is writ! [{workflow_name}]({run_url}) has composed verses most fair. Applause! 👏\",\"runFailure\":\"🎭 Alas! [{workflow_name}]({run_url}) {status}. The muse has fled, leaving verses unsung...\"}" - with: - github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - script: | - const { sanitizeLabelContent } = require("./sanitize_label_content.cjs"); - const { loadAgentOutput } = require("./load_agent_output.cjs"); - const { generateStagedPreview } = require("./staged_preview.cjs"); - const { generateFooter } = require("./generate_footer.cjs"); - const { getTrackerID } = require("./get_tracker_id.cjs"); - const { - generateTemporaryId, - isTemporaryId, - normalizeTemporaryId, - replaceTemporaryIdReferences, - serializeTemporaryIdMap, - } = require("./temporary_id.cjs"); - const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require("./repo_helpers.cjs"); - const { addExpirationComment } = require("./expiration_helpers.cjs"); - const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); + function removeDuplicateTitleFromDescription(title, description) { + if (!title || typeof title !== "string") { + return description || ""; + } + if (!description || typeof description !== "string") { + return ""; + } + const trimmedTitle = title.trim(); + const trimmedDescription = description.trim(); + if (!trimmedTitle || !trimmedDescription) { + return trimmedDescription; + } + const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); + if (headerRegex.test(trimmedDescription)) { + return trimmedDescription.replace(headerRegex, "").trim(); + } + return trimmedDescription; + } async function main() { core.setOutput("issue_number", ""); core.setOutput("issue_url", ""); @@ -9992,102 +10649,243 @@ jobs: throw error; } } - if (createdComments.length > 0) { - let summaryContent = "\n\n## GitHub PR Review Comments\n"; - for (const comment of createdComments) { - summaryContent += `- Review Comment #${comment.id}: [View Comment](${comment.html_url})\n`; + if (createdComments.length > 0) { + let summaryContent = "\n\n## GitHub PR Review Comments\n"; + for (const comment of createdComments) { + summaryContent += `- Review Comment #${comment.id}: [View Comment](${comment.html_url})\n`; + } + await core.summary.addRaw(summaryContent).write(); + } + core.info(`Successfully created ${createdComments.length} review comment(s)`); + return createdComments; + } + await main(); + + create_pull_request: + needs: + - activation + - agent + - detection + if: > + (((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'create_pull_request'))) && + (needs.detection.outputs.success == 'true') + runs-on: ubuntu-slim + permissions: + contents: write + issues: write + pull-requests: write + timeout-minutes: 10 + outputs: + branch_name: ${{ steps.create_pull_request.outputs.branch_name }} + fallback_used: ${{ steps.create_pull_request.outputs.fallback_used }} + issue_number: ${{ steps.create_pull_request.outputs.issue_number }} + issue_url: ${{ steps.create_pull_request.outputs.issue_url }} + pull_request_number: ${{ steps.create_pull_request.outputs.pull_request_number }} + pull_request_url: ${{ steps.create_pull_request.outputs.pull_request_url }} + steps: + - name: Download patch artifact + continue-on-error: true + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 + with: + name: aw.patch + path: /tmp/gh-aw/ + - name: Checkout repository + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 + with: + persist-credentials: false + fetch-depth: 0 + - name: Configure Git credentials + env: + REPO_NAME: ${{ github.repository }} + SERVER_URL: ${{ github.server_url }} + run: | + git config --global user.email "github-actions[bot]@users.noreply.github.com" + git config --global user.name "github-actions[bot]" + # Re-authenticate git with GitHub token + SERVER_URL_STRIPPED="${SERVER_URL#https://}" + git remote set-url origin "https://x-access-token:${{ github.token }}@${SERVER_URL_STRIPPED}/${REPO_NAME}.git" + echo "Git configured with standard GitHub Actions identity" + - name: Download agent output artifact + continue-on-error: true + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 + with: + name: agent_output.json + path: /tmp/gh-aw/safeoutputs/ + - name: Setup agent output environment variable + run: | + mkdir -p /tmp/gh-aw/safeoutputs/ + find "/tmp/gh-aw/safeoutputs/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" + - name: Create Pull Request + id: create_pull_request + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} + GH_AW_WORKFLOW_ID: "agent" + GH_AW_BASE_BRANCH: ${{ github.ref_name }} + GH_AW_PR_TITLE_PREFIX: "[🎨 POETRY] " + GH_AW_PR_LABELS: "poetry,automation,creative-writing" + GH_AW_PR_DRAFT: "false" + GH_AW_PR_IF_NO_CHANGES: "warn" + GH_AW_PR_ALLOW_EMPTY: "false" + GH_AW_MAX_PATCH_SIZE: 1024 + GH_AW_COMMENT_ID: ${{ needs.activation.outputs.comment_id }} + GH_AW_COMMENT_REPO: ${{ needs.activation.outputs.comment_repo }} + GH_AW_WORKFLOW_NAME: "Poem Bot - A Creative Agentic Workflow" + GH_AW_ENGINE_ID: "copilot" + GH_AW_ENGINE_MODEL: "gpt-5" + GH_AW_SAFE_OUTPUTS_STAGED: "true" + GH_AW_SAFE_OUTPUT_MESSAGES: "{\"footer\":\"\\u003e 🪶 *Verses penned by [{workflow_name}]({run_url})*\",\"runStarted\":\"🎭 Hear ye! The muse stirs! [{workflow_name}]({run_url}) takes quill in hand for this {event_type}...\",\"runSuccess\":\"🪶 The poem is writ! [{workflow_name}]({run_url}) has composed verses most fair. Applause! 👏\",\"runFailure\":\"🎭 Alas! [{workflow_name}]({run_url}) {status}. The muse has fled, leaving verses unsung...\"}" + with: + github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + script: | + const fs = require("fs"); + const crypto = require("crypto"); + async function updateActivationComment(github, context, core, itemUrl, itemNumber, itemType = "pull_request") { + const itemLabel = itemType === "issue" ? "issue" : "pull request"; + const linkMessage = + itemType === "issue" + ? `\n\n✅ Issue created: [#${itemNumber}](${itemUrl})` + : `\n\n✅ Pull request created: [#${itemNumber}](${itemUrl})`; + await updateActivationCommentWithMessage(github, context, core, linkMessage, itemLabel); + } + async function updateActivationCommentWithCommit(github, context, core, commitSha, commitUrl) { + const shortSha = commitSha.substring(0, 7); + const message = `\n\n✅ Commit pushed: [\`${shortSha}\`](${commitUrl})`; + await updateActivationCommentWithMessage(github, context, core, message, "commit"); + } + async function updateActivationCommentWithMessage(github, context, core, message, label = "") { + const commentId = process.env.GH_AW_COMMENT_ID; + const commentRepo = process.env.GH_AW_COMMENT_REPO; + if (!commentId) { + core.info("No activation comment to update (GH_AW_COMMENT_ID not set)"); + return; + } + core.info(`Updating activation comment ${commentId}`); + let repoOwner = context.repo.owner; + let repoName = context.repo.repo; + if (commentRepo) { + const parts = commentRepo.split("/"); + if (parts.length === 2) { + repoOwner = parts[0]; + repoName = parts[1]; + } else { + core.warning(`Invalid comment repo format: ${commentRepo}, expected "owner/repo". Falling back to context.repo.`); + } + } + core.info(`Updating comment in ${repoOwner}/${repoName}`); + const isDiscussionComment = commentId.startsWith("DC_"); + try { + if (isDiscussionComment) { + const currentComment = await github.graphql( + ` + query($commentId: ID!) { + node(id: $commentId) { + ... on DiscussionComment { + body + } + } + }`, + { commentId: commentId } + ); + if (!currentComment?.node?.body) { + core.warning("Unable to fetch current comment body, comment may have been deleted or is inaccessible"); + return; + } + const currentBody = currentComment.node.body; + const updatedBody = currentBody + message; + const result = await github.graphql( + ` + mutation($commentId: ID!, $body: String!) { + updateDiscussionComment(input: { commentId: $commentId, body: $body }) { + comment { + id + url + } + } + }`, + { commentId: commentId, body: updatedBody } + ); + const comment = result.updateDiscussionComment.comment; + const successMessage = label + ? `Successfully updated discussion comment with ${label} link` + : "Successfully updated discussion comment"; + core.info(successMessage); + core.info(`Comment ID: ${comment.id}`); + core.info(`Comment URL: ${comment.url}`); + } else { + const currentComment = await github.request("GET /repos/{owner}/{repo}/issues/comments/{comment_id}", { + owner: repoOwner, + repo: repoName, + comment_id: parseInt(commentId, 10), + headers: { + Accept: "application/vnd.github+json", + }, + }); + if (!currentComment?.data?.body) { + core.warning("Unable to fetch current comment body, comment may have been deleted"); + return; + } + const currentBody = currentComment.data.body; + const updatedBody = currentBody + message; + const response = await github.request("PATCH /repos/{owner}/{repo}/issues/comments/{comment_id}", { + owner: repoOwner, + repo: repoName, + comment_id: parseInt(commentId, 10), + body: updatedBody, + headers: { + Accept: "application/vnd.github+json", + }, + }); + const successMessage = label ? `Successfully updated comment with ${label} link` : "Successfully updated comment"; + core.info(successMessage); + core.info(`Comment ID: ${response.data.id}`); + core.info(`Comment URL: ${response.data.html_url}`); + } + } catch (error) { + core.warning(`Failed to update activation comment: ${error instanceof Error ? error.message : String(error)}`); + } + } + function getTrackerID(format) { + const trackerID = process.env.GH_AW_TRACKER_ID || ""; + if (trackerID) { + core.info(`Tracker ID: ${trackerID}`); + return format === "markdown" ? `\n\n` : trackerID; + } + return ""; + } + function addExpirationComment(bodyLines, envVarName, entityType) { + const expiresEnv = process.env[envVarName]; + if (expiresEnv) { + const expiresDays = parseInt(expiresEnv, 10); + if (!isNaN(expiresDays) && expiresDays > 0) { + const expirationDate = new Date(); + expirationDate.setDate(expirationDate.getDate() + expiresDays); + const expirationISO = expirationDate.toISOString(); + bodyLines.push(``); + core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); } - await core.summary.addRaw(summaryContent).write(); } - core.info(`Successfully created ${createdComments.length} review comment(s)`); - return createdComments; } - await main(); - - create_pull_request: - needs: - - activation - - agent - - detection - if: > - (((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'create_pull_request'))) && - (needs.detection.outputs.success == 'true') - runs-on: ubuntu-slim - permissions: - contents: write - issues: write - pull-requests: write - timeout-minutes: 10 - outputs: - branch_name: ${{ steps.create_pull_request.outputs.branch_name }} - fallback_used: ${{ steps.create_pull_request.outputs.fallback_used }} - issue_number: ${{ steps.create_pull_request.outputs.issue_number }} - issue_url: ${{ steps.create_pull_request.outputs.issue_url }} - pull_request_number: ${{ steps.create_pull_request.outputs.pull_request_number }} - pull_request_url: ${{ steps.create_pull_request.outputs.pull_request_url }} - steps: - - name: Download patch artifact - continue-on-error: true - uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 - with: - name: aw.patch - path: /tmp/gh-aw/ - - name: Checkout repository - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 - with: - persist-credentials: false - fetch-depth: 0 - - name: Configure Git credentials - env: - REPO_NAME: ${{ github.repository }} - SERVER_URL: ${{ github.server_url }} - run: | - git config --global user.email "github-actions[bot]@users.noreply.github.com" - git config --global user.name "github-actions[bot]" - # Re-authenticate git with GitHub token - SERVER_URL_STRIPPED="${SERVER_URL#https://}" - git remote set-url origin "https://x-access-token:${{ github.token }}@${SERVER_URL_STRIPPED}/${REPO_NAME}.git" - echo "Git configured with standard GitHub Actions identity" - - name: Download agent output artifact - continue-on-error: true - uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 - with: - name: agent_output.json - path: /tmp/gh-aw/safeoutputs/ - - name: Setup agent output environment variable - run: | - mkdir -p /tmp/gh-aw/safeoutputs/ - find "/tmp/gh-aw/safeoutputs/" -type f -print - echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" - - name: Create Pull Request - id: create_pull_request - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_WORKFLOW_ID: "agent" - GH_AW_BASE_BRANCH: ${{ github.ref_name }} - GH_AW_PR_TITLE_PREFIX: "[🎨 POETRY] " - GH_AW_PR_LABELS: "poetry,automation,creative-writing" - GH_AW_PR_DRAFT: "false" - GH_AW_PR_IF_NO_CHANGES: "warn" - GH_AW_PR_ALLOW_EMPTY: "false" - GH_AW_MAX_PATCH_SIZE: 1024 - GH_AW_COMMENT_ID: ${{ needs.activation.outputs.comment_id }} - GH_AW_COMMENT_REPO: ${{ needs.activation.outputs.comment_repo }} - GH_AW_WORKFLOW_NAME: "Poem Bot - A Creative Agentic Workflow" - GH_AW_ENGINE_ID: "copilot" - GH_AW_ENGINE_MODEL: "gpt-5" - GH_AW_SAFE_OUTPUTS_STAGED: "true" - GH_AW_SAFE_OUTPUT_MESSAGES: "{\"footer\":\"\\u003e 🪶 *Verses penned by [{workflow_name}]({run_url})*\",\"runStarted\":\"🎭 Hear ye! The muse stirs! [{workflow_name}]({run_url}) takes quill in hand for this {event_type}...\",\"runSuccess\":\"🪶 The poem is writ! [{workflow_name}]({run_url}) has composed verses most fair. Applause! 👏\",\"runFailure\":\"🎭 Alas! [{workflow_name}]({run_url}) {status}. The muse has fled, leaving verses unsung...\"}" - with: - github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - script: | - const fs = require("fs"); - const crypto = require("crypto"); - const { updateActivationComment } = require("./update_activation_comment.cjs"); - const { getTrackerID } = require("./get_tracker_id.cjs"); - const { addExpirationComment } = require("./expiration_helpers.cjs"); - const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); + function removeDuplicateTitleFromDescription(title, description) { + if (!title || typeof title !== "string") { + return description || ""; + } + if (!description || typeof description !== "string") { + return ""; + } + const trimmedTitle = title.trim(); + const trimmedDescription = description.trim(); + if (!trimmedTitle || !trimmedDescription) { + return trimmedDescription; + } + const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); + if (headerRegex.test(trimmedDescription)) { + return trimmedDescription.replace(headerRegex, "").trim(); + } + return trimmedDescription; + } function generatePatchPreview(patchContent) { if (!patchContent || !patchContent.trim()) { return ""; @@ -12146,7 +12944,296 @@ jobs: with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - const { runUpdateWorkflow, createRenderStagedItem, createGetSummaryLine } = require("./update_runner.cjs"); + const fs = require("fs"); + const MAX_LOG_CONTENT_LENGTH = 10000; + function truncateForLogging(content) { + if (content.length <= MAX_LOG_CONTENT_LENGTH) { + return content; + } + return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; + } + function loadAgentOutput() { + const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; + if (!agentOutputFile) { + core.info("No GH_AW_AGENT_OUTPUT environment variable found"); + return { success: false }; + } + let outputContent; + try { + outputContent = fs.readFileSync(agentOutputFile, "utf8"); + } catch (error) { + const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; + core.error(errorMessage); + return { success: false, error: errorMessage }; + } + if (outputContent.trim() === "") { + core.info("Agent output content is empty"); + return { success: false }; + } + core.info(`Agent output content length: ${outputContent.length}`); + let validatedOutput; + try { + validatedOutput = JSON.parse(outputContent); + } catch (error) { + const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; + core.error(errorMessage); + core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); + return { success: false, error: errorMessage }; + } + if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { + core.info("No valid items found in agent output"); + core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); + return { success: false }; + } + return { success: true, items: validatedOutput.items }; + } + async function generateStagedPreview(options) { + const { title, description, items, renderItem } = options; + let summaryContent = `## 🎭 Staged Mode: ${title} Preview\n\n`; + summaryContent += `${description}\n\n`; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + summaryContent += renderItem(item, i); + summaryContent += "---\n\n"; + } + try { + await core.summary.addRaw(summaryContent).write(); + core.info(summaryContent); + core.info(`📝 ${title} preview written to step summary`); + } catch (error) { + core.setFailed(error instanceof Error ? error : String(error)); + } + } + function removeDuplicateTitleFromDescription(title, description) { + if (!title || typeof title !== "string") { + return description || ""; + } + if (!description || typeof description !== "string") { + return ""; + } + const trimmedTitle = title.trim(); + const trimmedDescription = description.trim(); + if (!trimmedTitle || !trimmedDescription) { + return trimmedDescription; + } + const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); + if (headerRegex.test(trimmedDescription)) { + return trimmedDescription.replace(headerRegex, "").trim(); + } + return trimmedDescription; + } + function resolveTargetNumber(params) { + const { updateTarget, item, numberField, isValidContext, contextNumber, displayName } = params; + if (updateTarget === "*") { + const explicitNumber = item[numberField]; + if (explicitNumber) { + const parsed = parseInt(explicitNumber, 10); + if (isNaN(parsed) || parsed <= 0) { + return { success: false, error: `Invalid ${numberField} specified: ${explicitNumber}` }; + } + return { success: true, number: parsed }; + } else { + return { success: false, error: `Target is "*" but no ${numberField} specified in update item` }; + } + } else if (updateTarget && updateTarget !== "triggering") { + const parsed = parseInt(updateTarget, 10); + if (isNaN(parsed) || parsed <= 0) { + return { success: false, error: `Invalid ${displayName} number in target configuration: ${updateTarget}` }; + } + return { success: true, number: parsed }; + } else { + if (isValidContext && contextNumber) { + return { success: true, number: contextNumber }; + } + return { success: false, error: `Could not determine ${displayName} number` }; + } + } + function buildUpdateData(params) { + const { item, canUpdateStatus, canUpdateTitle, canUpdateBody, supportsStatus } = params; + const updateData = {}; + let hasUpdates = false; + const logMessages = []; + if (supportsStatus && canUpdateStatus && item.status !== undefined) { + if (item.status === "open" || item.status === "closed") { + updateData.state = item.status; + hasUpdates = true; + logMessages.push(`Will update status to: ${item.status}`); + } else { + logMessages.push(`Invalid status value: ${item.status}. Must be 'open' or 'closed'`); + } + } + let titleForDedup = null; + if (canUpdateTitle && item.title !== undefined) { + const trimmedTitle = typeof item.title === "string" ? item.title.trim() : ""; + if (trimmedTitle.length > 0) { + updateData.title = trimmedTitle; + titleForDedup = trimmedTitle; + hasUpdates = true; + logMessages.push(`Will update title to: ${trimmedTitle}`); + } else { + logMessages.push("Invalid title value: must be a non-empty string"); + } + } + if (canUpdateBody && item.body !== undefined) { + if (typeof item.body === "string") { + let processedBody = item.body; + if (titleForDedup) { + processedBody = removeDuplicateTitleFromDescription(titleForDedup, processedBody); + } + updateData.body = processedBody; + hasUpdates = true; + logMessages.push(`Will update body (length: ${processedBody.length})`); + } else { + logMessages.push("Invalid body value: must be a string"); + } + } + return { hasUpdates, updateData, logMessages }; + } + async function runUpdateWorkflow(config) { + const { + itemType, + displayName, + displayNamePlural, + numberField, + outputNumberKey, + outputUrlKey, + isValidContext, + getContextNumber, + supportsStatus, + supportsOperation, + renderStagedItem, + executeUpdate, + getSummaryLine, + } = config; + const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; + const result = loadAgentOutput(); + if (!result.success) { + return; + } + const updateItems = result.items.filter( item => item.type === itemType); + if (updateItems.length === 0) { + core.info(`No ${itemType} items found in agent output`); + return; + } + core.info(`Found ${updateItems.length} ${itemType} item(s)`); + if (isStaged) { + await generateStagedPreview({ + title: `Update ${displayNamePlural.charAt(0).toUpperCase() + displayNamePlural.slice(1)}`, + description: `The following ${displayName} updates would be applied if staged mode was disabled:`, + items: updateItems, + renderItem: renderStagedItem, + }); + return; + } + const updateTarget = process.env.GH_AW_UPDATE_TARGET || "triggering"; + const canUpdateStatus = process.env.GH_AW_UPDATE_STATUS === "true"; + const canUpdateTitle = process.env.GH_AW_UPDATE_TITLE === "true"; + const canUpdateBody = process.env.GH_AW_UPDATE_BODY === "true"; + core.info(`Update target configuration: ${updateTarget}`); + if (supportsStatus) { + core.info(`Can update status: ${canUpdateStatus}, title: ${canUpdateTitle}, body: ${canUpdateBody}`); + } else { + core.info(`Can update title: ${canUpdateTitle}, body: ${canUpdateBody}`); + } + const contextIsValid = isValidContext(context.eventName, context.payload); + const contextNumber = getContextNumber(context.payload); + if (updateTarget === "triggering" && !contextIsValid) { + core.info(`Target is "triggering" but not running in ${displayName} context, skipping ${displayName} update`); + return; + } + const updatedItems = []; + for (let i = 0; i < updateItems.length; i++) { + const updateItem = updateItems[i]; + core.info(`Processing ${itemType} item ${i + 1}/${updateItems.length}`); + const targetResult = resolveTargetNumber({ + updateTarget, + item: updateItem, + numberField, + isValidContext: contextIsValid, + contextNumber, + displayName, + }); + if (!targetResult.success) { + core.info(targetResult.error); + continue; + } + const targetNumber = targetResult.number; + core.info(`Updating ${displayName} #${targetNumber}`); + const { hasUpdates, updateData, logMessages } = buildUpdateData({ + item: updateItem, + canUpdateStatus, + canUpdateTitle, + canUpdateBody, + supportsStatus, + }); + for (const msg of logMessages) { + core.info(msg); + } + if (supportsOperation && canUpdateBody && updateItem.body !== undefined && typeof updateItem.body === "string") { + updateData._operation = updateItem.operation || "append"; + updateData._rawBody = updateItem.body; + } + if (!hasUpdates) { + core.info("No valid updates to apply for this item"); + continue; + } + try { + const updatedItem = await executeUpdate(github, context, targetNumber, updateData); + core.info(`Updated ${displayName} #${updatedItem.number}: ${updatedItem.html_url}`); + updatedItems.push(updatedItem); + if (i === updateItems.length - 1) { + core.setOutput(outputNumberKey, updatedItem.number); + core.setOutput(outputUrlKey, updatedItem.html_url); + } + } catch (error) { + core.error(`✗ Failed to update ${displayName} #${targetNumber}: ${error instanceof Error ? error.message : String(error)}`); + throw error; + } + } + if (updatedItems.length > 0) { + let summaryContent = `\n\n## Updated ${displayNamePlural.charAt(0).toUpperCase() + displayNamePlural.slice(1)}\n`; + for (const item of updatedItems) { + summaryContent += getSummaryLine(item); + } + await core.summary.addRaw(summaryContent).write(); + } + core.info(`Successfully updated ${updatedItems.length} ${displayName}(s)`); + return updatedItems; + } + function createRenderStagedItem(config) { + const { entityName, numberField, targetLabel, currentTargetText, includeOperation = false } = config; + return function renderStagedItem(item, index) { + let content = `### ${entityName} Update ${index + 1}\n`; + if (item[numberField]) { + content += `**${targetLabel}** #${item[numberField]}\n\n`; + } else { + content += `**Target:** ${currentTargetText}\n\n`; + } + if (item.title !== undefined) { + content += `**New Title:** ${item.title}\n\n`; + } + if (item.body !== undefined) { + if (includeOperation) { + const operation = item.operation || "append"; + content += `**Operation:** ${operation}\n`; + content += `**Body Content:**\n${item.body}\n\n`; + } else { + content += `**New Body:**\n${item.body}\n\n`; + } + } + if (item.status !== undefined) { + content += `**New Status:** ${item.status}\n\n`; + } + return content; + }; + } + function createGetSummaryLine(config) { + const { entityPrefix } = config; + return function getSummaryLine(item) { + return `- ${entityPrefix} #${item.number}: [${item.title}](${item.html_url})\n`; + }; + } function isIssueContext(eventName, _payload) { return eventName === "issues" || eventName === "issue_comment"; } diff --git a/.github/workflows/portfolio-analyst.lock.yml b/.github/workflows/portfolio-analyst.lock.yml index 85c1cc68e69..f874e695a6f 100644 --- a/.github/workflows/portfolio-analyst.lock.yml +++ b/.github/workflows/portfolio-analyst.lock.yml @@ -7022,13 +7022,400 @@ jobs: with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - const { loadAgentOutput } = require("./load_agent_output.cjs"); - const { getTrackerID } = require("./get_tracker_id.cjs"); - const { closeOlderDiscussions } = require("./close_older_discussions.cjs"); - const { replaceTemporaryIdReferences, loadTemporaryIdMap } = require("./temporary_id.cjs"); - const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require("./repo_helpers.cjs"); - const { addExpirationComment } = require("./expiration_helpers.cjs"); - const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); + const fs = require("fs"); + const crypto = require("crypto"); + const MAX_LOG_CONTENT_LENGTH = 10000; + function truncateForLogging(content) { + if (content.length <= MAX_LOG_CONTENT_LENGTH) { + return content; + } + return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; + } + function loadAgentOutput() { + const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; + if (!agentOutputFile) { + core.info("No GH_AW_AGENT_OUTPUT environment variable found"); + return { success: false }; + } + let outputContent; + try { + outputContent = fs.readFileSync(agentOutputFile, "utf8"); + } catch (error) { + const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; + core.error(errorMessage); + return { success: false, error: errorMessage }; + } + if (outputContent.trim() === "") { + core.info("Agent output content is empty"); + return { success: false }; + } + core.info(`Agent output content length: ${outputContent.length}`); + let validatedOutput; + try { + validatedOutput = JSON.parse(outputContent); + } catch (error) { + const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; + core.error(errorMessage); + core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); + return { success: false, error: errorMessage }; + } + if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { + core.info("No valid items found in agent output"); + core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); + return { success: false }; + } + return { success: true, items: validatedOutput.items }; + } + function getTrackerID(format) { + const trackerID = process.env.GH_AW_TRACKER_ID || ""; + if (trackerID) { + core.info(`Tracker ID: ${trackerID}`); + return format === "markdown" ? `\n\n` : trackerID; + } + return ""; + } + function getMessages() { + const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES; + if (!messagesEnv) { + return null; + } + try { + return JSON.parse(messagesEnv); + } catch (error) { + core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`); + return null; + } + } + function renderTemplate(template, context) { + return template.replace(/\{(\w+)\}/g, (match, key) => { + const value = context[key]; + return value !== undefined && value !== null ? String(value) : match; + }); + } + function toSnakeCase(obj) { + const result = {}; + for (const [key, value] of Object.entries(obj)) { + const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase(); + result[snakeKey] = value; + result[key] = value; + } + return result; + } + function getCloseOlderDiscussionMessage(ctx) { + const messages = getMessages(); + const templateContext = toSnakeCase(ctx); + const defaultMessage = `⚓ Avast! This discussion be marked as **outdated** by [{workflow_name}]({run_url}). + 🗺️ A newer treasure map awaits ye at **[Discussion #{new_discussion_number}]({new_discussion_url})**. + Fair winds, matey! 🏴‍☠️`; + return messages?.closeOlderDiscussion + ? renderTemplate(messages.closeOlderDiscussion, templateContext) + : renderTemplate(defaultMessage, templateContext); + } + const MAX_CLOSE_COUNT = 10; + const GRAPHQL_DELAY_MS = 500; + function delay(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); + } + async function searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, excludeNumber) { + let searchQuery = `repo:${owner}/${repo} is:open`; + if (titlePrefix) { + const escapedPrefix = titlePrefix.replace(/"/g, '\\"'); + searchQuery += ` in:title "${escapedPrefix}"`; + } + if (labels && labels.length > 0) { + for (const label of labels) { + const escapedLabel = label.replace(/"/g, '\\"'); + searchQuery += ` label:"${escapedLabel}"`; + } + } + const result = await github.graphql( + ` + query($searchTerms: String!, $first: Int!) { + search(query: $searchTerms, type: DISCUSSION, first: $first) { + nodes { + ... on Discussion { + id + number + title + url + category { + id + } + labels(first: 100) { + nodes { + name + } + } + closed + } + } + } + }`, + { searchTerms: searchQuery, first: 50 } + ); + if (!result || !result.search || !result.search.nodes) { + return []; + } + return result.search.nodes + .filter( + d => { + if (!d || d.number === excludeNumber || d.closed) { + return false; + } + if (titlePrefix && d.title && !d.title.startsWith(titlePrefix)) { + return false; + } + if (labels && labels.length > 0) { + const discussionLabels = d.labels?.nodes?.map(( l) => l.name) || []; + const hasAllLabels = labels.every(label => discussionLabels.includes(label)); + if (!hasAllLabels) { + return false; + } + } + if (categoryId && (!d.category || d.category.id !== categoryId)) { + return false; + } + return true; + } + ) + .map( + d => ({ + id: d.id, + number: d.number, + title: d.title, + url: d.url, + }) + ); + } + async function addDiscussionComment(github, discussionId, message) { + const result = await github.graphql( + ` + mutation($dId: ID!, $body: String!) { + addDiscussionComment(input: { discussionId: $dId, body: $body }) { + comment { + id + url + } + } + }`, + { dId: discussionId, body: message } + ); + return result.addDiscussionComment.comment; + } + async function closeDiscussionAsOutdated(github, discussionId) { + const result = await github.graphql( + ` + mutation($dId: ID!) { + closeDiscussion(input: { discussionId: $dId, reason: OUTDATED }) { + discussion { + id + url + } + } + }`, + { dId: discussionId } + ); + return result.closeDiscussion.discussion; + } + async function closeOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion, workflowName, runUrl) { + const searchCriteria = []; + if (titlePrefix) searchCriteria.push(`title prefix: "${titlePrefix}"`); + if (labels && labels.length > 0) searchCriteria.push(`labels: [${labels.join(", ")}]`); + core.info(`Searching for older discussions with ${searchCriteria.join(" and ")}`); + const olderDiscussions = await searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion.number); + if (olderDiscussions.length === 0) { + core.info("No older discussions found to close"); + return []; + } + core.info(`Found ${olderDiscussions.length} older discussion(s) to close`); + const discussionsToClose = olderDiscussions.slice(0, MAX_CLOSE_COUNT); + if (olderDiscussions.length > MAX_CLOSE_COUNT) { + core.warning(`Found ${olderDiscussions.length} older discussions, but only closing the first ${MAX_CLOSE_COUNT}`); + } + const closedDiscussions = []; + for (let i = 0; i < discussionsToClose.length; i++) { + const discussion = discussionsToClose[i]; + try { + const closingMessage = getCloseOlderDiscussionMessage({ + newDiscussionUrl: newDiscussion.url, + newDiscussionNumber: newDiscussion.number, + workflowName, + runUrl, + }); + core.info(`Adding closing comment to discussion #${discussion.number}`); + await addDiscussionComment(github, discussion.id, closingMessage); + core.info(`Closing discussion #${discussion.number} as outdated`); + await closeDiscussionAsOutdated(github, discussion.id); + closedDiscussions.push({ + number: discussion.number, + url: discussion.url, + }); + core.info(`✓ Closed discussion #${discussion.number}: ${discussion.url}`); + } catch (error) { + core.error(`✗ Failed to close discussion #${discussion.number}: ${error instanceof Error ? error.message : String(error)}`); + } + if (i < discussionsToClose.length - 1) { + await delay(GRAPHQL_DELAY_MS); + } + } + return closedDiscussions; + } + const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; + function generateTemporaryId() { + return "aw_" + crypto.randomBytes(6).toString("hex"); + } + function isTemporaryId(value) { + if (typeof value === "string") { + return /^aw_[0-9a-f]{12}$/i.test(value); + } + return false; + } + function normalizeTemporaryId(tempId) { + return String(tempId).toLowerCase(); + } + function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { + return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { + const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); + if (resolved !== undefined) { + if (currentRepo && resolved.repo === currentRepo) { + return `#${resolved.number}`; + } + return `${resolved.repo}#${resolved.number}`; + } + return match; + }); + } + function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { + return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { + const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); + if (issueNumber !== undefined) { + return `#${issueNumber}`; + } + return match; + }); + } + function loadTemporaryIdMap() { + const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; + if (!mapJson || mapJson === "{}") { + return new Map(); + } + try { + const mapObject = JSON.parse(mapJson); + const result = new Map(); + for (const [key, value] of Object.entries(mapObject)) { + const normalizedKey = normalizeTemporaryId(key); + if (typeof value === "number") { + const contextRepo = `${context.repo.owner}/${context.repo.repo}`; + result.set(normalizedKey, { repo: contextRepo, number: value }); + } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { + result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); + } + } + return result; + } catch (error) { + if (typeof core !== "undefined") { + core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); + } + return new Map(); + } + } + function resolveIssueNumber(value, temporaryIdMap) { + if (value === undefined || value === null) { + return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; + } + const valueStr = String(value); + if (isTemporaryId(valueStr)) { + const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); + if (resolvedPair !== undefined) { + return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; + } + return { + resolved: null, + wasTemporaryId: true, + errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, + }; + } + const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); + if (isNaN(issueNumber) || issueNumber <= 0) { + return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; + } + const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; + return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; + } + function serializeTemporaryIdMap(tempIdMap) { + const obj = Object.fromEntries(tempIdMap); + return JSON.stringify(obj); + } + function parseAllowedRepos() { + const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; + const set = new Set(); + if (allowedReposEnv) { + allowedReposEnv + .split(",") + .map(repo => repo.trim()) + .filter(repo => repo) + .forEach(repo => set.add(repo)); + } + return set; + } + function getDefaultTargetRepo() { + const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; + if (targetRepoSlug) { + return targetRepoSlug; + } + return `${context.repo.owner}/${context.repo.repo}`; + } + function validateRepo(repo, defaultRepo, allowedRepos) { + if (repo === defaultRepo) { + return { valid: true, error: null }; + } + if (allowedRepos.has(repo)) { + return { valid: true, error: null }; + } + return { + valid: false, + error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, + }; + } + function parseRepoSlug(repoSlug) { + const parts = repoSlug.split("/"); + if (parts.length !== 2 || !parts[0] || !parts[1]) { + return null; + } + return { owner: parts[0], repo: parts[1] }; + } + function addExpirationComment(bodyLines, envVarName, entityType) { + const expiresEnv = process.env[envVarName]; + if (expiresEnv) { + const expiresDays = parseInt(expiresEnv, 10); + if (!isNaN(expiresDays) && expiresDays > 0) { + const expirationDate = new Date(); + expirationDate.setDate(expirationDate.getDate() + expiresDays); + const expirationISO = expirationDate.toISOString(); + bodyLines.push(``); + core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); + } + } + } + function removeDuplicateTitleFromDescription(title, description) { + if (!title || typeof title !== "string") { + return description || ""; + } + if (!description || typeof description !== "string") { + return ""; + } + const trimmedTitle = title.trim(); + const trimmedDescription = description.trim(); + if (!trimmedTitle || !trimmedDescription) { + return trimmedDescription; + } + const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); + if (headerRegex.test(trimmedDescription)) { + return trimmedDescription.replace(headerRegex, "").trim(); + } + return trimmedDescription; + } async function fetchRepoDiscussionInfo(owner, repo) { const repositoryQuery = ` query($owner: String!, $repo: String!) { diff --git a/.github/workflows/pr-nitpick-reviewer.lock.yml b/.github/workflows/pr-nitpick-reviewer.lock.yml index 5ae3161935a..4c61a5607ac 100644 --- a/.github/workflows/pr-nitpick-reviewer.lock.yml +++ b/.github/workflows/pr-nitpick-reviewer.lock.yml @@ -7950,13 +7950,400 @@ jobs: with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - const { loadAgentOutput } = require("./load_agent_output.cjs"); - const { getTrackerID } = require("./get_tracker_id.cjs"); - const { closeOlderDiscussions } = require("./close_older_discussions.cjs"); - const { replaceTemporaryIdReferences, loadTemporaryIdMap } = require("./temporary_id.cjs"); - const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require("./repo_helpers.cjs"); - const { addExpirationComment } = require("./expiration_helpers.cjs"); - const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); + const fs = require("fs"); + const crypto = require("crypto"); + const MAX_LOG_CONTENT_LENGTH = 10000; + function truncateForLogging(content) { + if (content.length <= MAX_LOG_CONTENT_LENGTH) { + return content; + } + return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; + } + function loadAgentOutput() { + const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; + if (!agentOutputFile) { + core.info("No GH_AW_AGENT_OUTPUT environment variable found"); + return { success: false }; + } + let outputContent; + try { + outputContent = fs.readFileSync(agentOutputFile, "utf8"); + } catch (error) { + const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; + core.error(errorMessage); + return { success: false, error: errorMessage }; + } + if (outputContent.trim() === "") { + core.info("Agent output content is empty"); + return { success: false }; + } + core.info(`Agent output content length: ${outputContent.length}`); + let validatedOutput; + try { + validatedOutput = JSON.parse(outputContent); + } catch (error) { + const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; + core.error(errorMessage); + core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); + return { success: false, error: errorMessage }; + } + if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { + core.info("No valid items found in agent output"); + core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); + return { success: false }; + } + return { success: true, items: validatedOutput.items }; + } + function getTrackerID(format) { + const trackerID = process.env.GH_AW_TRACKER_ID || ""; + if (trackerID) { + core.info(`Tracker ID: ${trackerID}`); + return format === "markdown" ? `\n\n` : trackerID; + } + return ""; + } + function getMessages() { + const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES; + if (!messagesEnv) { + return null; + } + try { + return JSON.parse(messagesEnv); + } catch (error) { + core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`); + return null; + } + } + function renderTemplate(template, context) { + return template.replace(/\{(\w+)\}/g, (match, key) => { + const value = context[key]; + return value !== undefined && value !== null ? String(value) : match; + }); + } + function toSnakeCase(obj) { + const result = {}; + for (const [key, value] of Object.entries(obj)) { + const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase(); + result[snakeKey] = value; + result[key] = value; + } + return result; + } + function getCloseOlderDiscussionMessage(ctx) { + const messages = getMessages(); + const templateContext = toSnakeCase(ctx); + const defaultMessage = `⚓ Avast! This discussion be marked as **outdated** by [{workflow_name}]({run_url}). + 🗺️ A newer treasure map awaits ye at **[Discussion #{new_discussion_number}]({new_discussion_url})**. + Fair winds, matey! 🏴‍☠️`; + return messages?.closeOlderDiscussion + ? renderTemplate(messages.closeOlderDiscussion, templateContext) + : renderTemplate(defaultMessage, templateContext); + } + const MAX_CLOSE_COUNT = 10; + const GRAPHQL_DELAY_MS = 500; + function delay(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); + } + async function searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, excludeNumber) { + let searchQuery = `repo:${owner}/${repo} is:open`; + if (titlePrefix) { + const escapedPrefix = titlePrefix.replace(/"/g, '\\"'); + searchQuery += ` in:title "${escapedPrefix}"`; + } + if (labels && labels.length > 0) { + for (const label of labels) { + const escapedLabel = label.replace(/"/g, '\\"'); + searchQuery += ` label:"${escapedLabel}"`; + } + } + const result = await github.graphql( + ` + query($searchTerms: String!, $first: Int!) { + search(query: $searchTerms, type: DISCUSSION, first: $first) { + nodes { + ... on Discussion { + id + number + title + url + category { + id + } + labels(first: 100) { + nodes { + name + } + } + closed + } + } + } + }`, + { searchTerms: searchQuery, first: 50 } + ); + if (!result || !result.search || !result.search.nodes) { + return []; + } + return result.search.nodes + .filter( + d => { + if (!d || d.number === excludeNumber || d.closed) { + return false; + } + if (titlePrefix && d.title && !d.title.startsWith(titlePrefix)) { + return false; + } + if (labels && labels.length > 0) { + const discussionLabels = d.labels?.nodes?.map(( l) => l.name) || []; + const hasAllLabels = labels.every(label => discussionLabels.includes(label)); + if (!hasAllLabels) { + return false; + } + } + if (categoryId && (!d.category || d.category.id !== categoryId)) { + return false; + } + return true; + } + ) + .map( + d => ({ + id: d.id, + number: d.number, + title: d.title, + url: d.url, + }) + ); + } + async function addDiscussionComment(github, discussionId, message) { + const result = await github.graphql( + ` + mutation($dId: ID!, $body: String!) { + addDiscussionComment(input: { discussionId: $dId, body: $body }) { + comment { + id + url + } + } + }`, + { dId: discussionId, body: message } + ); + return result.addDiscussionComment.comment; + } + async function closeDiscussionAsOutdated(github, discussionId) { + const result = await github.graphql( + ` + mutation($dId: ID!) { + closeDiscussion(input: { discussionId: $dId, reason: OUTDATED }) { + discussion { + id + url + } + } + }`, + { dId: discussionId } + ); + return result.closeDiscussion.discussion; + } + async function closeOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion, workflowName, runUrl) { + const searchCriteria = []; + if (titlePrefix) searchCriteria.push(`title prefix: "${titlePrefix}"`); + if (labels && labels.length > 0) searchCriteria.push(`labels: [${labels.join(", ")}]`); + core.info(`Searching for older discussions with ${searchCriteria.join(" and ")}`); + const olderDiscussions = await searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion.number); + if (olderDiscussions.length === 0) { + core.info("No older discussions found to close"); + return []; + } + core.info(`Found ${olderDiscussions.length} older discussion(s) to close`); + const discussionsToClose = olderDiscussions.slice(0, MAX_CLOSE_COUNT); + if (olderDiscussions.length > MAX_CLOSE_COUNT) { + core.warning(`Found ${olderDiscussions.length} older discussions, but only closing the first ${MAX_CLOSE_COUNT}`); + } + const closedDiscussions = []; + for (let i = 0; i < discussionsToClose.length; i++) { + const discussion = discussionsToClose[i]; + try { + const closingMessage = getCloseOlderDiscussionMessage({ + newDiscussionUrl: newDiscussion.url, + newDiscussionNumber: newDiscussion.number, + workflowName, + runUrl, + }); + core.info(`Adding closing comment to discussion #${discussion.number}`); + await addDiscussionComment(github, discussion.id, closingMessage); + core.info(`Closing discussion #${discussion.number} as outdated`); + await closeDiscussionAsOutdated(github, discussion.id); + closedDiscussions.push({ + number: discussion.number, + url: discussion.url, + }); + core.info(`✓ Closed discussion #${discussion.number}: ${discussion.url}`); + } catch (error) { + core.error(`✗ Failed to close discussion #${discussion.number}: ${error instanceof Error ? error.message : String(error)}`); + } + if (i < discussionsToClose.length - 1) { + await delay(GRAPHQL_DELAY_MS); + } + } + return closedDiscussions; + } + const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; + function generateTemporaryId() { + return "aw_" + crypto.randomBytes(6).toString("hex"); + } + function isTemporaryId(value) { + if (typeof value === "string") { + return /^aw_[0-9a-f]{12}$/i.test(value); + } + return false; + } + function normalizeTemporaryId(tempId) { + return String(tempId).toLowerCase(); + } + function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { + return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { + const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); + if (resolved !== undefined) { + if (currentRepo && resolved.repo === currentRepo) { + return `#${resolved.number}`; + } + return `${resolved.repo}#${resolved.number}`; + } + return match; + }); + } + function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { + return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { + const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); + if (issueNumber !== undefined) { + return `#${issueNumber}`; + } + return match; + }); + } + function loadTemporaryIdMap() { + const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; + if (!mapJson || mapJson === "{}") { + return new Map(); + } + try { + const mapObject = JSON.parse(mapJson); + const result = new Map(); + for (const [key, value] of Object.entries(mapObject)) { + const normalizedKey = normalizeTemporaryId(key); + if (typeof value === "number") { + const contextRepo = `${context.repo.owner}/${context.repo.repo}`; + result.set(normalizedKey, { repo: contextRepo, number: value }); + } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { + result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); + } + } + return result; + } catch (error) { + if (typeof core !== "undefined") { + core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); + } + return new Map(); + } + } + function resolveIssueNumber(value, temporaryIdMap) { + if (value === undefined || value === null) { + return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; + } + const valueStr = String(value); + if (isTemporaryId(valueStr)) { + const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); + if (resolvedPair !== undefined) { + return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; + } + return { + resolved: null, + wasTemporaryId: true, + errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, + }; + } + const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); + if (isNaN(issueNumber) || issueNumber <= 0) { + return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; + } + const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; + return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; + } + function serializeTemporaryIdMap(tempIdMap) { + const obj = Object.fromEntries(tempIdMap); + return JSON.stringify(obj); + } + function parseAllowedRepos() { + const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; + const set = new Set(); + if (allowedReposEnv) { + allowedReposEnv + .split(",") + .map(repo => repo.trim()) + .filter(repo => repo) + .forEach(repo => set.add(repo)); + } + return set; + } + function getDefaultTargetRepo() { + const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; + if (targetRepoSlug) { + return targetRepoSlug; + } + return `${context.repo.owner}/${context.repo.repo}`; + } + function validateRepo(repo, defaultRepo, allowedRepos) { + if (repo === defaultRepo) { + return { valid: true, error: null }; + } + if (allowedRepos.has(repo)) { + return { valid: true, error: null }; + } + return { + valid: false, + error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, + }; + } + function parseRepoSlug(repoSlug) { + const parts = repoSlug.split("/"); + if (parts.length !== 2 || !parts[0] || !parts[1]) { + return null; + } + return { owner: parts[0], repo: parts[1] }; + } + function addExpirationComment(bodyLines, envVarName, entityType) { + const expiresEnv = process.env[envVarName]; + if (expiresEnv) { + const expiresDays = parseInt(expiresEnv, 10); + if (!isNaN(expiresDays) && expiresDays > 0) { + const expirationDate = new Date(); + expirationDate.setDate(expirationDate.getDate() + expiresDays); + const expirationISO = expirationDate.toISOString(); + bodyLines.push(``); + core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); + } + } + } + function removeDuplicateTitleFromDescription(title, description) { + if (!title || typeof title !== "string") { + return description || ""; + } + if (!description || typeof description !== "string") { + return ""; + } + const trimmedTitle = title.trim(); + const trimmedDescription = description.trim(); + if (!trimmedTitle || !trimmedDescription) { + return trimmedDescription; + } + const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); + if (headerRegex.test(trimmedDescription)) { + return trimmedDescription.replace(headerRegex, "").trim(); + } + return trimmedDescription; + } async function fetchRepoDiscussionInfo(owner, repo) { const repositoryQuery = ` query($owner: String!, $repo: String!) { diff --git a/.github/workflows/prompt-clustering-analysis.lock.yml b/.github/workflows/prompt-clustering-analysis.lock.yml index b2c4d9da3c9..f8ef762bea3 100644 --- a/.github/workflows/prompt-clustering-analysis.lock.yml +++ b/.github/workflows/prompt-clustering-analysis.lock.yml @@ -7446,13 +7446,400 @@ jobs: with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - const { loadAgentOutput } = require("./load_agent_output.cjs"); - const { getTrackerID } = require("./get_tracker_id.cjs"); - const { closeOlderDiscussions } = require("./close_older_discussions.cjs"); - const { replaceTemporaryIdReferences, loadTemporaryIdMap } = require("./temporary_id.cjs"); - const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require("./repo_helpers.cjs"); - const { addExpirationComment } = require("./expiration_helpers.cjs"); - const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); + const fs = require("fs"); + const crypto = require("crypto"); + const MAX_LOG_CONTENT_LENGTH = 10000; + function truncateForLogging(content) { + if (content.length <= MAX_LOG_CONTENT_LENGTH) { + return content; + } + return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; + } + function loadAgentOutput() { + const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; + if (!agentOutputFile) { + core.info("No GH_AW_AGENT_OUTPUT environment variable found"); + return { success: false }; + } + let outputContent; + try { + outputContent = fs.readFileSync(agentOutputFile, "utf8"); + } catch (error) { + const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; + core.error(errorMessage); + return { success: false, error: errorMessage }; + } + if (outputContent.trim() === "") { + core.info("Agent output content is empty"); + return { success: false }; + } + core.info(`Agent output content length: ${outputContent.length}`); + let validatedOutput; + try { + validatedOutput = JSON.parse(outputContent); + } catch (error) { + const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; + core.error(errorMessage); + core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); + return { success: false, error: errorMessage }; + } + if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { + core.info("No valid items found in agent output"); + core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); + return { success: false }; + } + return { success: true, items: validatedOutput.items }; + } + function getTrackerID(format) { + const trackerID = process.env.GH_AW_TRACKER_ID || ""; + if (trackerID) { + core.info(`Tracker ID: ${trackerID}`); + return format === "markdown" ? `\n\n` : trackerID; + } + return ""; + } + function getMessages() { + const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES; + if (!messagesEnv) { + return null; + } + try { + return JSON.parse(messagesEnv); + } catch (error) { + core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`); + return null; + } + } + function renderTemplate(template, context) { + return template.replace(/\{(\w+)\}/g, (match, key) => { + const value = context[key]; + return value !== undefined && value !== null ? String(value) : match; + }); + } + function toSnakeCase(obj) { + const result = {}; + for (const [key, value] of Object.entries(obj)) { + const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase(); + result[snakeKey] = value; + result[key] = value; + } + return result; + } + function getCloseOlderDiscussionMessage(ctx) { + const messages = getMessages(); + const templateContext = toSnakeCase(ctx); + const defaultMessage = `⚓ Avast! This discussion be marked as **outdated** by [{workflow_name}]({run_url}). + 🗺️ A newer treasure map awaits ye at **[Discussion #{new_discussion_number}]({new_discussion_url})**. + Fair winds, matey! 🏴‍☠️`; + return messages?.closeOlderDiscussion + ? renderTemplate(messages.closeOlderDiscussion, templateContext) + : renderTemplate(defaultMessage, templateContext); + } + const MAX_CLOSE_COUNT = 10; + const GRAPHQL_DELAY_MS = 500; + function delay(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); + } + async function searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, excludeNumber) { + let searchQuery = `repo:${owner}/${repo} is:open`; + if (titlePrefix) { + const escapedPrefix = titlePrefix.replace(/"/g, '\\"'); + searchQuery += ` in:title "${escapedPrefix}"`; + } + if (labels && labels.length > 0) { + for (const label of labels) { + const escapedLabel = label.replace(/"/g, '\\"'); + searchQuery += ` label:"${escapedLabel}"`; + } + } + const result = await github.graphql( + ` + query($searchTerms: String!, $first: Int!) { + search(query: $searchTerms, type: DISCUSSION, first: $first) { + nodes { + ... on Discussion { + id + number + title + url + category { + id + } + labels(first: 100) { + nodes { + name + } + } + closed + } + } + } + }`, + { searchTerms: searchQuery, first: 50 } + ); + if (!result || !result.search || !result.search.nodes) { + return []; + } + return result.search.nodes + .filter( + d => { + if (!d || d.number === excludeNumber || d.closed) { + return false; + } + if (titlePrefix && d.title && !d.title.startsWith(titlePrefix)) { + return false; + } + if (labels && labels.length > 0) { + const discussionLabels = d.labels?.nodes?.map(( l) => l.name) || []; + const hasAllLabels = labels.every(label => discussionLabels.includes(label)); + if (!hasAllLabels) { + return false; + } + } + if (categoryId && (!d.category || d.category.id !== categoryId)) { + return false; + } + return true; + } + ) + .map( + d => ({ + id: d.id, + number: d.number, + title: d.title, + url: d.url, + }) + ); + } + async function addDiscussionComment(github, discussionId, message) { + const result = await github.graphql( + ` + mutation($dId: ID!, $body: String!) { + addDiscussionComment(input: { discussionId: $dId, body: $body }) { + comment { + id + url + } + } + }`, + { dId: discussionId, body: message } + ); + return result.addDiscussionComment.comment; + } + async function closeDiscussionAsOutdated(github, discussionId) { + const result = await github.graphql( + ` + mutation($dId: ID!) { + closeDiscussion(input: { discussionId: $dId, reason: OUTDATED }) { + discussion { + id + url + } + } + }`, + { dId: discussionId } + ); + return result.closeDiscussion.discussion; + } + async function closeOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion, workflowName, runUrl) { + const searchCriteria = []; + if (titlePrefix) searchCriteria.push(`title prefix: "${titlePrefix}"`); + if (labels && labels.length > 0) searchCriteria.push(`labels: [${labels.join(", ")}]`); + core.info(`Searching for older discussions with ${searchCriteria.join(" and ")}`); + const olderDiscussions = await searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion.number); + if (olderDiscussions.length === 0) { + core.info("No older discussions found to close"); + return []; + } + core.info(`Found ${olderDiscussions.length} older discussion(s) to close`); + const discussionsToClose = olderDiscussions.slice(0, MAX_CLOSE_COUNT); + if (olderDiscussions.length > MAX_CLOSE_COUNT) { + core.warning(`Found ${olderDiscussions.length} older discussions, but only closing the first ${MAX_CLOSE_COUNT}`); + } + const closedDiscussions = []; + for (let i = 0; i < discussionsToClose.length; i++) { + const discussion = discussionsToClose[i]; + try { + const closingMessage = getCloseOlderDiscussionMessage({ + newDiscussionUrl: newDiscussion.url, + newDiscussionNumber: newDiscussion.number, + workflowName, + runUrl, + }); + core.info(`Adding closing comment to discussion #${discussion.number}`); + await addDiscussionComment(github, discussion.id, closingMessage); + core.info(`Closing discussion #${discussion.number} as outdated`); + await closeDiscussionAsOutdated(github, discussion.id); + closedDiscussions.push({ + number: discussion.number, + url: discussion.url, + }); + core.info(`✓ Closed discussion #${discussion.number}: ${discussion.url}`); + } catch (error) { + core.error(`✗ Failed to close discussion #${discussion.number}: ${error instanceof Error ? error.message : String(error)}`); + } + if (i < discussionsToClose.length - 1) { + await delay(GRAPHQL_DELAY_MS); + } + } + return closedDiscussions; + } + const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; + function generateTemporaryId() { + return "aw_" + crypto.randomBytes(6).toString("hex"); + } + function isTemporaryId(value) { + if (typeof value === "string") { + return /^aw_[0-9a-f]{12}$/i.test(value); + } + return false; + } + function normalizeTemporaryId(tempId) { + return String(tempId).toLowerCase(); + } + function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { + return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { + const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); + if (resolved !== undefined) { + if (currentRepo && resolved.repo === currentRepo) { + return `#${resolved.number}`; + } + return `${resolved.repo}#${resolved.number}`; + } + return match; + }); + } + function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { + return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { + const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); + if (issueNumber !== undefined) { + return `#${issueNumber}`; + } + return match; + }); + } + function loadTemporaryIdMap() { + const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; + if (!mapJson || mapJson === "{}") { + return new Map(); + } + try { + const mapObject = JSON.parse(mapJson); + const result = new Map(); + for (const [key, value] of Object.entries(mapObject)) { + const normalizedKey = normalizeTemporaryId(key); + if (typeof value === "number") { + const contextRepo = `${context.repo.owner}/${context.repo.repo}`; + result.set(normalizedKey, { repo: contextRepo, number: value }); + } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { + result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); + } + } + return result; + } catch (error) { + if (typeof core !== "undefined") { + core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); + } + return new Map(); + } + } + function resolveIssueNumber(value, temporaryIdMap) { + if (value === undefined || value === null) { + return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; + } + const valueStr = String(value); + if (isTemporaryId(valueStr)) { + const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); + if (resolvedPair !== undefined) { + return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; + } + return { + resolved: null, + wasTemporaryId: true, + errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, + }; + } + const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); + if (isNaN(issueNumber) || issueNumber <= 0) { + return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; + } + const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; + return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; + } + function serializeTemporaryIdMap(tempIdMap) { + const obj = Object.fromEntries(tempIdMap); + return JSON.stringify(obj); + } + function parseAllowedRepos() { + const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; + const set = new Set(); + if (allowedReposEnv) { + allowedReposEnv + .split(",") + .map(repo => repo.trim()) + .filter(repo => repo) + .forEach(repo => set.add(repo)); + } + return set; + } + function getDefaultTargetRepo() { + const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; + if (targetRepoSlug) { + return targetRepoSlug; + } + return `${context.repo.owner}/${context.repo.repo}`; + } + function validateRepo(repo, defaultRepo, allowedRepos) { + if (repo === defaultRepo) { + return { valid: true, error: null }; + } + if (allowedRepos.has(repo)) { + return { valid: true, error: null }; + } + return { + valid: false, + error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, + }; + } + function parseRepoSlug(repoSlug) { + const parts = repoSlug.split("/"); + if (parts.length !== 2 || !parts[0] || !parts[1]) { + return null; + } + return { owner: parts[0], repo: parts[1] }; + } + function addExpirationComment(bodyLines, envVarName, entityType) { + const expiresEnv = process.env[envVarName]; + if (expiresEnv) { + const expiresDays = parseInt(expiresEnv, 10); + if (!isNaN(expiresDays) && expiresDays > 0) { + const expirationDate = new Date(); + expirationDate.setDate(expirationDate.getDate() + expiresDays); + const expirationISO = expirationDate.toISOString(); + bodyLines.push(``); + core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); + } + } + } + function removeDuplicateTitleFromDescription(title, description) { + if (!title || typeof title !== "string") { + return description || ""; + } + if (!description || typeof description !== "string") { + return ""; + } + const trimmedTitle = title.trim(); + const trimmedDescription = description.trim(); + if (!trimmedTitle || !trimmedDescription) { + return trimmedDescription; + } + const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); + if (headerRegex.test(trimmedDescription)) { + return trimmedDescription.replace(headerRegex, "").trim(); + } + return trimmedDescription; + } async function fetchRepoDiscussionInfo(owner, repo) { const repositoryQuery = ` query($owner: String!, $repo: String!) { diff --git a/.github/workflows/python-data-charts.lock.yml b/.github/workflows/python-data-charts.lock.yml index 5137043f577..aabe59f648e 100644 --- a/.github/workflows/python-data-charts.lock.yml +++ b/.github/workflows/python-data-charts.lock.yml @@ -8015,13 +8015,400 @@ jobs: with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - const { loadAgentOutput } = require("./load_agent_output.cjs"); - const { getTrackerID } = require("./get_tracker_id.cjs"); - const { closeOlderDiscussions } = require("./close_older_discussions.cjs"); - const { replaceTemporaryIdReferences, loadTemporaryIdMap } = require("./temporary_id.cjs"); - const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require("./repo_helpers.cjs"); - const { addExpirationComment } = require("./expiration_helpers.cjs"); - const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); + const fs = require("fs"); + const crypto = require("crypto"); + const MAX_LOG_CONTENT_LENGTH = 10000; + function truncateForLogging(content) { + if (content.length <= MAX_LOG_CONTENT_LENGTH) { + return content; + } + return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; + } + function loadAgentOutput() { + const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; + if (!agentOutputFile) { + core.info("No GH_AW_AGENT_OUTPUT environment variable found"); + return { success: false }; + } + let outputContent; + try { + outputContent = fs.readFileSync(agentOutputFile, "utf8"); + } catch (error) { + const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; + core.error(errorMessage); + return { success: false, error: errorMessage }; + } + if (outputContent.trim() === "") { + core.info("Agent output content is empty"); + return { success: false }; + } + core.info(`Agent output content length: ${outputContent.length}`); + let validatedOutput; + try { + validatedOutput = JSON.parse(outputContent); + } catch (error) { + const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; + core.error(errorMessage); + core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); + return { success: false, error: errorMessage }; + } + if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { + core.info("No valid items found in agent output"); + core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); + return { success: false }; + } + return { success: true, items: validatedOutput.items }; + } + function getTrackerID(format) { + const trackerID = process.env.GH_AW_TRACKER_ID || ""; + if (trackerID) { + core.info(`Tracker ID: ${trackerID}`); + return format === "markdown" ? `\n\n` : trackerID; + } + return ""; + } + function getMessages() { + const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES; + if (!messagesEnv) { + return null; + } + try { + return JSON.parse(messagesEnv); + } catch (error) { + core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`); + return null; + } + } + function renderTemplate(template, context) { + return template.replace(/\{(\w+)\}/g, (match, key) => { + const value = context[key]; + return value !== undefined && value !== null ? String(value) : match; + }); + } + function toSnakeCase(obj) { + const result = {}; + for (const [key, value] of Object.entries(obj)) { + const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase(); + result[snakeKey] = value; + result[key] = value; + } + return result; + } + function getCloseOlderDiscussionMessage(ctx) { + const messages = getMessages(); + const templateContext = toSnakeCase(ctx); + const defaultMessage = `⚓ Avast! This discussion be marked as **outdated** by [{workflow_name}]({run_url}). + 🗺️ A newer treasure map awaits ye at **[Discussion #{new_discussion_number}]({new_discussion_url})**. + Fair winds, matey! 🏴‍☠️`; + return messages?.closeOlderDiscussion + ? renderTemplate(messages.closeOlderDiscussion, templateContext) + : renderTemplate(defaultMessage, templateContext); + } + const MAX_CLOSE_COUNT = 10; + const GRAPHQL_DELAY_MS = 500; + function delay(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); + } + async function searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, excludeNumber) { + let searchQuery = `repo:${owner}/${repo} is:open`; + if (titlePrefix) { + const escapedPrefix = titlePrefix.replace(/"/g, '\\"'); + searchQuery += ` in:title "${escapedPrefix}"`; + } + if (labels && labels.length > 0) { + for (const label of labels) { + const escapedLabel = label.replace(/"/g, '\\"'); + searchQuery += ` label:"${escapedLabel}"`; + } + } + const result = await github.graphql( + ` + query($searchTerms: String!, $first: Int!) { + search(query: $searchTerms, type: DISCUSSION, first: $first) { + nodes { + ... on Discussion { + id + number + title + url + category { + id + } + labels(first: 100) { + nodes { + name + } + } + closed + } + } + } + }`, + { searchTerms: searchQuery, first: 50 } + ); + if (!result || !result.search || !result.search.nodes) { + return []; + } + return result.search.nodes + .filter( + d => { + if (!d || d.number === excludeNumber || d.closed) { + return false; + } + if (titlePrefix && d.title && !d.title.startsWith(titlePrefix)) { + return false; + } + if (labels && labels.length > 0) { + const discussionLabels = d.labels?.nodes?.map(( l) => l.name) || []; + const hasAllLabels = labels.every(label => discussionLabels.includes(label)); + if (!hasAllLabels) { + return false; + } + } + if (categoryId && (!d.category || d.category.id !== categoryId)) { + return false; + } + return true; + } + ) + .map( + d => ({ + id: d.id, + number: d.number, + title: d.title, + url: d.url, + }) + ); + } + async function addDiscussionComment(github, discussionId, message) { + const result = await github.graphql( + ` + mutation($dId: ID!, $body: String!) { + addDiscussionComment(input: { discussionId: $dId, body: $body }) { + comment { + id + url + } + } + }`, + { dId: discussionId, body: message } + ); + return result.addDiscussionComment.comment; + } + async function closeDiscussionAsOutdated(github, discussionId) { + const result = await github.graphql( + ` + mutation($dId: ID!) { + closeDiscussion(input: { discussionId: $dId, reason: OUTDATED }) { + discussion { + id + url + } + } + }`, + { dId: discussionId } + ); + return result.closeDiscussion.discussion; + } + async function closeOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion, workflowName, runUrl) { + const searchCriteria = []; + if (titlePrefix) searchCriteria.push(`title prefix: "${titlePrefix}"`); + if (labels && labels.length > 0) searchCriteria.push(`labels: [${labels.join(", ")}]`); + core.info(`Searching for older discussions with ${searchCriteria.join(" and ")}`); + const olderDiscussions = await searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion.number); + if (olderDiscussions.length === 0) { + core.info("No older discussions found to close"); + return []; + } + core.info(`Found ${olderDiscussions.length} older discussion(s) to close`); + const discussionsToClose = olderDiscussions.slice(0, MAX_CLOSE_COUNT); + if (olderDiscussions.length > MAX_CLOSE_COUNT) { + core.warning(`Found ${olderDiscussions.length} older discussions, but only closing the first ${MAX_CLOSE_COUNT}`); + } + const closedDiscussions = []; + for (let i = 0; i < discussionsToClose.length; i++) { + const discussion = discussionsToClose[i]; + try { + const closingMessage = getCloseOlderDiscussionMessage({ + newDiscussionUrl: newDiscussion.url, + newDiscussionNumber: newDiscussion.number, + workflowName, + runUrl, + }); + core.info(`Adding closing comment to discussion #${discussion.number}`); + await addDiscussionComment(github, discussion.id, closingMessage); + core.info(`Closing discussion #${discussion.number} as outdated`); + await closeDiscussionAsOutdated(github, discussion.id); + closedDiscussions.push({ + number: discussion.number, + url: discussion.url, + }); + core.info(`✓ Closed discussion #${discussion.number}: ${discussion.url}`); + } catch (error) { + core.error(`✗ Failed to close discussion #${discussion.number}: ${error instanceof Error ? error.message : String(error)}`); + } + if (i < discussionsToClose.length - 1) { + await delay(GRAPHQL_DELAY_MS); + } + } + return closedDiscussions; + } + const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; + function generateTemporaryId() { + return "aw_" + crypto.randomBytes(6).toString("hex"); + } + function isTemporaryId(value) { + if (typeof value === "string") { + return /^aw_[0-9a-f]{12}$/i.test(value); + } + return false; + } + function normalizeTemporaryId(tempId) { + return String(tempId).toLowerCase(); + } + function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { + return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { + const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); + if (resolved !== undefined) { + if (currentRepo && resolved.repo === currentRepo) { + return `#${resolved.number}`; + } + return `${resolved.repo}#${resolved.number}`; + } + return match; + }); + } + function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { + return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { + const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); + if (issueNumber !== undefined) { + return `#${issueNumber}`; + } + return match; + }); + } + function loadTemporaryIdMap() { + const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; + if (!mapJson || mapJson === "{}") { + return new Map(); + } + try { + const mapObject = JSON.parse(mapJson); + const result = new Map(); + for (const [key, value] of Object.entries(mapObject)) { + const normalizedKey = normalizeTemporaryId(key); + if (typeof value === "number") { + const contextRepo = `${context.repo.owner}/${context.repo.repo}`; + result.set(normalizedKey, { repo: contextRepo, number: value }); + } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { + result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); + } + } + return result; + } catch (error) { + if (typeof core !== "undefined") { + core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); + } + return new Map(); + } + } + function resolveIssueNumber(value, temporaryIdMap) { + if (value === undefined || value === null) { + return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; + } + const valueStr = String(value); + if (isTemporaryId(valueStr)) { + const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); + if (resolvedPair !== undefined) { + return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; + } + return { + resolved: null, + wasTemporaryId: true, + errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, + }; + } + const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); + if (isNaN(issueNumber) || issueNumber <= 0) { + return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; + } + const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; + return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; + } + function serializeTemporaryIdMap(tempIdMap) { + const obj = Object.fromEntries(tempIdMap); + return JSON.stringify(obj); + } + function parseAllowedRepos() { + const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; + const set = new Set(); + if (allowedReposEnv) { + allowedReposEnv + .split(",") + .map(repo => repo.trim()) + .filter(repo => repo) + .forEach(repo => set.add(repo)); + } + return set; + } + function getDefaultTargetRepo() { + const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; + if (targetRepoSlug) { + return targetRepoSlug; + } + return `${context.repo.owner}/${context.repo.repo}`; + } + function validateRepo(repo, defaultRepo, allowedRepos) { + if (repo === defaultRepo) { + return { valid: true, error: null }; + } + if (allowedRepos.has(repo)) { + return { valid: true, error: null }; + } + return { + valid: false, + error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, + }; + } + function parseRepoSlug(repoSlug) { + const parts = repoSlug.split("/"); + if (parts.length !== 2 || !parts[0] || !parts[1]) { + return null; + } + return { owner: parts[0], repo: parts[1] }; + } + function addExpirationComment(bodyLines, envVarName, entityType) { + const expiresEnv = process.env[envVarName]; + if (expiresEnv) { + const expiresDays = parseInt(expiresEnv, 10); + if (!isNaN(expiresDays) && expiresDays > 0) { + const expirationDate = new Date(); + expirationDate.setDate(expirationDate.getDate() + expiresDays); + const expirationISO = expirationDate.toISOString(); + bodyLines.push(``); + core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); + } + } + } + function removeDuplicateTitleFromDescription(title, description) { + if (!title || typeof title !== "string") { + return description || ""; + } + if (!description || typeof description !== "string") { + return ""; + } + const trimmedTitle = title.trim(); + const trimmedDescription = description.trim(); + if (!trimmedTitle || !trimmedDescription) { + return trimmedDescription; + } + const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); + if (headerRegex.test(trimmedDescription)) { + return trimmedDescription.replace(headerRegex, "").trim(); + } + return trimmedDescription; + } async function fetchRepoDiscussionInfo(owner, repo) { const repositoryQuery = ` query($owner: String!, $repo: String!) { diff --git a/.github/workflows/q.lock.yml b/.github/workflows/q.lock.yml index cf6a8c0e4bb..c15353ca102 100644 --- a/.github/workflows/q.lock.yml +++ b/.github/workflows/q.lock.yml @@ -8257,10 +8257,151 @@ jobs: script: | const fs = require("fs"); const crypto = require("crypto"); - const { updateActivationComment } = require("./update_activation_comment.cjs"); - const { getTrackerID } = require("./get_tracker_id.cjs"); - const { addExpirationComment } = require("./expiration_helpers.cjs"); - const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); + async function updateActivationComment(github, context, core, itemUrl, itemNumber, itemType = "pull_request") { + const itemLabel = itemType === "issue" ? "issue" : "pull request"; + const linkMessage = + itemType === "issue" + ? `\n\n✅ Issue created: [#${itemNumber}](${itemUrl})` + : `\n\n✅ Pull request created: [#${itemNumber}](${itemUrl})`; + await updateActivationCommentWithMessage(github, context, core, linkMessage, itemLabel); + } + async function updateActivationCommentWithCommit(github, context, core, commitSha, commitUrl) { + const shortSha = commitSha.substring(0, 7); + const message = `\n\n✅ Commit pushed: [\`${shortSha}\`](${commitUrl})`; + await updateActivationCommentWithMessage(github, context, core, message, "commit"); + } + async function updateActivationCommentWithMessage(github, context, core, message, label = "") { + const commentId = process.env.GH_AW_COMMENT_ID; + const commentRepo = process.env.GH_AW_COMMENT_REPO; + if (!commentId) { + core.info("No activation comment to update (GH_AW_COMMENT_ID not set)"); + return; + } + core.info(`Updating activation comment ${commentId}`); + let repoOwner = context.repo.owner; + let repoName = context.repo.repo; + if (commentRepo) { + const parts = commentRepo.split("/"); + if (parts.length === 2) { + repoOwner = parts[0]; + repoName = parts[1]; + } else { + core.warning(`Invalid comment repo format: ${commentRepo}, expected "owner/repo". Falling back to context.repo.`); + } + } + core.info(`Updating comment in ${repoOwner}/${repoName}`); + const isDiscussionComment = commentId.startsWith("DC_"); + try { + if (isDiscussionComment) { + const currentComment = await github.graphql( + ` + query($commentId: ID!) { + node(id: $commentId) { + ... on DiscussionComment { + body + } + } + }`, + { commentId: commentId } + ); + if (!currentComment?.node?.body) { + core.warning("Unable to fetch current comment body, comment may have been deleted or is inaccessible"); + return; + } + const currentBody = currentComment.node.body; + const updatedBody = currentBody + message; + const result = await github.graphql( + ` + mutation($commentId: ID!, $body: String!) { + updateDiscussionComment(input: { commentId: $commentId, body: $body }) { + comment { + id + url + } + } + }`, + { commentId: commentId, body: updatedBody } + ); + const comment = result.updateDiscussionComment.comment; + const successMessage = label + ? `Successfully updated discussion comment with ${label} link` + : "Successfully updated discussion comment"; + core.info(successMessage); + core.info(`Comment ID: ${comment.id}`); + core.info(`Comment URL: ${comment.url}`); + } else { + const currentComment = await github.request("GET /repos/{owner}/{repo}/issues/comments/{comment_id}", { + owner: repoOwner, + repo: repoName, + comment_id: parseInt(commentId, 10), + headers: { + Accept: "application/vnd.github+json", + }, + }); + if (!currentComment?.data?.body) { + core.warning("Unable to fetch current comment body, comment may have been deleted"); + return; + } + const currentBody = currentComment.data.body; + const updatedBody = currentBody + message; + const response = await github.request("PATCH /repos/{owner}/{repo}/issues/comments/{comment_id}", { + owner: repoOwner, + repo: repoName, + comment_id: parseInt(commentId, 10), + body: updatedBody, + headers: { + Accept: "application/vnd.github+json", + }, + }); + const successMessage = label ? `Successfully updated comment with ${label} link` : "Successfully updated comment"; + core.info(successMessage); + core.info(`Comment ID: ${response.data.id}`); + core.info(`Comment URL: ${response.data.html_url}`); + } + } catch (error) { + core.warning(`Failed to update activation comment: ${error instanceof Error ? error.message : String(error)}`); + } + } + function getTrackerID(format) { + const trackerID = process.env.GH_AW_TRACKER_ID || ""; + if (trackerID) { + core.info(`Tracker ID: ${trackerID}`); + return format === "markdown" ? `\n\n` : trackerID; + } + return ""; + } + function addExpirationComment(bodyLines, envVarName, entityType) { + const expiresEnv = process.env[envVarName]; + if (expiresEnv) { + const expiresDays = parseInt(expiresEnv, 10); + if (!isNaN(expiresDays) && expiresDays > 0) { + const expirationDate = new Date(); + expirationDate.setDate(expirationDate.getDate() + expiresDays); + const expirationISO = expirationDate.toISOString(); + bodyLines.push(``); + core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); + } + } + } + function removeDuplicateTitleFromDescription(title, description) { + if (!title || typeof title !== "string") { + return description || ""; + } + if (!description || typeof description !== "string") { + return ""; + } + const trimmedTitle = title.trim(); + const trimmedDescription = description.trim(); + if (!trimmedTitle || !trimmedDescription) { + return trimmedDescription; + } + const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); + if (headerRegex.test(trimmedDescription)) { + return trimmedDescription.replace(headerRegex, "").trim(); + } + return trimmedDescription; + } function generatePatchPreview(patchContent) { if (!patchContent || !patchContent.trim()) { return ""; diff --git a/.github/workflows/repo-tree-map.lock.yml b/.github/workflows/repo-tree-map.lock.yml index 8662585e3e6..d033398ba4f 100644 --- a/.github/workflows/repo-tree-map.lock.yml +++ b/.github/workflows/repo-tree-map.lock.yml @@ -6178,13 +6178,400 @@ jobs: with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - const { loadAgentOutput } = require("./load_agent_output.cjs"); - const { getTrackerID } = require("./get_tracker_id.cjs"); - const { closeOlderDiscussions } = require("./close_older_discussions.cjs"); - const { replaceTemporaryIdReferences, loadTemporaryIdMap } = require("./temporary_id.cjs"); - const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require("./repo_helpers.cjs"); - const { addExpirationComment } = require("./expiration_helpers.cjs"); - const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); + const fs = require("fs"); + const crypto = require("crypto"); + const MAX_LOG_CONTENT_LENGTH = 10000; + function truncateForLogging(content) { + if (content.length <= MAX_LOG_CONTENT_LENGTH) { + return content; + } + return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; + } + function loadAgentOutput() { + const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; + if (!agentOutputFile) { + core.info("No GH_AW_AGENT_OUTPUT environment variable found"); + return { success: false }; + } + let outputContent; + try { + outputContent = fs.readFileSync(agentOutputFile, "utf8"); + } catch (error) { + const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; + core.error(errorMessage); + return { success: false, error: errorMessage }; + } + if (outputContent.trim() === "") { + core.info("Agent output content is empty"); + return { success: false }; + } + core.info(`Agent output content length: ${outputContent.length}`); + let validatedOutput; + try { + validatedOutput = JSON.parse(outputContent); + } catch (error) { + const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; + core.error(errorMessage); + core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); + return { success: false, error: errorMessage }; + } + if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { + core.info("No valid items found in agent output"); + core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); + return { success: false }; + } + return { success: true, items: validatedOutput.items }; + } + function getTrackerID(format) { + const trackerID = process.env.GH_AW_TRACKER_ID || ""; + if (trackerID) { + core.info(`Tracker ID: ${trackerID}`); + return format === "markdown" ? `\n\n` : trackerID; + } + return ""; + } + function getMessages() { + const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES; + if (!messagesEnv) { + return null; + } + try { + return JSON.parse(messagesEnv); + } catch (error) { + core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`); + return null; + } + } + function renderTemplate(template, context) { + return template.replace(/\{(\w+)\}/g, (match, key) => { + const value = context[key]; + return value !== undefined && value !== null ? String(value) : match; + }); + } + function toSnakeCase(obj) { + const result = {}; + for (const [key, value] of Object.entries(obj)) { + const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase(); + result[snakeKey] = value; + result[key] = value; + } + return result; + } + function getCloseOlderDiscussionMessage(ctx) { + const messages = getMessages(); + const templateContext = toSnakeCase(ctx); + const defaultMessage = `⚓ Avast! This discussion be marked as **outdated** by [{workflow_name}]({run_url}). + 🗺️ A newer treasure map awaits ye at **[Discussion #{new_discussion_number}]({new_discussion_url})**. + Fair winds, matey! 🏴‍☠️`; + return messages?.closeOlderDiscussion + ? renderTemplate(messages.closeOlderDiscussion, templateContext) + : renderTemplate(defaultMessage, templateContext); + } + const MAX_CLOSE_COUNT = 10; + const GRAPHQL_DELAY_MS = 500; + function delay(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); + } + async function searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, excludeNumber) { + let searchQuery = `repo:${owner}/${repo} is:open`; + if (titlePrefix) { + const escapedPrefix = titlePrefix.replace(/"/g, '\\"'); + searchQuery += ` in:title "${escapedPrefix}"`; + } + if (labels && labels.length > 0) { + for (const label of labels) { + const escapedLabel = label.replace(/"/g, '\\"'); + searchQuery += ` label:"${escapedLabel}"`; + } + } + const result = await github.graphql( + ` + query($searchTerms: String!, $first: Int!) { + search(query: $searchTerms, type: DISCUSSION, first: $first) { + nodes { + ... on Discussion { + id + number + title + url + category { + id + } + labels(first: 100) { + nodes { + name + } + } + closed + } + } + } + }`, + { searchTerms: searchQuery, first: 50 } + ); + if (!result || !result.search || !result.search.nodes) { + return []; + } + return result.search.nodes + .filter( + d => { + if (!d || d.number === excludeNumber || d.closed) { + return false; + } + if (titlePrefix && d.title && !d.title.startsWith(titlePrefix)) { + return false; + } + if (labels && labels.length > 0) { + const discussionLabels = d.labels?.nodes?.map(( l) => l.name) || []; + const hasAllLabels = labels.every(label => discussionLabels.includes(label)); + if (!hasAllLabels) { + return false; + } + } + if (categoryId && (!d.category || d.category.id !== categoryId)) { + return false; + } + return true; + } + ) + .map( + d => ({ + id: d.id, + number: d.number, + title: d.title, + url: d.url, + }) + ); + } + async function addDiscussionComment(github, discussionId, message) { + const result = await github.graphql( + ` + mutation($dId: ID!, $body: String!) { + addDiscussionComment(input: { discussionId: $dId, body: $body }) { + comment { + id + url + } + } + }`, + { dId: discussionId, body: message } + ); + return result.addDiscussionComment.comment; + } + async function closeDiscussionAsOutdated(github, discussionId) { + const result = await github.graphql( + ` + mutation($dId: ID!) { + closeDiscussion(input: { discussionId: $dId, reason: OUTDATED }) { + discussion { + id + url + } + } + }`, + { dId: discussionId } + ); + return result.closeDiscussion.discussion; + } + async function closeOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion, workflowName, runUrl) { + const searchCriteria = []; + if (titlePrefix) searchCriteria.push(`title prefix: "${titlePrefix}"`); + if (labels && labels.length > 0) searchCriteria.push(`labels: [${labels.join(", ")}]`); + core.info(`Searching for older discussions with ${searchCriteria.join(" and ")}`); + const olderDiscussions = await searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion.number); + if (olderDiscussions.length === 0) { + core.info("No older discussions found to close"); + return []; + } + core.info(`Found ${olderDiscussions.length} older discussion(s) to close`); + const discussionsToClose = olderDiscussions.slice(0, MAX_CLOSE_COUNT); + if (olderDiscussions.length > MAX_CLOSE_COUNT) { + core.warning(`Found ${olderDiscussions.length} older discussions, but only closing the first ${MAX_CLOSE_COUNT}`); + } + const closedDiscussions = []; + for (let i = 0; i < discussionsToClose.length; i++) { + const discussion = discussionsToClose[i]; + try { + const closingMessage = getCloseOlderDiscussionMessage({ + newDiscussionUrl: newDiscussion.url, + newDiscussionNumber: newDiscussion.number, + workflowName, + runUrl, + }); + core.info(`Adding closing comment to discussion #${discussion.number}`); + await addDiscussionComment(github, discussion.id, closingMessage); + core.info(`Closing discussion #${discussion.number} as outdated`); + await closeDiscussionAsOutdated(github, discussion.id); + closedDiscussions.push({ + number: discussion.number, + url: discussion.url, + }); + core.info(`✓ Closed discussion #${discussion.number}: ${discussion.url}`); + } catch (error) { + core.error(`✗ Failed to close discussion #${discussion.number}: ${error instanceof Error ? error.message : String(error)}`); + } + if (i < discussionsToClose.length - 1) { + await delay(GRAPHQL_DELAY_MS); + } + } + return closedDiscussions; + } + const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; + function generateTemporaryId() { + return "aw_" + crypto.randomBytes(6).toString("hex"); + } + function isTemporaryId(value) { + if (typeof value === "string") { + return /^aw_[0-9a-f]{12}$/i.test(value); + } + return false; + } + function normalizeTemporaryId(tempId) { + return String(tempId).toLowerCase(); + } + function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { + return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { + const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); + if (resolved !== undefined) { + if (currentRepo && resolved.repo === currentRepo) { + return `#${resolved.number}`; + } + return `${resolved.repo}#${resolved.number}`; + } + return match; + }); + } + function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { + return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { + const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); + if (issueNumber !== undefined) { + return `#${issueNumber}`; + } + return match; + }); + } + function loadTemporaryIdMap() { + const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; + if (!mapJson || mapJson === "{}") { + return new Map(); + } + try { + const mapObject = JSON.parse(mapJson); + const result = new Map(); + for (const [key, value] of Object.entries(mapObject)) { + const normalizedKey = normalizeTemporaryId(key); + if (typeof value === "number") { + const contextRepo = `${context.repo.owner}/${context.repo.repo}`; + result.set(normalizedKey, { repo: contextRepo, number: value }); + } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { + result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); + } + } + return result; + } catch (error) { + if (typeof core !== "undefined") { + core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); + } + return new Map(); + } + } + function resolveIssueNumber(value, temporaryIdMap) { + if (value === undefined || value === null) { + return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; + } + const valueStr = String(value); + if (isTemporaryId(valueStr)) { + const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); + if (resolvedPair !== undefined) { + return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; + } + return { + resolved: null, + wasTemporaryId: true, + errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, + }; + } + const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); + if (isNaN(issueNumber) || issueNumber <= 0) { + return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; + } + const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; + return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; + } + function serializeTemporaryIdMap(tempIdMap) { + const obj = Object.fromEntries(tempIdMap); + return JSON.stringify(obj); + } + function parseAllowedRepos() { + const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; + const set = new Set(); + if (allowedReposEnv) { + allowedReposEnv + .split(",") + .map(repo => repo.trim()) + .filter(repo => repo) + .forEach(repo => set.add(repo)); + } + return set; + } + function getDefaultTargetRepo() { + const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; + if (targetRepoSlug) { + return targetRepoSlug; + } + return `${context.repo.owner}/${context.repo.repo}`; + } + function validateRepo(repo, defaultRepo, allowedRepos) { + if (repo === defaultRepo) { + return { valid: true, error: null }; + } + if (allowedRepos.has(repo)) { + return { valid: true, error: null }; + } + return { + valid: false, + error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, + }; + } + function parseRepoSlug(repoSlug) { + const parts = repoSlug.split("/"); + if (parts.length !== 2 || !parts[0] || !parts[1]) { + return null; + } + return { owner: parts[0], repo: parts[1] }; + } + function addExpirationComment(bodyLines, envVarName, entityType) { + const expiresEnv = process.env[envVarName]; + if (expiresEnv) { + const expiresDays = parseInt(expiresEnv, 10); + if (!isNaN(expiresDays) && expiresDays > 0) { + const expirationDate = new Date(); + expirationDate.setDate(expirationDate.getDate() + expiresDays); + const expirationISO = expirationDate.toISOString(); + bodyLines.push(``); + core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); + } + } + } + function removeDuplicateTitleFromDescription(title, description) { + if (!title || typeof title !== "string") { + return description || ""; + } + if (!description || typeof description !== "string") { + return ""; + } + const trimmedTitle = title.trim(); + const trimmedDescription = description.trim(); + if (!trimmedTitle || !trimmedDescription) { + return trimmedDescription; + } + const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); + if (headerRegex.test(trimmedDescription)) { + return trimmedDescription.replace(headerRegex, "").trim(); + } + return trimmedDescription; + } async function fetchRepoDiscussionInfo(owner, repo) { const repositoryQuery = ` query($owner: String!, $repo: String!) { diff --git a/.github/workflows/repository-quality-improver.lock.yml b/.github/workflows/repository-quality-improver.lock.yml index 4c9ddf7d96c..d194cf910c1 100644 --- a/.github/workflows/repository-quality-improver.lock.yml +++ b/.github/workflows/repository-quality-improver.lock.yml @@ -7222,13 +7222,400 @@ jobs: with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - const { loadAgentOutput } = require("./load_agent_output.cjs"); - const { getTrackerID } = require("./get_tracker_id.cjs"); - const { closeOlderDiscussions } = require("./close_older_discussions.cjs"); - const { replaceTemporaryIdReferences, loadTemporaryIdMap } = require("./temporary_id.cjs"); - const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require("./repo_helpers.cjs"); - const { addExpirationComment } = require("./expiration_helpers.cjs"); - const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); + const fs = require("fs"); + const crypto = require("crypto"); + const MAX_LOG_CONTENT_LENGTH = 10000; + function truncateForLogging(content) { + if (content.length <= MAX_LOG_CONTENT_LENGTH) { + return content; + } + return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; + } + function loadAgentOutput() { + const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; + if (!agentOutputFile) { + core.info("No GH_AW_AGENT_OUTPUT environment variable found"); + return { success: false }; + } + let outputContent; + try { + outputContent = fs.readFileSync(agentOutputFile, "utf8"); + } catch (error) { + const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; + core.error(errorMessage); + return { success: false, error: errorMessage }; + } + if (outputContent.trim() === "") { + core.info("Agent output content is empty"); + return { success: false }; + } + core.info(`Agent output content length: ${outputContent.length}`); + let validatedOutput; + try { + validatedOutput = JSON.parse(outputContent); + } catch (error) { + const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; + core.error(errorMessage); + core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); + return { success: false, error: errorMessage }; + } + if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { + core.info("No valid items found in agent output"); + core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); + return { success: false }; + } + return { success: true, items: validatedOutput.items }; + } + function getTrackerID(format) { + const trackerID = process.env.GH_AW_TRACKER_ID || ""; + if (trackerID) { + core.info(`Tracker ID: ${trackerID}`); + return format === "markdown" ? `\n\n` : trackerID; + } + return ""; + } + function getMessages() { + const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES; + if (!messagesEnv) { + return null; + } + try { + return JSON.parse(messagesEnv); + } catch (error) { + core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`); + return null; + } + } + function renderTemplate(template, context) { + return template.replace(/\{(\w+)\}/g, (match, key) => { + const value = context[key]; + return value !== undefined && value !== null ? String(value) : match; + }); + } + function toSnakeCase(obj) { + const result = {}; + for (const [key, value] of Object.entries(obj)) { + const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase(); + result[snakeKey] = value; + result[key] = value; + } + return result; + } + function getCloseOlderDiscussionMessage(ctx) { + const messages = getMessages(); + const templateContext = toSnakeCase(ctx); + const defaultMessage = `⚓ Avast! This discussion be marked as **outdated** by [{workflow_name}]({run_url}). + 🗺️ A newer treasure map awaits ye at **[Discussion #{new_discussion_number}]({new_discussion_url})**. + Fair winds, matey! 🏴‍☠️`; + return messages?.closeOlderDiscussion + ? renderTemplate(messages.closeOlderDiscussion, templateContext) + : renderTemplate(defaultMessage, templateContext); + } + const MAX_CLOSE_COUNT = 10; + const GRAPHQL_DELAY_MS = 500; + function delay(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); + } + async function searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, excludeNumber) { + let searchQuery = `repo:${owner}/${repo} is:open`; + if (titlePrefix) { + const escapedPrefix = titlePrefix.replace(/"/g, '\\"'); + searchQuery += ` in:title "${escapedPrefix}"`; + } + if (labels && labels.length > 0) { + for (const label of labels) { + const escapedLabel = label.replace(/"/g, '\\"'); + searchQuery += ` label:"${escapedLabel}"`; + } + } + const result = await github.graphql( + ` + query($searchTerms: String!, $first: Int!) { + search(query: $searchTerms, type: DISCUSSION, first: $first) { + nodes { + ... on Discussion { + id + number + title + url + category { + id + } + labels(first: 100) { + nodes { + name + } + } + closed + } + } + } + }`, + { searchTerms: searchQuery, first: 50 } + ); + if (!result || !result.search || !result.search.nodes) { + return []; + } + return result.search.nodes + .filter( + d => { + if (!d || d.number === excludeNumber || d.closed) { + return false; + } + if (titlePrefix && d.title && !d.title.startsWith(titlePrefix)) { + return false; + } + if (labels && labels.length > 0) { + const discussionLabels = d.labels?.nodes?.map(( l) => l.name) || []; + const hasAllLabels = labels.every(label => discussionLabels.includes(label)); + if (!hasAllLabels) { + return false; + } + } + if (categoryId && (!d.category || d.category.id !== categoryId)) { + return false; + } + return true; + } + ) + .map( + d => ({ + id: d.id, + number: d.number, + title: d.title, + url: d.url, + }) + ); + } + async function addDiscussionComment(github, discussionId, message) { + const result = await github.graphql( + ` + mutation($dId: ID!, $body: String!) { + addDiscussionComment(input: { discussionId: $dId, body: $body }) { + comment { + id + url + } + } + }`, + { dId: discussionId, body: message } + ); + return result.addDiscussionComment.comment; + } + async function closeDiscussionAsOutdated(github, discussionId) { + const result = await github.graphql( + ` + mutation($dId: ID!) { + closeDiscussion(input: { discussionId: $dId, reason: OUTDATED }) { + discussion { + id + url + } + } + }`, + { dId: discussionId } + ); + return result.closeDiscussion.discussion; + } + async function closeOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion, workflowName, runUrl) { + const searchCriteria = []; + if (titlePrefix) searchCriteria.push(`title prefix: "${titlePrefix}"`); + if (labels && labels.length > 0) searchCriteria.push(`labels: [${labels.join(", ")}]`); + core.info(`Searching for older discussions with ${searchCriteria.join(" and ")}`); + const olderDiscussions = await searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion.number); + if (olderDiscussions.length === 0) { + core.info("No older discussions found to close"); + return []; + } + core.info(`Found ${olderDiscussions.length} older discussion(s) to close`); + const discussionsToClose = olderDiscussions.slice(0, MAX_CLOSE_COUNT); + if (olderDiscussions.length > MAX_CLOSE_COUNT) { + core.warning(`Found ${olderDiscussions.length} older discussions, but only closing the first ${MAX_CLOSE_COUNT}`); + } + const closedDiscussions = []; + for (let i = 0; i < discussionsToClose.length; i++) { + const discussion = discussionsToClose[i]; + try { + const closingMessage = getCloseOlderDiscussionMessage({ + newDiscussionUrl: newDiscussion.url, + newDiscussionNumber: newDiscussion.number, + workflowName, + runUrl, + }); + core.info(`Adding closing comment to discussion #${discussion.number}`); + await addDiscussionComment(github, discussion.id, closingMessage); + core.info(`Closing discussion #${discussion.number} as outdated`); + await closeDiscussionAsOutdated(github, discussion.id); + closedDiscussions.push({ + number: discussion.number, + url: discussion.url, + }); + core.info(`✓ Closed discussion #${discussion.number}: ${discussion.url}`); + } catch (error) { + core.error(`✗ Failed to close discussion #${discussion.number}: ${error instanceof Error ? error.message : String(error)}`); + } + if (i < discussionsToClose.length - 1) { + await delay(GRAPHQL_DELAY_MS); + } + } + return closedDiscussions; + } + const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; + function generateTemporaryId() { + return "aw_" + crypto.randomBytes(6).toString("hex"); + } + function isTemporaryId(value) { + if (typeof value === "string") { + return /^aw_[0-9a-f]{12}$/i.test(value); + } + return false; + } + function normalizeTemporaryId(tempId) { + return String(tempId).toLowerCase(); + } + function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { + return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { + const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); + if (resolved !== undefined) { + if (currentRepo && resolved.repo === currentRepo) { + return `#${resolved.number}`; + } + return `${resolved.repo}#${resolved.number}`; + } + return match; + }); + } + function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { + return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { + const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); + if (issueNumber !== undefined) { + return `#${issueNumber}`; + } + return match; + }); + } + function loadTemporaryIdMap() { + const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; + if (!mapJson || mapJson === "{}") { + return new Map(); + } + try { + const mapObject = JSON.parse(mapJson); + const result = new Map(); + for (const [key, value] of Object.entries(mapObject)) { + const normalizedKey = normalizeTemporaryId(key); + if (typeof value === "number") { + const contextRepo = `${context.repo.owner}/${context.repo.repo}`; + result.set(normalizedKey, { repo: contextRepo, number: value }); + } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { + result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); + } + } + return result; + } catch (error) { + if (typeof core !== "undefined") { + core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); + } + return new Map(); + } + } + function resolveIssueNumber(value, temporaryIdMap) { + if (value === undefined || value === null) { + return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; + } + const valueStr = String(value); + if (isTemporaryId(valueStr)) { + const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); + if (resolvedPair !== undefined) { + return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; + } + return { + resolved: null, + wasTemporaryId: true, + errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, + }; + } + const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); + if (isNaN(issueNumber) || issueNumber <= 0) { + return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; + } + const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; + return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; + } + function serializeTemporaryIdMap(tempIdMap) { + const obj = Object.fromEntries(tempIdMap); + return JSON.stringify(obj); + } + function parseAllowedRepos() { + const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; + const set = new Set(); + if (allowedReposEnv) { + allowedReposEnv + .split(",") + .map(repo => repo.trim()) + .filter(repo => repo) + .forEach(repo => set.add(repo)); + } + return set; + } + function getDefaultTargetRepo() { + const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; + if (targetRepoSlug) { + return targetRepoSlug; + } + return `${context.repo.owner}/${context.repo.repo}`; + } + function validateRepo(repo, defaultRepo, allowedRepos) { + if (repo === defaultRepo) { + return { valid: true, error: null }; + } + if (allowedRepos.has(repo)) { + return { valid: true, error: null }; + } + return { + valid: false, + error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, + }; + } + function parseRepoSlug(repoSlug) { + const parts = repoSlug.split("/"); + if (parts.length !== 2 || !parts[0] || !parts[1]) { + return null; + } + return { owner: parts[0], repo: parts[1] }; + } + function addExpirationComment(bodyLines, envVarName, entityType) { + const expiresEnv = process.env[envVarName]; + if (expiresEnv) { + const expiresDays = parseInt(expiresEnv, 10); + if (!isNaN(expiresDays) && expiresDays > 0) { + const expirationDate = new Date(); + expirationDate.setDate(expirationDate.getDate() + expiresDays); + const expirationISO = expirationDate.toISOString(); + bodyLines.push(``); + core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); + } + } + } + function removeDuplicateTitleFromDescription(title, description) { + if (!title || typeof title !== "string") { + return description || ""; + } + if (!description || typeof description !== "string") { + return ""; + } + const trimmedTitle = title.trim(); + const trimmedDescription = description.trim(); + if (!trimmedTitle || !trimmedDescription) { + return trimmedDescription; + } + const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); + if (headerRegex.test(trimmedDescription)) { + return trimmedDescription.replace(headerRegex, "").trim(); + } + return trimmedDescription; + } async function fetchRepoDiscussionInfo(owner, repo) { const repositoryQuery = ` query($owner: String!, $repo: String!) { diff --git a/.github/workflows/research.lock.yml b/.github/workflows/research.lock.yml index 57089e0f6e9..be4311f5b18 100644 --- a/.github/workflows/research.lock.yml +++ b/.github/workflows/research.lock.yml @@ -6091,13 +6091,400 @@ jobs: with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - const { loadAgentOutput } = require("./load_agent_output.cjs"); - const { getTrackerID } = require("./get_tracker_id.cjs"); - const { closeOlderDiscussions } = require("./close_older_discussions.cjs"); - const { replaceTemporaryIdReferences, loadTemporaryIdMap } = require("./temporary_id.cjs"); - const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require("./repo_helpers.cjs"); - const { addExpirationComment } = require("./expiration_helpers.cjs"); - const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); + const fs = require("fs"); + const crypto = require("crypto"); + const MAX_LOG_CONTENT_LENGTH = 10000; + function truncateForLogging(content) { + if (content.length <= MAX_LOG_CONTENT_LENGTH) { + return content; + } + return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; + } + function loadAgentOutput() { + const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; + if (!agentOutputFile) { + core.info("No GH_AW_AGENT_OUTPUT environment variable found"); + return { success: false }; + } + let outputContent; + try { + outputContent = fs.readFileSync(agentOutputFile, "utf8"); + } catch (error) { + const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; + core.error(errorMessage); + return { success: false, error: errorMessage }; + } + if (outputContent.trim() === "") { + core.info("Agent output content is empty"); + return { success: false }; + } + core.info(`Agent output content length: ${outputContent.length}`); + let validatedOutput; + try { + validatedOutput = JSON.parse(outputContent); + } catch (error) { + const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; + core.error(errorMessage); + core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); + return { success: false, error: errorMessage }; + } + if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { + core.info("No valid items found in agent output"); + core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); + return { success: false }; + } + return { success: true, items: validatedOutput.items }; + } + function getTrackerID(format) { + const trackerID = process.env.GH_AW_TRACKER_ID || ""; + if (trackerID) { + core.info(`Tracker ID: ${trackerID}`); + return format === "markdown" ? `\n\n` : trackerID; + } + return ""; + } + function getMessages() { + const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES; + if (!messagesEnv) { + return null; + } + try { + return JSON.parse(messagesEnv); + } catch (error) { + core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`); + return null; + } + } + function renderTemplate(template, context) { + return template.replace(/\{(\w+)\}/g, (match, key) => { + const value = context[key]; + return value !== undefined && value !== null ? String(value) : match; + }); + } + function toSnakeCase(obj) { + const result = {}; + for (const [key, value] of Object.entries(obj)) { + const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase(); + result[snakeKey] = value; + result[key] = value; + } + return result; + } + function getCloseOlderDiscussionMessage(ctx) { + const messages = getMessages(); + const templateContext = toSnakeCase(ctx); + const defaultMessage = `⚓ Avast! This discussion be marked as **outdated** by [{workflow_name}]({run_url}). + 🗺️ A newer treasure map awaits ye at **[Discussion #{new_discussion_number}]({new_discussion_url})**. + Fair winds, matey! 🏴‍☠️`; + return messages?.closeOlderDiscussion + ? renderTemplate(messages.closeOlderDiscussion, templateContext) + : renderTemplate(defaultMessage, templateContext); + } + const MAX_CLOSE_COUNT = 10; + const GRAPHQL_DELAY_MS = 500; + function delay(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); + } + async function searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, excludeNumber) { + let searchQuery = `repo:${owner}/${repo} is:open`; + if (titlePrefix) { + const escapedPrefix = titlePrefix.replace(/"/g, '\\"'); + searchQuery += ` in:title "${escapedPrefix}"`; + } + if (labels && labels.length > 0) { + for (const label of labels) { + const escapedLabel = label.replace(/"/g, '\\"'); + searchQuery += ` label:"${escapedLabel}"`; + } + } + const result = await github.graphql( + ` + query($searchTerms: String!, $first: Int!) { + search(query: $searchTerms, type: DISCUSSION, first: $first) { + nodes { + ... on Discussion { + id + number + title + url + category { + id + } + labels(first: 100) { + nodes { + name + } + } + closed + } + } + } + }`, + { searchTerms: searchQuery, first: 50 } + ); + if (!result || !result.search || !result.search.nodes) { + return []; + } + return result.search.nodes + .filter( + d => { + if (!d || d.number === excludeNumber || d.closed) { + return false; + } + if (titlePrefix && d.title && !d.title.startsWith(titlePrefix)) { + return false; + } + if (labels && labels.length > 0) { + const discussionLabels = d.labels?.nodes?.map(( l) => l.name) || []; + const hasAllLabels = labels.every(label => discussionLabels.includes(label)); + if (!hasAllLabels) { + return false; + } + } + if (categoryId && (!d.category || d.category.id !== categoryId)) { + return false; + } + return true; + } + ) + .map( + d => ({ + id: d.id, + number: d.number, + title: d.title, + url: d.url, + }) + ); + } + async function addDiscussionComment(github, discussionId, message) { + const result = await github.graphql( + ` + mutation($dId: ID!, $body: String!) { + addDiscussionComment(input: { discussionId: $dId, body: $body }) { + comment { + id + url + } + } + }`, + { dId: discussionId, body: message } + ); + return result.addDiscussionComment.comment; + } + async function closeDiscussionAsOutdated(github, discussionId) { + const result = await github.graphql( + ` + mutation($dId: ID!) { + closeDiscussion(input: { discussionId: $dId, reason: OUTDATED }) { + discussion { + id + url + } + } + }`, + { dId: discussionId } + ); + return result.closeDiscussion.discussion; + } + async function closeOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion, workflowName, runUrl) { + const searchCriteria = []; + if (titlePrefix) searchCriteria.push(`title prefix: "${titlePrefix}"`); + if (labels && labels.length > 0) searchCriteria.push(`labels: [${labels.join(", ")}]`); + core.info(`Searching for older discussions with ${searchCriteria.join(" and ")}`); + const olderDiscussions = await searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion.number); + if (olderDiscussions.length === 0) { + core.info("No older discussions found to close"); + return []; + } + core.info(`Found ${olderDiscussions.length} older discussion(s) to close`); + const discussionsToClose = olderDiscussions.slice(0, MAX_CLOSE_COUNT); + if (olderDiscussions.length > MAX_CLOSE_COUNT) { + core.warning(`Found ${olderDiscussions.length} older discussions, but only closing the first ${MAX_CLOSE_COUNT}`); + } + const closedDiscussions = []; + for (let i = 0; i < discussionsToClose.length; i++) { + const discussion = discussionsToClose[i]; + try { + const closingMessage = getCloseOlderDiscussionMessage({ + newDiscussionUrl: newDiscussion.url, + newDiscussionNumber: newDiscussion.number, + workflowName, + runUrl, + }); + core.info(`Adding closing comment to discussion #${discussion.number}`); + await addDiscussionComment(github, discussion.id, closingMessage); + core.info(`Closing discussion #${discussion.number} as outdated`); + await closeDiscussionAsOutdated(github, discussion.id); + closedDiscussions.push({ + number: discussion.number, + url: discussion.url, + }); + core.info(`✓ Closed discussion #${discussion.number}: ${discussion.url}`); + } catch (error) { + core.error(`✗ Failed to close discussion #${discussion.number}: ${error instanceof Error ? error.message : String(error)}`); + } + if (i < discussionsToClose.length - 1) { + await delay(GRAPHQL_DELAY_MS); + } + } + return closedDiscussions; + } + const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; + function generateTemporaryId() { + return "aw_" + crypto.randomBytes(6).toString("hex"); + } + function isTemporaryId(value) { + if (typeof value === "string") { + return /^aw_[0-9a-f]{12}$/i.test(value); + } + return false; + } + function normalizeTemporaryId(tempId) { + return String(tempId).toLowerCase(); + } + function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { + return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { + const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); + if (resolved !== undefined) { + if (currentRepo && resolved.repo === currentRepo) { + return `#${resolved.number}`; + } + return `${resolved.repo}#${resolved.number}`; + } + return match; + }); + } + function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { + return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { + const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); + if (issueNumber !== undefined) { + return `#${issueNumber}`; + } + return match; + }); + } + function loadTemporaryIdMap() { + const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; + if (!mapJson || mapJson === "{}") { + return new Map(); + } + try { + const mapObject = JSON.parse(mapJson); + const result = new Map(); + for (const [key, value] of Object.entries(mapObject)) { + const normalizedKey = normalizeTemporaryId(key); + if (typeof value === "number") { + const contextRepo = `${context.repo.owner}/${context.repo.repo}`; + result.set(normalizedKey, { repo: contextRepo, number: value }); + } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { + result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); + } + } + return result; + } catch (error) { + if (typeof core !== "undefined") { + core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); + } + return new Map(); + } + } + function resolveIssueNumber(value, temporaryIdMap) { + if (value === undefined || value === null) { + return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; + } + const valueStr = String(value); + if (isTemporaryId(valueStr)) { + const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); + if (resolvedPair !== undefined) { + return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; + } + return { + resolved: null, + wasTemporaryId: true, + errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, + }; + } + const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); + if (isNaN(issueNumber) || issueNumber <= 0) { + return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; + } + const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; + return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; + } + function serializeTemporaryIdMap(tempIdMap) { + const obj = Object.fromEntries(tempIdMap); + return JSON.stringify(obj); + } + function parseAllowedRepos() { + const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; + const set = new Set(); + if (allowedReposEnv) { + allowedReposEnv + .split(",") + .map(repo => repo.trim()) + .filter(repo => repo) + .forEach(repo => set.add(repo)); + } + return set; + } + function getDefaultTargetRepo() { + const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; + if (targetRepoSlug) { + return targetRepoSlug; + } + return `${context.repo.owner}/${context.repo.repo}`; + } + function validateRepo(repo, defaultRepo, allowedRepos) { + if (repo === defaultRepo) { + return { valid: true, error: null }; + } + if (allowedRepos.has(repo)) { + return { valid: true, error: null }; + } + return { + valid: false, + error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, + }; + } + function parseRepoSlug(repoSlug) { + const parts = repoSlug.split("/"); + if (parts.length !== 2 || !parts[0] || !parts[1]) { + return null; + } + return { owner: parts[0], repo: parts[1] }; + } + function addExpirationComment(bodyLines, envVarName, entityType) { + const expiresEnv = process.env[envVarName]; + if (expiresEnv) { + const expiresDays = parseInt(expiresEnv, 10); + if (!isNaN(expiresDays) && expiresDays > 0) { + const expirationDate = new Date(); + expirationDate.setDate(expirationDate.getDate() + expiresDays); + const expirationISO = expirationDate.toISOString(); + bodyLines.push(``); + core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); + } + } + } + function removeDuplicateTitleFromDescription(title, description) { + if (!title || typeof title !== "string") { + return description || ""; + } + if (!description || typeof description !== "string") { + return ""; + } + const trimmedTitle = title.trim(); + const trimmedDescription = description.trim(); + if (!trimmedTitle || !trimmedDescription) { + return trimmedDescription; + } + const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); + if (headerRegex.test(trimmedDescription)) { + return trimmedDescription.replace(headerRegex, "").trim(); + } + return trimmedDescription; + } async function fetchRepoDiscussionInfo(owner, repo) { const repositoryQuery = ` query($owner: String!, $repo: String!) { diff --git a/.github/workflows/safe-output-health.lock.yml b/.github/workflows/safe-output-health.lock.yml index c6ab427ba41..bddd25b1655 100644 --- a/.github/workflows/safe-output-health.lock.yml +++ b/.github/workflows/safe-output-health.lock.yml @@ -6467,13 +6467,400 @@ jobs: with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - const { loadAgentOutput } = require("./load_agent_output.cjs"); - const { getTrackerID } = require("./get_tracker_id.cjs"); - const { closeOlderDiscussions } = require("./close_older_discussions.cjs"); - const { replaceTemporaryIdReferences, loadTemporaryIdMap } = require("./temporary_id.cjs"); - const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require("./repo_helpers.cjs"); - const { addExpirationComment } = require("./expiration_helpers.cjs"); - const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); + const fs = require("fs"); + const crypto = require("crypto"); + const MAX_LOG_CONTENT_LENGTH = 10000; + function truncateForLogging(content) { + if (content.length <= MAX_LOG_CONTENT_LENGTH) { + return content; + } + return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; + } + function loadAgentOutput() { + const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; + if (!agentOutputFile) { + core.info("No GH_AW_AGENT_OUTPUT environment variable found"); + return { success: false }; + } + let outputContent; + try { + outputContent = fs.readFileSync(agentOutputFile, "utf8"); + } catch (error) { + const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; + core.error(errorMessage); + return { success: false, error: errorMessage }; + } + if (outputContent.trim() === "") { + core.info("Agent output content is empty"); + return { success: false }; + } + core.info(`Agent output content length: ${outputContent.length}`); + let validatedOutput; + try { + validatedOutput = JSON.parse(outputContent); + } catch (error) { + const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; + core.error(errorMessage); + core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); + return { success: false, error: errorMessage }; + } + if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { + core.info("No valid items found in agent output"); + core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); + return { success: false }; + } + return { success: true, items: validatedOutput.items }; + } + function getTrackerID(format) { + const trackerID = process.env.GH_AW_TRACKER_ID || ""; + if (trackerID) { + core.info(`Tracker ID: ${trackerID}`); + return format === "markdown" ? `\n\n` : trackerID; + } + return ""; + } + function getMessages() { + const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES; + if (!messagesEnv) { + return null; + } + try { + return JSON.parse(messagesEnv); + } catch (error) { + core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`); + return null; + } + } + function renderTemplate(template, context) { + return template.replace(/\{(\w+)\}/g, (match, key) => { + const value = context[key]; + return value !== undefined && value !== null ? String(value) : match; + }); + } + function toSnakeCase(obj) { + const result = {}; + for (const [key, value] of Object.entries(obj)) { + const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase(); + result[snakeKey] = value; + result[key] = value; + } + return result; + } + function getCloseOlderDiscussionMessage(ctx) { + const messages = getMessages(); + const templateContext = toSnakeCase(ctx); + const defaultMessage = `⚓ Avast! This discussion be marked as **outdated** by [{workflow_name}]({run_url}). + 🗺️ A newer treasure map awaits ye at **[Discussion #{new_discussion_number}]({new_discussion_url})**. + Fair winds, matey! 🏴‍☠️`; + return messages?.closeOlderDiscussion + ? renderTemplate(messages.closeOlderDiscussion, templateContext) + : renderTemplate(defaultMessage, templateContext); + } + const MAX_CLOSE_COUNT = 10; + const GRAPHQL_DELAY_MS = 500; + function delay(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); + } + async function searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, excludeNumber) { + let searchQuery = `repo:${owner}/${repo} is:open`; + if (titlePrefix) { + const escapedPrefix = titlePrefix.replace(/"/g, '\\"'); + searchQuery += ` in:title "${escapedPrefix}"`; + } + if (labels && labels.length > 0) { + for (const label of labels) { + const escapedLabel = label.replace(/"/g, '\\"'); + searchQuery += ` label:"${escapedLabel}"`; + } + } + const result = await github.graphql( + ` + query($searchTerms: String!, $first: Int!) { + search(query: $searchTerms, type: DISCUSSION, first: $first) { + nodes { + ... on Discussion { + id + number + title + url + category { + id + } + labels(first: 100) { + nodes { + name + } + } + closed + } + } + } + }`, + { searchTerms: searchQuery, first: 50 } + ); + if (!result || !result.search || !result.search.nodes) { + return []; + } + return result.search.nodes + .filter( + d => { + if (!d || d.number === excludeNumber || d.closed) { + return false; + } + if (titlePrefix && d.title && !d.title.startsWith(titlePrefix)) { + return false; + } + if (labels && labels.length > 0) { + const discussionLabels = d.labels?.nodes?.map(( l) => l.name) || []; + const hasAllLabels = labels.every(label => discussionLabels.includes(label)); + if (!hasAllLabels) { + return false; + } + } + if (categoryId && (!d.category || d.category.id !== categoryId)) { + return false; + } + return true; + } + ) + .map( + d => ({ + id: d.id, + number: d.number, + title: d.title, + url: d.url, + }) + ); + } + async function addDiscussionComment(github, discussionId, message) { + const result = await github.graphql( + ` + mutation($dId: ID!, $body: String!) { + addDiscussionComment(input: { discussionId: $dId, body: $body }) { + comment { + id + url + } + } + }`, + { dId: discussionId, body: message } + ); + return result.addDiscussionComment.comment; + } + async function closeDiscussionAsOutdated(github, discussionId) { + const result = await github.graphql( + ` + mutation($dId: ID!) { + closeDiscussion(input: { discussionId: $dId, reason: OUTDATED }) { + discussion { + id + url + } + } + }`, + { dId: discussionId } + ); + return result.closeDiscussion.discussion; + } + async function closeOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion, workflowName, runUrl) { + const searchCriteria = []; + if (titlePrefix) searchCriteria.push(`title prefix: "${titlePrefix}"`); + if (labels && labels.length > 0) searchCriteria.push(`labels: [${labels.join(", ")}]`); + core.info(`Searching for older discussions with ${searchCriteria.join(" and ")}`); + const olderDiscussions = await searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion.number); + if (olderDiscussions.length === 0) { + core.info("No older discussions found to close"); + return []; + } + core.info(`Found ${olderDiscussions.length} older discussion(s) to close`); + const discussionsToClose = olderDiscussions.slice(0, MAX_CLOSE_COUNT); + if (olderDiscussions.length > MAX_CLOSE_COUNT) { + core.warning(`Found ${olderDiscussions.length} older discussions, but only closing the first ${MAX_CLOSE_COUNT}`); + } + const closedDiscussions = []; + for (let i = 0; i < discussionsToClose.length; i++) { + const discussion = discussionsToClose[i]; + try { + const closingMessage = getCloseOlderDiscussionMessage({ + newDiscussionUrl: newDiscussion.url, + newDiscussionNumber: newDiscussion.number, + workflowName, + runUrl, + }); + core.info(`Adding closing comment to discussion #${discussion.number}`); + await addDiscussionComment(github, discussion.id, closingMessage); + core.info(`Closing discussion #${discussion.number} as outdated`); + await closeDiscussionAsOutdated(github, discussion.id); + closedDiscussions.push({ + number: discussion.number, + url: discussion.url, + }); + core.info(`✓ Closed discussion #${discussion.number}: ${discussion.url}`); + } catch (error) { + core.error(`✗ Failed to close discussion #${discussion.number}: ${error instanceof Error ? error.message : String(error)}`); + } + if (i < discussionsToClose.length - 1) { + await delay(GRAPHQL_DELAY_MS); + } + } + return closedDiscussions; + } + const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; + function generateTemporaryId() { + return "aw_" + crypto.randomBytes(6).toString("hex"); + } + function isTemporaryId(value) { + if (typeof value === "string") { + return /^aw_[0-9a-f]{12}$/i.test(value); + } + return false; + } + function normalizeTemporaryId(tempId) { + return String(tempId).toLowerCase(); + } + function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { + return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { + const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); + if (resolved !== undefined) { + if (currentRepo && resolved.repo === currentRepo) { + return `#${resolved.number}`; + } + return `${resolved.repo}#${resolved.number}`; + } + return match; + }); + } + function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { + return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { + const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); + if (issueNumber !== undefined) { + return `#${issueNumber}`; + } + return match; + }); + } + function loadTemporaryIdMap() { + const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; + if (!mapJson || mapJson === "{}") { + return new Map(); + } + try { + const mapObject = JSON.parse(mapJson); + const result = new Map(); + for (const [key, value] of Object.entries(mapObject)) { + const normalizedKey = normalizeTemporaryId(key); + if (typeof value === "number") { + const contextRepo = `${context.repo.owner}/${context.repo.repo}`; + result.set(normalizedKey, { repo: contextRepo, number: value }); + } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { + result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); + } + } + return result; + } catch (error) { + if (typeof core !== "undefined") { + core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); + } + return new Map(); + } + } + function resolveIssueNumber(value, temporaryIdMap) { + if (value === undefined || value === null) { + return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; + } + const valueStr = String(value); + if (isTemporaryId(valueStr)) { + const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); + if (resolvedPair !== undefined) { + return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; + } + return { + resolved: null, + wasTemporaryId: true, + errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, + }; + } + const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); + if (isNaN(issueNumber) || issueNumber <= 0) { + return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; + } + const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; + return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; + } + function serializeTemporaryIdMap(tempIdMap) { + const obj = Object.fromEntries(tempIdMap); + return JSON.stringify(obj); + } + function parseAllowedRepos() { + const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; + const set = new Set(); + if (allowedReposEnv) { + allowedReposEnv + .split(",") + .map(repo => repo.trim()) + .filter(repo => repo) + .forEach(repo => set.add(repo)); + } + return set; + } + function getDefaultTargetRepo() { + const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; + if (targetRepoSlug) { + return targetRepoSlug; + } + return `${context.repo.owner}/${context.repo.repo}`; + } + function validateRepo(repo, defaultRepo, allowedRepos) { + if (repo === defaultRepo) { + return { valid: true, error: null }; + } + if (allowedRepos.has(repo)) { + return { valid: true, error: null }; + } + return { + valid: false, + error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, + }; + } + function parseRepoSlug(repoSlug) { + const parts = repoSlug.split("/"); + if (parts.length !== 2 || !parts[0] || !parts[1]) { + return null; + } + return { owner: parts[0], repo: parts[1] }; + } + function addExpirationComment(bodyLines, envVarName, entityType) { + const expiresEnv = process.env[envVarName]; + if (expiresEnv) { + const expiresDays = parseInt(expiresEnv, 10); + if (!isNaN(expiresDays) && expiresDays > 0) { + const expirationDate = new Date(); + expirationDate.setDate(expirationDate.getDate() + expiresDays); + const expirationISO = expirationDate.toISOString(); + bodyLines.push(``); + core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); + } + } + } + function removeDuplicateTitleFromDescription(title, description) { + if (!title || typeof title !== "string") { + return description || ""; + } + if (!description || typeof description !== "string") { + return ""; + } + const trimmedTitle = title.trim(); + const trimmedDescription = description.trim(); + if (!trimmedTitle || !trimmedDescription) { + return trimmedDescription; + } + const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); + if (headerRegex.test(trimmedDescription)) { + return trimmedDescription.replace(headerRegex, "").trim(); + } + return trimmedDescription; + } async function fetchRepoDiscussionInfo(owner, repo) { const repositoryQuery = ` query($owner: String!, $repo: String!) { diff --git a/.github/workflows/schema-consistency-checker.lock.yml b/.github/workflows/schema-consistency-checker.lock.yml index ad9c690af4a..bf11ff2504c 100644 --- a/.github/workflows/schema-consistency-checker.lock.yml +++ b/.github/workflows/schema-consistency-checker.lock.yml @@ -6114,13 +6114,400 @@ jobs: with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - const { loadAgentOutput } = require("./load_agent_output.cjs"); - const { getTrackerID } = require("./get_tracker_id.cjs"); - const { closeOlderDiscussions } = require("./close_older_discussions.cjs"); - const { replaceTemporaryIdReferences, loadTemporaryIdMap } = require("./temporary_id.cjs"); - const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require("./repo_helpers.cjs"); - const { addExpirationComment } = require("./expiration_helpers.cjs"); - const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); + const fs = require("fs"); + const crypto = require("crypto"); + const MAX_LOG_CONTENT_LENGTH = 10000; + function truncateForLogging(content) { + if (content.length <= MAX_LOG_CONTENT_LENGTH) { + return content; + } + return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; + } + function loadAgentOutput() { + const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; + if (!agentOutputFile) { + core.info("No GH_AW_AGENT_OUTPUT environment variable found"); + return { success: false }; + } + let outputContent; + try { + outputContent = fs.readFileSync(agentOutputFile, "utf8"); + } catch (error) { + const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; + core.error(errorMessage); + return { success: false, error: errorMessage }; + } + if (outputContent.trim() === "") { + core.info("Agent output content is empty"); + return { success: false }; + } + core.info(`Agent output content length: ${outputContent.length}`); + let validatedOutput; + try { + validatedOutput = JSON.parse(outputContent); + } catch (error) { + const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; + core.error(errorMessage); + core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); + return { success: false, error: errorMessage }; + } + if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { + core.info("No valid items found in agent output"); + core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); + return { success: false }; + } + return { success: true, items: validatedOutput.items }; + } + function getTrackerID(format) { + const trackerID = process.env.GH_AW_TRACKER_ID || ""; + if (trackerID) { + core.info(`Tracker ID: ${trackerID}`); + return format === "markdown" ? `\n\n` : trackerID; + } + return ""; + } + function getMessages() { + const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES; + if (!messagesEnv) { + return null; + } + try { + return JSON.parse(messagesEnv); + } catch (error) { + core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`); + return null; + } + } + function renderTemplate(template, context) { + return template.replace(/\{(\w+)\}/g, (match, key) => { + const value = context[key]; + return value !== undefined && value !== null ? String(value) : match; + }); + } + function toSnakeCase(obj) { + const result = {}; + for (const [key, value] of Object.entries(obj)) { + const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase(); + result[snakeKey] = value; + result[key] = value; + } + return result; + } + function getCloseOlderDiscussionMessage(ctx) { + const messages = getMessages(); + const templateContext = toSnakeCase(ctx); + const defaultMessage = `⚓ Avast! This discussion be marked as **outdated** by [{workflow_name}]({run_url}). + 🗺️ A newer treasure map awaits ye at **[Discussion #{new_discussion_number}]({new_discussion_url})**. + Fair winds, matey! 🏴‍☠️`; + return messages?.closeOlderDiscussion + ? renderTemplate(messages.closeOlderDiscussion, templateContext) + : renderTemplate(defaultMessage, templateContext); + } + const MAX_CLOSE_COUNT = 10; + const GRAPHQL_DELAY_MS = 500; + function delay(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); + } + async function searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, excludeNumber) { + let searchQuery = `repo:${owner}/${repo} is:open`; + if (titlePrefix) { + const escapedPrefix = titlePrefix.replace(/"/g, '\\"'); + searchQuery += ` in:title "${escapedPrefix}"`; + } + if (labels && labels.length > 0) { + for (const label of labels) { + const escapedLabel = label.replace(/"/g, '\\"'); + searchQuery += ` label:"${escapedLabel}"`; + } + } + const result = await github.graphql( + ` + query($searchTerms: String!, $first: Int!) { + search(query: $searchTerms, type: DISCUSSION, first: $first) { + nodes { + ... on Discussion { + id + number + title + url + category { + id + } + labels(first: 100) { + nodes { + name + } + } + closed + } + } + } + }`, + { searchTerms: searchQuery, first: 50 } + ); + if (!result || !result.search || !result.search.nodes) { + return []; + } + return result.search.nodes + .filter( + d => { + if (!d || d.number === excludeNumber || d.closed) { + return false; + } + if (titlePrefix && d.title && !d.title.startsWith(titlePrefix)) { + return false; + } + if (labels && labels.length > 0) { + const discussionLabels = d.labels?.nodes?.map(( l) => l.name) || []; + const hasAllLabels = labels.every(label => discussionLabels.includes(label)); + if (!hasAllLabels) { + return false; + } + } + if (categoryId && (!d.category || d.category.id !== categoryId)) { + return false; + } + return true; + } + ) + .map( + d => ({ + id: d.id, + number: d.number, + title: d.title, + url: d.url, + }) + ); + } + async function addDiscussionComment(github, discussionId, message) { + const result = await github.graphql( + ` + mutation($dId: ID!, $body: String!) { + addDiscussionComment(input: { discussionId: $dId, body: $body }) { + comment { + id + url + } + } + }`, + { dId: discussionId, body: message } + ); + return result.addDiscussionComment.comment; + } + async function closeDiscussionAsOutdated(github, discussionId) { + const result = await github.graphql( + ` + mutation($dId: ID!) { + closeDiscussion(input: { discussionId: $dId, reason: OUTDATED }) { + discussion { + id + url + } + } + }`, + { dId: discussionId } + ); + return result.closeDiscussion.discussion; + } + async function closeOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion, workflowName, runUrl) { + const searchCriteria = []; + if (titlePrefix) searchCriteria.push(`title prefix: "${titlePrefix}"`); + if (labels && labels.length > 0) searchCriteria.push(`labels: [${labels.join(", ")}]`); + core.info(`Searching for older discussions with ${searchCriteria.join(" and ")}`); + const olderDiscussions = await searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion.number); + if (olderDiscussions.length === 0) { + core.info("No older discussions found to close"); + return []; + } + core.info(`Found ${olderDiscussions.length} older discussion(s) to close`); + const discussionsToClose = olderDiscussions.slice(0, MAX_CLOSE_COUNT); + if (olderDiscussions.length > MAX_CLOSE_COUNT) { + core.warning(`Found ${olderDiscussions.length} older discussions, but only closing the first ${MAX_CLOSE_COUNT}`); + } + const closedDiscussions = []; + for (let i = 0; i < discussionsToClose.length; i++) { + const discussion = discussionsToClose[i]; + try { + const closingMessage = getCloseOlderDiscussionMessage({ + newDiscussionUrl: newDiscussion.url, + newDiscussionNumber: newDiscussion.number, + workflowName, + runUrl, + }); + core.info(`Adding closing comment to discussion #${discussion.number}`); + await addDiscussionComment(github, discussion.id, closingMessage); + core.info(`Closing discussion #${discussion.number} as outdated`); + await closeDiscussionAsOutdated(github, discussion.id); + closedDiscussions.push({ + number: discussion.number, + url: discussion.url, + }); + core.info(`✓ Closed discussion #${discussion.number}: ${discussion.url}`); + } catch (error) { + core.error(`✗ Failed to close discussion #${discussion.number}: ${error instanceof Error ? error.message : String(error)}`); + } + if (i < discussionsToClose.length - 1) { + await delay(GRAPHQL_DELAY_MS); + } + } + return closedDiscussions; + } + const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; + function generateTemporaryId() { + return "aw_" + crypto.randomBytes(6).toString("hex"); + } + function isTemporaryId(value) { + if (typeof value === "string") { + return /^aw_[0-9a-f]{12}$/i.test(value); + } + return false; + } + function normalizeTemporaryId(tempId) { + return String(tempId).toLowerCase(); + } + function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { + return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { + const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); + if (resolved !== undefined) { + if (currentRepo && resolved.repo === currentRepo) { + return `#${resolved.number}`; + } + return `${resolved.repo}#${resolved.number}`; + } + return match; + }); + } + function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { + return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { + const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); + if (issueNumber !== undefined) { + return `#${issueNumber}`; + } + return match; + }); + } + function loadTemporaryIdMap() { + const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; + if (!mapJson || mapJson === "{}") { + return new Map(); + } + try { + const mapObject = JSON.parse(mapJson); + const result = new Map(); + for (const [key, value] of Object.entries(mapObject)) { + const normalizedKey = normalizeTemporaryId(key); + if (typeof value === "number") { + const contextRepo = `${context.repo.owner}/${context.repo.repo}`; + result.set(normalizedKey, { repo: contextRepo, number: value }); + } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { + result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); + } + } + return result; + } catch (error) { + if (typeof core !== "undefined") { + core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); + } + return new Map(); + } + } + function resolveIssueNumber(value, temporaryIdMap) { + if (value === undefined || value === null) { + return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; + } + const valueStr = String(value); + if (isTemporaryId(valueStr)) { + const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); + if (resolvedPair !== undefined) { + return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; + } + return { + resolved: null, + wasTemporaryId: true, + errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, + }; + } + const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); + if (isNaN(issueNumber) || issueNumber <= 0) { + return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; + } + const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; + return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; + } + function serializeTemporaryIdMap(tempIdMap) { + const obj = Object.fromEntries(tempIdMap); + return JSON.stringify(obj); + } + function parseAllowedRepos() { + const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; + const set = new Set(); + if (allowedReposEnv) { + allowedReposEnv + .split(",") + .map(repo => repo.trim()) + .filter(repo => repo) + .forEach(repo => set.add(repo)); + } + return set; + } + function getDefaultTargetRepo() { + const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; + if (targetRepoSlug) { + return targetRepoSlug; + } + return `${context.repo.owner}/${context.repo.repo}`; + } + function validateRepo(repo, defaultRepo, allowedRepos) { + if (repo === defaultRepo) { + return { valid: true, error: null }; + } + if (allowedRepos.has(repo)) { + return { valid: true, error: null }; + } + return { + valid: false, + error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, + }; + } + function parseRepoSlug(repoSlug) { + const parts = repoSlug.split("/"); + if (parts.length !== 2 || !parts[0] || !parts[1]) { + return null; + } + return { owner: parts[0], repo: parts[1] }; + } + function addExpirationComment(bodyLines, envVarName, entityType) { + const expiresEnv = process.env[envVarName]; + if (expiresEnv) { + const expiresDays = parseInt(expiresEnv, 10); + if (!isNaN(expiresDays) && expiresDays > 0) { + const expirationDate = new Date(); + expirationDate.setDate(expirationDate.getDate() + expiresDays); + const expirationISO = expirationDate.toISOString(); + bodyLines.push(``); + core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); + } + } + } + function removeDuplicateTitleFromDescription(title, description) { + if (!title || typeof title !== "string") { + return description || ""; + } + if (!description || typeof description !== "string") { + return ""; + } + const trimmedTitle = title.trim(); + const trimmedDescription = description.trim(); + if (!trimmedTitle || !trimmedDescription) { + return trimmedDescription; + } + const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); + if (headerRegex.test(trimmedDescription)) { + return trimmedDescription.replace(headerRegex, "").trim(); + } + return trimmedDescription; + } async function fetchRepoDiscussionInfo(owner, repo) { const repositoryQuery = ` query($owner: String!, $repo: String!) { diff --git a/.github/workflows/security-fix-pr.lock.yml b/.github/workflows/security-fix-pr.lock.yml index 100575c4a78..4d299d4e37f 100644 --- a/.github/workflows/security-fix-pr.lock.yml +++ b/.github/workflows/security-fix-pr.lock.yml @@ -5706,10 +5706,151 @@ jobs: script: | const fs = require("fs"); const crypto = require("crypto"); - const { updateActivationComment } = require("./update_activation_comment.cjs"); - const { getTrackerID } = require("./get_tracker_id.cjs"); - const { addExpirationComment } = require("./expiration_helpers.cjs"); - const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); + async function updateActivationComment(github, context, core, itemUrl, itemNumber, itemType = "pull_request") { + const itemLabel = itemType === "issue" ? "issue" : "pull request"; + const linkMessage = + itemType === "issue" + ? `\n\n✅ Issue created: [#${itemNumber}](${itemUrl})` + : `\n\n✅ Pull request created: [#${itemNumber}](${itemUrl})`; + await updateActivationCommentWithMessage(github, context, core, linkMessage, itemLabel); + } + async function updateActivationCommentWithCommit(github, context, core, commitSha, commitUrl) { + const shortSha = commitSha.substring(0, 7); + const message = `\n\n✅ Commit pushed: [\`${shortSha}\`](${commitUrl})`; + await updateActivationCommentWithMessage(github, context, core, message, "commit"); + } + async function updateActivationCommentWithMessage(github, context, core, message, label = "") { + const commentId = process.env.GH_AW_COMMENT_ID; + const commentRepo = process.env.GH_AW_COMMENT_REPO; + if (!commentId) { + core.info("No activation comment to update (GH_AW_COMMENT_ID not set)"); + return; + } + core.info(`Updating activation comment ${commentId}`); + let repoOwner = context.repo.owner; + let repoName = context.repo.repo; + if (commentRepo) { + const parts = commentRepo.split("/"); + if (parts.length === 2) { + repoOwner = parts[0]; + repoName = parts[1]; + } else { + core.warning(`Invalid comment repo format: ${commentRepo}, expected "owner/repo". Falling back to context.repo.`); + } + } + core.info(`Updating comment in ${repoOwner}/${repoName}`); + const isDiscussionComment = commentId.startsWith("DC_"); + try { + if (isDiscussionComment) { + const currentComment = await github.graphql( + ` + query($commentId: ID!) { + node(id: $commentId) { + ... on DiscussionComment { + body + } + } + }`, + { commentId: commentId } + ); + if (!currentComment?.node?.body) { + core.warning("Unable to fetch current comment body, comment may have been deleted or is inaccessible"); + return; + } + const currentBody = currentComment.node.body; + const updatedBody = currentBody + message; + const result = await github.graphql( + ` + mutation($commentId: ID!, $body: String!) { + updateDiscussionComment(input: { commentId: $commentId, body: $body }) { + comment { + id + url + } + } + }`, + { commentId: commentId, body: updatedBody } + ); + const comment = result.updateDiscussionComment.comment; + const successMessage = label + ? `Successfully updated discussion comment with ${label} link` + : "Successfully updated discussion comment"; + core.info(successMessage); + core.info(`Comment ID: ${comment.id}`); + core.info(`Comment URL: ${comment.url}`); + } else { + const currentComment = await github.request("GET /repos/{owner}/{repo}/issues/comments/{comment_id}", { + owner: repoOwner, + repo: repoName, + comment_id: parseInt(commentId, 10), + headers: { + Accept: "application/vnd.github+json", + }, + }); + if (!currentComment?.data?.body) { + core.warning("Unable to fetch current comment body, comment may have been deleted"); + return; + } + const currentBody = currentComment.data.body; + const updatedBody = currentBody + message; + const response = await github.request("PATCH /repos/{owner}/{repo}/issues/comments/{comment_id}", { + owner: repoOwner, + repo: repoName, + comment_id: parseInt(commentId, 10), + body: updatedBody, + headers: { + Accept: "application/vnd.github+json", + }, + }); + const successMessage = label ? `Successfully updated comment with ${label} link` : "Successfully updated comment"; + core.info(successMessage); + core.info(`Comment ID: ${response.data.id}`); + core.info(`Comment URL: ${response.data.html_url}`); + } + } catch (error) { + core.warning(`Failed to update activation comment: ${error instanceof Error ? error.message : String(error)}`); + } + } + function getTrackerID(format) { + const trackerID = process.env.GH_AW_TRACKER_ID || ""; + if (trackerID) { + core.info(`Tracker ID: ${trackerID}`); + return format === "markdown" ? `\n\n` : trackerID; + } + return ""; + } + function addExpirationComment(bodyLines, envVarName, entityType) { + const expiresEnv = process.env[envVarName]; + if (expiresEnv) { + const expiresDays = parseInt(expiresEnv, 10); + if (!isNaN(expiresDays) && expiresDays > 0) { + const expirationDate = new Date(); + expirationDate.setDate(expirationDate.getDate() + expiresDays); + const expirationISO = expirationDate.toISOString(); + bodyLines.push(``); + core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); + } + } + } + function removeDuplicateTitleFromDescription(title, description) { + if (!title || typeof title !== "string") { + return description || ""; + } + if (!description || typeof description !== "string") { + return ""; + } + const trimmedTitle = title.trim(); + const trimmedDescription = description.trim(); + if (!trimmedTitle || !trimmedDescription) { + return trimmedDescription; + } + const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); + if (headerRegex.test(trimmedDescription)) { + return trimmedDescription.replace(headerRegex, "").trim(); + } + return trimmedDescription; + } function generatePatchPreview(patchContent) { if (!patchContent || !patchContent.trim()) { return ""; diff --git a/.github/workflows/semantic-function-refactor.lock.yml b/.github/workflows/semantic-function-refactor.lock.yml index 1de8be94405..3f87d2251c9 100644 --- a/.github/workflows/semantic-function-refactor.lock.yml +++ b/.github/workflows/semantic-function-refactor.lock.yml @@ -6576,21 +6576,291 @@ jobs: with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - const { sanitizeLabelContent } = require("./sanitize_label_content.cjs"); - const { loadAgentOutput } = require("./load_agent_output.cjs"); - const { generateStagedPreview } = require("./staged_preview.cjs"); - const { generateFooter } = require("./generate_footer.cjs"); - const { getTrackerID } = require("./get_tracker_id.cjs"); - const { - generateTemporaryId, - isTemporaryId, - normalizeTemporaryId, - replaceTemporaryIdReferences, - serializeTemporaryIdMap, - } = require("./temporary_id.cjs"); - const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require("./repo_helpers.cjs"); - const { addExpirationComment } = require("./expiration_helpers.cjs"); - const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); + function sanitizeLabelContent(content) { + if (!content || typeof content !== "string") { + return ""; + } + let sanitized = content.trim(); + sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); + sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); + sanitized = sanitized.replace( + /(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, + (_m, p1, p2) => `${p1}\`@${p2}\`` + ); + sanitized = sanitized.replace(/[<>&'"]/g, ""); + return sanitized.trim(); + } + const fs = require("fs"); + const crypto = require("crypto"); + const MAX_LOG_CONTENT_LENGTH = 10000; + function truncateForLogging(content) { + if (content.length <= MAX_LOG_CONTENT_LENGTH) { + return content; + } + return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; + } + function loadAgentOutput() { + const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; + if (!agentOutputFile) { + core.info("No GH_AW_AGENT_OUTPUT environment variable found"); + return { success: false }; + } + let outputContent; + try { + outputContent = fs.readFileSync(agentOutputFile, "utf8"); + } catch (error) { + const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; + core.error(errorMessage); + return { success: false, error: errorMessage }; + } + if (outputContent.trim() === "") { + core.info("Agent output content is empty"); + return { success: false }; + } + core.info(`Agent output content length: ${outputContent.length}`); + let validatedOutput; + try { + validatedOutput = JSON.parse(outputContent); + } catch (error) { + const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; + core.error(errorMessage); + core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); + return { success: false, error: errorMessage }; + } + if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { + core.info("No valid items found in agent output"); + core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); + return { success: false }; + } + return { success: true, items: validatedOutput.items }; + } + async function generateStagedPreview(options) { + const { title, description, items, renderItem } = options; + let summaryContent = `## 🎭 Staged Mode: ${title} Preview\n\n`; + summaryContent += `${description}\n\n`; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + summaryContent += renderItem(item, i); + summaryContent += "---\n\n"; + } + try { + await core.summary.addRaw(summaryContent).write(); + core.info(summaryContent); + core.info(`📝 ${title} preview written to step summary`); + } catch (error) { + core.setFailed(error instanceof Error ? error : String(error)); + } + } + function generateXMLMarker(workflowName, runUrl) { + const engineId = process.env.GH_AW_ENGINE_ID || ""; + const engineVersion = process.env.GH_AW_ENGINE_VERSION || ""; + const engineModel = process.env.GH_AW_ENGINE_MODEL || ""; + const trackerId = process.env.GH_AW_TRACKER_ID || ""; + const parts = []; + parts.push(`agentic-workflow: ${workflowName}`); + if (trackerId) { + parts.push(`tracker-id: ${trackerId}`); + } + if (engineId) { + parts.push(`engine: ${engineId}`); + } + if (engineVersion) { + parts.push(`version: ${engineVersion}`); + } + if (engineModel) { + parts.push(`model: ${engineModel}`); + } + parts.push(`run: ${runUrl}`); + return ``; + } + function generateFooter( + workflowName, + runUrl, + workflowSource, + workflowSourceURL, + triggeringIssueNumber, + triggeringPRNumber, + triggeringDiscussionNumber + ) { + let footer = `\n\n> AI generated by [${workflowName}](${runUrl})`; + if (triggeringIssueNumber) { + footer += ` for #${triggeringIssueNumber}`; + } else if (triggeringPRNumber) { + footer += ` for #${triggeringPRNumber}`; + } else if (triggeringDiscussionNumber) { + footer += ` for discussion #${triggeringDiscussionNumber}`; + } + if (workflowSource && workflowSourceURL) { + footer += `\n>\n> To add this workflow in your repository, run \`gh aw add ${workflowSource}\`. See [usage guide](https://githubnext.github.io/gh-aw/tools/cli/).`; + } + footer += "\n\n" + generateXMLMarker(workflowName, runUrl); + footer += "\n"; + return footer; + } + function getTrackerID(format) { + const trackerID = process.env.GH_AW_TRACKER_ID || ""; + if (trackerID) { + core.info(`Tracker ID: ${trackerID}`); + return format === "markdown" ? `\n\n` : trackerID; + } + return ""; + } + const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; + function generateTemporaryId() { + return "aw_" + crypto.randomBytes(6).toString("hex"); + } + function isTemporaryId(value) { + if (typeof value === "string") { + return /^aw_[0-9a-f]{12}$/i.test(value); + } + return false; + } + function normalizeTemporaryId(tempId) { + return String(tempId).toLowerCase(); + } + function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { + return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { + const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); + if (resolved !== undefined) { + if (currentRepo && resolved.repo === currentRepo) { + return `#${resolved.number}`; + } + return `${resolved.repo}#${resolved.number}`; + } + return match; + }); + } + function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { + return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { + const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); + if (issueNumber !== undefined) { + return `#${issueNumber}`; + } + return match; + }); + } + function loadTemporaryIdMap() { + const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; + if (!mapJson || mapJson === "{}") { + return new Map(); + } + try { + const mapObject = JSON.parse(mapJson); + const result = new Map(); + for (const [key, value] of Object.entries(mapObject)) { + const normalizedKey = normalizeTemporaryId(key); + if (typeof value === "number") { + const contextRepo = `${context.repo.owner}/${context.repo.repo}`; + result.set(normalizedKey, { repo: contextRepo, number: value }); + } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { + result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); + } + } + return result; + } catch (error) { + if (typeof core !== "undefined") { + core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); + } + return new Map(); + } + } + function resolveIssueNumber(value, temporaryIdMap) { + if (value === undefined || value === null) { + return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; + } + const valueStr = String(value); + if (isTemporaryId(valueStr)) { + const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); + if (resolvedPair !== undefined) { + return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; + } + return { + resolved: null, + wasTemporaryId: true, + errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, + }; + } + const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); + if (isNaN(issueNumber) || issueNumber <= 0) { + return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; + } + const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; + return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; + } + function serializeTemporaryIdMap(tempIdMap) { + const obj = Object.fromEntries(tempIdMap); + return JSON.stringify(obj); + } + function parseAllowedRepos() { + const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; + const set = new Set(); + if (allowedReposEnv) { + allowedReposEnv + .split(",") + .map(repo => repo.trim()) + .filter(repo => repo) + .forEach(repo => set.add(repo)); + } + return set; + } + function getDefaultTargetRepo() { + const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; + if (targetRepoSlug) { + return targetRepoSlug; + } + return `${context.repo.owner}/${context.repo.repo}`; + } + function validateRepo(repo, defaultRepo, allowedRepos) { + if (repo === defaultRepo) { + return { valid: true, error: null }; + } + if (allowedRepos.has(repo)) { + return { valid: true, error: null }; + } + return { + valid: false, + error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, + }; + } + function parseRepoSlug(repoSlug) { + const parts = repoSlug.split("/"); + if (parts.length !== 2 || !parts[0] || !parts[1]) { + return null; + } + return { owner: parts[0], repo: parts[1] }; + } + function addExpirationComment(bodyLines, envVarName, entityType) { + const expiresEnv = process.env[envVarName]; + if (expiresEnv) { + const expiresDays = parseInt(expiresEnv, 10); + if (!isNaN(expiresDays) && expiresDays > 0) { + const expirationDate = new Date(); + expirationDate.setDate(expirationDate.getDate() + expiresDays); + const expirationISO = expirationDate.toISOString(); + bodyLines.push(``); + core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); + } + } + } + function removeDuplicateTitleFromDescription(title, description) { + if (!title || typeof title !== "string") { + return description || ""; + } + if (!description || typeof description !== "string") { + return ""; + } + const trimmedTitle = title.trim(); + const trimmedDescription = description.trim(); + if (!trimmedTitle || !trimmedDescription) { + return trimmedDescription; + } + const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); + if (headerRegex.test(trimmedDescription)) { + return trimmedDescription.replace(headerRegex, "").trim(); + } + return trimmedDescription; + } async function main() { core.setOutput("issue_number", ""); core.setOutput("issue_url", ""); diff --git a/.github/workflows/smoke-claude.lock.yml b/.github/workflows/smoke-claude.lock.yml index 05b523f1aed..f528a1bacf2 100644 --- a/.github/workflows/smoke-claude.lock.yml +++ b/.github/workflows/smoke-claude.lock.yml @@ -7403,21 +7403,291 @@ jobs: with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - const { sanitizeLabelContent } = require("./sanitize_label_content.cjs"); - const { loadAgentOutput } = require("./load_agent_output.cjs"); - const { generateStagedPreview } = require("./staged_preview.cjs"); - const { generateFooter } = require("./generate_footer.cjs"); - const { getTrackerID } = require("./get_tracker_id.cjs"); - const { - generateTemporaryId, - isTemporaryId, - normalizeTemporaryId, - replaceTemporaryIdReferences, - serializeTemporaryIdMap, - } = require("./temporary_id.cjs"); - const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require("./repo_helpers.cjs"); - const { addExpirationComment } = require("./expiration_helpers.cjs"); - const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); + function sanitizeLabelContent(content) { + if (!content || typeof content !== "string") { + return ""; + } + let sanitized = content.trim(); + sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); + sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); + sanitized = sanitized.replace( + /(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, + (_m, p1, p2) => `${p1}\`@${p2}\`` + ); + sanitized = sanitized.replace(/[<>&'"]/g, ""); + return sanitized.trim(); + } + const fs = require("fs"); + const crypto = require("crypto"); + const MAX_LOG_CONTENT_LENGTH = 10000; + function truncateForLogging(content) { + if (content.length <= MAX_LOG_CONTENT_LENGTH) { + return content; + } + return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; + } + function loadAgentOutput() { + const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; + if (!agentOutputFile) { + core.info("No GH_AW_AGENT_OUTPUT environment variable found"); + return { success: false }; + } + let outputContent; + try { + outputContent = fs.readFileSync(agentOutputFile, "utf8"); + } catch (error) { + const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; + core.error(errorMessage); + return { success: false, error: errorMessage }; + } + if (outputContent.trim() === "") { + core.info("Agent output content is empty"); + return { success: false }; + } + core.info(`Agent output content length: ${outputContent.length}`); + let validatedOutput; + try { + validatedOutput = JSON.parse(outputContent); + } catch (error) { + const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; + core.error(errorMessage); + core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); + return { success: false, error: errorMessage }; + } + if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { + core.info("No valid items found in agent output"); + core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); + return { success: false }; + } + return { success: true, items: validatedOutput.items }; + } + async function generateStagedPreview(options) { + const { title, description, items, renderItem } = options; + let summaryContent = `## 🎭 Staged Mode: ${title} Preview\n\n`; + summaryContent += `${description}\n\n`; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + summaryContent += renderItem(item, i); + summaryContent += "---\n\n"; + } + try { + await core.summary.addRaw(summaryContent).write(); + core.info(summaryContent); + core.info(`📝 ${title} preview written to step summary`); + } catch (error) { + core.setFailed(error instanceof Error ? error : String(error)); + } + } + function generateXMLMarker(workflowName, runUrl) { + const engineId = process.env.GH_AW_ENGINE_ID || ""; + const engineVersion = process.env.GH_AW_ENGINE_VERSION || ""; + const engineModel = process.env.GH_AW_ENGINE_MODEL || ""; + const trackerId = process.env.GH_AW_TRACKER_ID || ""; + const parts = []; + parts.push(`agentic-workflow: ${workflowName}`); + if (trackerId) { + parts.push(`tracker-id: ${trackerId}`); + } + if (engineId) { + parts.push(`engine: ${engineId}`); + } + if (engineVersion) { + parts.push(`version: ${engineVersion}`); + } + if (engineModel) { + parts.push(`model: ${engineModel}`); + } + parts.push(`run: ${runUrl}`); + return ``; + } + function generateFooter( + workflowName, + runUrl, + workflowSource, + workflowSourceURL, + triggeringIssueNumber, + triggeringPRNumber, + triggeringDiscussionNumber + ) { + let footer = `\n\n> AI generated by [${workflowName}](${runUrl})`; + if (triggeringIssueNumber) { + footer += ` for #${triggeringIssueNumber}`; + } else if (triggeringPRNumber) { + footer += ` for #${triggeringPRNumber}`; + } else if (triggeringDiscussionNumber) { + footer += ` for discussion #${triggeringDiscussionNumber}`; + } + if (workflowSource && workflowSourceURL) { + footer += `\n>\n> To add this workflow in your repository, run \`gh aw add ${workflowSource}\`. See [usage guide](https://githubnext.github.io/gh-aw/tools/cli/).`; + } + footer += "\n\n" + generateXMLMarker(workflowName, runUrl); + footer += "\n"; + return footer; + } + function getTrackerID(format) { + const trackerID = process.env.GH_AW_TRACKER_ID || ""; + if (trackerID) { + core.info(`Tracker ID: ${trackerID}`); + return format === "markdown" ? `\n\n` : trackerID; + } + return ""; + } + const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; + function generateTemporaryId() { + return "aw_" + crypto.randomBytes(6).toString("hex"); + } + function isTemporaryId(value) { + if (typeof value === "string") { + return /^aw_[0-9a-f]{12}$/i.test(value); + } + return false; + } + function normalizeTemporaryId(tempId) { + return String(tempId).toLowerCase(); + } + function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { + return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { + const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); + if (resolved !== undefined) { + if (currentRepo && resolved.repo === currentRepo) { + return `#${resolved.number}`; + } + return `${resolved.repo}#${resolved.number}`; + } + return match; + }); + } + function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { + return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { + const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); + if (issueNumber !== undefined) { + return `#${issueNumber}`; + } + return match; + }); + } + function loadTemporaryIdMap() { + const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; + if (!mapJson || mapJson === "{}") { + return new Map(); + } + try { + const mapObject = JSON.parse(mapJson); + const result = new Map(); + for (const [key, value] of Object.entries(mapObject)) { + const normalizedKey = normalizeTemporaryId(key); + if (typeof value === "number") { + const contextRepo = `${context.repo.owner}/${context.repo.repo}`; + result.set(normalizedKey, { repo: contextRepo, number: value }); + } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { + result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); + } + } + return result; + } catch (error) { + if (typeof core !== "undefined") { + core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); + } + return new Map(); + } + } + function resolveIssueNumber(value, temporaryIdMap) { + if (value === undefined || value === null) { + return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; + } + const valueStr = String(value); + if (isTemporaryId(valueStr)) { + const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); + if (resolvedPair !== undefined) { + return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; + } + return { + resolved: null, + wasTemporaryId: true, + errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, + }; + } + const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); + if (isNaN(issueNumber) || issueNumber <= 0) { + return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; + } + const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; + return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; + } + function serializeTemporaryIdMap(tempIdMap) { + const obj = Object.fromEntries(tempIdMap); + return JSON.stringify(obj); + } + function parseAllowedRepos() { + const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; + const set = new Set(); + if (allowedReposEnv) { + allowedReposEnv + .split(",") + .map(repo => repo.trim()) + .filter(repo => repo) + .forEach(repo => set.add(repo)); + } + return set; + } + function getDefaultTargetRepo() { + const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; + if (targetRepoSlug) { + return targetRepoSlug; + } + return `${context.repo.owner}/${context.repo.repo}`; + } + function validateRepo(repo, defaultRepo, allowedRepos) { + if (repo === defaultRepo) { + return { valid: true, error: null }; + } + if (allowedRepos.has(repo)) { + return { valid: true, error: null }; + } + return { + valid: false, + error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, + }; + } + function parseRepoSlug(repoSlug) { + const parts = repoSlug.split("/"); + if (parts.length !== 2 || !parts[0] || !parts[1]) { + return null; + } + return { owner: parts[0], repo: parts[1] }; + } + function addExpirationComment(bodyLines, envVarName, entityType) { + const expiresEnv = process.env[envVarName]; + if (expiresEnv) { + const expiresDays = parseInt(expiresEnv, 10); + if (!isNaN(expiresDays) && expiresDays > 0) { + const expirationDate = new Date(); + expirationDate.setDate(expirationDate.getDate() + expiresDays); + const expirationISO = expirationDate.toISOString(); + bodyLines.push(``); + core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); + } + } + } + function removeDuplicateTitleFromDescription(title, description) { + if (!title || typeof title !== "string") { + return description || ""; + } + if (!description || typeof description !== "string") { + return ""; + } + const trimmedTitle = title.trim(); + const trimmedDescription = description.trim(); + if (!trimmedTitle || !trimmedDescription) { + return trimmedDescription; + } + const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); + if (headerRegex.test(trimmedDescription)) { + return trimmedDescription.replace(headerRegex, "").trim(); + } + return trimmedDescription; + } async function main() { core.setOutput("issue_number", ""); core.setOutput("issue_url", ""); diff --git a/.github/workflows/smoke-codex.lock.yml b/.github/workflows/smoke-codex.lock.yml index 5b4d9be73b0..b91e6e389c0 100644 --- a/.github/workflows/smoke-codex.lock.yml +++ b/.github/workflows/smoke-codex.lock.yml @@ -7216,21 +7216,291 @@ jobs: with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - const { sanitizeLabelContent } = require("./sanitize_label_content.cjs"); - const { loadAgentOutput } = require("./load_agent_output.cjs"); - const { generateStagedPreview } = require("./staged_preview.cjs"); - const { generateFooter } = require("./generate_footer.cjs"); - const { getTrackerID } = require("./get_tracker_id.cjs"); - const { - generateTemporaryId, - isTemporaryId, - normalizeTemporaryId, - replaceTemporaryIdReferences, - serializeTemporaryIdMap, - } = require("./temporary_id.cjs"); - const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require("./repo_helpers.cjs"); - const { addExpirationComment } = require("./expiration_helpers.cjs"); - const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); + function sanitizeLabelContent(content) { + if (!content || typeof content !== "string") { + return ""; + } + let sanitized = content.trim(); + sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); + sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); + sanitized = sanitized.replace( + /(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, + (_m, p1, p2) => `${p1}\`@${p2}\`` + ); + sanitized = sanitized.replace(/[<>&'"]/g, ""); + return sanitized.trim(); + } + const fs = require("fs"); + const crypto = require("crypto"); + const MAX_LOG_CONTENT_LENGTH = 10000; + function truncateForLogging(content) { + if (content.length <= MAX_LOG_CONTENT_LENGTH) { + return content; + } + return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; + } + function loadAgentOutput() { + const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; + if (!agentOutputFile) { + core.info("No GH_AW_AGENT_OUTPUT environment variable found"); + return { success: false }; + } + let outputContent; + try { + outputContent = fs.readFileSync(agentOutputFile, "utf8"); + } catch (error) { + const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; + core.error(errorMessage); + return { success: false, error: errorMessage }; + } + if (outputContent.trim() === "") { + core.info("Agent output content is empty"); + return { success: false }; + } + core.info(`Agent output content length: ${outputContent.length}`); + let validatedOutput; + try { + validatedOutput = JSON.parse(outputContent); + } catch (error) { + const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; + core.error(errorMessage); + core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); + return { success: false, error: errorMessage }; + } + if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { + core.info("No valid items found in agent output"); + core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); + return { success: false }; + } + return { success: true, items: validatedOutput.items }; + } + async function generateStagedPreview(options) { + const { title, description, items, renderItem } = options; + let summaryContent = `## 🎭 Staged Mode: ${title} Preview\n\n`; + summaryContent += `${description}\n\n`; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + summaryContent += renderItem(item, i); + summaryContent += "---\n\n"; + } + try { + await core.summary.addRaw(summaryContent).write(); + core.info(summaryContent); + core.info(`📝 ${title} preview written to step summary`); + } catch (error) { + core.setFailed(error instanceof Error ? error : String(error)); + } + } + function generateXMLMarker(workflowName, runUrl) { + const engineId = process.env.GH_AW_ENGINE_ID || ""; + const engineVersion = process.env.GH_AW_ENGINE_VERSION || ""; + const engineModel = process.env.GH_AW_ENGINE_MODEL || ""; + const trackerId = process.env.GH_AW_TRACKER_ID || ""; + const parts = []; + parts.push(`agentic-workflow: ${workflowName}`); + if (trackerId) { + parts.push(`tracker-id: ${trackerId}`); + } + if (engineId) { + parts.push(`engine: ${engineId}`); + } + if (engineVersion) { + parts.push(`version: ${engineVersion}`); + } + if (engineModel) { + parts.push(`model: ${engineModel}`); + } + parts.push(`run: ${runUrl}`); + return ``; + } + function generateFooter( + workflowName, + runUrl, + workflowSource, + workflowSourceURL, + triggeringIssueNumber, + triggeringPRNumber, + triggeringDiscussionNumber + ) { + let footer = `\n\n> AI generated by [${workflowName}](${runUrl})`; + if (triggeringIssueNumber) { + footer += ` for #${triggeringIssueNumber}`; + } else if (triggeringPRNumber) { + footer += ` for #${triggeringPRNumber}`; + } else if (triggeringDiscussionNumber) { + footer += ` for discussion #${triggeringDiscussionNumber}`; + } + if (workflowSource && workflowSourceURL) { + footer += `\n>\n> To add this workflow in your repository, run \`gh aw add ${workflowSource}\`. See [usage guide](https://githubnext.github.io/gh-aw/tools/cli/).`; + } + footer += "\n\n" + generateXMLMarker(workflowName, runUrl); + footer += "\n"; + return footer; + } + function getTrackerID(format) { + const trackerID = process.env.GH_AW_TRACKER_ID || ""; + if (trackerID) { + core.info(`Tracker ID: ${trackerID}`); + return format === "markdown" ? `\n\n` : trackerID; + } + return ""; + } + const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; + function generateTemporaryId() { + return "aw_" + crypto.randomBytes(6).toString("hex"); + } + function isTemporaryId(value) { + if (typeof value === "string") { + return /^aw_[0-9a-f]{12}$/i.test(value); + } + return false; + } + function normalizeTemporaryId(tempId) { + return String(tempId).toLowerCase(); + } + function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { + return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { + const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); + if (resolved !== undefined) { + if (currentRepo && resolved.repo === currentRepo) { + return `#${resolved.number}`; + } + return `${resolved.repo}#${resolved.number}`; + } + return match; + }); + } + function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { + return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { + const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); + if (issueNumber !== undefined) { + return `#${issueNumber}`; + } + return match; + }); + } + function loadTemporaryIdMap() { + const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; + if (!mapJson || mapJson === "{}") { + return new Map(); + } + try { + const mapObject = JSON.parse(mapJson); + const result = new Map(); + for (const [key, value] of Object.entries(mapObject)) { + const normalizedKey = normalizeTemporaryId(key); + if (typeof value === "number") { + const contextRepo = `${context.repo.owner}/${context.repo.repo}`; + result.set(normalizedKey, { repo: contextRepo, number: value }); + } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { + result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); + } + } + return result; + } catch (error) { + if (typeof core !== "undefined") { + core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); + } + return new Map(); + } + } + function resolveIssueNumber(value, temporaryIdMap) { + if (value === undefined || value === null) { + return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; + } + const valueStr = String(value); + if (isTemporaryId(valueStr)) { + const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); + if (resolvedPair !== undefined) { + return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; + } + return { + resolved: null, + wasTemporaryId: true, + errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, + }; + } + const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); + if (isNaN(issueNumber) || issueNumber <= 0) { + return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; + } + const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; + return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; + } + function serializeTemporaryIdMap(tempIdMap) { + const obj = Object.fromEntries(tempIdMap); + return JSON.stringify(obj); + } + function parseAllowedRepos() { + const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; + const set = new Set(); + if (allowedReposEnv) { + allowedReposEnv + .split(",") + .map(repo => repo.trim()) + .filter(repo => repo) + .forEach(repo => set.add(repo)); + } + return set; + } + function getDefaultTargetRepo() { + const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; + if (targetRepoSlug) { + return targetRepoSlug; + } + return `${context.repo.owner}/${context.repo.repo}`; + } + function validateRepo(repo, defaultRepo, allowedRepos) { + if (repo === defaultRepo) { + return { valid: true, error: null }; + } + if (allowedRepos.has(repo)) { + return { valid: true, error: null }; + } + return { + valid: false, + error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, + }; + } + function parseRepoSlug(repoSlug) { + const parts = repoSlug.split("/"); + if (parts.length !== 2 || !parts[0] || !parts[1]) { + return null; + } + return { owner: parts[0], repo: parts[1] }; + } + function addExpirationComment(bodyLines, envVarName, entityType) { + const expiresEnv = process.env[envVarName]; + if (expiresEnv) { + const expiresDays = parseInt(expiresEnv, 10); + if (!isNaN(expiresDays) && expiresDays > 0) { + const expirationDate = new Date(); + expirationDate.setDate(expirationDate.getDate() + expiresDays); + const expirationISO = expirationDate.toISOString(); + bodyLines.push(``); + core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); + } + } + } + function removeDuplicateTitleFromDescription(title, description) { + if (!title || typeof title !== "string") { + return description || ""; + } + if (!description || typeof description !== "string") { + return ""; + } + const trimmedTitle = title.trim(); + const trimmedDescription = description.trim(); + if (!trimmedTitle || !trimmedDescription) { + return trimmedDescription; + } + const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); + if (headerRegex.test(trimmedDescription)) { + return trimmedDescription.replace(headerRegex, "").trim(); + } + return trimmedDescription; + } async function main() { core.setOutput("issue_number", ""); core.setOutput("issue_url", ""); diff --git a/.github/workflows/smoke-copilot-no-firewall.lock.yml b/.github/workflows/smoke-copilot-no-firewall.lock.yml index dfde83ff030..f3aae717596 100644 --- a/.github/workflows/smoke-copilot-no-firewall.lock.yml +++ b/.github/workflows/smoke-copilot-no-firewall.lock.yml @@ -9124,21 +9124,291 @@ jobs: with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - const { sanitizeLabelContent } = require("./sanitize_label_content.cjs"); - const { loadAgentOutput } = require("./load_agent_output.cjs"); - const { generateStagedPreview } = require("./staged_preview.cjs"); - const { generateFooter } = require("./generate_footer.cjs"); - const { getTrackerID } = require("./get_tracker_id.cjs"); - const { - generateTemporaryId, - isTemporaryId, - normalizeTemporaryId, - replaceTemporaryIdReferences, - serializeTemporaryIdMap, - } = require("./temporary_id.cjs"); - const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require("./repo_helpers.cjs"); - const { addExpirationComment } = require("./expiration_helpers.cjs"); - const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); + function sanitizeLabelContent(content) { + if (!content || typeof content !== "string") { + return ""; + } + let sanitized = content.trim(); + sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); + sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); + sanitized = sanitized.replace( + /(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, + (_m, p1, p2) => `${p1}\`@${p2}\`` + ); + sanitized = sanitized.replace(/[<>&'"]/g, ""); + return sanitized.trim(); + } + const fs = require("fs"); + const crypto = require("crypto"); + const MAX_LOG_CONTENT_LENGTH = 10000; + function truncateForLogging(content) { + if (content.length <= MAX_LOG_CONTENT_LENGTH) { + return content; + } + return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; + } + function loadAgentOutput() { + const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; + if (!agentOutputFile) { + core.info("No GH_AW_AGENT_OUTPUT environment variable found"); + return { success: false }; + } + let outputContent; + try { + outputContent = fs.readFileSync(agentOutputFile, "utf8"); + } catch (error) { + const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; + core.error(errorMessage); + return { success: false, error: errorMessage }; + } + if (outputContent.trim() === "") { + core.info("Agent output content is empty"); + return { success: false }; + } + core.info(`Agent output content length: ${outputContent.length}`); + let validatedOutput; + try { + validatedOutput = JSON.parse(outputContent); + } catch (error) { + const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; + core.error(errorMessage); + core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); + return { success: false, error: errorMessage }; + } + if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { + core.info("No valid items found in agent output"); + core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); + return { success: false }; + } + return { success: true, items: validatedOutput.items }; + } + async function generateStagedPreview(options) { + const { title, description, items, renderItem } = options; + let summaryContent = `## 🎭 Staged Mode: ${title} Preview\n\n`; + summaryContent += `${description}\n\n`; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + summaryContent += renderItem(item, i); + summaryContent += "---\n\n"; + } + try { + await core.summary.addRaw(summaryContent).write(); + core.info(summaryContent); + core.info(`📝 ${title} preview written to step summary`); + } catch (error) { + core.setFailed(error instanceof Error ? error : String(error)); + } + } + function generateXMLMarker(workflowName, runUrl) { + const engineId = process.env.GH_AW_ENGINE_ID || ""; + const engineVersion = process.env.GH_AW_ENGINE_VERSION || ""; + const engineModel = process.env.GH_AW_ENGINE_MODEL || ""; + const trackerId = process.env.GH_AW_TRACKER_ID || ""; + const parts = []; + parts.push(`agentic-workflow: ${workflowName}`); + if (trackerId) { + parts.push(`tracker-id: ${trackerId}`); + } + if (engineId) { + parts.push(`engine: ${engineId}`); + } + if (engineVersion) { + parts.push(`version: ${engineVersion}`); + } + if (engineModel) { + parts.push(`model: ${engineModel}`); + } + parts.push(`run: ${runUrl}`); + return ``; + } + function generateFooter( + workflowName, + runUrl, + workflowSource, + workflowSourceURL, + triggeringIssueNumber, + triggeringPRNumber, + triggeringDiscussionNumber + ) { + let footer = `\n\n> AI generated by [${workflowName}](${runUrl})`; + if (triggeringIssueNumber) { + footer += ` for #${triggeringIssueNumber}`; + } else if (triggeringPRNumber) { + footer += ` for #${triggeringPRNumber}`; + } else if (triggeringDiscussionNumber) { + footer += ` for discussion #${triggeringDiscussionNumber}`; + } + if (workflowSource && workflowSourceURL) { + footer += `\n>\n> To add this workflow in your repository, run \`gh aw add ${workflowSource}\`. See [usage guide](https://githubnext.github.io/gh-aw/tools/cli/).`; + } + footer += "\n\n" + generateXMLMarker(workflowName, runUrl); + footer += "\n"; + return footer; + } + function getTrackerID(format) { + const trackerID = process.env.GH_AW_TRACKER_ID || ""; + if (trackerID) { + core.info(`Tracker ID: ${trackerID}`); + return format === "markdown" ? `\n\n` : trackerID; + } + return ""; + } + const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; + function generateTemporaryId() { + return "aw_" + crypto.randomBytes(6).toString("hex"); + } + function isTemporaryId(value) { + if (typeof value === "string") { + return /^aw_[0-9a-f]{12}$/i.test(value); + } + return false; + } + function normalizeTemporaryId(tempId) { + return String(tempId).toLowerCase(); + } + function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { + return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { + const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); + if (resolved !== undefined) { + if (currentRepo && resolved.repo === currentRepo) { + return `#${resolved.number}`; + } + return `${resolved.repo}#${resolved.number}`; + } + return match; + }); + } + function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { + return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { + const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); + if (issueNumber !== undefined) { + return `#${issueNumber}`; + } + return match; + }); + } + function loadTemporaryIdMap() { + const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; + if (!mapJson || mapJson === "{}") { + return new Map(); + } + try { + const mapObject = JSON.parse(mapJson); + const result = new Map(); + for (const [key, value] of Object.entries(mapObject)) { + const normalizedKey = normalizeTemporaryId(key); + if (typeof value === "number") { + const contextRepo = `${context.repo.owner}/${context.repo.repo}`; + result.set(normalizedKey, { repo: contextRepo, number: value }); + } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { + result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); + } + } + return result; + } catch (error) { + if (typeof core !== "undefined") { + core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); + } + return new Map(); + } + } + function resolveIssueNumber(value, temporaryIdMap) { + if (value === undefined || value === null) { + return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; + } + const valueStr = String(value); + if (isTemporaryId(valueStr)) { + const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); + if (resolvedPair !== undefined) { + return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; + } + return { + resolved: null, + wasTemporaryId: true, + errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, + }; + } + const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); + if (isNaN(issueNumber) || issueNumber <= 0) { + return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; + } + const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; + return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; + } + function serializeTemporaryIdMap(tempIdMap) { + const obj = Object.fromEntries(tempIdMap); + return JSON.stringify(obj); + } + function parseAllowedRepos() { + const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; + const set = new Set(); + if (allowedReposEnv) { + allowedReposEnv + .split(",") + .map(repo => repo.trim()) + .filter(repo => repo) + .forEach(repo => set.add(repo)); + } + return set; + } + function getDefaultTargetRepo() { + const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; + if (targetRepoSlug) { + return targetRepoSlug; + } + return `${context.repo.owner}/${context.repo.repo}`; + } + function validateRepo(repo, defaultRepo, allowedRepos) { + if (repo === defaultRepo) { + return { valid: true, error: null }; + } + if (allowedRepos.has(repo)) { + return { valid: true, error: null }; + } + return { + valid: false, + error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, + }; + } + function parseRepoSlug(repoSlug) { + const parts = repoSlug.split("/"); + if (parts.length !== 2 || !parts[0] || !parts[1]) { + return null; + } + return { owner: parts[0], repo: parts[1] }; + } + function addExpirationComment(bodyLines, envVarName, entityType) { + const expiresEnv = process.env[envVarName]; + if (expiresEnv) { + const expiresDays = parseInt(expiresEnv, 10); + if (!isNaN(expiresDays) && expiresDays > 0) { + const expirationDate = new Date(); + expirationDate.setDate(expirationDate.getDate() + expiresDays); + const expirationISO = expirationDate.toISOString(); + bodyLines.push(``); + core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); + } + } + } + function removeDuplicateTitleFromDescription(title, description) { + if (!title || typeof title !== "string") { + return description || ""; + } + if (!description || typeof description !== "string") { + return ""; + } + const trimmedTitle = title.trim(); + const trimmedDescription = description.trim(); + if (!trimmedTitle || !trimmedDescription) { + return trimmedDescription; + } + const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); + if (headerRegex.test(trimmedDescription)) { + return trimmedDescription.replace(headerRegex, "").trim(); + } + return trimmedDescription; + } async function main() { core.setOutput("issue_number", ""); core.setOutput("issue_url", ""); @@ -9901,8 +10171,457 @@ jobs: with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - const { runUpdateWorkflow, createRenderStagedItem, createGetSummaryLine } = require("./update_runner.cjs"); - const { updatePRBody } = require("./update_pr_description_helpers.cjs"); + const fs = require("fs"); + const MAX_LOG_CONTENT_LENGTH = 10000; + function truncateForLogging(content) { + if (content.length <= MAX_LOG_CONTENT_LENGTH) { + return content; + } + return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; + } + function loadAgentOutput() { + const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; + if (!agentOutputFile) { + core.info("No GH_AW_AGENT_OUTPUT environment variable found"); + return { success: false }; + } + let outputContent; + try { + outputContent = fs.readFileSync(agentOutputFile, "utf8"); + } catch (error) { + const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; + core.error(errorMessage); + return { success: false, error: errorMessage }; + } + if (outputContent.trim() === "") { + core.info("Agent output content is empty"); + return { success: false }; + } + core.info(`Agent output content length: ${outputContent.length}`); + let validatedOutput; + try { + validatedOutput = JSON.parse(outputContent); + } catch (error) { + const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; + core.error(errorMessage); + core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); + return { success: false, error: errorMessage }; + } + if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { + core.info("No valid items found in agent output"); + core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); + return { success: false }; + } + return { success: true, items: validatedOutput.items }; + } + async function generateStagedPreview(options) { + const { title, description, items, renderItem } = options; + let summaryContent = `## 🎭 Staged Mode: ${title} Preview\n\n`; + summaryContent += `${description}\n\n`; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + summaryContent += renderItem(item, i); + summaryContent += "---\n\n"; + } + try { + await core.summary.addRaw(summaryContent).write(); + core.info(summaryContent); + core.info(`📝 ${title} preview written to step summary`); + } catch (error) { + core.setFailed(error instanceof Error ? error : String(error)); + } + } + function removeDuplicateTitleFromDescription(title, description) { + if (!title || typeof title !== "string") { + return description || ""; + } + if (!description || typeof description !== "string") { + return ""; + } + const trimmedTitle = title.trim(); + const trimmedDescription = description.trim(); + if (!trimmedTitle || !trimmedDescription) { + return trimmedDescription; + } + const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); + if (headerRegex.test(trimmedDescription)) { + return trimmedDescription.replace(headerRegex, "").trim(); + } + return trimmedDescription; + } + function resolveTargetNumber(params) { + const { updateTarget, item, numberField, isValidContext, contextNumber, displayName } = params; + if (updateTarget === "*") { + const explicitNumber = item[numberField]; + if (explicitNumber) { + const parsed = parseInt(explicitNumber, 10); + if (isNaN(parsed) || parsed <= 0) { + return { success: false, error: `Invalid ${numberField} specified: ${explicitNumber}` }; + } + return { success: true, number: parsed }; + } else { + return { success: false, error: `Target is "*" but no ${numberField} specified in update item` }; + } + } else if (updateTarget && updateTarget !== "triggering") { + const parsed = parseInt(updateTarget, 10); + if (isNaN(parsed) || parsed <= 0) { + return { success: false, error: `Invalid ${displayName} number in target configuration: ${updateTarget}` }; + } + return { success: true, number: parsed }; + } else { + if (isValidContext && contextNumber) { + return { success: true, number: contextNumber }; + } + return { success: false, error: `Could not determine ${displayName} number` }; + } + } + function buildUpdateData(params) { + const { item, canUpdateStatus, canUpdateTitle, canUpdateBody, supportsStatus } = params; + const updateData = {}; + let hasUpdates = false; + const logMessages = []; + if (supportsStatus && canUpdateStatus && item.status !== undefined) { + if (item.status === "open" || item.status === "closed") { + updateData.state = item.status; + hasUpdates = true; + logMessages.push(`Will update status to: ${item.status}`); + } else { + logMessages.push(`Invalid status value: ${item.status}. Must be 'open' or 'closed'`); + } + } + let titleForDedup = null; + if (canUpdateTitle && item.title !== undefined) { + const trimmedTitle = typeof item.title === "string" ? item.title.trim() : ""; + if (trimmedTitle.length > 0) { + updateData.title = trimmedTitle; + titleForDedup = trimmedTitle; + hasUpdates = true; + logMessages.push(`Will update title to: ${trimmedTitle}`); + } else { + logMessages.push("Invalid title value: must be a non-empty string"); + } + } + if (canUpdateBody && item.body !== undefined) { + if (typeof item.body === "string") { + let processedBody = item.body; + if (titleForDedup) { + processedBody = removeDuplicateTitleFromDescription(titleForDedup, processedBody); + } + updateData.body = processedBody; + hasUpdates = true; + logMessages.push(`Will update body (length: ${processedBody.length})`); + } else { + logMessages.push("Invalid body value: must be a string"); + } + } + return { hasUpdates, updateData, logMessages }; + } + async function runUpdateWorkflow(config) { + const { + itemType, + displayName, + displayNamePlural, + numberField, + outputNumberKey, + outputUrlKey, + isValidContext, + getContextNumber, + supportsStatus, + supportsOperation, + renderStagedItem, + executeUpdate, + getSummaryLine, + } = config; + const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; + const result = loadAgentOutput(); + if (!result.success) { + return; + } + const updateItems = result.items.filter( item => item.type === itemType); + if (updateItems.length === 0) { + core.info(`No ${itemType} items found in agent output`); + return; + } + core.info(`Found ${updateItems.length} ${itemType} item(s)`); + if (isStaged) { + await generateStagedPreview({ + title: `Update ${displayNamePlural.charAt(0).toUpperCase() + displayNamePlural.slice(1)}`, + description: `The following ${displayName} updates would be applied if staged mode was disabled:`, + items: updateItems, + renderItem: renderStagedItem, + }); + return; + } + const updateTarget = process.env.GH_AW_UPDATE_TARGET || "triggering"; + const canUpdateStatus = process.env.GH_AW_UPDATE_STATUS === "true"; + const canUpdateTitle = process.env.GH_AW_UPDATE_TITLE === "true"; + const canUpdateBody = process.env.GH_AW_UPDATE_BODY === "true"; + core.info(`Update target configuration: ${updateTarget}`); + if (supportsStatus) { + core.info(`Can update status: ${canUpdateStatus}, title: ${canUpdateTitle}, body: ${canUpdateBody}`); + } else { + core.info(`Can update title: ${canUpdateTitle}, body: ${canUpdateBody}`); + } + const contextIsValid = isValidContext(context.eventName, context.payload); + const contextNumber = getContextNumber(context.payload); + if (updateTarget === "triggering" && !contextIsValid) { + core.info(`Target is "triggering" but not running in ${displayName} context, skipping ${displayName} update`); + return; + } + const updatedItems = []; + for (let i = 0; i < updateItems.length; i++) { + const updateItem = updateItems[i]; + core.info(`Processing ${itemType} item ${i + 1}/${updateItems.length}`); + const targetResult = resolveTargetNumber({ + updateTarget, + item: updateItem, + numberField, + isValidContext: contextIsValid, + contextNumber, + displayName, + }); + if (!targetResult.success) { + core.info(targetResult.error); + continue; + } + const targetNumber = targetResult.number; + core.info(`Updating ${displayName} #${targetNumber}`); + const { hasUpdates, updateData, logMessages } = buildUpdateData({ + item: updateItem, + canUpdateStatus, + canUpdateTitle, + canUpdateBody, + supportsStatus, + }); + for (const msg of logMessages) { + core.info(msg); + } + if (supportsOperation && canUpdateBody && updateItem.body !== undefined && typeof updateItem.body === "string") { + updateData._operation = updateItem.operation || "append"; + updateData._rawBody = updateItem.body; + } + if (!hasUpdates) { + core.info("No valid updates to apply for this item"); + continue; + } + try { + const updatedItem = await executeUpdate(github, context, targetNumber, updateData); + core.info(`Updated ${displayName} #${updatedItem.number}: ${updatedItem.html_url}`); + updatedItems.push(updatedItem); + if (i === updateItems.length - 1) { + core.setOutput(outputNumberKey, updatedItem.number); + core.setOutput(outputUrlKey, updatedItem.html_url); + } + } catch (error) { + core.error(`✗ Failed to update ${displayName} #${targetNumber}: ${error instanceof Error ? error.message : String(error)}`); + throw error; + } + } + if (updatedItems.length > 0) { + let summaryContent = `\n\n## Updated ${displayNamePlural.charAt(0).toUpperCase() + displayNamePlural.slice(1)}\n`; + for (const item of updatedItems) { + summaryContent += getSummaryLine(item); + } + await core.summary.addRaw(summaryContent).write(); + } + core.info(`Successfully updated ${updatedItems.length} ${displayName}(s)`); + return updatedItems; + } + function createRenderStagedItem(config) { + const { entityName, numberField, targetLabel, currentTargetText, includeOperation = false } = config; + return function renderStagedItem(item, index) { + let content = `### ${entityName} Update ${index + 1}\n`; + if (item[numberField]) { + content += `**${targetLabel}** #${item[numberField]}\n\n`; + } else { + content += `**Target:** ${currentTargetText}\n\n`; + } + if (item.title !== undefined) { + content += `**New Title:** ${item.title}\n\n`; + } + if (item.body !== undefined) { + if (includeOperation) { + const operation = item.operation || "append"; + content += `**Operation:** ${operation}\n`; + content += `**Body Content:**\n${item.body}\n\n`; + } else { + content += `**New Body:**\n${item.body}\n\n`; + } + } + if (item.status !== undefined) { + content += `**New Status:** ${item.status}\n\n`; + } + return content; + }; + } + function createGetSummaryLine(config) { + const { entityPrefix } = config; + return function getSummaryLine(item) { + return `- ${entityPrefix} #${item.number}: [${item.title}](${item.html_url})\n`; + }; + } + function getMessages() { + const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES; + if (!messagesEnv) { + return null; + } + try { + return JSON.parse(messagesEnv); + } catch (error) { + core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`); + return null; + } + } + function renderTemplate(template, context) { + return template.replace(/\{(\w+)\}/g, (match, key) => { + const value = context[key]; + return value !== undefined && value !== null ? String(value) : match; + }); + } + function toSnakeCase(obj) { + const result = {}; + for (const [key, value] of Object.entries(obj)) { + const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase(); + result[snakeKey] = value; + result[key] = value; + } + return result; + } + function getFooterMessage(ctx) { + const messages = getMessages(); + const templateContext = toSnakeCase(ctx); + const defaultFooter = "> Ahoy! This treasure was crafted by [🏴‍☠️ {workflow_name}]({run_url})"; + let footer = messages?.footer ? renderTemplate(messages.footer, templateContext) : renderTemplate(defaultFooter, templateContext); + if (ctx.triggeringNumber) { + footer += ` fer issue #{triggering_number} 🗺️`.replace("{triggering_number}", String(ctx.triggeringNumber)); + } + return footer; + } + function getFooterInstallMessage(ctx) { + if (!ctx.workflowSource || !ctx.workflowSourceUrl) { + return ""; + } + const messages = getMessages(); + const templateContext = toSnakeCase(ctx); + const defaultInstall = + "> Arr! To plunder this workflow fer yer own ship, run `gh aw add {workflow_source}`. Chart yer course at [🦜 {workflow_source_url}]({workflow_source_url})!"; + return messages?.footerInstall + ? renderTemplate(messages.footerInstall, templateContext) + : renderTemplate(defaultInstall, templateContext); + } + function generateXMLMarker(workflowName, runUrl) { + const engineId = process.env.GH_AW_ENGINE_ID || ""; + const engineVersion = process.env.GH_AW_ENGINE_VERSION || ""; + const engineModel = process.env.GH_AW_ENGINE_MODEL || ""; + const trackerId = process.env.GH_AW_TRACKER_ID || ""; + const parts = []; + parts.push(`agentic-workflow: ${workflowName}`); + if (trackerId) { + parts.push(`tracker-id: ${trackerId}`); + } + if (engineId) { + parts.push(`engine: ${engineId}`); + } + if (engineVersion) { + parts.push(`version: ${engineVersion}`); + } + if (engineModel) { + parts.push(`model: ${engineModel}`); + } + parts.push(`run: ${runUrl}`); + return ``; + } + function generateFooterWithMessages( + workflowName, + runUrl, + workflowSource, + workflowSourceURL, + triggeringIssueNumber, + triggeringPRNumber, + triggeringDiscussionNumber + ) { + let triggeringNumber; + if (triggeringIssueNumber) { + triggeringNumber = triggeringIssueNumber; + } else if (triggeringPRNumber) { + triggeringNumber = triggeringPRNumber; + } else if (triggeringDiscussionNumber) { + triggeringNumber = `discussion #${triggeringDiscussionNumber}`; + } + const ctx = { + workflowName, + runUrl, + workflowSource, + workflowSourceUrl: workflowSourceURL, + triggeringNumber, + }; + let footer = "\n\n" + getFooterMessage(ctx); + const installMessage = getFooterInstallMessage(ctx); + if (installMessage) { + footer += "\n>\n" + installMessage; + } + footer += "\n\n" + generateXMLMarker(workflowName, runUrl); + footer += "\n"; + return footer; + } + function buildAIFooter(workflowName, runUrl) { + return "\n\n" + getFooterMessage({ workflowName, runUrl }); + } + function buildIslandStartMarker(runId) { + return ``; + } + function buildIslandEndMarker(runId) { + return ``; + } + function findIsland(body, runId) { + const startMarker = buildIslandStartMarker(runId); + const endMarker = buildIslandEndMarker(runId); + const startIndex = body.indexOf(startMarker); + if (startIndex === -1) { + return { found: false, startIndex: -1, endIndex: -1 }; + } + const endIndex = body.indexOf(endMarker, startIndex); + if (endIndex === -1) { + return { found: false, startIndex: -1, endIndex: -1 }; + } + return { found: true, startIndex, endIndex: endIndex + endMarker.length }; + } + function updatePRBody(params) { + const { currentBody, newContent, operation, workflowName, runUrl, runId } = params; + const aiFooter = buildAIFooter(workflowName, runUrl); + if (operation === "replace") { + core.info("Operation: replace (full body replacement)"); + return newContent; + } + if (operation === "replace-island") { + const island = findIsland(currentBody, runId); + if (island.found) { + core.info(`Operation: replace-island (updating existing island for run ${runId})`); + const startMarker = buildIslandStartMarker(runId); + const endMarker = buildIslandEndMarker(runId); + const islandContent = `${startMarker}\n${newContent}${aiFooter}\n${endMarker}`; + const before = currentBody.substring(0, island.startIndex); + const after = currentBody.substring(island.endIndex); + return before + islandContent + after; + } else { + core.info(`Operation: replace-island (island not found for run ${runId}, falling back to append)`); + const startMarker = buildIslandStartMarker(runId); + const endMarker = buildIslandEndMarker(runId); + const islandContent = `${startMarker}\n${newContent}${aiFooter}\n${endMarker}`; + const appendSection = `\n\n---\n\n${islandContent}`; + return currentBody + appendSection; + } + } + if (operation === "prepend") { + core.info("Operation: prepend (add to start with separator)"); + const prependSection = `${newContent}${aiFooter}\n\n---\n\n`; + return prependSection + currentBody; + } + core.info("Operation: append (add to end with separator)"); + const appendSection = `\n\n---\n\n${newContent}${aiFooter}`; + return currentBody + appendSection; + } function isPRContext(eventName, payload) { const isPR = eventName === "pull_request" || diff --git a/.github/workflows/smoke-copilot-playwright.lock.yml b/.github/workflows/smoke-copilot-playwright.lock.yml index f05bcff78a9..f48f85249a1 100644 --- a/.github/workflows/smoke-copilot-playwright.lock.yml +++ b/.github/workflows/smoke-copilot-playwright.lock.yml @@ -9115,21 +9115,291 @@ jobs: with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - const { sanitizeLabelContent } = require("./sanitize_label_content.cjs"); - const { loadAgentOutput } = require("./load_agent_output.cjs"); - const { generateStagedPreview } = require("./staged_preview.cjs"); - const { generateFooter } = require("./generate_footer.cjs"); - const { getTrackerID } = require("./get_tracker_id.cjs"); - const { - generateTemporaryId, - isTemporaryId, - normalizeTemporaryId, - replaceTemporaryIdReferences, - serializeTemporaryIdMap, - } = require("./temporary_id.cjs"); - const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require("./repo_helpers.cjs"); - const { addExpirationComment } = require("./expiration_helpers.cjs"); - const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); + function sanitizeLabelContent(content) { + if (!content || typeof content !== "string") { + return ""; + } + let sanitized = content.trim(); + sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); + sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); + sanitized = sanitized.replace( + /(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, + (_m, p1, p2) => `${p1}\`@${p2}\`` + ); + sanitized = sanitized.replace(/[<>&'"]/g, ""); + return sanitized.trim(); + } + const fs = require("fs"); + const crypto = require("crypto"); + const MAX_LOG_CONTENT_LENGTH = 10000; + function truncateForLogging(content) { + if (content.length <= MAX_LOG_CONTENT_LENGTH) { + return content; + } + return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; + } + function loadAgentOutput() { + const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; + if (!agentOutputFile) { + core.info("No GH_AW_AGENT_OUTPUT environment variable found"); + return { success: false }; + } + let outputContent; + try { + outputContent = fs.readFileSync(agentOutputFile, "utf8"); + } catch (error) { + const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; + core.error(errorMessage); + return { success: false, error: errorMessage }; + } + if (outputContent.trim() === "") { + core.info("Agent output content is empty"); + return { success: false }; + } + core.info(`Agent output content length: ${outputContent.length}`); + let validatedOutput; + try { + validatedOutput = JSON.parse(outputContent); + } catch (error) { + const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; + core.error(errorMessage); + core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); + return { success: false, error: errorMessage }; + } + if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { + core.info("No valid items found in agent output"); + core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); + return { success: false }; + } + return { success: true, items: validatedOutput.items }; + } + async function generateStagedPreview(options) { + const { title, description, items, renderItem } = options; + let summaryContent = `## 🎭 Staged Mode: ${title} Preview\n\n`; + summaryContent += `${description}\n\n`; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + summaryContent += renderItem(item, i); + summaryContent += "---\n\n"; + } + try { + await core.summary.addRaw(summaryContent).write(); + core.info(summaryContent); + core.info(`📝 ${title} preview written to step summary`); + } catch (error) { + core.setFailed(error instanceof Error ? error : String(error)); + } + } + function generateXMLMarker(workflowName, runUrl) { + const engineId = process.env.GH_AW_ENGINE_ID || ""; + const engineVersion = process.env.GH_AW_ENGINE_VERSION || ""; + const engineModel = process.env.GH_AW_ENGINE_MODEL || ""; + const trackerId = process.env.GH_AW_TRACKER_ID || ""; + const parts = []; + parts.push(`agentic-workflow: ${workflowName}`); + if (trackerId) { + parts.push(`tracker-id: ${trackerId}`); + } + if (engineId) { + parts.push(`engine: ${engineId}`); + } + if (engineVersion) { + parts.push(`version: ${engineVersion}`); + } + if (engineModel) { + parts.push(`model: ${engineModel}`); + } + parts.push(`run: ${runUrl}`); + return ``; + } + function generateFooter( + workflowName, + runUrl, + workflowSource, + workflowSourceURL, + triggeringIssueNumber, + triggeringPRNumber, + triggeringDiscussionNumber + ) { + let footer = `\n\n> AI generated by [${workflowName}](${runUrl})`; + if (triggeringIssueNumber) { + footer += ` for #${triggeringIssueNumber}`; + } else if (triggeringPRNumber) { + footer += ` for #${triggeringPRNumber}`; + } else if (triggeringDiscussionNumber) { + footer += ` for discussion #${triggeringDiscussionNumber}`; + } + if (workflowSource && workflowSourceURL) { + footer += `\n>\n> To add this workflow in your repository, run \`gh aw add ${workflowSource}\`. See [usage guide](https://githubnext.github.io/gh-aw/tools/cli/).`; + } + footer += "\n\n" + generateXMLMarker(workflowName, runUrl); + footer += "\n"; + return footer; + } + function getTrackerID(format) { + const trackerID = process.env.GH_AW_TRACKER_ID || ""; + if (trackerID) { + core.info(`Tracker ID: ${trackerID}`); + return format === "markdown" ? `\n\n` : trackerID; + } + return ""; + } + const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; + function generateTemporaryId() { + return "aw_" + crypto.randomBytes(6).toString("hex"); + } + function isTemporaryId(value) { + if (typeof value === "string") { + return /^aw_[0-9a-f]{12}$/i.test(value); + } + return false; + } + function normalizeTemporaryId(tempId) { + return String(tempId).toLowerCase(); + } + function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { + return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { + const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); + if (resolved !== undefined) { + if (currentRepo && resolved.repo === currentRepo) { + return `#${resolved.number}`; + } + return `${resolved.repo}#${resolved.number}`; + } + return match; + }); + } + function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { + return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { + const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); + if (issueNumber !== undefined) { + return `#${issueNumber}`; + } + return match; + }); + } + function loadTemporaryIdMap() { + const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; + if (!mapJson || mapJson === "{}") { + return new Map(); + } + try { + const mapObject = JSON.parse(mapJson); + const result = new Map(); + for (const [key, value] of Object.entries(mapObject)) { + const normalizedKey = normalizeTemporaryId(key); + if (typeof value === "number") { + const contextRepo = `${context.repo.owner}/${context.repo.repo}`; + result.set(normalizedKey, { repo: contextRepo, number: value }); + } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { + result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); + } + } + return result; + } catch (error) { + if (typeof core !== "undefined") { + core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); + } + return new Map(); + } + } + function resolveIssueNumber(value, temporaryIdMap) { + if (value === undefined || value === null) { + return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; + } + const valueStr = String(value); + if (isTemporaryId(valueStr)) { + const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); + if (resolvedPair !== undefined) { + return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; + } + return { + resolved: null, + wasTemporaryId: true, + errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, + }; + } + const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); + if (isNaN(issueNumber) || issueNumber <= 0) { + return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; + } + const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; + return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; + } + function serializeTemporaryIdMap(tempIdMap) { + const obj = Object.fromEntries(tempIdMap); + return JSON.stringify(obj); + } + function parseAllowedRepos() { + const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; + const set = new Set(); + if (allowedReposEnv) { + allowedReposEnv + .split(",") + .map(repo => repo.trim()) + .filter(repo => repo) + .forEach(repo => set.add(repo)); + } + return set; + } + function getDefaultTargetRepo() { + const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; + if (targetRepoSlug) { + return targetRepoSlug; + } + return `${context.repo.owner}/${context.repo.repo}`; + } + function validateRepo(repo, defaultRepo, allowedRepos) { + if (repo === defaultRepo) { + return { valid: true, error: null }; + } + if (allowedRepos.has(repo)) { + return { valid: true, error: null }; + } + return { + valid: false, + error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, + }; + } + function parseRepoSlug(repoSlug) { + const parts = repoSlug.split("/"); + if (parts.length !== 2 || !parts[0] || !parts[1]) { + return null; + } + return { owner: parts[0], repo: parts[1] }; + } + function addExpirationComment(bodyLines, envVarName, entityType) { + const expiresEnv = process.env[envVarName]; + if (expiresEnv) { + const expiresDays = parseInt(expiresEnv, 10); + if (!isNaN(expiresDays) && expiresDays > 0) { + const expirationDate = new Date(); + expirationDate.setDate(expirationDate.getDate() + expiresDays); + const expirationISO = expirationDate.toISOString(); + bodyLines.push(``); + core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); + } + } + } + function removeDuplicateTitleFromDescription(title, description) { + if (!title || typeof title !== "string") { + return description || ""; + } + if (!description || typeof description !== "string") { + return ""; + } + const trimmedTitle = title.trim(); + const trimmedDescription = description.trim(); + if (!trimmedTitle || !trimmedDescription) { + return trimmedDescription; + } + const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); + if (headerRegex.test(trimmedDescription)) { + return trimmedDescription.replace(headerRegex, "").trim(); + } + return trimmedDescription; + } async function main() { core.setOutput("issue_number", ""); core.setOutput("issue_url", ""); diff --git a/.github/workflows/smoke-copilot-safe-inputs.lock.yml b/.github/workflows/smoke-copilot-safe-inputs.lock.yml index 809bb0faf44..26c4d3ed2e6 100644 --- a/.github/workflows/smoke-copilot-safe-inputs.lock.yml +++ b/.github/workflows/smoke-copilot-safe-inputs.lock.yml @@ -8821,21 +8821,291 @@ jobs: with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - const { sanitizeLabelContent } = require("./sanitize_label_content.cjs"); - const { loadAgentOutput } = require("./load_agent_output.cjs"); - const { generateStagedPreview } = require("./staged_preview.cjs"); - const { generateFooter } = require("./generate_footer.cjs"); - const { getTrackerID } = require("./get_tracker_id.cjs"); - const { - generateTemporaryId, - isTemporaryId, - normalizeTemporaryId, - replaceTemporaryIdReferences, - serializeTemporaryIdMap, - } = require("./temporary_id.cjs"); - const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require("./repo_helpers.cjs"); - const { addExpirationComment } = require("./expiration_helpers.cjs"); - const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); + function sanitizeLabelContent(content) { + if (!content || typeof content !== "string") { + return ""; + } + let sanitized = content.trim(); + sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); + sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); + sanitized = sanitized.replace( + /(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, + (_m, p1, p2) => `${p1}\`@${p2}\`` + ); + sanitized = sanitized.replace(/[<>&'"]/g, ""); + return sanitized.trim(); + } + const fs = require("fs"); + const crypto = require("crypto"); + const MAX_LOG_CONTENT_LENGTH = 10000; + function truncateForLogging(content) { + if (content.length <= MAX_LOG_CONTENT_LENGTH) { + return content; + } + return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; + } + function loadAgentOutput() { + const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; + if (!agentOutputFile) { + core.info("No GH_AW_AGENT_OUTPUT environment variable found"); + return { success: false }; + } + let outputContent; + try { + outputContent = fs.readFileSync(agentOutputFile, "utf8"); + } catch (error) { + const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; + core.error(errorMessage); + return { success: false, error: errorMessage }; + } + if (outputContent.trim() === "") { + core.info("Agent output content is empty"); + return { success: false }; + } + core.info(`Agent output content length: ${outputContent.length}`); + let validatedOutput; + try { + validatedOutput = JSON.parse(outputContent); + } catch (error) { + const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; + core.error(errorMessage); + core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); + return { success: false, error: errorMessage }; + } + if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { + core.info("No valid items found in agent output"); + core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); + return { success: false }; + } + return { success: true, items: validatedOutput.items }; + } + async function generateStagedPreview(options) { + const { title, description, items, renderItem } = options; + let summaryContent = `## 🎭 Staged Mode: ${title} Preview\n\n`; + summaryContent += `${description}\n\n`; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + summaryContent += renderItem(item, i); + summaryContent += "---\n\n"; + } + try { + await core.summary.addRaw(summaryContent).write(); + core.info(summaryContent); + core.info(`📝 ${title} preview written to step summary`); + } catch (error) { + core.setFailed(error instanceof Error ? error : String(error)); + } + } + function generateXMLMarker(workflowName, runUrl) { + const engineId = process.env.GH_AW_ENGINE_ID || ""; + const engineVersion = process.env.GH_AW_ENGINE_VERSION || ""; + const engineModel = process.env.GH_AW_ENGINE_MODEL || ""; + const trackerId = process.env.GH_AW_TRACKER_ID || ""; + const parts = []; + parts.push(`agentic-workflow: ${workflowName}`); + if (trackerId) { + parts.push(`tracker-id: ${trackerId}`); + } + if (engineId) { + parts.push(`engine: ${engineId}`); + } + if (engineVersion) { + parts.push(`version: ${engineVersion}`); + } + if (engineModel) { + parts.push(`model: ${engineModel}`); + } + parts.push(`run: ${runUrl}`); + return ``; + } + function generateFooter( + workflowName, + runUrl, + workflowSource, + workflowSourceURL, + triggeringIssueNumber, + triggeringPRNumber, + triggeringDiscussionNumber + ) { + let footer = `\n\n> AI generated by [${workflowName}](${runUrl})`; + if (triggeringIssueNumber) { + footer += ` for #${triggeringIssueNumber}`; + } else if (triggeringPRNumber) { + footer += ` for #${triggeringPRNumber}`; + } else if (triggeringDiscussionNumber) { + footer += ` for discussion #${triggeringDiscussionNumber}`; + } + if (workflowSource && workflowSourceURL) { + footer += `\n>\n> To add this workflow in your repository, run \`gh aw add ${workflowSource}\`. See [usage guide](https://githubnext.github.io/gh-aw/tools/cli/).`; + } + footer += "\n\n" + generateXMLMarker(workflowName, runUrl); + footer += "\n"; + return footer; + } + function getTrackerID(format) { + const trackerID = process.env.GH_AW_TRACKER_ID || ""; + if (trackerID) { + core.info(`Tracker ID: ${trackerID}`); + return format === "markdown" ? `\n\n` : trackerID; + } + return ""; + } + const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; + function generateTemporaryId() { + return "aw_" + crypto.randomBytes(6).toString("hex"); + } + function isTemporaryId(value) { + if (typeof value === "string") { + return /^aw_[0-9a-f]{12}$/i.test(value); + } + return false; + } + function normalizeTemporaryId(tempId) { + return String(tempId).toLowerCase(); + } + function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { + return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { + const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); + if (resolved !== undefined) { + if (currentRepo && resolved.repo === currentRepo) { + return `#${resolved.number}`; + } + return `${resolved.repo}#${resolved.number}`; + } + return match; + }); + } + function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { + return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { + const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); + if (issueNumber !== undefined) { + return `#${issueNumber}`; + } + return match; + }); + } + function loadTemporaryIdMap() { + const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; + if (!mapJson || mapJson === "{}") { + return new Map(); + } + try { + const mapObject = JSON.parse(mapJson); + const result = new Map(); + for (const [key, value] of Object.entries(mapObject)) { + const normalizedKey = normalizeTemporaryId(key); + if (typeof value === "number") { + const contextRepo = `${context.repo.owner}/${context.repo.repo}`; + result.set(normalizedKey, { repo: contextRepo, number: value }); + } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { + result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); + } + } + return result; + } catch (error) { + if (typeof core !== "undefined") { + core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); + } + return new Map(); + } + } + function resolveIssueNumber(value, temporaryIdMap) { + if (value === undefined || value === null) { + return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; + } + const valueStr = String(value); + if (isTemporaryId(valueStr)) { + const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); + if (resolvedPair !== undefined) { + return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; + } + return { + resolved: null, + wasTemporaryId: true, + errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, + }; + } + const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); + if (isNaN(issueNumber) || issueNumber <= 0) { + return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; + } + const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; + return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; + } + function serializeTemporaryIdMap(tempIdMap) { + const obj = Object.fromEntries(tempIdMap); + return JSON.stringify(obj); + } + function parseAllowedRepos() { + const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; + const set = new Set(); + if (allowedReposEnv) { + allowedReposEnv + .split(",") + .map(repo => repo.trim()) + .filter(repo => repo) + .forEach(repo => set.add(repo)); + } + return set; + } + function getDefaultTargetRepo() { + const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; + if (targetRepoSlug) { + return targetRepoSlug; + } + return `${context.repo.owner}/${context.repo.repo}`; + } + function validateRepo(repo, defaultRepo, allowedRepos) { + if (repo === defaultRepo) { + return { valid: true, error: null }; + } + if (allowedRepos.has(repo)) { + return { valid: true, error: null }; + } + return { + valid: false, + error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, + }; + } + function parseRepoSlug(repoSlug) { + const parts = repoSlug.split("/"); + if (parts.length !== 2 || !parts[0] || !parts[1]) { + return null; + } + return { owner: parts[0], repo: parts[1] }; + } + function addExpirationComment(bodyLines, envVarName, entityType) { + const expiresEnv = process.env[envVarName]; + if (expiresEnv) { + const expiresDays = parseInt(expiresEnv, 10); + if (!isNaN(expiresDays) && expiresDays > 0) { + const expirationDate = new Date(); + expirationDate.setDate(expirationDate.getDate() + expiresDays); + const expirationISO = expirationDate.toISOString(); + bodyLines.push(``); + core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); + } + } + } + function removeDuplicateTitleFromDescription(title, description) { + if (!title || typeof title !== "string") { + return description || ""; + } + if (!description || typeof description !== "string") { + return ""; + } + const trimmedTitle = title.trim(); + const trimmedDescription = description.trim(); + if (!trimmedTitle || !trimmedDescription) { + return trimmedDescription; + } + const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); + if (headerRegex.test(trimmedDescription)) { + return trimmedDescription.replace(headerRegex, "").trim(); + } + return trimmedDescription; + } async function main() { core.setOutput("issue_number", ""); core.setOutput("issue_url", ""); diff --git a/.github/workflows/smoke-copilot.lock.yml b/.github/workflows/smoke-copilot.lock.yml index 725de651f10..3c6bd7c34c4 100644 --- a/.github/workflows/smoke-copilot.lock.yml +++ b/.github/workflows/smoke-copilot.lock.yml @@ -7646,21 +7646,291 @@ jobs: with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - const { sanitizeLabelContent } = require("./sanitize_label_content.cjs"); - const { loadAgentOutput } = require("./load_agent_output.cjs"); - const { generateStagedPreview } = require("./staged_preview.cjs"); - const { generateFooter } = require("./generate_footer.cjs"); - const { getTrackerID } = require("./get_tracker_id.cjs"); - const { - generateTemporaryId, - isTemporaryId, - normalizeTemporaryId, - replaceTemporaryIdReferences, - serializeTemporaryIdMap, - } = require("./temporary_id.cjs"); - const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require("./repo_helpers.cjs"); - const { addExpirationComment } = require("./expiration_helpers.cjs"); - const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); + function sanitizeLabelContent(content) { + if (!content || typeof content !== "string") { + return ""; + } + let sanitized = content.trim(); + sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); + sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); + sanitized = sanitized.replace( + /(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, + (_m, p1, p2) => `${p1}\`@${p2}\`` + ); + sanitized = sanitized.replace(/[<>&'"]/g, ""); + return sanitized.trim(); + } + const fs = require("fs"); + const crypto = require("crypto"); + const MAX_LOG_CONTENT_LENGTH = 10000; + function truncateForLogging(content) { + if (content.length <= MAX_LOG_CONTENT_LENGTH) { + return content; + } + return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; + } + function loadAgentOutput() { + const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; + if (!agentOutputFile) { + core.info("No GH_AW_AGENT_OUTPUT environment variable found"); + return { success: false }; + } + let outputContent; + try { + outputContent = fs.readFileSync(agentOutputFile, "utf8"); + } catch (error) { + const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; + core.error(errorMessage); + return { success: false, error: errorMessage }; + } + if (outputContent.trim() === "") { + core.info("Agent output content is empty"); + return { success: false }; + } + core.info(`Agent output content length: ${outputContent.length}`); + let validatedOutput; + try { + validatedOutput = JSON.parse(outputContent); + } catch (error) { + const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; + core.error(errorMessage); + core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); + return { success: false, error: errorMessage }; + } + if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { + core.info("No valid items found in agent output"); + core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); + return { success: false }; + } + return { success: true, items: validatedOutput.items }; + } + async function generateStagedPreview(options) { + const { title, description, items, renderItem } = options; + let summaryContent = `## 🎭 Staged Mode: ${title} Preview\n\n`; + summaryContent += `${description}\n\n`; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + summaryContent += renderItem(item, i); + summaryContent += "---\n\n"; + } + try { + await core.summary.addRaw(summaryContent).write(); + core.info(summaryContent); + core.info(`📝 ${title} preview written to step summary`); + } catch (error) { + core.setFailed(error instanceof Error ? error : String(error)); + } + } + function generateXMLMarker(workflowName, runUrl) { + const engineId = process.env.GH_AW_ENGINE_ID || ""; + const engineVersion = process.env.GH_AW_ENGINE_VERSION || ""; + const engineModel = process.env.GH_AW_ENGINE_MODEL || ""; + const trackerId = process.env.GH_AW_TRACKER_ID || ""; + const parts = []; + parts.push(`agentic-workflow: ${workflowName}`); + if (trackerId) { + parts.push(`tracker-id: ${trackerId}`); + } + if (engineId) { + parts.push(`engine: ${engineId}`); + } + if (engineVersion) { + parts.push(`version: ${engineVersion}`); + } + if (engineModel) { + parts.push(`model: ${engineModel}`); + } + parts.push(`run: ${runUrl}`); + return ``; + } + function generateFooter( + workflowName, + runUrl, + workflowSource, + workflowSourceURL, + triggeringIssueNumber, + triggeringPRNumber, + triggeringDiscussionNumber + ) { + let footer = `\n\n> AI generated by [${workflowName}](${runUrl})`; + if (triggeringIssueNumber) { + footer += ` for #${triggeringIssueNumber}`; + } else if (triggeringPRNumber) { + footer += ` for #${triggeringPRNumber}`; + } else if (triggeringDiscussionNumber) { + footer += ` for discussion #${triggeringDiscussionNumber}`; + } + if (workflowSource && workflowSourceURL) { + footer += `\n>\n> To add this workflow in your repository, run \`gh aw add ${workflowSource}\`. See [usage guide](https://githubnext.github.io/gh-aw/tools/cli/).`; + } + footer += "\n\n" + generateXMLMarker(workflowName, runUrl); + footer += "\n"; + return footer; + } + function getTrackerID(format) { + const trackerID = process.env.GH_AW_TRACKER_ID || ""; + if (trackerID) { + core.info(`Tracker ID: ${trackerID}`); + return format === "markdown" ? `\n\n` : trackerID; + } + return ""; + } + const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; + function generateTemporaryId() { + return "aw_" + crypto.randomBytes(6).toString("hex"); + } + function isTemporaryId(value) { + if (typeof value === "string") { + return /^aw_[0-9a-f]{12}$/i.test(value); + } + return false; + } + function normalizeTemporaryId(tempId) { + return String(tempId).toLowerCase(); + } + function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { + return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { + const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); + if (resolved !== undefined) { + if (currentRepo && resolved.repo === currentRepo) { + return `#${resolved.number}`; + } + return `${resolved.repo}#${resolved.number}`; + } + return match; + }); + } + function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { + return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { + const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); + if (issueNumber !== undefined) { + return `#${issueNumber}`; + } + return match; + }); + } + function loadTemporaryIdMap() { + const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; + if (!mapJson || mapJson === "{}") { + return new Map(); + } + try { + const mapObject = JSON.parse(mapJson); + const result = new Map(); + for (const [key, value] of Object.entries(mapObject)) { + const normalizedKey = normalizeTemporaryId(key); + if (typeof value === "number") { + const contextRepo = `${context.repo.owner}/${context.repo.repo}`; + result.set(normalizedKey, { repo: contextRepo, number: value }); + } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { + result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); + } + } + return result; + } catch (error) { + if (typeof core !== "undefined") { + core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); + } + return new Map(); + } + } + function resolveIssueNumber(value, temporaryIdMap) { + if (value === undefined || value === null) { + return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; + } + const valueStr = String(value); + if (isTemporaryId(valueStr)) { + const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); + if (resolvedPair !== undefined) { + return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; + } + return { + resolved: null, + wasTemporaryId: true, + errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, + }; + } + const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); + if (isNaN(issueNumber) || issueNumber <= 0) { + return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; + } + const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; + return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; + } + function serializeTemporaryIdMap(tempIdMap) { + const obj = Object.fromEntries(tempIdMap); + return JSON.stringify(obj); + } + function parseAllowedRepos() { + const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; + const set = new Set(); + if (allowedReposEnv) { + allowedReposEnv + .split(",") + .map(repo => repo.trim()) + .filter(repo => repo) + .forEach(repo => set.add(repo)); + } + return set; + } + function getDefaultTargetRepo() { + const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; + if (targetRepoSlug) { + return targetRepoSlug; + } + return `${context.repo.owner}/${context.repo.repo}`; + } + function validateRepo(repo, defaultRepo, allowedRepos) { + if (repo === defaultRepo) { + return { valid: true, error: null }; + } + if (allowedRepos.has(repo)) { + return { valid: true, error: null }; + } + return { + valid: false, + error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, + }; + } + function parseRepoSlug(repoSlug) { + const parts = repoSlug.split("/"); + if (parts.length !== 2 || !parts[0] || !parts[1]) { + return null; + } + return { owner: parts[0], repo: parts[1] }; + } + function addExpirationComment(bodyLines, envVarName, entityType) { + const expiresEnv = process.env[envVarName]; + if (expiresEnv) { + const expiresDays = parseInt(expiresEnv, 10); + if (!isNaN(expiresDays) && expiresDays > 0) { + const expirationDate = new Date(); + expirationDate.setDate(expirationDate.getDate() + expiresDays); + const expirationISO = expirationDate.toISOString(); + bodyLines.push(``); + core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); + } + } + } + function removeDuplicateTitleFromDescription(title, description) { + if (!title || typeof title !== "string") { + return description || ""; + } + if (!description || typeof description !== "string") { + return ""; + } + const trimmedTitle = title.trim(); + const trimmedDescription = description.trim(); + if (!trimmedTitle || !trimmedDescription) { + return trimmedDescription; + } + const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); + if (headerRegex.test(trimmedDescription)) { + return trimmedDescription.replace(headerRegex, "").trim(); + } + return trimmedDescription; + } async function main() { core.setOutput("issue_number", ""); core.setOutput("issue_url", ""); diff --git a/.github/workflows/smoke-detector.lock.yml b/.github/workflows/smoke-detector.lock.yml index 8480e96c1fe..ff5e1efba18 100644 --- a/.github/workflows/smoke-detector.lock.yml +++ b/.github/workflows/smoke-detector.lock.yml @@ -7149,21 +7149,291 @@ jobs: with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - const { sanitizeLabelContent } = require("./sanitize_label_content.cjs"); - const { loadAgentOutput } = require("./load_agent_output.cjs"); - const { generateStagedPreview } = require("./staged_preview.cjs"); - const { generateFooter } = require("./generate_footer.cjs"); - const { getTrackerID } = require("./get_tracker_id.cjs"); - const { - generateTemporaryId, - isTemporaryId, - normalizeTemporaryId, - replaceTemporaryIdReferences, - serializeTemporaryIdMap, - } = require("./temporary_id.cjs"); - const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require("./repo_helpers.cjs"); - const { addExpirationComment } = require("./expiration_helpers.cjs"); - const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); + function sanitizeLabelContent(content) { + if (!content || typeof content !== "string") { + return ""; + } + let sanitized = content.trim(); + sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); + sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); + sanitized = sanitized.replace( + /(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, + (_m, p1, p2) => `${p1}\`@${p2}\`` + ); + sanitized = sanitized.replace(/[<>&'"]/g, ""); + return sanitized.trim(); + } + const fs = require("fs"); + const crypto = require("crypto"); + const MAX_LOG_CONTENT_LENGTH = 10000; + function truncateForLogging(content) { + if (content.length <= MAX_LOG_CONTENT_LENGTH) { + return content; + } + return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; + } + function loadAgentOutput() { + const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; + if (!agentOutputFile) { + core.info("No GH_AW_AGENT_OUTPUT environment variable found"); + return { success: false }; + } + let outputContent; + try { + outputContent = fs.readFileSync(agentOutputFile, "utf8"); + } catch (error) { + const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; + core.error(errorMessage); + return { success: false, error: errorMessage }; + } + if (outputContent.trim() === "") { + core.info("Agent output content is empty"); + return { success: false }; + } + core.info(`Agent output content length: ${outputContent.length}`); + let validatedOutput; + try { + validatedOutput = JSON.parse(outputContent); + } catch (error) { + const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; + core.error(errorMessage); + core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); + return { success: false, error: errorMessage }; + } + if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { + core.info("No valid items found in agent output"); + core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); + return { success: false }; + } + return { success: true, items: validatedOutput.items }; + } + async function generateStagedPreview(options) { + const { title, description, items, renderItem } = options; + let summaryContent = `## 🎭 Staged Mode: ${title} Preview\n\n`; + summaryContent += `${description}\n\n`; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + summaryContent += renderItem(item, i); + summaryContent += "---\n\n"; + } + try { + await core.summary.addRaw(summaryContent).write(); + core.info(summaryContent); + core.info(`📝 ${title} preview written to step summary`); + } catch (error) { + core.setFailed(error instanceof Error ? error : String(error)); + } + } + function generateXMLMarker(workflowName, runUrl) { + const engineId = process.env.GH_AW_ENGINE_ID || ""; + const engineVersion = process.env.GH_AW_ENGINE_VERSION || ""; + const engineModel = process.env.GH_AW_ENGINE_MODEL || ""; + const trackerId = process.env.GH_AW_TRACKER_ID || ""; + const parts = []; + parts.push(`agentic-workflow: ${workflowName}`); + if (trackerId) { + parts.push(`tracker-id: ${trackerId}`); + } + if (engineId) { + parts.push(`engine: ${engineId}`); + } + if (engineVersion) { + parts.push(`version: ${engineVersion}`); + } + if (engineModel) { + parts.push(`model: ${engineModel}`); + } + parts.push(`run: ${runUrl}`); + return ``; + } + function generateFooter( + workflowName, + runUrl, + workflowSource, + workflowSourceURL, + triggeringIssueNumber, + triggeringPRNumber, + triggeringDiscussionNumber + ) { + let footer = `\n\n> AI generated by [${workflowName}](${runUrl})`; + if (triggeringIssueNumber) { + footer += ` for #${triggeringIssueNumber}`; + } else if (triggeringPRNumber) { + footer += ` for #${triggeringPRNumber}`; + } else if (triggeringDiscussionNumber) { + footer += ` for discussion #${triggeringDiscussionNumber}`; + } + if (workflowSource && workflowSourceURL) { + footer += `\n>\n> To add this workflow in your repository, run \`gh aw add ${workflowSource}\`. See [usage guide](https://githubnext.github.io/gh-aw/tools/cli/).`; + } + footer += "\n\n" + generateXMLMarker(workflowName, runUrl); + footer += "\n"; + return footer; + } + function getTrackerID(format) { + const trackerID = process.env.GH_AW_TRACKER_ID || ""; + if (trackerID) { + core.info(`Tracker ID: ${trackerID}`); + return format === "markdown" ? `\n\n` : trackerID; + } + return ""; + } + const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; + function generateTemporaryId() { + return "aw_" + crypto.randomBytes(6).toString("hex"); + } + function isTemporaryId(value) { + if (typeof value === "string") { + return /^aw_[0-9a-f]{12}$/i.test(value); + } + return false; + } + function normalizeTemporaryId(tempId) { + return String(tempId).toLowerCase(); + } + function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { + return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { + const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); + if (resolved !== undefined) { + if (currentRepo && resolved.repo === currentRepo) { + return `#${resolved.number}`; + } + return `${resolved.repo}#${resolved.number}`; + } + return match; + }); + } + function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { + return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { + const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); + if (issueNumber !== undefined) { + return `#${issueNumber}`; + } + return match; + }); + } + function loadTemporaryIdMap() { + const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; + if (!mapJson || mapJson === "{}") { + return new Map(); + } + try { + const mapObject = JSON.parse(mapJson); + const result = new Map(); + for (const [key, value] of Object.entries(mapObject)) { + const normalizedKey = normalizeTemporaryId(key); + if (typeof value === "number") { + const contextRepo = `${context.repo.owner}/${context.repo.repo}`; + result.set(normalizedKey, { repo: contextRepo, number: value }); + } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { + result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); + } + } + return result; + } catch (error) { + if (typeof core !== "undefined") { + core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); + } + return new Map(); + } + } + function resolveIssueNumber(value, temporaryIdMap) { + if (value === undefined || value === null) { + return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; + } + const valueStr = String(value); + if (isTemporaryId(valueStr)) { + const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); + if (resolvedPair !== undefined) { + return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; + } + return { + resolved: null, + wasTemporaryId: true, + errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, + }; + } + const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); + if (isNaN(issueNumber) || issueNumber <= 0) { + return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; + } + const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; + return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; + } + function serializeTemporaryIdMap(tempIdMap) { + const obj = Object.fromEntries(tempIdMap); + return JSON.stringify(obj); + } + function parseAllowedRepos() { + const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; + const set = new Set(); + if (allowedReposEnv) { + allowedReposEnv + .split(",") + .map(repo => repo.trim()) + .filter(repo => repo) + .forEach(repo => set.add(repo)); + } + return set; + } + function getDefaultTargetRepo() { + const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; + if (targetRepoSlug) { + return targetRepoSlug; + } + return `${context.repo.owner}/${context.repo.repo}`; + } + function validateRepo(repo, defaultRepo, allowedRepos) { + if (repo === defaultRepo) { + return { valid: true, error: null }; + } + if (allowedRepos.has(repo)) { + return { valid: true, error: null }; + } + return { + valid: false, + error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, + }; + } + function parseRepoSlug(repoSlug) { + const parts = repoSlug.split("/"); + if (parts.length !== 2 || !parts[0] || !parts[1]) { + return null; + } + return { owner: parts[0], repo: parts[1] }; + } + function addExpirationComment(bodyLines, envVarName, entityType) { + const expiresEnv = process.env[envVarName]; + if (expiresEnv) { + const expiresDays = parseInt(expiresEnv, 10); + if (!isNaN(expiresDays) && expiresDays > 0) { + const expirationDate = new Date(); + expirationDate.setDate(expirationDate.getDate() + expiresDays); + const expirationISO = expirationDate.toISOString(); + bodyLines.push(``); + core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); + } + } + } + function removeDuplicateTitleFromDescription(title, description) { + if (!title || typeof title !== "string") { + return description || ""; + } + if (!description || typeof description !== "string") { + return ""; + } + const trimmedTitle = title.trim(); + const trimmedDescription = description.trim(); + if (!trimmedTitle || !trimmedDescription) { + return trimmedDescription; + } + const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); + if (headerRegex.test(trimmedDescription)) { + return trimmedDescription.replace(headerRegex, "").trim(); + } + return trimmedDescription; + } async function main() { core.setOutput("issue_number", ""); core.setOutput("issue_url", ""); diff --git a/.github/workflows/spec-kit-execute.lock.yml b/.github/workflows/spec-kit-execute.lock.yml index 697f5e4b627..b30a35de64a 100644 --- a/.github/workflows/spec-kit-execute.lock.yml +++ b/.github/workflows/spec-kit-execute.lock.yml @@ -6770,10 +6770,151 @@ jobs: script: | const fs = require("fs"); const crypto = require("crypto"); - const { updateActivationComment } = require("./update_activation_comment.cjs"); - const { getTrackerID } = require("./get_tracker_id.cjs"); - const { addExpirationComment } = require("./expiration_helpers.cjs"); - const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); + async function updateActivationComment(github, context, core, itemUrl, itemNumber, itemType = "pull_request") { + const itemLabel = itemType === "issue" ? "issue" : "pull request"; + const linkMessage = + itemType === "issue" + ? `\n\n✅ Issue created: [#${itemNumber}](${itemUrl})` + : `\n\n✅ Pull request created: [#${itemNumber}](${itemUrl})`; + await updateActivationCommentWithMessage(github, context, core, linkMessage, itemLabel); + } + async function updateActivationCommentWithCommit(github, context, core, commitSha, commitUrl) { + const shortSha = commitSha.substring(0, 7); + const message = `\n\n✅ Commit pushed: [\`${shortSha}\`](${commitUrl})`; + await updateActivationCommentWithMessage(github, context, core, message, "commit"); + } + async function updateActivationCommentWithMessage(github, context, core, message, label = "") { + const commentId = process.env.GH_AW_COMMENT_ID; + const commentRepo = process.env.GH_AW_COMMENT_REPO; + if (!commentId) { + core.info("No activation comment to update (GH_AW_COMMENT_ID not set)"); + return; + } + core.info(`Updating activation comment ${commentId}`); + let repoOwner = context.repo.owner; + let repoName = context.repo.repo; + if (commentRepo) { + const parts = commentRepo.split("/"); + if (parts.length === 2) { + repoOwner = parts[0]; + repoName = parts[1]; + } else { + core.warning(`Invalid comment repo format: ${commentRepo}, expected "owner/repo". Falling back to context.repo.`); + } + } + core.info(`Updating comment in ${repoOwner}/${repoName}`); + const isDiscussionComment = commentId.startsWith("DC_"); + try { + if (isDiscussionComment) { + const currentComment = await github.graphql( + ` + query($commentId: ID!) { + node(id: $commentId) { + ... on DiscussionComment { + body + } + } + }`, + { commentId: commentId } + ); + if (!currentComment?.node?.body) { + core.warning("Unable to fetch current comment body, comment may have been deleted or is inaccessible"); + return; + } + const currentBody = currentComment.node.body; + const updatedBody = currentBody + message; + const result = await github.graphql( + ` + mutation($commentId: ID!, $body: String!) { + updateDiscussionComment(input: { commentId: $commentId, body: $body }) { + comment { + id + url + } + } + }`, + { commentId: commentId, body: updatedBody } + ); + const comment = result.updateDiscussionComment.comment; + const successMessage = label + ? `Successfully updated discussion comment with ${label} link` + : "Successfully updated discussion comment"; + core.info(successMessage); + core.info(`Comment ID: ${comment.id}`); + core.info(`Comment URL: ${comment.url}`); + } else { + const currentComment = await github.request("GET /repos/{owner}/{repo}/issues/comments/{comment_id}", { + owner: repoOwner, + repo: repoName, + comment_id: parseInt(commentId, 10), + headers: { + Accept: "application/vnd.github+json", + }, + }); + if (!currentComment?.data?.body) { + core.warning("Unable to fetch current comment body, comment may have been deleted"); + return; + } + const currentBody = currentComment.data.body; + const updatedBody = currentBody + message; + const response = await github.request("PATCH /repos/{owner}/{repo}/issues/comments/{comment_id}", { + owner: repoOwner, + repo: repoName, + comment_id: parseInt(commentId, 10), + body: updatedBody, + headers: { + Accept: "application/vnd.github+json", + }, + }); + const successMessage = label ? `Successfully updated comment with ${label} link` : "Successfully updated comment"; + core.info(successMessage); + core.info(`Comment ID: ${response.data.id}`); + core.info(`Comment URL: ${response.data.html_url}`); + } + } catch (error) { + core.warning(`Failed to update activation comment: ${error instanceof Error ? error.message : String(error)}`); + } + } + function getTrackerID(format) { + const trackerID = process.env.GH_AW_TRACKER_ID || ""; + if (trackerID) { + core.info(`Tracker ID: ${trackerID}`); + return format === "markdown" ? `\n\n` : trackerID; + } + return ""; + } + function addExpirationComment(bodyLines, envVarName, entityType) { + const expiresEnv = process.env[envVarName]; + if (expiresEnv) { + const expiresDays = parseInt(expiresEnv, 10); + if (!isNaN(expiresDays) && expiresDays > 0) { + const expirationDate = new Date(); + expirationDate.setDate(expirationDate.getDate() + expiresDays); + const expirationISO = expirationDate.toISOString(); + bodyLines.push(``); + core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); + } + } + } + function removeDuplicateTitleFromDescription(title, description) { + if (!title || typeof title !== "string") { + return description || ""; + } + if (!description || typeof description !== "string") { + return ""; + } + const trimmedTitle = title.trim(); + const trimmedDescription = description.trim(); + if (!trimmedTitle || !trimmedDescription) { + return trimmedDescription; + } + const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); + if (headerRegex.test(trimmedDescription)) { + return trimmedDescription.replace(headerRegex, "").trim(); + } + return trimmedDescription; + } function generatePatchPreview(patchContent) { if (!patchContent || !patchContent.trim()) { return ""; diff --git a/.github/workflows/spec-kit-executor.lock.yml b/.github/workflows/spec-kit-executor.lock.yml index 8621729eca9..1aa8b03cf76 100644 --- a/.github/workflows/spec-kit-executor.lock.yml +++ b/.github/workflows/spec-kit-executor.lock.yml @@ -6460,10 +6460,151 @@ jobs: script: | const fs = require("fs"); const crypto = require("crypto"); - const { updateActivationComment } = require("./update_activation_comment.cjs"); - const { getTrackerID } = require("./get_tracker_id.cjs"); - const { addExpirationComment } = require("./expiration_helpers.cjs"); - const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); + async function updateActivationComment(github, context, core, itemUrl, itemNumber, itemType = "pull_request") { + const itemLabel = itemType === "issue" ? "issue" : "pull request"; + const linkMessage = + itemType === "issue" + ? `\n\n✅ Issue created: [#${itemNumber}](${itemUrl})` + : `\n\n✅ Pull request created: [#${itemNumber}](${itemUrl})`; + await updateActivationCommentWithMessage(github, context, core, linkMessage, itemLabel); + } + async function updateActivationCommentWithCommit(github, context, core, commitSha, commitUrl) { + const shortSha = commitSha.substring(0, 7); + const message = `\n\n✅ Commit pushed: [\`${shortSha}\`](${commitUrl})`; + await updateActivationCommentWithMessage(github, context, core, message, "commit"); + } + async function updateActivationCommentWithMessage(github, context, core, message, label = "") { + const commentId = process.env.GH_AW_COMMENT_ID; + const commentRepo = process.env.GH_AW_COMMENT_REPO; + if (!commentId) { + core.info("No activation comment to update (GH_AW_COMMENT_ID not set)"); + return; + } + core.info(`Updating activation comment ${commentId}`); + let repoOwner = context.repo.owner; + let repoName = context.repo.repo; + if (commentRepo) { + const parts = commentRepo.split("/"); + if (parts.length === 2) { + repoOwner = parts[0]; + repoName = parts[1]; + } else { + core.warning(`Invalid comment repo format: ${commentRepo}, expected "owner/repo". Falling back to context.repo.`); + } + } + core.info(`Updating comment in ${repoOwner}/${repoName}`); + const isDiscussionComment = commentId.startsWith("DC_"); + try { + if (isDiscussionComment) { + const currentComment = await github.graphql( + ` + query($commentId: ID!) { + node(id: $commentId) { + ... on DiscussionComment { + body + } + } + }`, + { commentId: commentId } + ); + if (!currentComment?.node?.body) { + core.warning("Unable to fetch current comment body, comment may have been deleted or is inaccessible"); + return; + } + const currentBody = currentComment.node.body; + const updatedBody = currentBody + message; + const result = await github.graphql( + ` + mutation($commentId: ID!, $body: String!) { + updateDiscussionComment(input: { commentId: $commentId, body: $body }) { + comment { + id + url + } + } + }`, + { commentId: commentId, body: updatedBody } + ); + const comment = result.updateDiscussionComment.comment; + const successMessage = label + ? `Successfully updated discussion comment with ${label} link` + : "Successfully updated discussion comment"; + core.info(successMessage); + core.info(`Comment ID: ${comment.id}`); + core.info(`Comment URL: ${comment.url}`); + } else { + const currentComment = await github.request("GET /repos/{owner}/{repo}/issues/comments/{comment_id}", { + owner: repoOwner, + repo: repoName, + comment_id: parseInt(commentId, 10), + headers: { + Accept: "application/vnd.github+json", + }, + }); + if (!currentComment?.data?.body) { + core.warning("Unable to fetch current comment body, comment may have been deleted"); + return; + } + const currentBody = currentComment.data.body; + const updatedBody = currentBody + message; + const response = await github.request("PATCH /repos/{owner}/{repo}/issues/comments/{comment_id}", { + owner: repoOwner, + repo: repoName, + comment_id: parseInt(commentId, 10), + body: updatedBody, + headers: { + Accept: "application/vnd.github+json", + }, + }); + const successMessage = label ? `Successfully updated comment with ${label} link` : "Successfully updated comment"; + core.info(successMessage); + core.info(`Comment ID: ${response.data.id}`); + core.info(`Comment URL: ${response.data.html_url}`); + } + } catch (error) { + core.warning(`Failed to update activation comment: ${error instanceof Error ? error.message : String(error)}`); + } + } + function getTrackerID(format) { + const trackerID = process.env.GH_AW_TRACKER_ID || ""; + if (trackerID) { + core.info(`Tracker ID: ${trackerID}`); + return format === "markdown" ? `\n\n` : trackerID; + } + return ""; + } + function addExpirationComment(bodyLines, envVarName, entityType) { + const expiresEnv = process.env[envVarName]; + if (expiresEnv) { + const expiresDays = parseInt(expiresEnv, 10); + if (!isNaN(expiresDays) && expiresDays > 0) { + const expirationDate = new Date(); + expirationDate.setDate(expirationDate.getDate() + expiresDays); + const expirationISO = expirationDate.toISOString(); + bodyLines.push(``); + core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); + } + } + } + function removeDuplicateTitleFromDescription(title, description) { + if (!title || typeof title !== "string") { + return description || ""; + } + if (!description || typeof description !== "string") { + return ""; + } + const trimmedTitle = title.trim(); + const trimmedDescription = description.trim(); + if (!trimmedTitle || !trimmedDescription) { + return trimmedDescription; + } + const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); + if (headerRegex.test(trimmedDescription)) { + return trimmedDescription.replace(headerRegex, "").trim(); + } + return trimmedDescription; + } function generatePatchPreview(patchContent) { if (!patchContent || !patchContent.trim()) { return ""; diff --git a/.github/workflows/speckit-dispatcher.lock.yml b/.github/workflows/speckit-dispatcher.lock.yml index ce28cf630bb..f403fc9e3d6 100644 --- a/.github/workflows/speckit-dispatcher.lock.yml +++ b/.github/workflows/speckit-dispatcher.lock.yml @@ -8116,21 +8116,291 @@ jobs: with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - const { sanitizeLabelContent } = require("./sanitize_label_content.cjs"); - const { loadAgentOutput } = require("./load_agent_output.cjs"); - const { generateStagedPreview } = require("./staged_preview.cjs"); - const { generateFooter } = require("./generate_footer.cjs"); - const { getTrackerID } = require("./get_tracker_id.cjs"); - const { - generateTemporaryId, - isTemporaryId, - normalizeTemporaryId, - replaceTemporaryIdReferences, - serializeTemporaryIdMap, - } = require("./temporary_id.cjs"); - const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require("./repo_helpers.cjs"); - const { addExpirationComment } = require("./expiration_helpers.cjs"); - const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); + function sanitizeLabelContent(content) { + if (!content || typeof content !== "string") { + return ""; + } + let sanitized = content.trim(); + sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); + sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); + sanitized = sanitized.replace( + /(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, + (_m, p1, p2) => `${p1}\`@${p2}\`` + ); + sanitized = sanitized.replace(/[<>&'"]/g, ""); + return sanitized.trim(); + } + const fs = require("fs"); + const crypto = require("crypto"); + const MAX_LOG_CONTENT_LENGTH = 10000; + function truncateForLogging(content) { + if (content.length <= MAX_LOG_CONTENT_LENGTH) { + return content; + } + return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; + } + function loadAgentOutput() { + const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; + if (!agentOutputFile) { + core.info("No GH_AW_AGENT_OUTPUT environment variable found"); + return { success: false }; + } + let outputContent; + try { + outputContent = fs.readFileSync(agentOutputFile, "utf8"); + } catch (error) { + const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; + core.error(errorMessage); + return { success: false, error: errorMessage }; + } + if (outputContent.trim() === "") { + core.info("Agent output content is empty"); + return { success: false }; + } + core.info(`Agent output content length: ${outputContent.length}`); + let validatedOutput; + try { + validatedOutput = JSON.parse(outputContent); + } catch (error) { + const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; + core.error(errorMessage); + core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); + return { success: false, error: errorMessage }; + } + if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { + core.info("No valid items found in agent output"); + core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); + return { success: false }; + } + return { success: true, items: validatedOutput.items }; + } + async function generateStagedPreview(options) { + const { title, description, items, renderItem } = options; + let summaryContent = `## 🎭 Staged Mode: ${title} Preview\n\n`; + summaryContent += `${description}\n\n`; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + summaryContent += renderItem(item, i); + summaryContent += "---\n\n"; + } + try { + await core.summary.addRaw(summaryContent).write(); + core.info(summaryContent); + core.info(`📝 ${title} preview written to step summary`); + } catch (error) { + core.setFailed(error instanceof Error ? error : String(error)); + } + } + function generateXMLMarker(workflowName, runUrl) { + const engineId = process.env.GH_AW_ENGINE_ID || ""; + const engineVersion = process.env.GH_AW_ENGINE_VERSION || ""; + const engineModel = process.env.GH_AW_ENGINE_MODEL || ""; + const trackerId = process.env.GH_AW_TRACKER_ID || ""; + const parts = []; + parts.push(`agentic-workflow: ${workflowName}`); + if (trackerId) { + parts.push(`tracker-id: ${trackerId}`); + } + if (engineId) { + parts.push(`engine: ${engineId}`); + } + if (engineVersion) { + parts.push(`version: ${engineVersion}`); + } + if (engineModel) { + parts.push(`model: ${engineModel}`); + } + parts.push(`run: ${runUrl}`); + return ``; + } + function generateFooter( + workflowName, + runUrl, + workflowSource, + workflowSourceURL, + triggeringIssueNumber, + triggeringPRNumber, + triggeringDiscussionNumber + ) { + let footer = `\n\n> AI generated by [${workflowName}](${runUrl})`; + if (triggeringIssueNumber) { + footer += ` for #${triggeringIssueNumber}`; + } else if (triggeringPRNumber) { + footer += ` for #${triggeringPRNumber}`; + } else if (triggeringDiscussionNumber) { + footer += ` for discussion #${triggeringDiscussionNumber}`; + } + if (workflowSource && workflowSourceURL) { + footer += `\n>\n> To add this workflow in your repository, run \`gh aw add ${workflowSource}\`. See [usage guide](https://githubnext.github.io/gh-aw/tools/cli/).`; + } + footer += "\n\n" + generateXMLMarker(workflowName, runUrl); + footer += "\n"; + return footer; + } + function getTrackerID(format) { + const trackerID = process.env.GH_AW_TRACKER_ID || ""; + if (trackerID) { + core.info(`Tracker ID: ${trackerID}`); + return format === "markdown" ? `\n\n` : trackerID; + } + return ""; + } + const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; + function generateTemporaryId() { + return "aw_" + crypto.randomBytes(6).toString("hex"); + } + function isTemporaryId(value) { + if (typeof value === "string") { + return /^aw_[0-9a-f]{12}$/i.test(value); + } + return false; + } + function normalizeTemporaryId(tempId) { + return String(tempId).toLowerCase(); + } + function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { + return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { + const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); + if (resolved !== undefined) { + if (currentRepo && resolved.repo === currentRepo) { + return `#${resolved.number}`; + } + return `${resolved.repo}#${resolved.number}`; + } + return match; + }); + } + function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { + return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { + const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); + if (issueNumber !== undefined) { + return `#${issueNumber}`; + } + return match; + }); + } + function loadTemporaryIdMap() { + const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; + if (!mapJson || mapJson === "{}") { + return new Map(); + } + try { + const mapObject = JSON.parse(mapJson); + const result = new Map(); + for (const [key, value] of Object.entries(mapObject)) { + const normalizedKey = normalizeTemporaryId(key); + if (typeof value === "number") { + const contextRepo = `${context.repo.owner}/${context.repo.repo}`; + result.set(normalizedKey, { repo: contextRepo, number: value }); + } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { + result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); + } + } + return result; + } catch (error) { + if (typeof core !== "undefined") { + core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); + } + return new Map(); + } + } + function resolveIssueNumber(value, temporaryIdMap) { + if (value === undefined || value === null) { + return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; + } + const valueStr = String(value); + if (isTemporaryId(valueStr)) { + const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); + if (resolvedPair !== undefined) { + return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; + } + return { + resolved: null, + wasTemporaryId: true, + errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, + }; + } + const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); + if (isNaN(issueNumber) || issueNumber <= 0) { + return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; + } + const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; + return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; + } + function serializeTemporaryIdMap(tempIdMap) { + const obj = Object.fromEntries(tempIdMap); + return JSON.stringify(obj); + } + function parseAllowedRepos() { + const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; + const set = new Set(); + if (allowedReposEnv) { + allowedReposEnv + .split(",") + .map(repo => repo.trim()) + .filter(repo => repo) + .forEach(repo => set.add(repo)); + } + return set; + } + function getDefaultTargetRepo() { + const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; + if (targetRepoSlug) { + return targetRepoSlug; + } + return `${context.repo.owner}/${context.repo.repo}`; + } + function validateRepo(repo, defaultRepo, allowedRepos) { + if (repo === defaultRepo) { + return { valid: true, error: null }; + } + if (allowedRepos.has(repo)) { + return { valid: true, error: null }; + } + return { + valid: false, + error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, + }; + } + function parseRepoSlug(repoSlug) { + const parts = repoSlug.split("/"); + if (parts.length !== 2 || !parts[0] || !parts[1]) { + return null; + } + return { owner: parts[0], repo: parts[1] }; + } + function addExpirationComment(bodyLines, envVarName, entityType) { + const expiresEnv = process.env[envVarName]; + if (expiresEnv) { + const expiresDays = parseInt(expiresEnv, 10); + if (!isNaN(expiresDays) && expiresDays > 0) { + const expirationDate = new Date(); + expirationDate.setDate(expirationDate.getDate() + expiresDays); + const expirationISO = expirationDate.toISOString(); + bodyLines.push(``); + core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); + } + } + } + function removeDuplicateTitleFromDescription(title, description) { + if (!title || typeof title !== "string") { + return description || ""; + } + if (!description || typeof description !== "string") { + return ""; + } + const trimmedTitle = title.trim(); + const trimmedDescription = description.trim(); + if (!trimmedTitle || !trimmedDescription) { + return trimmedDescription; + } + const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); + if (headerRegex.test(trimmedDescription)) { + return trimmedDescription.replace(headerRegex, "").trim(); + } + return trimmedDescription; + } async function main() { core.setOutput("issue_number", ""); core.setOutput("issue_url", ""); diff --git a/.github/workflows/stale-repo-identifier.lock.yml b/.github/workflows/stale-repo-identifier.lock.yml index 1668f01df60..51e3291f9e3 100644 --- a/.github/workflows/stale-repo-identifier.lock.yml +++ b/.github/workflows/stale-repo-identifier.lock.yml @@ -7888,21 +7888,291 @@ jobs: with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - const { sanitizeLabelContent } = require("./sanitize_label_content.cjs"); - const { loadAgentOutput } = require("./load_agent_output.cjs"); - const { generateStagedPreview } = require("./staged_preview.cjs"); - const { generateFooter } = require("./generate_footer.cjs"); - const { getTrackerID } = require("./get_tracker_id.cjs"); - const { - generateTemporaryId, - isTemporaryId, - normalizeTemporaryId, - replaceTemporaryIdReferences, - serializeTemporaryIdMap, - } = require("./temporary_id.cjs"); - const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require("./repo_helpers.cjs"); - const { addExpirationComment } = require("./expiration_helpers.cjs"); - const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); + function sanitizeLabelContent(content) { + if (!content || typeof content !== "string") { + return ""; + } + let sanitized = content.trim(); + sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); + sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); + sanitized = sanitized.replace( + /(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, + (_m, p1, p2) => `${p1}\`@${p2}\`` + ); + sanitized = sanitized.replace(/[<>&'"]/g, ""); + return sanitized.trim(); + } + const fs = require("fs"); + const crypto = require("crypto"); + const MAX_LOG_CONTENT_LENGTH = 10000; + function truncateForLogging(content) { + if (content.length <= MAX_LOG_CONTENT_LENGTH) { + return content; + } + return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; + } + function loadAgentOutput() { + const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; + if (!agentOutputFile) { + core.info("No GH_AW_AGENT_OUTPUT environment variable found"); + return { success: false }; + } + let outputContent; + try { + outputContent = fs.readFileSync(agentOutputFile, "utf8"); + } catch (error) { + const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; + core.error(errorMessage); + return { success: false, error: errorMessage }; + } + if (outputContent.trim() === "") { + core.info("Agent output content is empty"); + return { success: false }; + } + core.info(`Agent output content length: ${outputContent.length}`); + let validatedOutput; + try { + validatedOutput = JSON.parse(outputContent); + } catch (error) { + const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; + core.error(errorMessage); + core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); + return { success: false, error: errorMessage }; + } + if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { + core.info("No valid items found in agent output"); + core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); + return { success: false }; + } + return { success: true, items: validatedOutput.items }; + } + async function generateStagedPreview(options) { + const { title, description, items, renderItem } = options; + let summaryContent = `## 🎭 Staged Mode: ${title} Preview\n\n`; + summaryContent += `${description}\n\n`; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + summaryContent += renderItem(item, i); + summaryContent += "---\n\n"; + } + try { + await core.summary.addRaw(summaryContent).write(); + core.info(summaryContent); + core.info(`📝 ${title} preview written to step summary`); + } catch (error) { + core.setFailed(error instanceof Error ? error : String(error)); + } + } + function generateXMLMarker(workflowName, runUrl) { + const engineId = process.env.GH_AW_ENGINE_ID || ""; + const engineVersion = process.env.GH_AW_ENGINE_VERSION || ""; + const engineModel = process.env.GH_AW_ENGINE_MODEL || ""; + const trackerId = process.env.GH_AW_TRACKER_ID || ""; + const parts = []; + parts.push(`agentic-workflow: ${workflowName}`); + if (trackerId) { + parts.push(`tracker-id: ${trackerId}`); + } + if (engineId) { + parts.push(`engine: ${engineId}`); + } + if (engineVersion) { + parts.push(`version: ${engineVersion}`); + } + if (engineModel) { + parts.push(`model: ${engineModel}`); + } + parts.push(`run: ${runUrl}`); + return ``; + } + function generateFooter( + workflowName, + runUrl, + workflowSource, + workflowSourceURL, + triggeringIssueNumber, + triggeringPRNumber, + triggeringDiscussionNumber + ) { + let footer = `\n\n> AI generated by [${workflowName}](${runUrl})`; + if (triggeringIssueNumber) { + footer += ` for #${triggeringIssueNumber}`; + } else if (triggeringPRNumber) { + footer += ` for #${triggeringPRNumber}`; + } else if (triggeringDiscussionNumber) { + footer += ` for discussion #${triggeringDiscussionNumber}`; + } + if (workflowSource && workflowSourceURL) { + footer += `\n>\n> To add this workflow in your repository, run \`gh aw add ${workflowSource}\`. See [usage guide](https://githubnext.github.io/gh-aw/tools/cli/).`; + } + footer += "\n\n" + generateXMLMarker(workflowName, runUrl); + footer += "\n"; + return footer; + } + function getTrackerID(format) { + const trackerID = process.env.GH_AW_TRACKER_ID || ""; + if (trackerID) { + core.info(`Tracker ID: ${trackerID}`); + return format === "markdown" ? `\n\n` : trackerID; + } + return ""; + } + const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; + function generateTemporaryId() { + return "aw_" + crypto.randomBytes(6).toString("hex"); + } + function isTemporaryId(value) { + if (typeof value === "string") { + return /^aw_[0-9a-f]{12}$/i.test(value); + } + return false; + } + function normalizeTemporaryId(tempId) { + return String(tempId).toLowerCase(); + } + function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { + return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { + const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); + if (resolved !== undefined) { + if (currentRepo && resolved.repo === currentRepo) { + return `#${resolved.number}`; + } + return `${resolved.repo}#${resolved.number}`; + } + return match; + }); + } + function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { + return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { + const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); + if (issueNumber !== undefined) { + return `#${issueNumber}`; + } + return match; + }); + } + function loadTemporaryIdMap() { + const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; + if (!mapJson || mapJson === "{}") { + return new Map(); + } + try { + const mapObject = JSON.parse(mapJson); + const result = new Map(); + for (const [key, value] of Object.entries(mapObject)) { + const normalizedKey = normalizeTemporaryId(key); + if (typeof value === "number") { + const contextRepo = `${context.repo.owner}/${context.repo.repo}`; + result.set(normalizedKey, { repo: contextRepo, number: value }); + } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { + result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); + } + } + return result; + } catch (error) { + if (typeof core !== "undefined") { + core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); + } + return new Map(); + } + } + function resolveIssueNumber(value, temporaryIdMap) { + if (value === undefined || value === null) { + return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; + } + const valueStr = String(value); + if (isTemporaryId(valueStr)) { + const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); + if (resolvedPair !== undefined) { + return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; + } + return { + resolved: null, + wasTemporaryId: true, + errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, + }; + } + const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); + if (isNaN(issueNumber) || issueNumber <= 0) { + return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; + } + const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; + return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; + } + function serializeTemporaryIdMap(tempIdMap) { + const obj = Object.fromEntries(tempIdMap); + return JSON.stringify(obj); + } + function parseAllowedRepos() { + const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; + const set = new Set(); + if (allowedReposEnv) { + allowedReposEnv + .split(",") + .map(repo => repo.trim()) + .filter(repo => repo) + .forEach(repo => set.add(repo)); + } + return set; + } + function getDefaultTargetRepo() { + const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; + if (targetRepoSlug) { + return targetRepoSlug; + } + return `${context.repo.owner}/${context.repo.repo}`; + } + function validateRepo(repo, defaultRepo, allowedRepos) { + if (repo === defaultRepo) { + return { valid: true, error: null }; + } + if (allowedRepos.has(repo)) { + return { valid: true, error: null }; + } + return { + valid: false, + error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, + }; + } + function parseRepoSlug(repoSlug) { + const parts = repoSlug.split("/"); + if (parts.length !== 2 || !parts[0] || !parts[1]) { + return null; + } + return { owner: parts[0], repo: parts[1] }; + } + function addExpirationComment(bodyLines, envVarName, entityType) { + const expiresEnv = process.env[envVarName]; + if (expiresEnv) { + const expiresDays = parseInt(expiresEnv, 10); + if (!isNaN(expiresDays) && expiresDays > 0) { + const expirationDate = new Date(); + expirationDate.setDate(expirationDate.getDate() + expiresDays); + const expirationISO = expirationDate.toISOString(); + bodyLines.push(``); + core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); + } + } + } + function removeDuplicateTitleFromDescription(title, description) { + if (!title || typeof title !== "string") { + return description || ""; + } + if (!description || typeof description !== "string") { + return ""; + } + const trimmedTitle = title.trim(); + const trimmedDescription = description.trim(); + if (!trimmedTitle || !trimmedDescription) { + return trimmedDescription; + } + const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); + if (headerRegex.test(trimmedDescription)) { + return trimmedDescription.replace(headerRegex, "").trim(); + } + return trimmedDescription; + } async function main() { core.setOutput("issue_number", ""); core.setOutput("issue_url", ""); diff --git a/.github/workflows/static-analysis-report.lock.yml b/.github/workflows/static-analysis-report.lock.yml index be7bfa90fef..7050b27b3d5 100644 --- a/.github/workflows/static-analysis-report.lock.yml +++ b/.github/workflows/static-analysis-report.lock.yml @@ -6206,13 +6206,400 @@ jobs: with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - const { loadAgentOutput } = require("./load_agent_output.cjs"); - const { getTrackerID } = require("./get_tracker_id.cjs"); - const { closeOlderDiscussions } = require("./close_older_discussions.cjs"); - const { replaceTemporaryIdReferences, loadTemporaryIdMap } = require("./temporary_id.cjs"); - const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require("./repo_helpers.cjs"); - const { addExpirationComment } = require("./expiration_helpers.cjs"); - const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); + const fs = require("fs"); + const crypto = require("crypto"); + const MAX_LOG_CONTENT_LENGTH = 10000; + function truncateForLogging(content) { + if (content.length <= MAX_LOG_CONTENT_LENGTH) { + return content; + } + return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; + } + function loadAgentOutput() { + const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; + if (!agentOutputFile) { + core.info("No GH_AW_AGENT_OUTPUT environment variable found"); + return { success: false }; + } + let outputContent; + try { + outputContent = fs.readFileSync(agentOutputFile, "utf8"); + } catch (error) { + const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; + core.error(errorMessage); + return { success: false, error: errorMessage }; + } + if (outputContent.trim() === "") { + core.info("Agent output content is empty"); + return { success: false }; + } + core.info(`Agent output content length: ${outputContent.length}`); + let validatedOutput; + try { + validatedOutput = JSON.parse(outputContent); + } catch (error) { + const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; + core.error(errorMessage); + core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); + return { success: false, error: errorMessage }; + } + if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { + core.info("No valid items found in agent output"); + core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); + return { success: false }; + } + return { success: true, items: validatedOutput.items }; + } + function getTrackerID(format) { + const trackerID = process.env.GH_AW_TRACKER_ID || ""; + if (trackerID) { + core.info(`Tracker ID: ${trackerID}`); + return format === "markdown" ? `\n\n` : trackerID; + } + return ""; + } + function getMessages() { + const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES; + if (!messagesEnv) { + return null; + } + try { + return JSON.parse(messagesEnv); + } catch (error) { + core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`); + return null; + } + } + function renderTemplate(template, context) { + return template.replace(/\{(\w+)\}/g, (match, key) => { + const value = context[key]; + return value !== undefined && value !== null ? String(value) : match; + }); + } + function toSnakeCase(obj) { + const result = {}; + for (const [key, value] of Object.entries(obj)) { + const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase(); + result[snakeKey] = value; + result[key] = value; + } + return result; + } + function getCloseOlderDiscussionMessage(ctx) { + const messages = getMessages(); + const templateContext = toSnakeCase(ctx); + const defaultMessage = `⚓ Avast! This discussion be marked as **outdated** by [{workflow_name}]({run_url}). + 🗺️ A newer treasure map awaits ye at **[Discussion #{new_discussion_number}]({new_discussion_url})**. + Fair winds, matey! 🏴‍☠️`; + return messages?.closeOlderDiscussion + ? renderTemplate(messages.closeOlderDiscussion, templateContext) + : renderTemplate(defaultMessage, templateContext); + } + const MAX_CLOSE_COUNT = 10; + const GRAPHQL_DELAY_MS = 500; + function delay(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); + } + async function searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, excludeNumber) { + let searchQuery = `repo:${owner}/${repo} is:open`; + if (titlePrefix) { + const escapedPrefix = titlePrefix.replace(/"/g, '\\"'); + searchQuery += ` in:title "${escapedPrefix}"`; + } + if (labels && labels.length > 0) { + for (const label of labels) { + const escapedLabel = label.replace(/"/g, '\\"'); + searchQuery += ` label:"${escapedLabel}"`; + } + } + const result = await github.graphql( + ` + query($searchTerms: String!, $first: Int!) { + search(query: $searchTerms, type: DISCUSSION, first: $first) { + nodes { + ... on Discussion { + id + number + title + url + category { + id + } + labels(first: 100) { + nodes { + name + } + } + closed + } + } + } + }`, + { searchTerms: searchQuery, first: 50 } + ); + if (!result || !result.search || !result.search.nodes) { + return []; + } + return result.search.nodes + .filter( + d => { + if (!d || d.number === excludeNumber || d.closed) { + return false; + } + if (titlePrefix && d.title && !d.title.startsWith(titlePrefix)) { + return false; + } + if (labels && labels.length > 0) { + const discussionLabels = d.labels?.nodes?.map(( l) => l.name) || []; + const hasAllLabels = labels.every(label => discussionLabels.includes(label)); + if (!hasAllLabels) { + return false; + } + } + if (categoryId && (!d.category || d.category.id !== categoryId)) { + return false; + } + return true; + } + ) + .map( + d => ({ + id: d.id, + number: d.number, + title: d.title, + url: d.url, + }) + ); + } + async function addDiscussionComment(github, discussionId, message) { + const result = await github.graphql( + ` + mutation($dId: ID!, $body: String!) { + addDiscussionComment(input: { discussionId: $dId, body: $body }) { + comment { + id + url + } + } + }`, + { dId: discussionId, body: message } + ); + return result.addDiscussionComment.comment; + } + async function closeDiscussionAsOutdated(github, discussionId) { + const result = await github.graphql( + ` + mutation($dId: ID!) { + closeDiscussion(input: { discussionId: $dId, reason: OUTDATED }) { + discussion { + id + url + } + } + }`, + { dId: discussionId } + ); + return result.closeDiscussion.discussion; + } + async function closeOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion, workflowName, runUrl) { + const searchCriteria = []; + if (titlePrefix) searchCriteria.push(`title prefix: "${titlePrefix}"`); + if (labels && labels.length > 0) searchCriteria.push(`labels: [${labels.join(", ")}]`); + core.info(`Searching for older discussions with ${searchCriteria.join(" and ")}`); + const olderDiscussions = await searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion.number); + if (olderDiscussions.length === 0) { + core.info("No older discussions found to close"); + return []; + } + core.info(`Found ${olderDiscussions.length} older discussion(s) to close`); + const discussionsToClose = olderDiscussions.slice(0, MAX_CLOSE_COUNT); + if (olderDiscussions.length > MAX_CLOSE_COUNT) { + core.warning(`Found ${olderDiscussions.length} older discussions, but only closing the first ${MAX_CLOSE_COUNT}`); + } + const closedDiscussions = []; + for (let i = 0; i < discussionsToClose.length; i++) { + const discussion = discussionsToClose[i]; + try { + const closingMessage = getCloseOlderDiscussionMessage({ + newDiscussionUrl: newDiscussion.url, + newDiscussionNumber: newDiscussion.number, + workflowName, + runUrl, + }); + core.info(`Adding closing comment to discussion #${discussion.number}`); + await addDiscussionComment(github, discussion.id, closingMessage); + core.info(`Closing discussion #${discussion.number} as outdated`); + await closeDiscussionAsOutdated(github, discussion.id); + closedDiscussions.push({ + number: discussion.number, + url: discussion.url, + }); + core.info(`✓ Closed discussion #${discussion.number}: ${discussion.url}`); + } catch (error) { + core.error(`✗ Failed to close discussion #${discussion.number}: ${error instanceof Error ? error.message : String(error)}`); + } + if (i < discussionsToClose.length - 1) { + await delay(GRAPHQL_DELAY_MS); + } + } + return closedDiscussions; + } + const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; + function generateTemporaryId() { + return "aw_" + crypto.randomBytes(6).toString("hex"); + } + function isTemporaryId(value) { + if (typeof value === "string") { + return /^aw_[0-9a-f]{12}$/i.test(value); + } + return false; + } + function normalizeTemporaryId(tempId) { + return String(tempId).toLowerCase(); + } + function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { + return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { + const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); + if (resolved !== undefined) { + if (currentRepo && resolved.repo === currentRepo) { + return `#${resolved.number}`; + } + return `${resolved.repo}#${resolved.number}`; + } + return match; + }); + } + function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { + return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { + const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); + if (issueNumber !== undefined) { + return `#${issueNumber}`; + } + return match; + }); + } + function loadTemporaryIdMap() { + const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; + if (!mapJson || mapJson === "{}") { + return new Map(); + } + try { + const mapObject = JSON.parse(mapJson); + const result = new Map(); + for (const [key, value] of Object.entries(mapObject)) { + const normalizedKey = normalizeTemporaryId(key); + if (typeof value === "number") { + const contextRepo = `${context.repo.owner}/${context.repo.repo}`; + result.set(normalizedKey, { repo: contextRepo, number: value }); + } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { + result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); + } + } + return result; + } catch (error) { + if (typeof core !== "undefined") { + core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); + } + return new Map(); + } + } + function resolveIssueNumber(value, temporaryIdMap) { + if (value === undefined || value === null) { + return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; + } + const valueStr = String(value); + if (isTemporaryId(valueStr)) { + const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); + if (resolvedPair !== undefined) { + return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; + } + return { + resolved: null, + wasTemporaryId: true, + errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, + }; + } + const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); + if (isNaN(issueNumber) || issueNumber <= 0) { + return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; + } + const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; + return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; + } + function serializeTemporaryIdMap(tempIdMap) { + const obj = Object.fromEntries(tempIdMap); + return JSON.stringify(obj); + } + function parseAllowedRepos() { + const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; + const set = new Set(); + if (allowedReposEnv) { + allowedReposEnv + .split(",") + .map(repo => repo.trim()) + .filter(repo => repo) + .forEach(repo => set.add(repo)); + } + return set; + } + function getDefaultTargetRepo() { + const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; + if (targetRepoSlug) { + return targetRepoSlug; + } + return `${context.repo.owner}/${context.repo.repo}`; + } + function validateRepo(repo, defaultRepo, allowedRepos) { + if (repo === defaultRepo) { + return { valid: true, error: null }; + } + if (allowedRepos.has(repo)) { + return { valid: true, error: null }; + } + return { + valid: false, + error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, + }; + } + function parseRepoSlug(repoSlug) { + const parts = repoSlug.split("/"); + if (parts.length !== 2 || !parts[0] || !parts[1]) { + return null; + } + return { owner: parts[0], repo: parts[1] }; + } + function addExpirationComment(bodyLines, envVarName, entityType) { + const expiresEnv = process.env[envVarName]; + if (expiresEnv) { + const expiresDays = parseInt(expiresEnv, 10); + if (!isNaN(expiresDays) && expiresDays > 0) { + const expirationDate = new Date(); + expirationDate.setDate(expirationDate.getDate() + expiresDays); + const expirationISO = expirationDate.toISOString(); + bodyLines.push(``); + core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); + } + } + } + function removeDuplicateTitleFromDescription(title, description) { + if (!title || typeof title !== "string") { + return description || ""; + } + if (!description || typeof description !== "string") { + return ""; + } + const trimmedTitle = title.trim(); + const trimmedDescription = description.trim(); + if (!trimmedTitle || !trimmedDescription) { + return trimmedDescription; + } + const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); + if (headerRegex.test(trimmedDescription)) { + return trimmedDescription.replace(headerRegex, "").trim(); + } + return trimmedDescription; + } async function fetchRepoDiscussionInfo(owner, repo) { const repositoryQuery = ` query($owner: String!, $repo: String!) { diff --git a/.github/workflows/super-linter.lock.yml b/.github/workflows/super-linter.lock.yml index 70a43eb51cf..c1eca98e6df 100644 --- a/.github/workflows/super-linter.lock.yml +++ b/.github/workflows/super-linter.lock.yml @@ -6401,21 +6401,291 @@ jobs: with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - const { sanitizeLabelContent } = require("./sanitize_label_content.cjs"); - const { loadAgentOutput } = require("./load_agent_output.cjs"); - const { generateStagedPreview } = require("./staged_preview.cjs"); - const { generateFooter } = require("./generate_footer.cjs"); - const { getTrackerID } = require("./get_tracker_id.cjs"); - const { - generateTemporaryId, - isTemporaryId, - normalizeTemporaryId, - replaceTemporaryIdReferences, - serializeTemporaryIdMap, - } = require("./temporary_id.cjs"); - const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require("./repo_helpers.cjs"); - const { addExpirationComment } = require("./expiration_helpers.cjs"); - const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); + function sanitizeLabelContent(content) { + if (!content || typeof content !== "string") { + return ""; + } + let sanitized = content.trim(); + sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); + sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); + sanitized = sanitized.replace( + /(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, + (_m, p1, p2) => `${p1}\`@${p2}\`` + ); + sanitized = sanitized.replace(/[<>&'"]/g, ""); + return sanitized.trim(); + } + const fs = require("fs"); + const crypto = require("crypto"); + const MAX_LOG_CONTENT_LENGTH = 10000; + function truncateForLogging(content) { + if (content.length <= MAX_LOG_CONTENT_LENGTH) { + return content; + } + return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; + } + function loadAgentOutput() { + const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; + if (!agentOutputFile) { + core.info("No GH_AW_AGENT_OUTPUT environment variable found"); + return { success: false }; + } + let outputContent; + try { + outputContent = fs.readFileSync(agentOutputFile, "utf8"); + } catch (error) { + const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; + core.error(errorMessage); + return { success: false, error: errorMessage }; + } + if (outputContent.trim() === "") { + core.info("Agent output content is empty"); + return { success: false }; + } + core.info(`Agent output content length: ${outputContent.length}`); + let validatedOutput; + try { + validatedOutput = JSON.parse(outputContent); + } catch (error) { + const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; + core.error(errorMessage); + core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); + return { success: false, error: errorMessage }; + } + if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { + core.info("No valid items found in agent output"); + core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); + return { success: false }; + } + return { success: true, items: validatedOutput.items }; + } + async function generateStagedPreview(options) { + const { title, description, items, renderItem } = options; + let summaryContent = `## 🎭 Staged Mode: ${title} Preview\n\n`; + summaryContent += `${description}\n\n`; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + summaryContent += renderItem(item, i); + summaryContent += "---\n\n"; + } + try { + await core.summary.addRaw(summaryContent).write(); + core.info(summaryContent); + core.info(`📝 ${title} preview written to step summary`); + } catch (error) { + core.setFailed(error instanceof Error ? error : String(error)); + } + } + function generateXMLMarker(workflowName, runUrl) { + const engineId = process.env.GH_AW_ENGINE_ID || ""; + const engineVersion = process.env.GH_AW_ENGINE_VERSION || ""; + const engineModel = process.env.GH_AW_ENGINE_MODEL || ""; + const trackerId = process.env.GH_AW_TRACKER_ID || ""; + const parts = []; + parts.push(`agentic-workflow: ${workflowName}`); + if (trackerId) { + parts.push(`tracker-id: ${trackerId}`); + } + if (engineId) { + parts.push(`engine: ${engineId}`); + } + if (engineVersion) { + parts.push(`version: ${engineVersion}`); + } + if (engineModel) { + parts.push(`model: ${engineModel}`); + } + parts.push(`run: ${runUrl}`); + return ``; + } + function generateFooter( + workflowName, + runUrl, + workflowSource, + workflowSourceURL, + triggeringIssueNumber, + triggeringPRNumber, + triggeringDiscussionNumber + ) { + let footer = `\n\n> AI generated by [${workflowName}](${runUrl})`; + if (triggeringIssueNumber) { + footer += ` for #${triggeringIssueNumber}`; + } else if (triggeringPRNumber) { + footer += ` for #${triggeringPRNumber}`; + } else if (triggeringDiscussionNumber) { + footer += ` for discussion #${triggeringDiscussionNumber}`; + } + if (workflowSource && workflowSourceURL) { + footer += `\n>\n> To add this workflow in your repository, run \`gh aw add ${workflowSource}\`. See [usage guide](https://githubnext.github.io/gh-aw/tools/cli/).`; + } + footer += "\n\n" + generateXMLMarker(workflowName, runUrl); + footer += "\n"; + return footer; + } + function getTrackerID(format) { + const trackerID = process.env.GH_AW_TRACKER_ID || ""; + if (trackerID) { + core.info(`Tracker ID: ${trackerID}`); + return format === "markdown" ? `\n\n` : trackerID; + } + return ""; + } + const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; + function generateTemporaryId() { + return "aw_" + crypto.randomBytes(6).toString("hex"); + } + function isTemporaryId(value) { + if (typeof value === "string") { + return /^aw_[0-9a-f]{12}$/i.test(value); + } + return false; + } + function normalizeTemporaryId(tempId) { + return String(tempId).toLowerCase(); + } + function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { + return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { + const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); + if (resolved !== undefined) { + if (currentRepo && resolved.repo === currentRepo) { + return `#${resolved.number}`; + } + return `${resolved.repo}#${resolved.number}`; + } + return match; + }); + } + function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { + return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { + const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); + if (issueNumber !== undefined) { + return `#${issueNumber}`; + } + return match; + }); + } + function loadTemporaryIdMap() { + const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; + if (!mapJson || mapJson === "{}") { + return new Map(); + } + try { + const mapObject = JSON.parse(mapJson); + const result = new Map(); + for (const [key, value] of Object.entries(mapObject)) { + const normalizedKey = normalizeTemporaryId(key); + if (typeof value === "number") { + const contextRepo = `${context.repo.owner}/${context.repo.repo}`; + result.set(normalizedKey, { repo: contextRepo, number: value }); + } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { + result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); + } + } + return result; + } catch (error) { + if (typeof core !== "undefined") { + core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); + } + return new Map(); + } + } + function resolveIssueNumber(value, temporaryIdMap) { + if (value === undefined || value === null) { + return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; + } + const valueStr = String(value); + if (isTemporaryId(valueStr)) { + const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); + if (resolvedPair !== undefined) { + return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; + } + return { + resolved: null, + wasTemporaryId: true, + errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, + }; + } + const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); + if (isNaN(issueNumber) || issueNumber <= 0) { + return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; + } + const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; + return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; + } + function serializeTemporaryIdMap(tempIdMap) { + const obj = Object.fromEntries(tempIdMap); + return JSON.stringify(obj); + } + function parseAllowedRepos() { + const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; + const set = new Set(); + if (allowedReposEnv) { + allowedReposEnv + .split(",") + .map(repo => repo.trim()) + .filter(repo => repo) + .forEach(repo => set.add(repo)); + } + return set; + } + function getDefaultTargetRepo() { + const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; + if (targetRepoSlug) { + return targetRepoSlug; + } + return `${context.repo.owner}/${context.repo.repo}`; + } + function validateRepo(repo, defaultRepo, allowedRepos) { + if (repo === defaultRepo) { + return { valid: true, error: null }; + } + if (allowedRepos.has(repo)) { + return { valid: true, error: null }; + } + return { + valid: false, + error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, + }; + } + function parseRepoSlug(repoSlug) { + const parts = repoSlug.split("/"); + if (parts.length !== 2 || !parts[0] || !parts[1]) { + return null; + } + return { owner: parts[0], repo: parts[1] }; + } + function addExpirationComment(bodyLines, envVarName, entityType) { + const expiresEnv = process.env[envVarName]; + if (expiresEnv) { + const expiresDays = parseInt(expiresEnv, 10); + if (!isNaN(expiresDays) && expiresDays > 0) { + const expirationDate = new Date(); + expirationDate.setDate(expirationDate.getDate() + expiresDays); + const expirationISO = expirationDate.toISOString(); + bodyLines.push(``); + core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); + } + } + } + function removeDuplicateTitleFromDescription(title, description) { + if (!title || typeof title !== "string") { + return description || ""; + } + if (!description || typeof description !== "string") { + return ""; + } + const trimmedTitle = title.trim(); + const trimmedDescription = description.trim(); + if (!trimmedTitle || !trimmedDescription) { + return trimmedDescription; + } + const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); + if (headerRegex.test(trimmedDescription)) { + return trimmedDescription.replace(headerRegex, "").trim(); + } + return trimmedDescription; + } async function main() { core.setOutput("issue_number", ""); core.setOutput("issue_url", ""); diff --git a/.github/workflows/technical-doc-writer.lock.yml b/.github/workflows/technical-doc-writer.lock.yml index d0c1ae2522a..e6754cc55a6 100644 --- a/.github/workflows/technical-doc-writer.lock.yml +++ b/.github/workflows/technical-doc-writer.lock.yml @@ -7512,10 +7512,151 @@ jobs: script: | const fs = require("fs"); const crypto = require("crypto"); - const { updateActivationComment } = require("./update_activation_comment.cjs"); - const { getTrackerID } = require("./get_tracker_id.cjs"); - const { addExpirationComment } = require("./expiration_helpers.cjs"); - const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); + async function updateActivationComment(github, context, core, itemUrl, itemNumber, itemType = "pull_request") { + const itemLabel = itemType === "issue" ? "issue" : "pull request"; + const linkMessage = + itemType === "issue" + ? `\n\n✅ Issue created: [#${itemNumber}](${itemUrl})` + : `\n\n✅ Pull request created: [#${itemNumber}](${itemUrl})`; + await updateActivationCommentWithMessage(github, context, core, linkMessage, itemLabel); + } + async function updateActivationCommentWithCommit(github, context, core, commitSha, commitUrl) { + const shortSha = commitSha.substring(0, 7); + const message = `\n\n✅ Commit pushed: [\`${shortSha}\`](${commitUrl})`; + await updateActivationCommentWithMessage(github, context, core, message, "commit"); + } + async function updateActivationCommentWithMessage(github, context, core, message, label = "") { + const commentId = process.env.GH_AW_COMMENT_ID; + const commentRepo = process.env.GH_AW_COMMENT_REPO; + if (!commentId) { + core.info("No activation comment to update (GH_AW_COMMENT_ID not set)"); + return; + } + core.info(`Updating activation comment ${commentId}`); + let repoOwner = context.repo.owner; + let repoName = context.repo.repo; + if (commentRepo) { + const parts = commentRepo.split("/"); + if (parts.length === 2) { + repoOwner = parts[0]; + repoName = parts[1]; + } else { + core.warning(`Invalid comment repo format: ${commentRepo}, expected "owner/repo". Falling back to context.repo.`); + } + } + core.info(`Updating comment in ${repoOwner}/${repoName}`); + const isDiscussionComment = commentId.startsWith("DC_"); + try { + if (isDiscussionComment) { + const currentComment = await github.graphql( + ` + query($commentId: ID!) { + node(id: $commentId) { + ... on DiscussionComment { + body + } + } + }`, + { commentId: commentId } + ); + if (!currentComment?.node?.body) { + core.warning("Unable to fetch current comment body, comment may have been deleted or is inaccessible"); + return; + } + const currentBody = currentComment.node.body; + const updatedBody = currentBody + message; + const result = await github.graphql( + ` + mutation($commentId: ID!, $body: String!) { + updateDiscussionComment(input: { commentId: $commentId, body: $body }) { + comment { + id + url + } + } + }`, + { commentId: commentId, body: updatedBody } + ); + const comment = result.updateDiscussionComment.comment; + const successMessage = label + ? `Successfully updated discussion comment with ${label} link` + : "Successfully updated discussion comment"; + core.info(successMessage); + core.info(`Comment ID: ${comment.id}`); + core.info(`Comment URL: ${comment.url}`); + } else { + const currentComment = await github.request("GET /repos/{owner}/{repo}/issues/comments/{comment_id}", { + owner: repoOwner, + repo: repoName, + comment_id: parseInt(commentId, 10), + headers: { + Accept: "application/vnd.github+json", + }, + }); + if (!currentComment?.data?.body) { + core.warning("Unable to fetch current comment body, comment may have been deleted"); + return; + } + const currentBody = currentComment.data.body; + const updatedBody = currentBody + message; + const response = await github.request("PATCH /repos/{owner}/{repo}/issues/comments/{comment_id}", { + owner: repoOwner, + repo: repoName, + comment_id: parseInt(commentId, 10), + body: updatedBody, + headers: { + Accept: "application/vnd.github+json", + }, + }); + const successMessage = label ? `Successfully updated comment with ${label} link` : "Successfully updated comment"; + core.info(successMessage); + core.info(`Comment ID: ${response.data.id}`); + core.info(`Comment URL: ${response.data.html_url}`); + } + } catch (error) { + core.warning(`Failed to update activation comment: ${error instanceof Error ? error.message : String(error)}`); + } + } + function getTrackerID(format) { + const trackerID = process.env.GH_AW_TRACKER_ID || ""; + if (trackerID) { + core.info(`Tracker ID: ${trackerID}`); + return format === "markdown" ? `\n\n` : trackerID; + } + return ""; + } + function addExpirationComment(bodyLines, envVarName, entityType) { + const expiresEnv = process.env[envVarName]; + if (expiresEnv) { + const expiresDays = parseInt(expiresEnv, 10); + if (!isNaN(expiresDays) && expiresDays > 0) { + const expirationDate = new Date(); + expirationDate.setDate(expirationDate.getDate() + expiresDays); + const expirationISO = expirationDate.toISOString(); + bodyLines.push(``); + core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); + } + } + } + function removeDuplicateTitleFromDescription(title, description) { + if (!title || typeof title !== "string") { + return description || ""; + } + if (!description || typeof description !== "string") { + return ""; + } + const trimmedTitle = title.trim(); + const trimmedDescription = description.trim(); + if (!trimmedTitle || !trimmedDescription) { + return trimmedDescription; + } + const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); + if (headerRegex.test(trimmedDescription)) { + return trimmedDescription.replace(headerRegex, "").trim(); + } + return trimmedDescription; + } function generatePatchPreview(patchContent) { if (!patchContent || !patchContent.trim()) { return ""; diff --git a/.github/workflows/test-discussion-expires.lock.yml b/.github/workflows/test-discussion-expires.lock.yml index b13b6a5b56f..b230abe655a 100644 --- a/.github/workflows/test-discussion-expires.lock.yml +++ b/.github/workflows/test-discussion-expires.lock.yml @@ -5778,13 +5778,400 @@ jobs: with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - const { loadAgentOutput } = require("./load_agent_output.cjs"); - const { getTrackerID } = require("./get_tracker_id.cjs"); - const { closeOlderDiscussions } = require("./close_older_discussions.cjs"); - const { replaceTemporaryIdReferences, loadTemporaryIdMap } = require("./temporary_id.cjs"); - const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require("./repo_helpers.cjs"); - const { addExpirationComment } = require("./expiration_helpers.cjs"); - const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); + const fs = require("fs"); + const crypto = require("crypto"); + const MAX_LOG_CONTENT_LENGTH = 10000; + function truncateForLogging(content) { + if (content.length <= MAX_LOG_CONTENT_LENGTH) { + return content; + } + return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; + } + function loadAgentOutput() { + const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; + if (!agentOutputFile) { + core.info("No GH_AW_AGENT_OUTPUT environment variable found"); + return { success: false }; + } + let outputContent; + try { + outputContent = fs.readFileSync(agentOutputFile, "utf8"); + } catch (error) { + const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; + core.error(errorMessage); + return { success: false, error: errorMessage }; + } + if (outputContent.trim() === "") { + core.info("Agent output content is empty"); + return { success: false }; + } + core.info(`Agent output content length: ${outputContent.length}`); + let validatedOutput; + try { + validatedOutput = JSON.parse(outputContent); + } catch (error) { + const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; + core.error(errorMessage); + core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); + return { success: false, error: errorMessage }; + } + if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { + core.info("No valid items found in agent output"); + core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); + return { success: false }; + } + return { success: true, items: validatedOutput.items }; + } + function getTrackerID(format) { + const trackerID = process.env.GH_AW_TRACKER_ID || ""; + if (trackerID) { + core.info(`Tracker ID: ${trackerID}`); + return format === "markdown" ? `\n\n` : trackerID; + } + return ""; + } + function getMessages() { + const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES; + if (!messagesEnv) { + return null; + } + try { + return JSON.parse(messagesEnv); + } catch (error) { + core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`); + return null; + } + } + function renderTemplate(template, context) { + return template.replace(/\{(\w+)\}/g, (match, key) => { + const value = context[key]; + return value !== undefined && value !== null ? String(value) : match; + }); + } + function toSnakeCase(obj) { + const result = {}; + for (const [key, value] of Object.entries(obj)) { + const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase(); + result[snakeKey] = value; + result[key] = value; + } + return result; + } + function getCloseOlderDiscussionMessage(ctx) { + const messages = getMessages(); + const templateContext = toSnakeCase(ctx); + const defaultMessage = `⚓ Avast! This discussion be marked as **outdated** by [{workflow_name}]({run_url}). + 🗺️ A newer treasure map awaits ye at **[Discussion #{new_discussion_number}]({new_discussion_url})**. + Fair winds, matey! 🏴‍☠️`; + return messages?.closeOlderDiscussion + ? renderTemplate(messages.closeOlderDiscussion, templateContext) + : renderTemplate(defaultMessage, templateContext); + } + const MAX_CLOSE_COUNT = 10; + const GRAPHQL_DELAY_MS = 500; + function delay(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); + } + async function searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, excludeNumber) { + let searchQuery = `repo:${owner}/${repo} is:open`; + if (titlePrefix) { + const escapedPrefix = titlePrefix.replace(/"/g, '\\"'); + searchQuery += ` in:title "${escapedPrefix}"`; + } + if (labels && labels.length > 0) { + for (const label of labels) { + const escapedLabel = label.replace(/"/g, '\\"'); + searchQuery += ` label:"${escapedLabel}"`; + } + } + const result = await github.graphql( + ` + query($searchTerms: String!, $first: Int!) { + search(query: $searchTerms, type: DISCUSSION, first: $first) { + nodes { + ... on Discussion { + id + number + title + url + category { + id + } + labels(first: 100) { + nodes { + name + } + } + closed + } + } + } + }`, + { searchTerms: searchQuery, first: 50 } + ); + if (!result || !result.search || !result.search.nodes) { + return []; + } + return result.search.nodes + .filter( + d => { + if (!d || d.number === excludeNumber || d.closed) { + return false; + } + if (titlePrefix && d.title && !d.title.startsWith(titlePrefix)) { + return false; + } + if (labels && labels.length > 0) { + const discussionLabels = d.labels?.nodes?.map(( l) => l.name) || []; + const hasAllLabels = labels.every(label => discussionLabels.includes(label)); + if (!hasAllLabels) { + return false; + } + } + if (categoryId && (!d.category || d.category.id !== categoryId)) { + return false; + } + return true; + } + ) + .map( + d => ({ + id: d.id, + number: d.number, + title: d.title, + url: d.url, + }) + ); + } + async function addDiscussionComment(github, discussionId, message) { + const result = await github.graphql( + ` + mutation($dId: ID!, $body: String!) { + addDiscussionComment(input: { discussionId: $dId, body: $body }) { + comment { + id + url + } + } + }`, + { dId: discussionId, body: message } + ); + return result.addDiscussionComment.comment; + } + async function closeDiscussionAsOutdated(github, discussionId) { + const result = await github.graphql( + ` + mutation($dId: ID!) { + closeDiscussion(input: { discussionId: $dId, reason: OUTDATED }) { + discussion { + id + url + } + } + }`, + { dId: discussionId } + ); + return result.closeDiscussion.discussion; + } + async function closeOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion, workflowName, runUrl) { + const searchCriteria = []; + if (titlePrefix) searchCriteria.push(`title prefix: "${titlePrefix}"`); + if (labels && labels.length > 0) searchCriteria.push(`labels: [${labels.join(", ")}]`); + core.info(`Searching for older discussions with ${searchCriteria.join(" and ")}`); + const olderDiscussions = await searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion.number); + if (olderDiscussions.length === 0) { + core.info("No older discussions found to close"); + return []; + } + core.info(`Found ${olderDiscussions.length} older discussion(s) to close`); + const discussionsToClose = olderDiscussions.slice(0, MAX_CLOSE_COUNT); + if (olderDiscussions.length > MAX_CLOSE_COUNT) { + core.warning(`Found ${olderDiscussions.length} older discussions, but only closing the first ${MAX_CLOSE_COUNT}`); + } + const closedDiscussions = []; + for (let i = 0; i < discussionsToClose.length; i++) { + const discussion = discussionsToClose[i]; + try { + const closingMessage = getCloseOlderDiscussionMessage({ + newDiscussionUrl: newDiscussion.url, + newDiscussionNumber: newDiscussion.number, + workflowName, + runUrl, + }); + core.info(`Adding closing comment to discussion #${discussion.number}`); + await addDiscussionComment(github, discussion.id, closingMessage); + core.info(`Closing discussion #${discussion.number} as outdated`); + await closeDiscussionAsOutdated(github, discussion.id); + closedDiscussions.push({ + number: discussion.number, + url: discussion.url, + }); + core.info(`✓ Closed discussion #${discussion.number}: ${discussion.url}`); + } catch (error) { + core.error(`✗ Failed to close discussion #${discussion.number}: ${error instanceof Error ? error.message : String(error)}`); + } + if (i < discussionsToClose.length - 1) { + await delay(GRAPHQL_DELAY_MS); + } + } + return closedDiscussions; + } + const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; + function generateTemporaryId() { + return "aw_" + crypto.randomBytes(6).toString("hex"); + } + function isTemporaryId(value) { + if (typeof value === "string") { + return /^aw_[0-9a-f]{12}$/i.test(value); + } + return false; + } + function normalizeTemporaryId(tempId) { + return String(tempId).toLowerCase(); + } + function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { + return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { + const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); + if (resolved !== undefined) { + if (currentRepo && resolved.repo === currentRepo) { + return `#${resolved.number}`; + } + return `${resolved.repo}#${resolved.number}`; + } + return match; + }); + } + function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { + return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { + const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); + if (issueNumber !== undefined) { + return `#${issueNumber}`; + } + return match; + }); + } + function loadTemporaryIdMap() { + const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; + if (!mapJson || mapJson === "{}") { + return new Map(); + } + try { + const mapObject = JSON.parse(mapJson); + const result = new Map(); + for (const [key, value] of Object.entries(mapObject)) { + const normalizedKey = normalizeTemporaryId(key); + if (typeof value === "number") { + const contextRepo = `${context.repo.owner}/${context.repo.repo}`; + result.set(normalizedKey, { repo: contextRepo, number: value }); + } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { + result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); + } + } + return result; + } catch (error) { + if (typeof core !== "undefined") { + core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); + } + return new Map(); + } + } + function resolveIssueNumber(value, temporaryIdMap) { + if (value === undefined || value === null) { + return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; + } + const valueStr = String(value); + if (isTemporaryId(valueStr)) { + const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); + if (resolvedPair !== undefined) { + return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; + } + return { + resolved: null, + wasTemporaryId: true, + errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, + }; + } + const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); + if (isNaN(issueNumber) || issueNumber <= 0) { + return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; + } + const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; + return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; + } + function serializeTemporaryIdMap(tempIdMap) { + const obj = Object.fromEntries(tempIdMap); + return JSON.stringify(obj); + } + function parseAllowedRepos() { + const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; + const set = new Set(); + if (allowedReposEnv) { + allowedReposEnv + .split(",") + .map(repo => repo.trim()) + .filter(repo => repo) + .forEach(repo => set.add(repo)); + } + return set; + } + function getDefaultTargetRepo() { + const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; + if (targetRepoSlug) { + return targetRepoSlug; + } + return `${context.repo.owner}/${context.repo.repo}`; + } + function validateRepo(repo, defaultRepo, allowedRepos) { + if (repo === defaultRepo) { + return { valid: true, error: null }; + } + if (allowedRepos.has(repo)) { + return { valid: true, error: null }; + } + return { + valid: false, + error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, + }; + } + function parseRepoSlug(repoSlug) { + const parts = repoSlug.split("/"); + if (parts.length !== 2 || !parts[0] || !parts[1]) { + return null; + } + return { owner: parts[0], repo: parts[1] }; + } + function addExpirationComment(bodyLines, envVarName, entityType) { + const expiresEnv = process.env[envVarName]; + if (expiresEnv) { + const expiresDays = parseInt(expiresEnv, 10); + if (!isNaN(expiresDays) && expiresDays > 0) { + const expirationDate = new Date(); + expirationDate.setDate(expirationDate.getDate() + expiresDays); + const expirationISO = expirationDate.toISOString(); + bodyLines.push(``); + core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); + } + } + } + function removeDuplicateTitleFromDescription(title, description) { + if (!title || typeof title !== "string") { + return description || ""; + } + if (!description || typeof description !== "string") { + return ""; + } + const trimmedTitle = title.trim(); + const trimmedDescription = description.trim(); + if (!trimmedTitle || !trimmedDescription) { + return trimmedDescription; + } + const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); + if (headerRegex.test(trimmedDescription)) { + return trimmedDescription.replace(headerRegex, "").trim(); + } + return trimmedDescription; + } async function fetchRepoDiscussionInfo(owner, repo) { const repositoryQuery = ` query($owner: String!, $repo: String!) { diff --git a/.github/workflows/test-python-safe-input.lock.yml b/.github/workflows/test-python-safe-input.lock.yml index 5a0c386847a..dfc1751e8e8 100644 --- a/.github/workflows/test-python-safe-input.lock.yml +++ b/.github/workflows/test-python-safe-input.lock.yml @@ -7396,21 +7396,291 @@ jobs: with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - const { sanitizeLabelContent } = require("./sanitize_label_content.cjs"); - const { loadAgentOutput } = require("./load_agent_output.cjs"); - const { generateStagedPreview } = require("./staged_preview.cjs"); - const { generateFooter } = require("./generate_footer.cjs"); - const { getTrackerID } = require("./get_tracker_id.cjs"); - const { - generateTemporaryId, - isTemporaryId, - normalizeTemporaryId, - replaceTemporaryIdReferences, - serializeTemporaryIdMap, - } = require("./temporary_id.cjs"); - const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require("./repo_helpers.cjs"); - const { addExpirationComment } = require("./expiration_helpers.cjs"); - const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); + function sanitizeLabelContent(content) { + if (!content || typeof content !== "string") { + return ""; + } + let sanitized = content.trim(); + sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); + sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); + sanitized = sanitized.replace( + /(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, + (_m, p1, p2) => `${p1}\`@${p2}\`` + ); + sanitized = sanitized.replace(/[<>&'"]/g, ""); + return sanitized.trim(); + } + const fs = require("fs"); + const crypto = require("crypto"); + const MAX_LOG_CONTENT_LENGTH = 10000; + function truncateForLogging(content) { + if (content.length <= MAX_LOG_CONTENT_LENGTH) { + return content; + } + return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; + } + function loadAgentOutput() { + const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; + if (!agentOutputFile) { + core.info("No GH_AW_AGENT_OUTPUT environment variable found"); + return { success: false }; + } + let outputContent; + try { + outputContent = fs.readFileSync(agentOutputFile, "utf8"); + } catch (error) { + const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; + core.error(errorMessage); + return { success: false, error: errorMessage }; + } + if (outputContent.trim() === "") { + core.info("Agent output content is empty"); + return { success: false }; + } + core.info(`Agent output content length: ${outputContent.length}`); + let validatedOutput; + try { + validatedOutput = JSON.parse(outputContent); + } catch (error) { + const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; + core.error(errorMessage); + core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); + return { success: false, error: errorMessage }; + } + if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { + core.info("No valid items found in agent output"); + core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); + return { success: false }; + } + return { success: true, items: validatedOutput.items }; + } + async function generateStagedPreview(options) { + const { title, description, items, renderItem } = options; + let summaryContent = `## 🎭 Staged Mode: ${title} Preview\n\n`; + summaryContent += `${description}\n\n`; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + summaryContent += renderItem(item, i); + summaryContent += "---\n\n"; + } + try { + await core.summary.addRaw(summaryContent).write(); + core.info(summaryContent); + core.info(`📝 ${title} preview written to step summary`); + } catch (error) { + core.setFailed(error instanceof Error ? error : String(error)); + } + } + function generateXMLMarker(workflowName, runUrl) { + const engineId = process.env.GH_AW_ENGINE_ID || ""; + const engineVersion = process.env.GH_AW_ENGINE_VERSION || ""; + const engineModel = process.env.GH_AW_ENGINE_MODEL || ""; + const trackerId = process.env.GH_AW_TRACKER_ID || ""; + const parts = []; + parts.push(`agentic-workflow: ${workflowName}`); + if (trackerId) { + parts.push(`tracker-id: ${trackerId}`); + } + if (engineId) { + parts.push(`engine: ${engineId}`); + } + if (engineVersion) { + parts.push(`version: ${engineVersion}`); + } + if (engineModel) { + parts.push(`model: ${engineModel}`); + } + parts.push(`run: ${runUrl}`); + return ``; + } + function generateFooter( + workflowName, + runUrl, + workflowSource, + workflowSourceURL, + triggeringIssueNumber, + triggeringPRNumber, + triggeringDiscussionNumber + ) { + let footer = `\n\n> AI generated by [${workflowName}](${runUrl})`; + if (triggeringIssueNumber) { + footer += ` for #${triggeringIssueNumber}`; + } else if (triggeringPRNumber) { + footer += ` for #${triggeringPRNumber}`; + } else if (triggeringDiscussionNumber) { + footer += ` for discussion #${triggeringDiscussionNumber}`; + } + if (workflowSource && workflowSourceURL) { + footer += `\n>\n> To add this workflow in your repository, run \`gh aw add ${workflowSource}\`. See [usage guide](https://githubnext.github.io/gh-aw/tools/cli/).`; + } + footer += "\n\n" + generateXMLMarker(workflowName, runUrl); + footer += "\n"; + return footer; + } + function getTrackerID(format) { + const trackerID = process.env.GH_AW_TRACKER_ID || ""; + if (trackerID) { + core.info(`Tracker ID: ${trackerID}`); + return format === "markdown" ? `\n\n` : trackerID; + } + return ""; + } + const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; + function generateTemporaryId() { + return "aw_" + crypto.randomBytes(6).toString("hex"); + } + function isTemporaryId(value) { + if (typeof value === "string") { + return /^aw_[0-9a-f]{12}$/i.test(value); + } + return false; + } + function normalizeTemporaryId(tempId) { + return String(tempId).toLowerCase(); + } + function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { + return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { + const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); + if (resolved !== undefined) { + if (currentRepo && resolved.repo === currentRepo) { + return `#${resolved.number}`; + } + return `${resolved.repo}#${resolved.number}`; + } + return match; + }); + } + function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { + return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { + const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); + if (issueNumber !== undefined) { + return `#${issueNumber}`; + } + return match; + }); + } + function loadTemporaryIdMap() { + const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; + if (!mapJson || mapJson === "{}") { + return new Map(); + } + try { + const mapObject = JSON.parse(mapJson); + const result = new Map(); + for (const [key, value] of Object.entries(mapObject)) { + const normalizedKey = normalizeTemporaryId(key); + if (typeof value === "number") { + const contextRepo = `${context.repo.owner}/${context.repo.repo}`; + result.set(normalizedKey, { repo: contextRepo, number: value }); + } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { + result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); + } + } + return result; + } catch (error) { + if (typeof core !== "undefined") { + core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); + } + return new Map(); + } + } + function resolveIssueNumber(value, temporaryIdMap) { + if (value === undefined || value === null) { + return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; + } + const valueStr = String(value); + if (isTemporaryId(valueStr)) { + const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); + if (resolvedPair !== undefined) { + return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; + } + return { + resolved: null, + wasTemporaryId: true, + errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, + }; + } + const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); + if (isNaN(issueNumber) || issueNumber <= 0) { + return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; + } + const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; + return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; + } + function serializeTemporaryIdMap(tempIdMap) { + const obj = Object.fromEntries(tempIdMap); + return JSON.stringify(obj); + } + function parseAllowedRepos() { + const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; + const set = new Set(); + if (allowedReposEnv) { + allowedReposEnv + .split(",") + .map(repo => repo.trim()) + .filter(repo => repo) + .forEach(repo => set.add(repo)); + } + return set; + } + function getDefaultTargetRepo() { + const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; + if (targetRepoSlug) { + return targetRepoSlug; + } + return `${context.repo.owner}/${context.repo.repo}`; + } + function validateRepo(repo, defaultRepo, allowedRepos) { + if (repo === defaultRepo) { + return { valid: true, error: null }; + } + if (allowedRepos.has(repo)) { + return { valid: true, error: null }; + } + return { + valid: false, + error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, + }; + } + function parseRepoSlug(repoSlug) { + const parts = repoSlug.split("/"); + if (parts.length !== 2 || !parts[0] || !parts[1]) { + return null; + } + return { owner: parts[0], repo: parts[1] }; + } + function addExpirationComment(bodyLines, envVarName, entityType) { + const expiresEnv = process.env[envVarName]; + if (expiresEnv) { + const expiresDays = parseInt(expiresEnv, 10); + if (!isNaN(expiresDays) && expiresDays > 0) { + const expirationDate = new Date(); + expirationDate.setDate(expirationDate.getDate() + expiresDays); + const expirationISO = expirationDate.toISOString(); + bodyLines.push(``); + core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); + } + } + } + function removeDuplicateTitleFromDescription(title, description) { + if (!title || typeof title !== "string") { + return description || ""; + } + if (!description || typeof description !== "string") { + return ""; + } + const trimmedTitle = title.trim(); + const trimmedDescription = description.trim(); + if (!trimmedTitle || !trimmedDescription) { + return trimmedDescription; + } + const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); + if (headerRegex.test(trimmedDescription)) { + return trimmedDescription.replace(headerRegex, "").trim(); + } + return trimmedDescription; + } async function main() { core.setOutput("issue_number", ""); core.setOutput("issue_url", ""); diff --git a/.github/workflows/tidy.lock.yml b/.github/workflows/tidy.lock.yml index 83694c84b82..56a7abc9453 100644 --- a/.github/workflows/tidy.lock.yml +++ b/.github/workflows/tidy.lock.yml @@ -6557,10 +6557,151 @@ jobs: script: | const fs = require("fs"); const crypto = require("crypto"); - const { updateActivationComment } = require("./update_activation_comment.cjs"); - const { getTrackerID } = require("./get_tracker_id.cjs"); - const { addExpirationComment } = require("./expiration_helpers.cjs"); - const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); + async function updateActivationComment(github, context, core, itemUrl, itemNumber, itemType = "pull_request") { + const itemLabel = itemType === "issue" ? "issue" : "pull request"; + const linkMessage = + itemType === "issue" + ? `\n\n✅ Issue created: [#${itemNumber}](${itemUrl})` + : `\n\n✅ Pull request created: [#${itemNumber}](${itemUrl})`; + await updateActivationCommentWithMessage(github, context, core, linkMessage, itemLabel); + } + async function updateActivationCommentWithCommit(github, context, core, commitSha, commitUrl) { + const shortSha = commitSha.substring(0, 7); + const message = `\n\n✅ Commit pushed: [\`${shortSha}\`](${commitUrl})`; + await updateActivationCommentWithMessage(github, context, core, message, "commit"); + } + async function updateActivationCommentWithMessage(github, context, core, message, label = "") { + const commentId = process.env.GH_AW_COMMENT_ID; + const commentRepo = process.env.GH_AW_COMMENT_REPO; + if (!commentId) { + core.info("No activation comment to update (GH_AW_COMMENT_ID not set)"); + return; + } + core.info(`Updating activation comment ${commentId}`); + let repoOwner = context.repo.owner; + let repoName = context.repo.repo; + if (commentRepo) { + const parts = commentRepo.split("/"); + if (parts.length === 2) { + repoOwner = parts[0]; + repoName = parts[1]; + } else { + core.warning(`Invalid comment repo format: ${commentRepo}, expected "owner/repo". Falling back to context.repo.`); + } + } + core.info(`Updating comment in ${repoOwner}/${repoName}`); + const isDiscussionComment = commentId.startsWith("DC_"); + try { + if (isDiscussionComment) { + const currentComment = await github.graphql( + ` + query($commentId: ID!) { + node(id: $commentId) { + ... on DiscussionComment { + body + } + } + }`, + { commentId: commentId } + ); + if (!currentComment?.node?.body) { + core.warning("Unable to fetch current comment body, comment may have been deleted or is inaccessible"); + return; + } + const currentBody = currentComment.node.body; + const updatedBody = currentBody + message; + const result = await github.graphql( + ` + mutation($commentId: ID!, $body: String!) { + updateDiscussionComment(input: { commentId: $commentId, body: $body }) { + comment { + id + url + } + } + }`, + { commentId: commentId, body: updatedBody } + ); + const comment = result.updateDiscussionComment.comment; + const successMessage = label + ? `Successfully updated discussion comment with ${label} link` + : "Successfully updated discussion comment"; + core.info(successMessage); + core.info(`Comment ID: ${comment.id}`); + core.info(`Comment URL: ${comment.url}`); + } else { + const currentComment = await github.request("GET /repos/{owner}/{repo}/issues/comments/{comment_id}", { + owner: repoOwner, + repo: repoName, + comment_id: parseInt(commentId, 10), + headers: { + Accept: "application/vnd.github+json", + }, + }); + if (!currentComment?.data?.body) { + core.warning("Unable to fetch current comment body, comment may have been deleted"); + return; + } + const currentBody = currentComment.data.body; + const updatedBody = currentBody + message; + const response = await github.request("PATCH /repos/{owner}/{repo}/issues/comments/{comment_id}", { + owner: repoOwner, + repo: repoName, + comment_id: parseInt(commentId, 10), + body: updatedBody, + headers: { + Accept: "application/vnd.github+json", + }, + }); + const successMessage = label ? `Successfully updated comment with ${label} link` : "Successfully updated comment"; + core.info(successMessage); + core.info(`Comment ID: ${response.data.id}`); + core.info(`Comment URL: ${response.data.html_url}`); + } + } catch (error) { + core.warning(`Failed to update activation comment: ${error instanceof Error ? error.message : String(error)}`); + } + } + function getTrackerID(format) { + const trackerID = process.env.GH_AW_TRACKER_ID || ""; + if (trackerID) { + core.info(`Tracker ID: ${trackerID}`); + return format === "markdown" ? `\n\n` : trackerID; + } + return ""; + } + function addExpirationComment(bodyLines, envVarName, entityType) { + const expiresEnv = process.env[envVarName]; + if (expiresEnv) { + const expiresDays = parseInt(expiresEnv, 10); + if (!isNaN(expiresDays) && expiresDays > 0) { + const expirationDate = new Date(); + expirationDate.setDate(expirationDate.getDate() + expiresDays); + const expirationISO = expirationDate.toISOString(); + bodyLines.push(``); + core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); + } + } + } + function removeDuplicateTitleFromDescription(title, description) { + if (!title || typeof title !== "string") { + return description || ""; + } + if (!description || typeof description !== "string") { + return ""; + } + const trimmedTitle = title.trim(); + const trimmedDescription = description.trim(); + if (!trimmedTitle || !trimmedDescription) { + return trimmedDescription; + } + const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); + if (headerRegex.test(trimmedDescription)) { + return trimmedDescription.replace(headerRegex, "").trim(); + } + return trimmedDescription; + } function generatePatchPreview(patchContent) { if (!patchContent || !patchContent.trim()) { return ""; diff --git a/.github/workflows/typist.lock.yml b/.github/workflows/typist.lock.yml index e907ca3dd9d..e3d01d28c0a 100644 --- a/.github/workflows/typist.lock.yml +++ b/.github/workflows/typist.lock.yml @@ -6524,13 +6524,400 @@ jobs: with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - const { loadAgentOutput } = require("./load_agent_output.cjs"); - const { getTrackerID } = require("./get_tracker_id.cjs"); - const { closeOlderDiscussions } = require("./close_older_discussions.cjs"); - const { replaceTemporaryIdReferences, loadTemporaryIdMap } = require("./temporary_id.cjs"); - const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require("./repo_helpers.cjs"); - const { addExpirationComment } = require("./expiration_helpers.cjs"); - const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); + const fs = require("fs"); + const crypto = require("crypto"); + const MAX_LOG_CONTENT_LENGTH = 10000; + function truncateForLogging(content) { + if (content.length <= MAX_LOG_CONTENT_LENGTH) { + return content; + } + return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; + } + function loadAgentOutput() { + const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; + if (!agentOutputFile) { + core.info("No GH_AW_AGENT_OUTPUT environment variable found"); + return { success: false }; + } + let outputContent; + try { + outputContent = fs.readFileSync(agentOutputFile, "utf8"); + } catch (error) { + const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; + core.error(errorMessage); + return { success: false, error: errorMessage }; + } + if (outputContent.trim() === "") { + core.info("Agent output content is empty"); + return { success: false }; + } + core.info(`Agent output content length: ${outputContent.length}`); + let validatedOutput; + try { + validatedOutput = JSON.parse(outputContent); + } catch (error) { + const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; + core.error(errorMessage); + core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); + return { success: false, error: errorMessage }; + } + if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { + core.info("No valid items found in agent output"); + core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); + return { success: false }; + } + return { success: true, items: validatedOutput.items }; + } + function getTrackerID(format) { + const trackerID = process.env.GH_AW_TRACKER_ID || ""; + if (trackerID) { + core.info(`Tracker ID: ${trackerID}`); + return format === "markdown" ? `\n\n` : trackerID; + } + return ""; + } + function getMessages() { + const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES; + if (!messagesEnv) { + return null; + } + try { + return JSON.parse(messagesEnv); + } catch (error) { + core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`); + return null; + } + } + function renderTemplate(template, context) { + return template.replace(/\{(\w+)\}/g, (match, key) => { + const value = context[key]; + return value !== undefined && value !== null ? String(value) : match; + }); + } + function toSnakeCase(obj) { + const result = {}; + for (const [key, value] of Object.entries(obj)) { + const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase(); + result[snakeKey] = value; + result[key] = value; + } + return result; + } + function getCloseOlderDiscussionMessage(ctx) { + const messages = getMessages(); + const templateContext = toSnakeCase(ctx); + const defaultMessage = `⚓ Avast! This discussion be marked as **outdated** by [{workflow_name}]({run_url}). + 🗺️ A newer treasure map awaits ye at **[Discussion #{new_discussion_number}]({new_discussion_url})**. + Fair winds, matey! 🏴‍☠️`; + return messages?.closeOlderDiscussion + ? renderTemplate(messages.closeOlderDiscussion, templateContext) + : renderTemplate(defaultMessage, templateContext); + } + const MAX_CLOSE_COUNT = 10; + const GRAPHQL_DELAY_MS = 500; + function delay(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); + } + async function searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, excludeNumber) { + let searchQuery = `repo:${owner}/${repo} is:open`; + if (titlePrefix) { + const escapedPrefix = titlePrefix.replace(/"/g, '\\"'); + searchQuery += ` in:title "${escapedPrefix}"`; + } + if (labels && labels.length > 0) { + for (const label of labels) { + const escapedLabel = label.replace(/"/g, '\\"'); + searchQuery += ` label:"${escapedLabel}"`; + } + } + const result = await github.graphql( + ` + query($searchTerms: String!, $first: Int!) { + search(query: $searchTerms, type: DISCUSSION, first: $first) { + nodes { + ... on Discussion { + id + number + title + url + category { + id + } + labels(first: 100) { + nodes { + name + } + } + closed + } + } + } + }`, + { searchTerms: searchQuery, first: 50 } + ); + if (!result || !result.search || !result.search.nodes) { + return []; + } + return result.search.nodes + .filter( + d => { + if (!d || d.number === excludeNumber || d.closed) { + return false; + } + if (titlePrefix && d.title && !d.title.startsWith(titlePrefix)) { + return false; + } + if (labels && labels.length > 0) { + const discussionLabels = d.labels?.nodes?.map(( l) => l.name) || []; + const hasAllLabels = labels.every(label => discussionLabels.includes(label)); + if (!hasAllLabels) { + return false; + } + } + if (categoryId && (!d.category || d.category.id !== categoryId)) { + return false; + } + return true; + } + ) + .map( + d => ({ + id: d.id, + number: d.number, + title: d.title, + url: d.url, + }) + ); + } + async function addDiscussionComment(github, discussionId, message) { + const result = await github.graphql( + ` + mutation($dId: ID!, $body: String!) { + addDiscussionComment(input: { discussionId: $dId, body: $body }) { + comment { + id + url + } + } + }`, + { dId: discussionId, body: message } + ); + return result.addDiscussionComment.comment; + } + async function closeDiscussionAsOutdated(github, discussionId) { + const result = await github.graphql( + ` + mutation($dId: ID!) { + closeDiscussion(input: { discussionId: $dId, reason: OUTDATED }) { + discussion { + id + url + } + } + }`, + { dId: discussionId } + ); + return result.closeDiscussion.discussion; + } + async function closeOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion, workflowName, runUrl) { + const searchCriteria = []; + if (titlePrefix) searchCriteria.push(`title prefix: "${titlePrefix}"`); + if (labels && labels.length > 0) searchCriteria.push(`labels: [${labels.join(", ")}]`); + core.info(`Searching for older discussions with ${searchCriteria.join(" and ")}`); + const olderDiscussions = await searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion.number); + if (olderDiscussions.length === 0) { + core.info("No older discussions found to close"); + return []; + } + core.info(`Found ${olderDiscussions.length} older discussion(s) to close`); + const discussionsToClose = olderDiscussions.slice(0, MAX_CLOSE_COUNT); + if (olderDiscussions.length > MAX_CLOSE_COUNT) { + core.warning(`Found ${olderDiscussions.length} older discussions, but only closing the first ${MAX_CLOSE_COUNT}`); + } + const closedDiscussions = []; + for (let i = 0; i < discussionsToClose.length; i++) { + const discussion = discussionsToClose[i]; + try { + const closingMessage = getCloseOlderDiscussionMessage({ + newDiscussionUrl: newDiscussion.url, + newDiscussionNumber: newDiscussion.number, + workflowName, + runUrl, + }); + core.info(`Adding closing comment to discussion #${discussion.number}`); + await addDiscussionComment(github, discussion.id, closingMessage); + core.info(`Closing discussion #${discussion.number} as outdated`); + await closeDiscussionAsOutdated(github, discussion.id); + closedDiscussions.push({ + number: discussion.number, + url: discussion.url, + }); + core.info(`✓ Closed discussion #${discussion.number}: ${discussion.url}`); + } catch (error) { + core.error(`✗ Failed to close discussion #${discussion.number}: ${error instanceof Error ? error.message : String(error)}`); + } + if (i < discussionsToClose.length - 1) { + await delay(GRAPHQL_DELAY_MS); + } + } + return closedDiscussions; + } + const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; + function generateTemporaryId() { + return "aw_" + crypto.randomBytes(6).toString("hex"); + } + function isTemporaryId(value) { + if (typeof value === "string") { + return /^aw_[0-9a-f]{12}$/i.test(value); + } + return false; + } + function normalizeTemporaryId(tempId) { + return String(tempId).toLowerCase(); + } + function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { + return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { + const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); + if (resolved !== undefined) { + if (currentRepo && resolved.repo === currentRepo) { + return `#${resolved.number}`; + } + return `${resolved.repo}#${resolved.number}`; + } + return match; + }); + } + function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { + return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { + const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); + if (issueNumber !== undefined) { + return `#${issueNumber}`; + } + return match; + }); + } + function loadTemporaryIdMap() { + const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; + if (!mapJson || mapJson === "{}") { + return new Map(); + } + try { + const mapObject = JSON.parse(mapJson); + const result = new Map(); + for (const [key, value] of Object.entries(mapObject)) { + const normalizedKey = normalizeTemporaryId(key); + if (typeof value === "number") { + const contextRepo = `${context.repo.owner}/${context.repo.repo}`; + result.set(normalizedKey, { repo: contextRepo, number: value }); + } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { + result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); + } + } + return result; + } catch (error) { + if (typeof core !== "undefined") { + core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); + } + return new Map(); + } + } + function resolveIssueNumber(value, temporaryIdMap) { + if (value === undefined || value === null) { + return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; + } + const valueStr = String(value); + if (isTemporaryId(valueStr)) { + const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); + if (resolvedPair !== undefined) { + return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; + } + return { + resolved: null, + wasTemporaryId: true, + errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, + }; + } + const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); + if (isNaN(issueNumber) || issueNumber <= 0) { + return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; + } + const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; + return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; + } + function serializeTemporaryIdMap(tempIdMap) { + const obj = Object.fromEntries(tempIdMap); + return JSON.stringify(obj); + } + function parseAllowedRepos() { + const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; + const set = new Set(); + if (allowedReposEnv) { + allowedReposEnv + .split(",") + .map(repo => repo.trim()) + .filter(repo => repo) + .forEach(repo => set.add(repo)); + } + return set; + } + function getDefaultTargetRepo() { + const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; + if (targetRepoSlug) { + return targetRepoSlug; + } + return `${context.repo.owner}/${context.repo.repo}`; + } + function validateRepo(repo, defaultRepo, allowedRepos) { + if (repo === defaultRepo) { + return { valid: true, error: null }; + } + if (allowedRepos.has(repo)) { + return { valid: true, error: null }; + } + return { + valid: false, + error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, + }; + } + function parseRepoSlug(repoSlug) { + const parts = repoSlug.split("/"); + if (parts.length !== 2 || !parts[0] || !parts[1]) { + return null; + } + return { owner: parts[0], repo: parts[1] }; + } + function addExpirationComment(bodyLines, envVarName, entityType) { + const expiresEnv = process.env[envVarName]; + if (expiresEnv) { + const expiresDays = parseInt(expiresEnv, 10); + if (!isNaN(expiresDays) && expiresDays > 0) { + const expirationDate = new Date(); + expirationDate.setDate(expirationDate.getDate() + expiresDays); + const expirationISO = expirationDate.toISOString(); + bodyLines.push(``); + core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); + } + } + } + function removeDuplicateTitleFromDescription(title, description) { + if (!title || typeof title !== "string") { + return description || ""; + } + if (!description || typeof description !== "string") { + return ""; + } + const trimmedTitle = title.trim(); + const trimmedDescription = description.trim(); + if (!trimmedTitle || !trimmedDescription) { + return trimmedDescription; + } + const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); + if (headerRegex.test(trimmedDescription)) { + return trimmedDescription.replace(headerRegex, "").trim(); + } + return trimmedDescription; + } async function fetchRepoDiscussionInfo(owner, repo) { const repositoryQuery = ` query($owner: String!, $repo: String!) { diff --git a/.github/workflows/unbloat-docs.lock.yml b/.github/workflows/unbloat-docs.lock.yml index 9ed5c7e07f1..84f7bcc45e4 100644 --- a/.github/workflows/unbloat-docs.lock.yml +++ b/.github/workflows/unbloat-docs.lock.yml @@ -7321,10 +7321,151 @@ jobs: script: | const fs = require("fs"); const crypto = require("crypto"); - const { updateActivationComment } = require("./update_activation_comment.cjs"); - const { getTrackerID } = require("./get_tracker_id.cjs"); - const { addExpirationComment } = require("./expiration_helpers.cjs"); - const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); + async function updateActivationComment(github, context, core, itemUrl, itemNumber, itemType = "pull_request") { + const itemLabel = itemType === "issue" ? "issue" : "pull request"; + const linkMessage = + itemType === "issue" + ? `\n\n✅ Issue created: [#${itemNumber}](${itemUrl})` + : `\n\n✅ Pull request created: [#${itemNumber}](${itemUrl})`; + await updateActivationCommentWithMessage(github, context, core, linkMessage, itemLabel); + } + async function updateActivationCommentWithCommit(github, context, core, commitSha, commitUrl) { + const shortSha = commitSha.substring(0, 7); + const message = `\n\n✅ Commit pushed: [\`${shortSha}\`](${commitUrl})`; + await updateActivationCommentWithMessage(github, context, core, message, "commit"); + } + async function updateActivationCommentWithMessage(github, context, core, message, label = "") { + const commentId = process.env.GH_AW_COMMENT_ID; + const commentRepo = process.env.GH_AW_COMMENT_REPO; + if (!commentId) { + core.info("No activation comment to update (GH_AW_COMMENT_ID not set)"); + return; + } + core.info(`Updating activation comment ${commentId}`); + let repoOwner = context.repo.owner; + let repoName = context.repo.repo; + if (commentRepo) { + const parts = commentRepo.split("/"); + if (parts.length === 2) { + repoOwner = parts[0]; + repoName = parts[1]; + } else { + core.warning(`Invalid comment repo format: ${commentRepo}, expected "owner/repo". Falling back to context.repo.`); + } + } + core.info(`Updating comment in ${repoOwner}/${repoName}`); + const isDiscussionComment = commentId.startsWith("DC_"); + try { + if (isDiscussionComment) { + const currentComment = await github.graphql( + ` + query($commentId: ID!) { + node(id: $commentId) { + ... on DiscussionComment { + body + } + } + }`, + { commentId: commentId } + ); + if (!currentComment?.node?.body) { + core.warning("Unable to fetch current comment body, comment may have been deleted or is inaccessible"); + return; + } + const currentBody = currentComment.node.body; + const updatedBody = currentBody + message; + const result = await github.graphql( + ` + mutation($commentId: ID!, $body: String!) { + updateDiscussionComment(input: { commentId: $commentId, body: $body }) { + comment { + id + url + } + } + }`, + { commentId: commentId, body: updatedBody } + ); + const comment = result.updateDiscussionComment.comment; + const successMessage = label + ? `Successfully updated discussion comment with ${label} link` + : "Successfully updated discussion comment"; + core.info(successMessage); + core.info(`Comment ID: ${comment.id}`); + core.info(`Comment URL: ${comment.url}`); + } else { + const currentComment = await github.request("GET /repos/{owner}/{repo}/issues/comments/{comment_id}", { + owner: repoOwner, + repo: repoName, + comment_id: parseInt(commentId, 10), + headers: { + Accept: "application/vnd.github+json", + }, + }); + if (!currentComment?.data?.body) { + core.warning("Unable to fetch current comment body, comment may have been deleted"); + return; + } + const currentBody = currentComment.data.body; + const updatedBody = currentBody + message; + const response = await github.request("PATCH /repos/{owner}/{repo}/issues/comments/{comment_id}", { + owner: repoOwner, + repo: repoName, + comment_id: parseInt(commentId, 10), + body: updatedBody, + headers: { + Accept: "application/vnd.github+json", + }, + }); + const successMessage = label ? `Successfully updated comment with ${label} link` : "Successfully updated comment"; + core.info(successMessage); + core.info(`Comment ID: ${response.data.id}`); + core.info(`Comment URL: ${response.data.html_url}`); + } + } catch (error) { + core.warning(`Failed to update activation comment: ${error instanceof Error ? error.message : String(error)}`); + } + } + function getTrackerID(format) { + const trackerID = process.env.GH_AW_TRACKER_ID || ""; + if (trackerID) { + core.info(`Tracker ID: ${trackerID}`); + return format === "markdown" ? `\n\n` : trackerID; + } + return ""; + } + function addExpirationComment(bodyLines, envVarName, entityType) { + const expiresEnv = process.env[envVarName]; + if (expiresEnv) { + const expiresDays = parseInt(expiresEnv, 10); + if (!isNaN(expiresDays) && expiresDays > 0) { + const expirationDate = new Date(); + expirationDate.setDate(expirationDate.getDate() + expiresDays); + const expirationISO = expirationDate.toISOString(); + bodyLines.push(``); + core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); + } + } + } + function removeDuplicateTitleFromDescription(title, description) { + if (!title || typeof title !== "string") { + return description || ""; + } + if (!description || typeof description !== "string") { + return ""; + } + const trimmedTitle = title.trim(); + const trimmedDescription = description.trim(); + if (!trimmedTitle || !trimmedDescription) { + return trimmedDescription; + } + const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); + if (headerRegex.test(trimmedDescription)) { + return trimmedDescription.replace(headerRegex, "").trim(); + } + return trimmedDescription; + } function generatePatchPreview(patchContent) { if (!patchContent || !patchContent.trim()) { return ""; diff --git a/.github/workflows/video-analyzer.lock.yml b/.github/workflows/video-analyzer.lock.yml index 61e1545ea40..42b49729dbe 100644 --- a/.github/workflows/video-analyzer.lock.yml +++ b/.github/workflows/video-analyzer.lock.yml @@ -6435,21 +6435,291 @@ jobs: with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - const { sanitizeLabelContent } = require("./sanitize_label_content.cjs"); - const { loadAgentOutput } = require("./load_agent_output.cjs"); - const { generateStagedPreview } = require("./staged_preview.cjs"); - const { generateFooter } = require("./generate_footer.cjs"); - const { getTrackerID } = require("./get_tracker_id.cjs"); - const { - generateTemporaryId, - isTemporaryId, - normalizeTemporaryId, - replaceTemporaryIdReferences, - serializeTemporaryIdMap, - } = require("./temporary_id.cjs"); - const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require("./repo_helpers.cjs"); - const { addExpirationComment } = require("./expiration_helpers.cjs"); - const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); + function sanitizeLabelContent(content) { + if (!content || typeof content !== "string") { + return ""; + } + let sanitized = content.trim(); + sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); + sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); + sanitized = sanitized.replace( + /(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, + (_m, p1, p2) => `${p1}\`@${p2}\`` + ); + sanitized = sanitized.replace(/[<>&'"]/g, ""); + return sanitized.trim(); + } + const fs = require("fs"); + const crypto = require("crypto"); + const MAX_LOG_CONTENT_LENGTH = 10000; + function truncateForLogging(content) { + if (content.length <= MAX_LOG_CONTENT_LENGTH) { + return content; + } + return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; + } + function loadAgentOutput() { + const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; + if (!agentOutputFile) { + core.info("No GH_AW_AGENT_OUTPUT environment variable found"); + return { success: false }; + } + let outputContent; + try { + outputContent = fs.readFileSync(agentOutputFile, "utf8"); + } catch (error) { + const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; + core.error(errorMessage); + return { success: false, error: errorMessage }; + } + if (outputContent.trim() === "") { + core.info("Agent output content is empty"); + return { success: false }; + } + core.info(`Agent output content length: ${outputContent.length}`); + let validatedOutput; + try { + validatedOutput = JSON.parse(outputContent); + } catch (error) { + const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; + core.error(errorMessage); + core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); + return { success: false, error: errorMessage }; + } + if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { + core.info("No valid items found in agent output"); + core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); + return { success: false }; + } + return { success: true, items: validatedOutput.items }; + } + async function generateStagedPreview(options) { + const { title, description, items, renderItem } = options; + let summaryContent = `## 🎭 Staged Mode: ${title} Preview\n\n`; + summaryContent += `${description}\n\n`; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + summaryContent += renderItem(item, i); + summaryContent += "---\n\n"; + } + try { + await core.summary.addRaw(summaryContent).write(); + core.info(summaryContent); + core.info(`📝 ${title} preview written to step summary`); + } catch (error) { + core.setFailed(error instanceof Error ? error : String(error)); + } + } + function generateXMLMarker(workflowName, runUrl) { + const engineId = process.env.GH_AW_ENGINE_ID || ""; + const engineVersion = process.env.GH_AW_ENGINE_VERSION || ""; + const engineModel = process.env.GH_AW_ENGINE_MODEL || ""; + const trackerId = process.env.GH_AW_TRACKER_ID || ""; + const parts = []; + parts.push(`agentic-workflow: ${workflowName}`); + if (trackerId) { + parts.push(`tracker-id: ${trackerId}`); + } + if (engineId) { + parts.push(`engine: ${engineId}`); + } + if (engineVersion) { + parts.push(`version: ${engineVersion}`); + } + if (engineModel) { + parts.push(`model: ${engineModel}`); + } + parts.push(`run: ${runUrl}`); + return ``; + } + function generateFooter( + workflowName, + runUrl, + workflowSource, + workflowSourceURL, + triggeringIssueNumber, + triggeringPRNumber, + triggeringDiscussionNumber + ) { + let footer = `\n\n> AI generated by [${workflowName}](${runUrl})`; + if (triggeringIssueNumber) { + footer += ` for #${triggeringIssueNumber}`; + } else if (triggeringPRNumber) { + footer += ` for #${triggeringPRNumber}`; + } else if (triggeringDiscussionNumber) { + footer += ` for discussion #${triggeringDiscussionNumber}`; + } + if (workflowSource && workflowSourceURL) { + footer += `\n>\n> To add this workflow in your repository, run \`gh aw add ${workflowSource}\`. See [usage guide](https://githubnext.github.io/gh-aw/tools/cli/).`; + } + footer += "\n\n" + generateXMLMarker(workflowName, runUrl); + footer += "\n"; + return footer; + } + function getTrackerID(format) { + const trackerID = process.env.GH_AW_TRACKER_ID || ""; + if (trackerID) { + core.info(`Tracker ID: ${trackerID}`); + return format === "markdown" ? `\n\n` : trackerID; + } + return ""; + } + const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; + function generateTemporaryId() { + return "aw_" + crypto.randomBytes(6).toString("hex"); + } + function isTemporaryId(value) { + if (typeof value === "string") { + return /^aw_[0-9a-f]{12}$/i.test(value); + } + return false; + } + function normalizeTemporaryId(tempId) { + return String(tempId).toLowerCase(); + } + function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { + return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { + const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); + if (resolved !== undefined) { + if (currentRepo && resolved.repo === currentRepo) { + return `#${resolved.number}`; + } + return `${resolved.repo}#${resolved.number}`; + } + return match; + }); + } + function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { + return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { + const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); + if (issueNumber !== undefined) { + return `#${issueNumber}`; + } + return match; + }); + } + function loadTemporaryIdMap() { + const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; + if (!mapJson || mapJson === "{}") { + return new Map(); + } + try { + const mapObject = JSON.parse(mapJson); + const result = new Map(); + for (const [key, value] of Object.entries(mapObject)) { + const normalizedKey = normalizeTemporaryId(key); + if (typeof value === "number") { + const contextRepo = `${context.repo.owner}/${context.repo.repo}`; + result.set(normalizedKey, { repo: contextRepo, number: value }); + } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { + result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); + } + } + return result; + } catch (error) { + if (typeof core !== "undefined") { + core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); + } + return new Map(); + } + } + function resolveIssueNumber(value, temporaryIdMap) { + if (value === undefined || value === null) { + return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; + } + const valueStr = String(value); + if (isTemporaryId(valueStr)) { + const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); + if (resolvedPair !== undefined) { + return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; + } + return { + resolved: null, + wasTemporaryId: true, + errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, + }; + } + const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); + if (isNaN(issueNumber) || issueNumber <= 0) { + return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; + } + const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; + return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; + } + function serializeTemporaryIdMap(tempIdMap) { + const obj = Object.fromEntries(tempIdMap); + return JSON.stringify(obj); + } + function parseAllowedRepos() { + const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; + const set = new Set(); + if (allowedReposEnv) { + allowedReposEnv + .split(",") + .map(repo => repo.trim()) + .filter(repo => repo) + .forEach(repo => set.add(repo)); + } + return set; + } + function getDefaultTargetRepo() { + const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; + if (targetRepoSlug) { + return targetRepoSlug; + } + return `${context.repo.owner}/${context.repo.repo}`; + } + function validateRepo(repo, defaultRepo, allowedRepos) { + if (repo === defaultRepo) { + return { valid: true, error: null }; + } + if (allowedRepos.has(repo)) { + return { valid: true, error: null }; + } + return { + valid: false, + error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, + }; + } + function parseRepoSlug(repoSlug) { + const parts = repoSlug.split("/"); + if (parts.length !== 2 || !parts[0] || !parts[1]) { + return null; + } + return { owner: parts[0], repo: parts[1] }; + } + function addExpirationComment(bodyLines, envVarName, entityType) { + const expiresEnv = process.env[envVarName]; + if (expiresEnv) { + const expiresDays = parseInt(expiresEnv, 10); + if (!isNaN(expiresDays) && expiresDays > 0) { + const expirationDate = new Date(); + expirationDate.setDate(expirationDate.getDate() + expiresDays); + const expirationISO = expirationDate.toISOString(); + bodyLines.push(``); + core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); + } + } + } + function removeDuplicateTitleFromDescription(title, description) { + if (!title || typeof title !== "string") { + return description || ""; + } + if (!description || typeof description !== "string") { + return ""; + } + const trimmedTitle = title.trim(); + const trimmedDescription = description.trim(); + if (!trimmedTitle || !trimmedDescription) { + return trimmedDescription; + } + const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); + if (headerRegex.test(trimmedDescription)) { + return trimmedDescription.replace(headerRegex, "").trim(); + } + return trimmedDescription; + } async function main() { core.setOutput("issue_number", ""); core.setOutput("issue_url", ""); diff --git a/.github/workflows/weekly-issue-summary.lock.yml b/.github/workflows/weekly-issue-summary.lock.yml index 609429a4669..ed24f249730 100644 --- a/.github/workflows/weekly-issue-summary.lock.yml +++ b/.github/workflows/weekly-issue-summary.lock.yml @@ -7246,13 +7246,400 @@ jobs: with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - const { loadAgentOutput } = require("./load_agent_output.cjs"); - const { getTrackerID } = require("./get_tracker_id.cjs"); - const { closeOlderDiscussions } = require("./close_older_discussions.cjs"); - const { replaceTemporaryIdReferences, loadTemporaryIdMap } = require("./temporary_id.cjs"); - const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require("./repo_helpers.cjs"); - const { addExpirationComment } = require("./expiration_helpers.cjs"); - const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); + const fs = require("fs"); + const crypto = require("crypto"); + const MAX_LOG_CONTENT_LENGTH = 10000; + function truncateForLogging(content) { + if (content.length <= MAX_LOG_CONTENT_LENGTH) { + return content; + } + return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; + } + function loadAgentOutput() { + const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; + if (!agentOutputFile) { + core.info("No GH_AW_AGENT_OUTPUT environment variable found"); + return { success: false }; + } + let outputContent; + try { + outputContent = fs.readFileSync(agentOutputFile, "utf8"); + } catch (error) { + const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; + core.error(errorMessage); + return { success: false, error: errorMessage }; + } + if (outputContent.trim() === "") { + core.info("Agent output content is empty"); + return { success: false }; + } + core.info(`Agent output content length: ${outputContent.length}`); + let validatedOutput; + try { + validatedOutput = JSON.parse(outputContent); + } catch (error) { + const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; + core.error(errorMessage); + core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); + return { success: false, error: errorMessage }; + } + if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { + core.info("No valid items found in agent output"); + core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); + return { success: false }; + } + return { success: true, items: validatedOutput.items }; + } + function getTrackerID(format) { + const trackerID = process.env.GH_AW_TRACKER_ID || ""; + if (trackerID) { + core.info(`Tracker ID: ${trackerID}`); + return format === "markdown" ? `\n\n` : trackerID; + } + return ""; + } + function getMessages() { + const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES; + if (!messagesEnv) { + return null; + } + try { + return JSON.parse(messagesEnv); + } catch (error) { + core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`); + return null; + } + } + function renderTemplate(template, context) { + return template.replace(/\{(\w+)\}/g, (match, key) => { + const value = context[key]; + return value !== undefined && value !== null ? String(value) : match; + }); + } + function toSnakeCase(obj) { + const result = {}; + for (const [key, value] of Object.entries(obj)) { + const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase(); + result[snakeKey] = value; + result[key] = value; + } + return result; + } + function getCloseOlderDiscussionMessage(ctx) { + const messages = getMessages(); + const templateContext = toSnakeCase(ctx); + const defaultMessage = `⚓ Avast! This discussion be marked as **outdated** by [{workflow_name}]({run_url}). + 🗺️ A newer treasure map awaits ye at **[Discussion #{new_discussion_number}]({new_discussion_url})**. + Fair winds, matey! 🏴‍☠️`; + return messages?.closeOlderDiscussion + ? renderTemplate(messages.closeOlderDiscussion, templateContext) + : renderTemplate(defaultMessage, templateContext); + } + const MAX_CLOSE_COUNT = 10; + const GRAPHQL_DELAY_MS = 500; + function delay(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); + } + async function searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, excludeNumber) { + let searchQuery = `repo:${owner}/${repo} is:open`; + if (titlePrefix) { + const escapedPrefix = titlePrefix.replace(/"/g, '\\"'); + searchQuery += ` in:title "${escapedPrefix}"`; + } + if (labels && labels.length > 0) { + for (const label of labels) { + const escapedLabel = label.replace(/"/g, '\\"'); + searchQuery += ` label:"${escapedLabel}"`; + } + } + const result = await github.graphql( + ` + query($searchTerms: String!, $first: Int!) { + search(query: $searchTerms, type: DISCUSSION, first: $first) { + nodes { + ... on Discussion { + id + number + title + url + category { + id + } + labels(first: 100) { + nodes { + name + } + } + closed + } + } + } + }`, + { searchTerms: searchQuery, first: 50 } + ); + if (!result || !result.search || !result.search.nodes) { + return []; + } + return result.search.nodes + .filter( + d => { + if (!d || d.number === excludeNumber || d.closed) { + return false; + } + if (titlePrefix && d.title && !d.title.startsWith(titlePrefix)) { + return false; + } + if (labels && labels.length > 0) { + const discussionLabels = d.labels?.nodes?.map(( l) => l.name) || []; + const hasAllLabels = labels.every(label => discussionLabels.includes(label)); + if (!hasAllLabels) { + return false; + } + } + if (categoryId && (!d.category || d.category.id !== categoryId)) { + return false; + } + return true; + } + ) + .map( + d => ({ + id: d.id, + number: d.number, + title: d.title, + url: d.url, + }) + ); + } + async function addDiscussionComment(github, discussionId, message) { + const result = await github.graphql( + ` + mutation($dId: ID!, $body: String!) { + addDiscussionComment(input: { discussionId: $dId, body: $body }) { + comment { + id + url + } + } + }`, + { dId: discussionId, body: message } + ); + return result.addDiscussionComment.comment; + } + async function closeDiscussionAsOutdated(github, discussionId) { + const result = await github.graphql( + ` + mutation($dId: ID!) { + closeDiscussion(input: { discussionId: $dId, reason: OUTDATED }) { + discussion { + id + url + } + } + }`, + { dId: discussionId } + ); + return result.closeDiscussion.discussion; + } + async function closeOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion, workflowName, runUrl) { + const searchCriteria = []; + if (titlePrefix) searchCriteria.push(`title prefix: "${titlePrefix}"`); + if (labels && labels.length > 0) searchCriteria.push(`labels: [${labels.join(", ")}]`); + core.info(`Searching for older discussions with ${searchCriteria.join(" and ")}`); + const olderDiscussions = await searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion.number); + if (olderDiscussions.length === 0) { + core.info("No older discussions found to close"); + return []; + } + core.info(`Found ${olderDiscussions.length} older discussion(s) to close`); + const discussionsToClose = olderDiscussions.slice(0, MAX_CLOSE_COUNT); + if (olderDiscussions.length > MAX_CLOSE_COUNT) { + core.warning(`Found ${olderDiscussions.length} older discussions, but only closing the first ${MAX_CLOSE_COUNT}`); + } + const closedDiscussions = []; + for (let i = 0; i < discussionsToClose.length; i++) { + const discussion = discussionsToClose[i]; + try { + const closingMessage = getCloseOlderDiscussionMessage({ + newDiscussionUrl: newDiscussion.url, + newDiscussionNumber: newDiscussion.number, + workflowName, + runUrl, + }); + core.info(`Adding closing comment to discussion #${discussion.number}`); + await addDiscussionComment(github, discussion.id, closingMessage); + core.info(`Closing discussion #${discussion.number} as outdated`); + await closeDiscussionAsOutdated(github, discussion.id); + closedDiscussions.push({ + number: discussion.number, + url: discussion.url, + }); + core.info(`✓ Closed discussion #${discussion.number}: ${discussion.url}`); + } catch (error) { + core.error(`✗ Failed to close discussion #${discussion.number}: ${error instanceof Error ? error.message : String(error)}`); + } + if (i < discussionsToClose.length - 1) { + await delay(GRAPHQL_DELAY_MS); + } + } + return closedDiscussions; + } + const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; + function generateTemporaryId() { + return "aw_" + crypto.randomBytes(6).toString("hex"); + } + function isTemporaryId(value) { + if (typeof value === "string") { + return /^aw_[0-9a-f]{12}$/i.test(value); + } + return false; + } + function normalizeTemporaryId(tempId) { + return String(tempId).toLowerCase(); + } + function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { + return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { + const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); + if (resolved !== undefined) { + if (currentRepo && resolved.repo === currentRepo) { + return `#${resolved.number}`; + } + return `${resolved.repo}#${resolved.number}`; + } + return match; + }); + } + function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { + return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { + const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); + if (issueNumber !== undefined) { + return `#${issueNumber}`; + } + return match; + }); + } + function loadTemporaryIdMap() { + const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; + if (!mapJson || mapJson === "{}") { + return new Map(); + } + try { + const mapObject = JSON.parse(mapJson); + const result = new Map(); + for (const [key, value] of Object.entries(mapObject)) { + const normalizedKey = normalizeTemporaryId(key); + if (typeof value === "number") { + const contextRepo = `${context.repo.owner}/${context.repo.repo}`; + result.set(normalizedKey, { repo: contextRepo, number: value }); + } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { + result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); + } + } + return result; + } catch (error) { + if (typeof core !== "undefined") { + core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); + } + return new Map(); + } + } + function resolveIssueNumber(value, temporaryIdMap) { + if (value === undefined || value === null) { + return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; + } + const valueStr = String(value); + if (isTemporaryId(valueStr)) { + const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); + if (resolvedPair !== undefined) { + return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; + } + return { + resolved: null, + wasTemporaryId: true, + errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, + }; + } + const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); + if (isNaN(issueNumber) || issueNumber <= 0) { + return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; + } + const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; + return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; + } + function serializeTemporaryIdMap(tempIdMap) { + const obj = Object.fromEntries(tempIdMap); + return JSON.stringify(obj); + } + function parseAllowedRepos() { + const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; + const set = new Set(); + if (allowedReposEnv) { + allowedReposEnv + .split(",") + .map(repo => repo.trim()) + .filter(repo => repo) + .forEach(repo => set.add(repo)); + } + return set; + } + function getDefaultTargetRepo() { + const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; + if (targetRepoSlug) { + return targetRepoSlug; + } + return `${context.repo.owner}/${context.repo.repo}`; + } + function validateRepo(repo, defaultRepo, allowedRepos) { + if (repo === defaultRepo) { + return { valid: true, error: null }; + } + if (allowedRepos.has(repo)) { + return { valid: true, error: null }; + } + return { + valid: false, + error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, + }; + } + function parseRepoSlug(repoSlug) { + const parts = repoSlug.split("/"); + if (parts.length !== 2 || !parts[0] || !parts[1]) { + return null; + } + return { owner: parts[0], repo: parts[1] }; + } + function addExpirationComment(bodyLines, envVarName, entityType) { + const expiresEnv = process.env[envVarName]; + if (expiresEnv) { + const expiresDays = parseInt(expiresEnv, 10); + if (!isNaN(expiresDays) && expiresDays > 0) { + const expirationDate = new Date(); + expirationDate.setDate(expirationDate.getDate() + expiresDays); + const expirationISO = expirationDate.toISOString(); + bodyLines.push(``); + core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); + } + } + } + function removeDuplicateTitleFromDescription(title, description) { + if (!title || typeof title !== "string") { + return description || ""; + } + if (!description || typeof description !== "string") { + return ""; + } + const trimmedTitle = title.trim(); + const trimmedDescription = description.trim(); + if (!trimmedTitle || !trimmedDescription) { + return trimmedDescription; + } + const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); + if (headerRegex.test(trimmedDescription)) { + return trimmedDescription.replace(headerRegex, "").trim(); + } + return trimmedDescription; + } async function fetchRepoDiscussionInfo(owner, repo) { const repositoryQuery = ` query($owner: String!, $repo: String!) { diff --git a/pkg/workflow/js.go b/pkg/workflow/js.go index d680d9b2d06..f1971a9b449 100644 --- a/pkg/workflow/js.go +++ b/pkg/workflow/js.go @@ -246,6 +246,9 @@ var safeOutputTypeValidatorScript string //go:embed js/repo_helpers.cjs var repoHelpersScript string +//go:embed js/remove_duplicate_title.cjs +var removeDuplicateTitleScript string + //go:embed js/safe_outputs_config.cjs var safeOutputsConfigScript string @@ -314,6 +317,7 @@ func GetJavaScriptSources() map[string]string { "mcp_handler_python.cjs": mcpHandlerPythonScript, "safe_output_type_validator.cjs": safeOutputTypeValidatorScript, "repo_helpers.cjs": repoHelpersScript, + "remove_duplicate_title.cjs": removeDuplicateTitleScript, "safe_outputs_config.cjs": safeOutputsConfigScript, "safe_outputs_append.cjs": safeOutputsAppendScript, "safe_outputs_handlers.cjs": safeOutputsHandlersScript, From fc80ace591bf82bf31c3d747cb4b1938051fad09 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 11 Dec 2025 04:18:23 +0000 Subject: [PATCH 5/5] Add changeset [skip-ci] --- .changeset/patch-remove-duplicate-title.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .changeset/patch-remove-duplicate-title.md diff --git a/.changeset/patch-remove-duplicate-title.md b/.changeset/patch-remove-duplicate-title.md new file mode 100644 index 00000000000..cd2680971fa --- /dev/null +++ b/.changeset/patch-remove-duplicate-title.md @@ -0,0 +1,7 @@ +--- +"gh-aw": patch +--- + +Add a JavaScript helper that removes duplicate titles from safe output descriptions and register it in the bundler. + +The helper `removeDuplicateTitleFromDescription` is used by create/update scripts for issues, discussions, and pull requests to avoid repeating the title in the description body.