Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .github/workflows/contribution-check.lock.yml

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

1 change: 1 addition & 0 deletions .github/workflows/contribution-check.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ safe-outputs:
labels:
- contribution-report
close-older-issues: true
group-by-day: true
expires: 1d
add-labels:
allowed: [spam, needs-work, outdated, lgtm]
Expand Down
3 changes: 2 additions & 1 deletion actions/setup/js/close_older_issues.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Array<{number: number, title: string, html_url: string, labels: Array<{name: string}>}>>} Matching issues
* @returns {Promise<Array<{number: number, title: string, html_url: string, labels: Array<{name: string}>, 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}`);
Expand Down Expand Up @@ -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:`);
Expand Down
54 changes: 51 additions & 3 deletions actions/setup/js/create_issue.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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, addIssueComment } = require("./close_older_issues.cjs");
const { parseBoolTemplatable } = require("./templatable.cjs");
const { tryEnforceArrayLimit } = require("./limit_enforcement_helpers.cjs");
const { logStagedPreviewInfo } = require("./staged_preview.cjs");
Expand Down Expand Up @@ -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 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) {
Expand Down Expand Up @@ -248,6 +249,12 @@ async function main(config = {}) {
core.info(` Using explicit close-older-key: "${closeOlderKey}"`);
}
}
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(`Group-by-day 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;
Expand Down Expand Up @@ -283,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)) {
Expand Down Expand Up @@ -480,6 +485,49 @@ async function main(config = {}) {
bodyLines.push("");
const body = bodyLines.join("\n").trim();

// 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.
// 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)
Comment on lines +488 to +494
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The max-count guard runs before the group-by-day pre-check. If processedCount >= maxCount (e.g., max: 1 and one issue was already created earlier in the same run), the handler returns early and never attempts group-by-day, even though grouping is intended not to consume a max slot. Consider moving the group-by-day lookup before the max-count check/increment, or only increment/apply max when an issue is actually created (not when grouping as a comment).

Copilot uses AI. Check for mistakes.
try {
const existingIssues = await searchOlderIssues(
githubClient,
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(`Group-by-day: found open issue #${todayIssue.number} created today (${today}) — posting new content as a comment`);
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}`);
Comment on lines +496 to +512
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Group-by-day uses the global github client for searchOlderIssues/addIssueComment, but the handler otherwise creates an authenticated githubClient (which may use a per-handler github-token for cross-repo operations). This can cause the pre-check/comment to run with the wrong token and fail or operate on the wrong permissions. Use githubClient consistently for the search and comment API calls here (and for any other GitHub API calls in this handler) so authentication matches the target repo configuration.

Copilot uses AI. Check for mistakes.
return {
success: true,
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(`Group-by-day pre-check failed: ${getErrorMessage(error)} — proceeding with issue creation`);
}
}

// 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) {
Expand Down
151 changes: 150 additions & 1 deletion actions/setup/js/create_issue.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -466,4 +471,148 @@ describe("create_issue", () => {
expect(result.error).toContain("received 6");
});
});

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: {
total_count: 1,
items: [
{
number: 99,
title: "[Contribution Check Report] Contribution Check",
html_url: "https://github.com/test-owner/test-repo/issues/99",
body: "<!-- gh-aw-workflow-id: test-workflow -->",
created_at: `${today}T10:00:00Z`,
state: "open",
pull_request: undefined,
},
],
},
});

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.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 () => {
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: "<!-- gh-aw-workflow-id: test-workflow -->",
created_at: `${yesterday}T10:00:00Z`,
state: "open",
pull_request: undefined,
},
],
},
});

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.grouped).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({ 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.grouped).toBeUndefined();
expect(mockGithub.rest.issues.create).toHaveBeenCalledOnce();
});

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({ 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.grouped).toBeUndefined();
expect(mockGithub.rest.issues.create).toHaveBeenCalledOnce();
expect(mockCore.warning).toHaveBeenCalledWith(expect.stringContaining("Group-by-day pre-check failed"));
});

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: {
total_count: 1,
items: [
{
number: 77,
title: "Existing Issue",
html_url: "https://github.com/test-owner/test-repo/issues/77",
body: "<!-- gh-aw-workflow-id: test-workflow -->",
created_at: `${today}T10:00:00Z`,
state: "open",
pull_request: undefined,
},
],
},
});

// 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.grouped).toBeUndefined();
expect(mockGithub.rest.issues.create).toHaveBeenCalledOnce();
});

it("should not consume max count slot when grouped", 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: "<!-- gh-aw-workflow-id: test-workflow -->",
created_at: `${today}T10:00:00Z`,
state: "open",
pull_request: undefined,
},
],
},
});

const handler = await main({ group_by_day: true, close_older_issues: true, max: 1 });

// First call is grouped — max slot should not be consumed
const result1 = await handler({ title: "First Issue", body: "Body" });
expect(result1.grouped).toBe(true);

// Second call also finds today's issue — also grouped
const result2 = await handler({ title: "Second Issue", body: "Body" });
expect(result2.grouped).toBe(true);

// Neither call should have created an issue
expect(mockGithub.rest.issues.create).not.toHaveBeenCalled();
});
});
});
8 changes: 8 additions & 0 deletions docs/src/content/docs/reference/frontmatter-full.md
Original file line number Diff line number Diff line change
Expand Up @@ -2504,6 +2504,14 @@ safe-outputs:
# (optional)
close-older-key: "example-value"

# 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)
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,
# metadata) are still included for searchability. Defaults to true.
Expand Down
20 changes: 20 additions & 0 deletions docs/src/content/docs/reference/safe-outputs.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,26 @@ When enabled:
- Maximum 10 older issues will be closed
- Only runs if the new issue creation succeeds

#### Group By Day

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:
create-issue:
title-prefix: "[Contribution Check Report]"
labels: [report]
close-older-issues: true
group-by-day: true
```

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 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

All items created by workflows (issues, pull requests, discussions, and comments) include a hidden **workflow-id marker** in their body:
Expand Down
5 changes: 5 additions & 0 deletions pkg/parser/schemas/main_workflow_schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -4516,6 +4516,11 @@
"minLength": 1,
"pattern": "\\S"
},
"group-by-day": {
"type": "boolean",
"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": {
"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.",
Expand Down
1 change: 1 addition & 0 deletions pkg/workflow/compiler_safe_outputs_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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("group_by_day", c.GroupByDay).
AddTemplatableBool("footer", getEffectiveFooterForTemplatable(c.Footer, cfg.Footer)).
AddIfNotEmpty("github-token", c.GitHubToken).
AddIfTrue("staged", c.Staged).
Expand Down
Loading
Loading