From 8ba96358f3b786e2dc7492d3ed397d0b1a51ec4c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 24 Mar 2026 16:36:06 +0000 Subject: [PATCH 1/4] Initial plan From 137f26b40845a74148a08162149f896faa755fe3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 24 Mar 2026 17:04:06 +0000 Subject: [PATCH 2/4] feat: add idempotent issue creation to prevent same-day duplicate reports Add `idempotent: true` option to the `create-issue` safe-output handler. When enabled, the handler searches for an existing open issue created today (UTC) with the same workflow-id or close-older-key marker before creating a new one. If found, creation is silently skipped and the max-count slot is not consumed. This prevents same-day duplicate issues from occurring when a scheduled workflow (like contribution-check) runs multiple times per day. Also enable `idempotent: true` in contribution-check.md workflow to fix the duplicate report issue described in the issue. Changes: - pkg/workflow/create_issue.go: add Idempotent field to CreateIssuesConfig - pkg/workflow/compiler_safe_outputs_config.go: wire idempotent to handler config - pkg/parser/schemas/main_workflow_schema.json: add idempotent to schema - actions/setup/js/create_issue.cjs: implement dedupe-before-create logic - actions/setup/js/close_older_issues.cjs: expose created_at in search results - actions/setup/js/create_issue.test.cjs: add 6 tests for idempotent mode - .github/workflows/contribution-check.md: enable idempotent: true - docs: document new idempotent option in safe-outputs and frontmatter-full Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> Agent-Logs-Url: https://github.com/github/gh-aw/sessions/081eb637-d376-4a2e-8ac0-e58c24da8a84 --- .github/workflows/contribution-check.lock.yml | 6 +- .github/workflows/contribution-check.md | 1 + actions/setup/js/close_older_issues.cjs | 1 + actions/setup/js/create_issue.cjs | 46 +++++- actions/setup/js/create_issue.test.cjs | 144 ++++++++++++++++++ .../docs/reference/frontmatter-full.md | 8 + .../content/docs/reference/safe-outputs.md | 20 +++ pkg/parser/schemas/main_workflow_schema.json | 5 + pkg/workflow/compiler_safe_outputs_config.go | 1 + pkg/workflow/create_issue.go | 3 +- 10 files changed, 230 insertions(+), 5 deletions(-) diff --git a/.github/workflows/contribution-check.lock.yml b/.github/workflows/contribution-check.lock.yml index 837c3c90d5d..cece33a1281 100644 --- a/.github/workflows/contribution-check.lock.yml +++ b/.github/workflows/contribution-check.lock.yml @@ -21,7 +21,7 @@ # For more information: https://github.github.com/gh-aw/introduction/overview/ # # -# gh-aw-metadata: {"schema_version":"v3","frontmatter_hash":"f065feb7ad239a734f6def6a44417a260407d25d6d0fb58e829e44ca32ef065c","strict":true,"agent_id":"copilot"} +# gh-aw-metadata: {"schema_version":"v3","frontmatter_hash":"68fe72d088ae680ad10bb3719d3972a3d5f227f243c59c102c42e183f8196378","strict":true,"agent_id":"copilot"} name: "Contribution Check" "on": @@ -349,7 +349,7 @@ jobs: mkdir -p /tmp/gh-aw/safeoutputs mkdir -p /tmp/gh-aw/mcp-logs/safeoutputs cat > ${RUNNER_TEMP}/gh-aw/safeoutputs/config.json << 'GH_AW_SAFE_OUTPUTS_CONFIG_EOF' - {"add_comment":{"hide_older_comments":true,"max":10,"target":"*","target-repo":"${{ vars.TARGET_REPOSITORY }}"},"add_labels":{"allowed":["spam","needs-work","outdated","lgtm"],"max":4,"target":"*","target-repo":"${{ vars.TARGET_REPOSITORY }}"},"create_issue":{"close_older_issues":true,"expires":24,"labels":["contribution-report"],"max":1,"title_prefix":"[Contribution Check Report]"},"missing_data":{},"missing_tool":{},"noop":{"max":1,"report-as-issue":"true"}} + {"add_comment":{"hide_older_comments":true,"max":10,"target":"*","target-repo":"${{ vars.TARGET_REPOSITORY }}"},"add_labels":{"allowed":["spam","needs-work","outdated","lgtm"],"max":4,"target":"*","target-repo":"${{ vars.TARGET_REPOSITORY }}"},"create_issue":{"close_older_issues":true,"expires":24,"idempotent":true,"labels":["contribution-report"],"max":1,"title_prefix":"[Contribution Check Report]"},"missing_data":{},"missing_tool":{},"noop":{"max":1,"report-as-issue":"true"}} GH_AW_SAFE_OUTPUTS_CONFIG_EOF - name: Write Safe Outputs Tools run: | @@ -1102,7 +1102,7 @@ jobs: GH_AW_ALLOWED_DOMAINS: "api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,github.com,host.docker.internal,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,ppa.launchpad.net,raw.githubusercontent.com,registry.npmjs.org,s.symcb.com,s.symcd.com,security.ubuntu.com,telemetry.enterprise.githubcopilot.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com,www.googleapis.com" GITHUB_SERVER_URL: ${{ github.server_url }} GITHUB_API_URL: ${{ github.api_url }} - GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG: "{\"add_comment\":{\"hide_older_comments\":true,\"max\":10,\"target\":\"*\",\"target-repo\":\"${{ vars.TARGET_REPOSITORY }}\"},\"add_labels\":{\"allowed\":[\"spam\",\"needs-work\",\"outdated\",\"lgtm\"],\"max\":4,\"target\":\"*\",\"target-repo\":\"${{ vars.TARGET_REPOSITORY }}\"},\"create_issue\":{\"close_older_issues\":true,\"expires\":24,\"labels\":[\"contribution-report\"],\"max\":1,\"title_prefix\":\"[Contribution Check Report]\"},\"missing_data\":{},\"missing_tool\":{},\"noop\":{\"max\":1,\"report-as-issue\":\"true\"}}" + GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG: "{\"add_comment\":{\"hide_older_comments\":true,\"max\":10,\"target\":\"*\",\"target-repo\":\"${{ vars.TARGET_REPOSITORY }}\"},\"add_labels\":{\"allowed\":[\"spam\",\"needs-work\",\"outdated\",\"lgtm\"],\"max\":4,\"target\":\"*\",\"target-repo\":\"${{ vars.TARGET_REPOSITORY }}\"},\"create_issue\":{\"close_older_issues\":true,\"expires\":24,\"idempotent\":true,\"labels\":[\"contribution-report\"],\"max\":1,\"title_prefix\":\"[Contribution Check Report]\"},\"missing_data\":{},\"missing_tool\":{},\"noop\":{\"max\":1,\"report-as-issue\":\"true\"}}" with: github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | diff --git a/.github/workflows/contribution-check.md b/.github/workflows/contribution-check.md index d0133c81299..ffe84a7e48a 100644 --- a/.github/workflows/contribution-check.md +++ b/.github/workflows/contribution-check.md @@ -23,6 +23,7 @@ safe-outputs: labels: - contribution-report close-older-issues: true + idempotent: true expires: 1d add-labels: allowed: [spam, needs-work, outdated, lgtm] diff --git a/actions/setup/js/close_older_issues.cjs b/actions/setup/js/close_older_issues.cjs index e630caf5541..f9725376cd3 100644 --- a/actions/setup/js/close_older_issues.cjs +++ b/actions/setup/js/close_older_issues.cjs @@ -121,6 +121,7 @@ async function searchOlderIssues(github, owner, repo, workflowId, excludeNumber, title: item.title, html_url: item.html_url, labels: item.labels || [], + created_at: item.created_at, })); core.info(`Filtering complete:`); diff --git a/actions/setup/js/create_issue.cjs b/actions/setup/js/create_issue.cjs index c3c8f7ff4b3..6e685639849 100644 --- a/actions/setup/js/create_issue.cjs +++ b/actions/setup/js/create_issue.cjs @@ -40,7 +40,7 @@ const { ERR_VALIDATION } = require("./error_codes.cjs"); const { renderTemplateFromFile } = require("./messages_core.cjs"); const { createExpirationLine, addExpirationToFooter } = require("./ephemerals.cjs"); const { MAX_SUB_ISSUES, getSubIssueCount } = require("./sub_issue_helpers.cjs"); -const { closeOlderIssues } = require("./close_older_issues.cjs"); +const { closeOlderIssues, searchOlderIssues } = require("./close_older_issues.cjs"); const { parseBoolTemplatable } = require("./templatable.cjs"); const { tryEnforceArrayLimit } = require("./limit_enforcement_helpers.cjs"); const { logStagedPreviewInfo } = require("./staged_preview.cjs"); @@ -205,6 +205,7 @@ async function main(config = {}) { const { defaultTargetRepo, allowedRepos } = resolveTargetRepoConfig(config); const groupEnabled = parseBoolTemplatable(config.group, false); const closeOlderIssuesEnabled = parseBoolTemplatable(config.close_older_issues, false); + const idempotentEnabled = parseBoolTemplatable(config.idempotent, false); const rawCloseOlderKey = config.close_older_key ? String(config.close_older_key) : ""; const closeOlderKey = rawCloseOlderKey ? normalizeCloseOlderKey(rawCloseOlderKey) : ""; if (rawCloseOlderKey && !closeOlderKey) { @@ -248,6 +249,12 @@ async function main(config = {}) { core.info(` Using explicit close-older-key: "${closeOlderKey}"`); } } + if (idempotentEnabled) { + core.info(`Idempotent mode enabled: creation will be skipped if an open issue was already created today`); + if (!closeOlderKey && !process.env.GH_AW_WORKFLOW_ID) { + core.warning(`Idempotent mode has no effect: neither close-older-key nor GH_AW_WORKFLOW_ID is set — issues cannot be searched`); + } + } // Track how many items we've processed for max limit let processedCount = 0; @@ -480,6 +487,43 @@ async function main(config = {}) { bodyLines.push(""); const body = bodyLines.join("\n").trim(); + // Idempotency check: if enabled, search for an existing open issue created today + // before attempting to create a new one. This prevents same-day duplicate issues + // when the workflow reruns (e.g. scheduled every 4 hours, two runs on the same day). + // NOTE: processedCount was already incremented above. If we skip creation, we undo + // the increment here so the max-count slot is not consumed for idempotent skips. + if (idempotentEnabled && (closeOlderKey || workflowId)) { + const today = new Date().toISOString().split("T")[0]; // YYYY-MM-DD (UTC) + try { + const existingIssues = await searchOlderIssues( + github, + repoParts.owner, + repoParts.repo, + workflowId, + 0, // no issue to exclude — this is a pre-creation check + callerWorkflowId, + closeOlderKey + ); + const todayIssue = existingIssues.find(issue => { + const createdDate = issue.created_at ? String(issue.created_at).split("T")[0] : ""; + return createdDate === today; + }); + if (todayIssue) { + core.info(`Idempotent: skipping issue creation — open issue #${todayIssue.number} was already created today (${today}): ${todayIssue.html_url}`); + // Undo the processedCount increment so the max limit is not consumed + processedCount--; + return { + success: true, + skipped: true, + reason: `idempotent: open issue #${todayIssue.number} already created today`, + }; + } + } catch (error) { + // Log but do not abort — fall through to normal creation + core.warning(`Idempotent pre-check failed: ${getErrorMessage(error)} — proceeding with issue creation`); + } + } + core.info(`Creating issue in ${qualifiedItemRepo} with title: ${title}`); core.info(`Labels: ${labels.join(", ")}`); if (assignees.length > 0) { diff --git a/actions/setup/js/create_issue.test.cjs b/actions/setup/js/create_issue.test.cjs index 2c2d2069233..0894551f39f 100644 --- a/actions/setup/js/create_issue.test.cjs +++ b/actions/setup/js/create_issue.test.cjs @@ -466,4 +466,148 @@ describe("create_issue", () => { expect(result.error).toContain("received 6"); }); }); + + describe("idempotent mode", () => { + it("should skip creation if an open issue was already created today", async () => { + const today = new Date().toISOString().split("T")[0]; + mockGithub.rest.search.issuesAndPullRequests.mockResolvedValueOnce({ + data: { + total_count: 1, + items: [ + { + number: 99, + title: "[Contribution Check Report] Contribution Check", + html_url: "https://github.com/test-owner/test-repo/issues/99", + body: "", + created_at: `${today}T10:00:00Z`, + state: "open", + pull_request: undefined, + }, + ], + }, + }); + + const handler = await main({ idempotent: true, close_older_issues: true }); + const result = await handler({ title: "Test Issue", body: "Test body" }); + + expect(result.success).toBe(true); + expect(result.skipped).toBe(true); + expect(result.reason).toContain("idempotent"); + expect(result.reason).toContain("99"); + expect(mockGithub.rest.issues.create).not.toHaveBeenCalled(); + }); + + it("should create issue if no open issue was created today", async () => { + const yesterday = new Date(Date.now() - 86400000).toISOString().split("T")[0]; + mockGithub.rest.search.issuesAndPullRequests.mockResolvedValueOnce({ + data: { + total_count: 1, + items: [ + { + number: 50, + title: "[Contribution Check Report] Contribution Check", + html_url: "https://github.com/test-owner/test-repo/issues/50", + body: "", + created_at: `${yesterday}T10:00:00Z`, + state: "open", + pull_request: undefined, + }, + ], + }, + }); + + const handler = await main({ idempotent: true, close_older_issues: true }); + const result = await handler({ title: "Test Issue", body: "Test body" }); + + expect(result.success).toBe(true); + expect(result.skipped).toBeUndefined(); + expect(mockGithub.rest.issues.create).toHaveBeenCalledOnce(); + }); + + it("should create issue if no existing issues are found", async () => { + mockGithub.rest.search.issuesAndPullRequests.mockResolvedValueOnce({ + data: { total_count: 0, items: [] }, + }); + + const handler = await main({ idempotent: true, close_older_issues: true }); + const result = await handler({ title: "Test Issue", body: "Test body" }); + + expect(result.success).toBe(true); + expect(result.skipped).toBeUndefined(); + expect(mockGithub.rest.issues.create).toHaveBeenCalledOnce(); + }); + + it("should proceed with creation if idempotent pre-check throws", async () => { + mockGithub.rest.search.issuesAndPullRequests.mockRejectedValueOnce(new Error("Search API error")); + + const handler = await main({ idempotent: true, close_older_issues: true }); + const result = await handler({ title: "Test Issue", body: "Test body" }); + + expect(result.success).toBe(true); + expect(result.skipped).toBeUndefined(); + expect(mockGithub.rest.issues.create).toHaveBeenCalledOnce(); + expect(mockCore.warning).toHaveBeenCalledWith(expect.stringContaining("Idempotent pre-check failed")); + }); + + it("should not skip if idempotent is false even with today's issue", async () => { + const today = new Date().toISOString().split("T")[0]; + mockGithub.rest.search.issuesAndPullRequests.mockResolvedValue({ + data: { + total_count: 1, + items: [ + { + number: 77, + title: "Existing Issue", + html_url: "https://github.com/test-owner/test-repo/issues/77", + body: "", + created_at: `${today}T10:00:00Z`, + state: "open", + pull_request: undefined, + }, + ], + }, + }); + + // idempotent is false (default) — creation should NOT be skipped + const handler = await main({ close_older_issues: false }); + const result = await handler({ title: "Test Issue", body: "Test body" }); + + expect(result.success).toBe(true); + expect(result.skipped).toBeUndefined(); + expect(mockGithub.rest.issues.create).toHaveBeenCalledOnce(); + }); + + it("should not consume max count slot when skipped due to idempotency", async () => { + const today = new Date().toISOString().split("T")[0]; + mockGithub.rest.search.issuesAndPullRequests.mockResolvedValue({ + data: { + total_count: 1, + items: [ + { + number: 88, + title: "Existing Issue", + html_url: "https://github.com/test-owner/test-repo/issues/88", + body: "", + created_at: `${today}T10:00:00Z`, + state: "open", + pull_request: undefined, + }, + ], + }, + }); + + const handler = await main({ idempotent: true, close_older_issues: true, max: 1 }); + + // First call is skipped due to idempotency — max slot should not be consumed + const result1 = await handler({ title: "First Issue", body: "Body" }); + expect(result1.skipped).toBe(true); + + // Reset search mock so second call also finds a today issue — also skipped + const result2 = await handler({ title: "Second Issue", body: "Body" }); + expect(result2.skipped).toBe(true); + + // Neither call should have created an issue + expect(mockGithub.rest.issues.create).not.toHaveBeenCalled(); + }); + }); }); diff --git a/docs/src/content/docs/reference/frontmatter-full.md b/docs/src/content/docs/reference/frontmatter-full.md index 82805bf870a..dcdf01f04af 100644 --- a/docs/src/content/docs/reference/frontmatter-full.md +++ b/docs/src/content/docs/reference/frontmatter-full.md @@ -2504,6 +2504,14 @@ safe-outputs: # (optional) close-older-key: "example-value" + # When true, skip issue creation if an open issue with the same close-older-key + # (or workflow-id marker when no key is set) was already created today (UTC). + # Prevents same-day duplicate issues when the workflow is re-run (e.g. a scheduled + # workflow that runs every few hours). Works best when combined with + # close-older-issues: true. + # (optional) + idempotent: true + # Controls whether AI-generated footer is added to the issue. When false, the # visible footer content is omitted but XML markers (workflow-id, tracker-id, # metadata) are still included for searchability. Defaults to true. diff --git a/docs/src/content/docs/reference/safe-outputs.md b/docs/src/content/docs/reference/safe-outputs.md index 9f5b86467d7..6b5cd7954f9 100644 --- a/docs/src/content/docs/reference/safe-outputs.md +++ b/docs/src/content/docs/reference/safe-outputs.md @@ -161,6 +161,26 @@ When enabled: - Maximum 10 older issues will be closed - Only runs if the new issue creation succeeds +#### Idempotent Issue Creation + +The `idempotent` field (default: `false`) prevents same-day duplicate issues when a workflow reruns. When enabled, the handler searches for an existing open issue created **today (UTC)** with the same workflow-id marker (or `close-older-key` if set) before creating a new one. If a matching issue already exists, creation is silently skipped. + +```yaml wrap +safe-outputs: + create-issue: + title-prefix: "[Contribution Check Report]" + labels: [report] + close-older-issues: true + idempotent: true +``` + +This is useful for scheduled workflows (e.g. every 4 hours) that produce recurring daily reports: only one report issue is created per day, eliminating duplicate open/closed issues from multiple same-day runs. + +- Performs a pre-creation search for open issues matching the workflow-id or `close-older-key` +- If a matching issue was created on today's UTC date, creation is skipped +- The max-count slot is not consumed when a creation is skipped +- On failure of the pre-check, creation proceeds normally as a fallback + #### Searching for Workflow-Created Items All items created by workflows (issues, pull requests, discussions, and comments) include a hidden **workflow-id marker** in their body: diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index 35076622bda..7e5e69d7f8e 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -4516,6 +4516,11 @@ "minLength": 1, "pattern": "\\S" }, + "idempotent": { + "type": "boolean", + "description": "When true, skip issue creation if an open issue with the same close-older-key (or workflow-id marker when no key is set) was already created today (UTC). Prevents same-day duplicate issues when the workflow is re-run. Works best when combined with close-older-issues: true.", + "default": false + }, "footer": { "type": "boolean", "description": "Controls whether AI-generated footer is added to the issue. When false, the visible footer content is omitted but XML markers (workflow-id, tracker-id, metadata) are still included for searchability. Defaults to true.", diff --git a/pkg/workflow/compiler_safe_outputs_config.go b/pkg/workflow/compiler_safe_outputs_config.go index 16005d8f4e4..a6736a0712c 100644 --- a/pkg/workflow/compiler_safe_outputs_config.go +++ b/pkg/workflow/compiler_safe_outputs_config.go @@ -163,6 +163,7 @@ var handlerRegistry = map[string]handlerBuilder{ AddTemplatableBool("group", c.Group). AddTemplatableBool("close_older_issues", c.CloseOlderIssues). AddIfNotEmpty("close_older_key", c.CloseOlderKey). + AddTemplatableBool("idempotent", c.Idempotent). AddTemplatableBool("footer", getEffectiveFooterForTemplatable(c.Footer, cfg.Footer)). AddIfNotEmpty("github-token", c.GitHubToken). AddIfTrue("staged", c.Staged). diff --git a/pkg/workflow/create_issue.go b/pkg/workflow/create_issue.go index e70e5f90d44..9fc5cd247c9 100644 --- a/pkg/workflow/create_issue.go +++ b/pkg/workflow/create_issue.go @@ -19,6 +19,7 @@ type CreateIssuesConfig struct { AllowedRepos []string `yaml:"allowed-repos,omitempty"` // List of additional repositories that issues can be created in CloseOlderIssues *string `yaml:"close-older-issues,omitempty"` // When true, close older issues with same title prefix or labels as "not planned" CloseOlderKey string `yaml:"close-older-key,omitempty"` // Optional explicit deduplication key for close-older matching. When set, uses gh-aw-close-key marker instead of workflow-id markers. + Idempotent *string `yaml:"idempotent,omitempty"` // When true, skip creation if an open issue with the same key was already created today (UTC). Prevents same-day duplicate issues on reruns. Works best with close-older-issues: true. Expires int `yaml:"expires,omitempty"` // Hours until the issue expires and should be automatically closed Group *string `yaml:"group,omitempty"` // If true, group issues as sub-issues under a parent issue (workflow ID is used as group identifier) Footer *string `yaml:"footer,omitempty"` // Controls whether AI-generated footer is added. When false, visible footer is omitted but XML markers are kept. @@ -41,7 +42,7 @@ func (c *Compiler) parseIssuesConfig(outputMap map[string]any) *CreateIssuesConf // Pre-process templatable bool fields: convert literal booleans to strings so that // GitHub Actions expression strings (e.g. "${{ inputs.close-older-issues }}") are also accepted. - for _, field := range []string{"close-older-issues", "group", "footer"} { + for _, field := range []string{"close-older-issues", "group", "footer", "idempotent"} { if err := preprocessBoolFieldAsString(configData, field, createIssueLog); err != nil { createIssueLog.Printf("Invalid %s value: %v", field, err) return nil From 4911a6121141b6edde1c2bbea67b8c5cc62171e2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 24 Mar 2026 17:22:30 +0000 Subject: [PATCH 3/4] feat: rename idempotent to group-by-day, post as comment instead of skip Per feedback, rename the `idempotent` feature to `group-by-day` and change the behavior: instead of skipping issue creation when a same-day issue is found, post the new content as a comment on the existing issue so all same-day runs are grouped in one place. - Rename config key: idempotent -> group-by-day (Go, JS, schema, docs) - Change behavior: post body as comment on existing today-issue - Return { grouped: true, existingIssueNumber, commentUrl } on grouping - Update contribution-check.md to use group-by-day: true - Update tests and documentation Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> Agent-Logs-Url: https://github.com/github/gh-aw/sessions/45df665e-9d93-459c-8451-8aec175aba33 --- .github/workflows/contribution-check.lock.yml | 6 +-- .github/workflows/contribution-check.md | 2 +- actions/setup/js/create_issue.cjs | 34 ++++++------ actions/setup/js/create_issue.test.cjs | 53 ++++++++++--------- .../docs/reference/frontmatter-full.md | 10 ++-- .../content/docs/reference/safe-outputs.md | 14 ++--- pkg/parser/schemas/main_workflow_schema.json | 4 +- pkg/workflow/compiler_safe_outputs_config.go | 2 +- pkg/workflow/create_issue.go | 4 +- 9 files changed, 69 insertions(+), 60 deletions(-) diff --git a/.github/workflows/contribution-check.lock.yml b/.github/workflows/contribution-check.lock.yml index cece33a1281..ac9a27254bc 100644 --- a/.github/workflows/contribution-check.lock.yml +++ b/.github/workflows/contribution-check.lock.yml @@ -21,7 +21,7 @@ # For more information: https://github.github.com/gh-aw/introduction/overview/ # # -# gh-aw-metadata: {"schema_version":"v3","frontmatter_hash":"68fe72d088ae680ad10bb3719d3972a3d5f227f243c59c102c42e183f8196378","strict":true,"agent_id":"copilot"} +# gh-aw-metadata: {"schema_version":"v3","frontmatter_hash":"c6a3f4c1233714c024d91fc9b1c524d86770132bae2044a11ce34b31aeaaa870","strict":true,"agent_id":"copilot"} name: "Contribution Check" "on": @@ -349,7 +349,7 @@ jobs: mkdir -p /tmp/gh-aw/safeoutputs mkdir -p /tmp/gh-aw/mcp-logs/safeoutputs cat > ${RUNNER_TEMP}/gh-aw/safeoutputs/config.json << 'GH_AW_SAFE_OUTPUTS_CONFIG_EOF' - {"add_comment":{"hide_older_comments":true,"max":10,"target":"*","target-repo":"${{ vars.TARGET_REPOSITORY }}"},"add_labels":{"allowed":["spam","needs-work","outdated","lgtm"],"max":4,"target":"*","target-repo":"${{ vars.TARGET_REPOSITORY }}"},"create_issue":{"close_older_issues":true,"expires":24,"idempotent":true,"labels":["contribution-report"],"max":1,"title_prefix":"[Contribution Check Report]"},"missing_data":{},"missing_tool":{},"noop":{"max":1,"report-as-issue":"true"}} + {"add_comment":{"hide_older_comments":true,"max":10,"target":"*","target-repo":"${{ vars.TARGET_REPOSITORY }}"},"add_labels":{"allowed":["spam","needs-work","outdated","lgtm"],"max":4,"target":"*","target-repo":"${{ vars.TARGET_REPOSITORY }}"},"create_issue":{"close_older_issues":true,"expires":24,"group_by_day":true,"labels":["contribution-report"],"max":1,"title_prefix":"[Contribution Check Report]"},"missing_data":{},"missing_tool":{},"noop":{"max":1,"report-as-issue":"true"}} GH_AW_SAFE_OUTPUTS_CONFIG_EOF - name: Write Safe Outputs Tools run: | @@ -1102,7 +1102,7 @@ jobs: GH_AW_ALLOWED_DOMAINS: "api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,github.com,host.docker.internal,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,ppa.launchpad.net,raw.githubusercontent.com,registry.npmjs.org,s.symcb.com,s.symcd.com,security.ubuntu.com,telemetry.enterprise.githubcopilot.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com,www.googleapis.com" GITHUB_SERVER_URL: ${{ github.server_url }} GITHUB_API_URL: ${{ github.api_url }} - GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG: "{\"add_comment\":{\"hide_older_comments\":true,\"max\":10,\"target\":\"*\",\"target-repo\":\"${{ vars.TARGET_REPOSITORY }}\"},\"add_labels\":{\"allowed\":[\"spam\",\"needs-work\",\"outdated\",\"lgtm\"],\"max\":4,\"target\":\"*\",\"target-repo\":\"${{ vars.TARGET_REPOSITORY }}\"},\"create_issue\":{\"close_older_issues\":true,\"expires\":24,\"idempotent\":true,\"labels\":[\"contribution-report\"],\"max\":1,\"title_prefix\":\"[Contribution Check Report]\"},\"missing_data\":{},\"missing_tool\":{},\"noop\":{\"max\":1,\"report-as-issue\":\"true\"}}" + GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG: "{\"add_comment\":{\"hide_older_comments\":true,\"max\":10,\"target\":\"*\",\"target-repo\":\"${{ vars.TARGET_REPOSITORY }}\"},\"add_labels\":{\"allowed\":[\"spam\",\"needs-work\",\"outdated\",\"lgtm\"],\"max\":4,\"target\":\"*\",\"target-repo\":\"${{ vars.TARGET_REPOSITORY }}\"},\"create_issue\":{\"close_older_issues\":true,\"expires\":24,\"group_by_day\":true,\"labels\":[\"contribution-report\"],\"max\":1,\"title_prefix\":\"[Contribution Check Report]\"},\"missing_data\":{},\"missing_tool\":{},\"noop\":{\"max\":1,\"report-as-issue\":\"true\"}}" with: github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | diff --git a/.github/workflows/contribution-check.md b/.github/workflows/contribution-check.md index ffe84a7e48a..708bee21c68 100644 --- a/.github/workflows/contribution-check.md +++ b/.github/workflows/contribution-check.md @@ -23,7 +23,7 @@ safe-outputs: labels: - contribution-report close-older-issues: true - idempotent: true + group-by-day: true expires: 1d add-labels: allowed: [spam, needs-work, outdated, lgtm] diff --git a/actions/setup/js/create_issue.cjs b/actions/setup/js/create_issue.cjs index 6e685639849..91b84b70fc5 100644 --- a/actions/setup/js/create_issue.cjs +++ b/actions/setup/js/create_issue.cjs @@ -40,7 +40,7 @@ const { ERR_VALIDATION } = require("./error_codes.cjs"); const { renderTemplateFromFile } = require("./messages_core.cjs"); const { createExpirationLine, addExpirationToFooter } = require("./ephemerals.cjs"); const { MAX_SUB_ISSUES, getSubIssueCount } = require("./sub_issue_helpers.cjs"); -const { closeOlderIssues, searchOlderIssues } = require("./close_older_issues.cjs"); +const { closeOlderIssues, searchOlderIssues, addIssueComment } = require("./close_older_issues.cjs"); const { parseBoolTemplatable } = require("./templatable.cjs"); const { tryEnforceArrayLimit } = require("./limit_enforcement_helpers.cjs"); const { logStagedPreviewInfo } = require("./staged_preview.cjs"); @@ -205,7 +205,7 @@ async function main(config = {}) { const { defaultTargetRepo, allowedRepos } = resolveTargetRepoConfig(config); const groupEnabled = parseBoolTemplatable(config.group, false); const closeOlderIssuesEnabled = parseBoolTemplatable(config.close_older_issues, false); - const idempotentEnabled = parseBoolTemplatable(config.idempotent, false); + const groupByDayEnabled = parseBoolTemplatable(config.group_by_day, false); const rawCloseOlderKey = config.close_older_key ? String(config.close_older_key) : ""; const closeOlderKey = rawCloseOlderKey ? normalizeCloseOlderKey(rawCloseOlderKey) : ""; if (rawCloseOlderKey && !closeOlderKey) { @@ -249,10 +249,10 @@ async function main(config = {}) { core.info(` Using explicit close-older-key: "${closeOlderKey}"`); } } - if (idempotentEnabled) { - core.info(`Idempotent mode enabled: creation will be skipped if an open issue was already created today`); + if (groupByDayEnabled) { + core.info(`Group-by-day mode enabled: if an open issue was already created today, new content will be posted as a comment`); if (!closeOlderKey && !process.env.GH_AW_WORKFLOW_ID) { - core.warning(`Idempotent mode has no effect: neither close-older-key nor GH_AW_WORKFLOW_ID is set — issues cannot be searched`); + core.warning(`Group-by-day mode has no effect: neither close-older-key nor GH_AW_WORKFLOW_ID is set — issues cannot be searched`); } } @@ -487,12 +487,12 @@ async function main(config = {}) { bodyLines.push(""); const body = bodyLines.join("\n").trim(); - // Idempotency check: if enabled, search for an existing open issue created today - // before attempting to create a new one. This prevents same-day duplicate issues - // when the workflow reruns (e.g. scheduled every 4 hours, two runs on the same day). - // NOTE: processedCount was already incremented above. If we skip creation, we undo - // the increment here so the max-count slot is not consumed for idempotent skips. - if (idempotentEnabled && (closeOlderKey || workflowId)) { + // Group-by-day check: if enabled, search for an existing open issue created today. + // When found, post the new content as a comment on the existing issue instead of + // creating a duplicate. This groups multiple same-day runs into a single issue. + // NOTE: processedCount was already incremented above. If we post as a comment, we undo + // the increment here so the max-count slot is not consumed. + if (groupByDayEnabled && (closeOlderKey || workflowId)) { const today = new Date().toISOString().split("T")[0]; // YYYY-MM-DD (UTC) try { const existingIssues = await searchOlderIssues( @@ -509,18 +509,22 @@ async function main(config = {}) { return createdDate === today; }); if (todayIssue) { - core.info(`Idempotent: skipping issue creation — open issue #${todayIssue.number} was already created today (${today}): ${todayIssue.html_url}`); + core.info(`Group-by-day: found open issue #${todayIssue.number} created today (${today}) — posting new content as a comment`); + const comment = await addIssueComment(github, repoParts.owner, repoParts.repo, todayIssue.number, body); + core.info(`Posted content as comment ${comment.html_url} on issue #${todayIssue.number}`); // Undo the processedCount increment so the max limit is not consumed processedCount--; return { success: true, - skipped: true, - reason: `idempotent: open issue #${todayIssue.number} already created today`, + grouped: true, + existingIssueNumber: todayIssue.number, + existingIssueUrl: todayIssue.html_url, + commentUrl: comment.html_url, }; } } catch (error) { // Log but do not abort — fall through to normal creation - core.warning(`Idempotent pre-check failed: ${getErrorMessage(error)} — proceeding with issue creation`); + core.warning(`Group-by-day pre-check failed: ${getErrorMessage(error)} — proceeding with issue creation`); } } diff --git a/actions/setup/js/create_issue.test.cjs b/actions/setup/js/create_issue.test.cjs index 0894551f39f..47d85786042 100644 --- a/actions/setup/js/create_issue.test.cjs +++ b/actions/setup/js/create_issue.test.cjs @@ -29,7 +29,12 @@ describe("create_issue", () => { title: "Test Issue", }, }), - createComment: vi.fn().mockResolvedValue({}), + createComment: vi.fn().mockResolvedValue({ + data: { + id: 456, + html_url: "https://github.com/owner/repo/issues/99#issuecomment-456", + }, + }), }, search: { issuesAndPullRequests: vi.fn().mockResolvedValue({ @@ -467,8 +472,8 @@ describe("create_issue", () => { }); }); - describe("idempotent mode", () => { - it("should skip creation if an open issue was already created today", async () => { + describe("group-by-day mode", () => { + it("should post new content as a comment if an open issue was already created today", async () => { const today = new Date().toISOString().split("T")[0]; mockGithub.rest.search.issuesAndPullRequests.mockResolvedValueOnce({ data: { @@ -487,14 +492,14 @@ describe("create_issue", () => { }, }); - const handler = await main({ idempotent: true, close_older_issues: true }); + const handler = await main({ group_by_day: true, close_older_issues: true }); const result = await handler({ title: "Test Issue", body: "Test body" }); expect(result.success).toBe(true); - expect(result.skipped).toBe(true); - expect(result.reason).toContain("idempotent"); - expect(result.reason).toContain("99"); + expect(result.grouped).toBe(true); + expect(result.existingIssueNumber).toBe(99); expect(mockGithub.rest.issues.create).not.toHaveBeenCalled(); + expect(mockGithub.rest.issues.createComment).toHaveBeenCalledWith(expect.objectContaining({ issue_number: 99 })); }); it("should create issue if no open issue was created today", async () => { @@ -516,11 +521,11 @@ describe("create_issue", () => { }, }); - const handler = await main({ idempotent: true, close_older_issues: true }); + const handler = await main({ group_by_day: true, close_older_issues: true }); const result = await handler({ title: "Test Issue", body: "Test body" }); expect(result.success).toBe(true); - expect(result.skipped).toBeUndefined(); + expect(result.grouped).toBeUndefined(); expect(mockGithub.rest.issues.create).toHaveBeenCalledOnce(); }); @@ -529,27 +534,27 @@ describe("create_issue", () => { data: { total_count: 0, items: [] }, }); - const handler = await main({ idempotent: true, close_older_issues: true }); + const handler = await main({ group_by_day: true, close_older_issues: true }); const result = await handler({ title: "Test Issue", body: "Test body" }); expect(result.success).toBe(true); - expect(result.skipped).toBeUndefined(); + expect(result.grouped).toBeUndefined(); expect(mockGithub.rest.issues.create).toHaveBeenCalledOnce(); }); - it("should proceed with creation if idempotent pre-check throws", async () => { + it("should proceed with creation if group-by-day pre-check throws", async () => { mockGithub.rest.search.issuesAndPullRequests.mockRejectedValueOnce(new Error("Search API error")); - const handler = await main({ idempotent: true, close_older_issues: true }); + const handler = await main({ group_by_day: true, close_older_issues: true }); const result = await handler({ title: "Test Issue", body: "Test body" }); expect(result.success).toBe(true); - expect(result.skipped).toBeUndefined(); + expect(result.grouped).toBeUndefined(); expect(mockGithub.rest.issues.create).toHaveBeenCalledOnce(); - expect(mockCore.warning).toHaveBeenCalledWith(expect.stringContaining("Idempotent pre-check failed")); + expect(mockCore.warning).toHaveBeenCalledWith(expect.stringContaining("Group-by-day pre-check failed")); }); - it("should not skip if idempotent is false even with today's issue", async () => { + it("should not group if group-by-day is false even with today's issue", async () => { const today = new Date().toISOString().split("T")[0]; mockGithub.rest.search.issuesAndPullRequests.mockResolvedValue({ data: { @@ -568,16 +573,16 @@ describe("create_issue", () => { }, }); - // idempotent is false (default) — creation should NOT be skipped + // group_by_day is false (default) — creation should NOT be grouped const handler = await main({ close_older_issues: false }); const result = await handler({ title: "Test Issue", body: "Test body" }); expect(result.success).toBe(true); - expect(result.skipped).toBeUndefined(); + expect(result.grouped).toBeUndefined(); expect(mockGithub.rest.issues.create).toHaveBeenCalledOnce(); }); - it("should not consume max count slot when skipped due to idempotency", async () => { + it("should not consume max count slot when grouped", async () => { const today = new Date().toISOString().split("T")[0]; mockGithub.rest.search.issuesAndPullRequests.mockResolvedValue({ data: { @@ -596,15 +601,15 @@ describe("create_issue", () => { }, }); - const handler = await main({ idempotent: true, close_older_issues: true, max: 1 }); + const handler = await main({ group_by_day: true, close_older_issues: true, max: 1 }); - // First call is skipped due to idempotency — max slot should not be consumed + // First call is grouped — max slot should not be consumed const result1 = await handler({ title: "First Issue", body: "Body" }); - expect(result1.skipped).toBe(true); + expect(result1.grouped).toBe(true); - // Reset search mock so second call also finds a today issue — also skipped + // Second call also finds today's issue — also grouped const result2 = await handler({ title: "Second Issue", body: "Body" }); - expect(result2.skipped).toBe(true); + expect(result2.grouped).toBe(true); // Neither call should have created an issue expect(mockGithub.rest.issues.create).not.toHaveBeenCalled(); diff --git a/docs/src/content/docs/reference/frontmatter-full.md b/docs/src/content/docs/reference/frontmatter-full.md index dcdf01f04af..54ade376735 100644 --- a/docs/src/content/docs/reference/frontmatter-full.md +++ b/docs/src/content/docs/reference/frontmatter-full.md @@ -2504,13 +2504,13 @@ safe-outputs: # (optional) close-older-key: "example-value" - # When true, skip issue creation if an open issue with the same close-older-key - # (or workflow-id marker when no key is set) was already created today (UTC). - # Prevents same-day duplicate issues when the workflow is re-run (e.g. a scheduled - # workflow that runs every few hours). Works best when combined with + # When true, if an open issue with the same close-older-key (or workflow-id marker + # when no key is set) was already created today (UTC), post the new content as a + # comment on that existing issue instead of creating a new one. Groups multiple + # same-day runs into a single issue. Works best when combined with # close-older-issues: true. # (optional) - idempotent: true + group-by-day: true # Controls whether AI-generated footer is added to the issue. When false, the # visible footer content is omitted but XML markers (workflow-id, tracker-id, diff --git a/docs/src/content/docs/reference/safe-outputs.md b/docs/src/content/docs/reference/safe-outputs.md index 6b5cd7954f9..9cf7c6eabb7 100644 --- a/docs/src/content/docs/reference/safe-outputs.md +++ b/docs/src/content/docs/reference/safe-outputs.md @@ -161,9 +161,9 @@ When enabled: - Maximum 10 older issues will be closed - Only runs if the new issue creation succeeds -#### Idempotent Issue Creation +#### Group By Day -The `idempotent` field (default: `false`) prevents same-day duplicate issues when a workflow reruns. When enabled, the handler searches for an existing open issue created **today (UTC)** with the same workflow-id marker (or `close-older-key` if set) before creating a new one. If a matching issue already exists, creation is silently skipped. +The `group-by-day` field (default: `false`) groups multiple same-day workflow runs into a single issue. When enabled, the handler searches for an existing open issue created **today (UTC)** with the same workflow-id marker (or `close-older-key` if set). If found, the new content is posted as a **comment** on that existing issue instead of creating a new one. ```yaml wrap safe-outputs: @@ -171,15 +171,15 @@ safe-outputs: title-prefix: "[Contribution Check Report]" labels: [report] close-older-issues: true - idempotent: true + group-by-day: true ``` -This is useful for scheduled workflows (e.g. every 4 hours) that produce recurring daily reports: only one report issue is created per day, eliminating duplicate open/closed issues from multiple same-day runs. +This is useful for scheduled workflows (e.g. every 4 hours) that produce recurring daily reports: all runs on the same day contribute to one issue, eliminating duplicate open/closed issues. - Performs a pre-creation search for open issues matching the workflow-id or `close-older-key` -- If a matching issue was created on today's UTC date, creation is skipped -- The max-count slot is not consumed when a creation is skipped -- On failure of the pre-check, creation proceeds normally as a fallback +- If a matching issue was created today (UTC), new content is posted as a comment on it +- The max-count slot is not consumed when posting as a comment +- On failure of the pre-check, normal issue creation proceeds as a fallback #### Searching for Workflow-Created Items diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index 7e5e69d7f8e..6fec138631d 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -4516,9 +4516,9 @@ "minLength": 1, "pattern": "\\S" }, - "idempotent": { + "group-by-day": { "type": "boolean", - "description": "When true, skip issue creation if an open issue with the same close-older-key (or workflow-id marker when no key is set) was already created today (UTC). Prevents same-day duplicate issues when the workflow is re-run. Works best when combined with close-older-issues: true.", + "description": "When true, if an open issue with the same close-older-key (or workflow-id marker when no key is set) was already created today (UTC), post the new content as a comment on that existing issue instead of creating a new one. Groups multiple same-day runs into a single issue. Works best when combined with close-older-issues: true.", "default": false }, "footer": { diff --git a/pkg/workflow/compiler_safe_outputs_config.go b/pkg/workflow/compiler_safe_outputs_config.go index a6736a0712c..57094cf7b6a 100644 --- a/pkg/workflow/compiler_safe_outputs_config.go +++ b/pkg/workflow/compiler_safe_outputs_config.go @@ -163,7 +163,7 @@ var handlerRegistry = map[string]handlerBuilder{ AddTemplatableBool("group", c.Group). AddTemplatableBool("close_older_issues", c.CloseOlderIssues). AddIfNotEmpty("close_older_key", c.CloseOlderKey). - AddTemplatableBool("idempotent", c.Idempotent). + AddTemplatableBool("group_by_day", c.GroupByDay). AddTemplatableBool("footer", getEffectiveFooterForTemplatable(c.Footer, cfg.Footer)). AddIfNotEmpty("github-token", c.GitHubToken). AddIfTrue("staged", c.Staged). diff --git a/pkg/workflow/create_issue.go b/pkg/workflow/create_issue.go index 9fc5cd247c9..87be1954f19 100644 --- a/pkg/workflow/create_issue.go +++ b/pkg/workflow/create_issue.go @@ -19,7 +19,7 @@ type CreateIssuesConfig struct { AllowedRepos []string `yaml:"allowed-repos,omitempty"` // List of additional repositories that issues can be created in CloseOlderIssues *string `yaml:"close-older-issues,omitempty"` // When true, close older issues with same title prefix or labels as "not planned" CloseOlderKey string `yaml:"close-older-key,omitempty"` // Optional explicit deduplication key for close-older matching. When set, uses gh-aw-close-key marker instead of workflow-id markers. - Idempotent *string `yaml:"idempotent,omitempty"` // When true, skip creation if an open issue with the same key was already created today (UTC). Prevents same-day duplicate issues on reruns. Works best with close-older-issues: true. + GroupByDay *string `yaml:"group-by-day,omitempty"` // When true, if an open issue was already created today (UTC), post new content as a comment on it instead of creating a duplicate. Works best with close-older-issues: true. Expires int `yaml:"expires,omitempty"` // Hours until the issue expires and should be automatically closed Group *string `yaml:"group,omitempty"` // If true, group issues as sub-issues under a parent issue (workflow ID is used as group identifier) Footer *string `yaml:"footer,omitempty"` // Controls whether AI-generated footer is added. When false, visible footer is omitted but XML markers are kept. @@ -42,7 +42,7 @@ func (c *Compiler) parseIssuesConfig(outputMap map[string]any) *CreateIssuesConf // Pre-process templatable bool fields: convert literal booleans to strings so that // GitHub Actions expression strings (e.g. "${{ inputs.close-older-issues }}") are also accepted. - for _, field := range []string{"close-older-issues", "group", "footer", "idempotent"} { + for _, field := range []string{"close-older-issues", "group", "footer", "group-by-day"} { if err := preprocessBoolFieldAsString(configData, field, createIssueLog); err != nil { createIssueLog.Printf("Invalid %s value: %v", field, err) return nil From 877520a1e98bef946d14006f318d03f2511b1858 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 24 Mar 2026 17:55:00 +0000 Subject: [PATCH 4/4] fix: address review comments on group-by-day feature - Update searchOlderIssues JSDoc @returns to include created_at field - Use githubClient (not global github) for searchOlderIssues and addIssueComment so custom github-token config is respected for cross-repo operations - Move processedCount++ after group-by-day check so max-count slots are never consumed for grouped-as-comment paths (removes the processedCount-- hack) Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> Agent-Logs-Url: https://github.com/github/gh-aw/sessions/853fae40-1f00-4e50-bbb7-5decaeea89c9 --- actions/setup/js/close_older_issues.cjs | 2 +- actions/setup/js/create_issue.cjs | 16 ++++++++-------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/actions/setup/js/close_older_issues.cjs b/actions/setup/js/close_older_issues.cjs index f9725376cd3..d225684df54 100644 --- a/actions/setup/js/close_older_issues.cjs +++ b/actions/setup/js/close_older_issues.cjs @@ -29,7 +29,7 @@ const API_DELAY_MS = 500; * @param {string} [closeOlderKey] - Optional explicit deduplication key. When set, the * `gh-aw-close-key` marker is used as the primary search term and exact filter instead * of the workflow-id / workflow-call-id markers. - * @returns {Promise}>>} Matching issues + * @returns {Promise, created_at: string}>>} Matching issues */ async function searchOlderIssues(github, owner, repo, workflowId, excludeNumber, callerWorkflowId, closeOlderKey) { core.info(`Starting search for older issues in ${owner}/${repo}`); diff --git a/actions/setup/js/create_issue.cjs b/actions/setup/js/create_issue.cjs index 91b84b70fc5..72897d5d2c1 100644 --- a/actions/setup/js/create_issue.cjs +++ b/actions/setup/js/create_issue.cjs @@ -290,8 +290,6 @@ async function main(config = {}) { }; } - processedCount++; - // Merge external resolved temp IDs with our local map if (resolvedTemporaryIds) { for (const [tempId, resolved] of Object.entries(resolvedTemporaryIds)) { @@ -490,13 +488,13 @@ async function main(config = {}) { // Group-by-day check: if enabled, search for an existing open issue created today. // When found, post the new content as a comment on the existing issue instead of // creating a duplicate. This groups multiple same-day runs into a single issue. - // NOTE: processedCount was already incremented above. If we post as a comment, we undo - // the increment here so the max-count slot is not consumed. + // The max-count slot is NOT consumed when posting as a comment (processedCount is + // only incremented below, just before actual issue creation). if (groupByDayEnabled && (closeOlderKey || workflowId)) { const today = new Date().toISOString().split("T")[0]; // YYYY-MM-DD (UTC) try { const existingIssues = await searchOlderIssues( - github, + githubClient, repoParts.owner, repoParts.repo, workflowId, @@ -510,10 +508,8 @@ async function main(config = {}) { }); if (todayIssue) { core.info(`Group-by-day: found open issue #${todayIssue.number} created today (${today}) — posting new content as a comment`); - const comment = await addIssueComment(github, repoParts.owner, repoParts.repo, todayIssue.number, body); + const comment = await addIssueComment(githubClient, repoParts.owner, repoParts.repo, todayIssue.number, body); core.info(`Posted content as comment ${comment.html_url} on issue #${todayIssue.number}`); - // Undo the processedCount increment so the max limit is not consumed - processedCount--; return { success: true, grouped: true, @@ -528,6 +524,10 @@ async function main(config = {}) { } } + // Increment processed count only when we are about to create an issue + // (group-by-day comment paths return above without consuming a slot) + processedCount++; + core.info(`Creating issue in ${qualifiedItemRepo} with title: ${title}`); core.info(`Labels: ${labels.join(", ")}`); if (assignees.length > 0) {