diff --git a/.github/workflows/agentics-maintenance.yml b/.github/workflows/agentics-maintenance.yml index 978c48c15f..bcebc75c79 100644 --- a/.github/workflows/agentics-maintenance.yml +++ b/.github/workflows/agentics-maintenance.yml @@ -17,286 +17,179 @@ jobs: uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 with: script: | - // @ts-check -/// - -/** - * Maximum number of discussions to update per run - */ -const MAX_UPDATES_PER_RUN = 100; - -/** - * Delay between GraphQL API calls in milliseconds to avoid rate limiting - */ -const GRAPHQL_DELAY_MS = 500; - -/** - * Delay execution for a specified number of milliseconds - * @param {number} ms - Milliseconds to delay - * @returns {Promise} - */ -function delay(ms) { - return new Promise(resolve => setTimeout(resolve, ms)); -} - -/** - * Search for open discussions with expiration markers - * @param {any} github - GitHub GraphQL instance - * @param {string} owner - Repository owner - * @param {string} repo - Repository name - * @returns {Promise>} Matching discussions - */ -async function searchDiscussionsWithExpiration(github, owner, repo) { - const discussions = []; - let hasNextPage = true; - let cursor = null; - - while (hasNextPage) { - const query = ` - query($owner: String!, $repo: String!, $cursor: String) { - repository(owner: $owner, name: $repo) { - discussions(first: 100, after: $cursor, states: [OPEN]) { - pageInfo { - hasNextPage - endCursor + const MAX_UPDATES_PER_RUN = 100; + const GRAPHQL_DELAY_MS = 500; + function delay(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); } - nodes { - id - number - title - url - body - createdAt + async function searchDiscussionsWithExpiration(github, owner, repo) { + const discussions = []; + let hasNextPage = true; + let cursor = null; + while (hasNextPage) { + const query = ` + query($owner: String!, $repo: String!, $cursor: String) { + repository(owner: $owner, name: $repo) { + discussions(first: 100, after: $cursor, states: [OPEN]) { + pageInfo { + hasNextPage + endCursor + } + nodes { + id + number + title + url + body + createdAt + } + } + } + } + `; + const result = await github.graphql(query, { + owner: owner, + repo: repo, + cursor: cursor, + }); + if (!result || !result.repository || !result.repository.discussions) { + break; + } + const nodes = result.repository.discussions.nodes || []; + for (const discussion of nodes) { + const agenticPattern = /^> AI generated by/m; + const isAgenticWorkflow = discussion.body && agenticPattern.test(discussion.body); + if (!isAgenticWorkflow) { + continue; + } + const expirationPattern = //; + const match = discussion.body ? discussion.body.match(expirationPattern) : null; + if (match) { + discussions.push(discussion); + } + } + hasNextPage = result.repository.discussions.pageInfo.hasNextPage; + cursor = result.repository.discussions.pageInfo.endCursor; + } + return discussions; } - } - } - } - `; - - const result = await github.graphql(query, { - owner: owner, - repo: repo, - cursor: cursor, - }); - - if (!result || !result.repository || !result.repository.discussions) { - break; - } - - const nodes = result.repository.discussions.nodes || []; - - // Filter for discussions with agentic workflow markers and expiration comments - for (const discussion of nodes) { - // Check if created by an agentic workflow (body contains "> AI generated by" at start of line) - const agenticPattern = /^> AI generated by/m; - const isAgenticWorkflow = discussion.body && agenticPattern.test(discussion.body); - - if (!isAgenticWorkflow) { - continue; - } - - // Check if has expiration marker - const expirationPattern = //; - const match = discussion.body ? discussion.body.match(expirationPattern) : null; - - if (match) { - discussions.push(discussion); - } - } - - hasNextPage = result.repository.discussions.pageInfo.hasNextPage; - cursor = result.repository.discussions.pageInfo.endCursor; - } - - return discussions; -} - -/** - * Extract expiration date from discussion body - * @param {string} body - Discussion body - * @returns {Date|null} Expiration date or null if not found/invalid - */ -function extractExpirationDate(body) { - const expirationPattern = //; - const match = body.match(expirationPattern); - - if (!match) { - return null; - } - - const expirationISO = match[1].trim(); - const expirationDate = new Date(expirationISO); - - // Validate the date - if (isNaN(expirationDate.getTime())) { - return null; - } - - return expirationDate; -} - -/** - * Validate discussion creation date - * @param {string} createdAt - ISO 8601 creation date - * @returns {boolean} True if valid - */ -function validateCreationDate(createdAt) { - const creationDate = new Date(createdAt); - return !isNaN(creationDate.getTime()); -} - -/** - * Add comment to a GitHub Discussion using GraphQL - * @param {any} github - GitHub GraphQL instance - * @param {string} discussionId - Discussion node ID - * @param {string} message - Comment body - * @returns {Promise<{id: string, url: string}>} Comment details - */ -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; -} - -/** - * Close a GitHub Discussion as OUTDATED using GraphQL - * @param {any} github - GitHub GraphQL instance - * @param {string} discussionId - Discussion node ID - * @returns {Promise<{id: string, url: string}>} Discussion details - */ -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 main() { - const owner = context.repo.owner; - const repo = context.repo.repo; - - core.info(`Searching for expired discussions in ${owner}/${repo}`); - - // Search for discussions with expiration markers - const discussionsWithExpiration = await searchDiscussionsWithExpiration(github, owner, repo); - - if (discussionsWithExpiration.length === 0) { - core.info("No discussions with expiration markers found"); - return; - } - - core.info(`Found ${discussionsWithExpiration.length} discussion(s) with expiration markers`); - - // Check which discussions are expired - const now = new Date(); - const expiredDiscussions = []; - - for (const discussion of discussionsWithExpiration) { - // Validate creation date - if (!validateCreationDate(discussion.createdAt)) { - core.warning(`Discussion #${discussion.number} has invalid creation date, skipping`); - continue; - } - - // Extract and validate expiration date - const expirationDate = extractExpirationDate(discussion.body); - if (!expirationDate) { - core.warning(`Discussion #${discussion.number} has invalid expiration date, skipping`); - continue; - } - - // Check if expired - if (now >= expirationDate) { - expiredDiscussions.push({ - ...discussion, - expirationDate: expirationDate, - }); - } - } - - if (expiredDiscussions.length === 0) { - core.info("No expired discussions found"); - return; - } - - core.info(`Found ${expiredDiscussions.length} expired discussion(s)`); - - // Limit to MAX_UPDATES_PER_RUN - const discussionsToClose = expiredDiscussions.slice(0, MAX_UPDATES_PER_RUN); - - if (expiredDiscussions.length > MAX_UPDATES_PER_RUN) { - core.warning(`Found ${expiredDiscussions.length} expired discussions, but only closing the first ${MAX_UPDATES_PER_RUN}`); - } - - let closedCount = 0; - const closedDiscussions = []; - - for (let i = 0; i < discussionsToClose.length; i++) { - const discussion = discussionsToClose[i]; - - try { - const closingMessage = `This discussion was automatically closed because it expired on ${discussion.expirationDate.toISOString()}.`; - - // Add comment first - core.info(`Adding closing comment to discussion #${discussion.number}`); - await addDiscussionComment(github, discussion.id, closingMessage); - - // Then close the discussion as outdated - core.info(`Closing discussion #${discussion.number} as outdated`); - await closeDiscussionAsOutdated(github, discussion.id); - - closedDiscussions.push({ - number: discussion.number, - url: discussion.url, - title: discussion.title, - }); - - closedCount++; - 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)}`); - // Continue with other discussions even if one fails - } - - // Add delay between GraphQL operations to avoid rate limiting (except for the last item) - if (i < discussionsToClose.length - 1) { - await delay(GRAPHQL_DELAY_MS); - } - } - - // Write summary - if (closedCount > 0) { - let summaryContent = `## Closed Expired Discussions\n\n`; - summaryContent += `Closed **${closedCount}** expired discussion(s):\n\n`; - for (const closed of closedDiscussions) { - summaryContent += `- Discussion #${closed.number}: [${closed.title}](${closed.url})\n`; - } - await core.summary.addRaw(summaryContent).write(); - } - - core.info(`Successfully closed ${closedCount} expired discussion(s)`); -} - -await main(); - + function extractExpirationDate(body) { + const expirationPattern = //; + const match = body.match(expirationPattern); + if (!match) { + return null; + } + const expirationISO = match[1].trim(); + const expirationDate = new Date(expirationISO); + if (isNaN(expirationDate.getTime())) { + return null; + } + return expirationDate; + } + function validateCreationDate(createdAt) { + const creationDate = new Date(createdAt); + return !isNaN(creationDate.getTime()); + } + 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 main() { + const owner = context.repo.owner; + const repo = context.repo.repo; + core.info(`Searching for expired discussions in ${owner}/${repo}`); + const discussionsWithExpiration = await searchDiscussionsWithExpiration(github, owner, repo); + if (discussionsWithExpiration.length === 0) { + core.info("No discussions with expiration markers found"); + return; + } + core.info(`Found ${discussionsWithExpiration.length} discussion(s) with expiration markers`); + const now = new Date(); + const expiredDiscussions = []; + for (const discussion of discussionsWithExpiration) { + if (!validateCreationDate(discussion.createdAt)) { + core.warning(`Discussion #${discussion.number} has invalid creation date, skipping`); + continue; + } + const expirationDate = extractExpirationDate(discussion.body); + if (!expirationDate) { + core.warning(`Discussion #${discussion.number} has invalid expiration date, skipping`); + continue; + } + if (now >= expirationDate) { + expiredDiscussions.push({ + ...discussion, + expirationDate: expirationDate, + }); + } + } + if (expiredDiscussions.length === 0) { + core.info("No expired discussions found"); + return; + } + core.info(`Found ${expiredDiscussions.length} expired discussion(s)`); + const discussionsToClose = expiredDiscussions.slice(0, MAX_UPDATES_PER_RUN); + if (expiredDiscussions.length > MAX_UPDATES_PER_RUN) { + core.warning(`Found ${expiredDiscussions.length} expired discussions, but only closing the first ${MAX_UPDATES_PER_RUN}`); + } + let closedCount = 0; + const closedDiscussions = []; + for (let i = 0; i < discussionsToClose.length; i++) { + const discussion = discussionsToClose[i]; + try { + const closingMessage = `This discussion was automatically closed because it expired on ${discussion.expirationDate.toISOString()}.`; + 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, + title: discussion.title, + }); + closedCount++; + 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); + } + } + if (closedCount > 0) { + let summaryContent = `## Closed Expired Discussions\n\n`; + summaryContent += `Closed **${closedCount}** expired discussion(s):\n\n`; + for (const closed of closedDiscussions) { + summaryContent += `- Discussion #${closed.number}: [${closed.title}](${closed.url})\n`; + } + await core.summary.addRaw(summaryContent).write(); + } + core.info(`Successfully closed ${closedCount} expired discussion(s)`); + } + await main(); diff --git a/.github/workflows/release.lock.yml b/.github/workflows/release.lock.yml index 160a26701e..a73e060474 100644 --- a/.github/workflows/release.lock.yml +++ b/.github/workflows/release.lock.yml @@ -6119,19 +6119,19 @@ jobs: - name: Download Go modules run: go mod download - name: Generate SBOM (SPDX format) - uses: anchore/sbom-action@fbfd9c6c189226748411491745178e0c2017392d # v0 + uses: anchore/sbom-action@fbfd9c6c189226748411491745178e0c2017392d # v0.20.10 with: artifact-name: sbom.spdx.json format: spdx-json output-file: sbom.spdx.json - name: Generate SBOM (CycloneDX format) - uses: anchore/sbom-action@fbfd9c6c189226748411491745178e0c2017392d # v0 + uses: anchore/sbom-action@fbfd9c6c189226748411491745178e0c2017392d # v0.20.10 with: artifact-name: sbom.cdx.json format: cyclonedx-json output-file: sbom.cdx.json - name: Upload SBOM artifacts - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5 with: name: sbom-artifacts path: | diff --git a/pkg/workflow/js/close_expired_discussions.cjs b/pkg/workflow/js/close_expired_discussions.cjs index 7af39b7a6c..8cf285b39d 100644 --- a/pkg/workflow/js/close_expired_discussions.cjs +++ b/pkg/workflow/js/close_expired_discussions.cjs @@ -1,5 +1,5 @@ // @ts-check -/// +// /** * Maximum number of discussions to update per run diff --git a/pkg/workflow/maintenance_workflow.go b/pkg/workflow/maintenance_workflow.go index 86d57e164f..eb1cc910a2 100644 --- a/pkg/workflow/maintenance_workflow.go +++ b/pkg/workflow/maintenance_workflow.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "path/filepath" + "strings" "github.com/githubnext/gh-aw/pkg/logger" ) @@ -34,9 +35,10 @@ func GenerateMaintenanceWorkflow(workflowDataList []*WorkflowData, workflowDir s maintenanceLog.Print("Generating maintenance workflow for expired discussions") - // Create the maintenance workflow content - script := getMaintenanceScript() - content := fmt.Sprintf(`name: Agentics Maintenance + // Create the maintenance workflow content using strings.Builder + var yaml strings.Builder + + yaml.WriteString(`name: Agentics Maintenance on: schedule: @@ -55,8 +57,13 @@ jobs: uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 with: script: | - %s -`, script) +`) + + // Add the JavaScript script with proper indentation + script := getMaintenanceScript() + WriteJavaScriptToYAML(&yaml, script) + + content := yaml.String() // Write the maintenance workflow file maintenanceFile := filepath.Join(workflowDir, "agentics-maintenance.yml")