Skip to content
Open
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
4 changes: 4 additions & 0 deletions .github/actions/file/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ inputs:
cached_filings:
description: "Cached filings from previous runs, as stringified JSON. Without this, duplicate issues may be filed."
required: false
open_grouped_issues:
description: "In the 'file' step, also open grouped issues which link to all issues with the same root cause"
required: false
default: "false"

outputs:
filings:
Expand Down
73 changes: 65 additions & 8 deletions .github/actions/file/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
import type { Finding, ResolvedFiling, RepeatedFiling } from "./types.d.js";
import type {
Finding,
ResolvedFiling,
RepeatedFiling,
FindingGroupIssue,
Filing,
} from "./types.d.js";
import process from "node:process";
import core from "@actions/core";
import { Octokit } from "@octokit/core";
Expand All @@ -11,18 +17,20 @@ import { isResolvedFiling } from "./isResolvedFiling.js";
import { openIssue } from "./openIssue.js";
import { reopenIssue } from "./reopenIssue.js";
import { updateFilingsWithNewFindings } from "./updateFilingsWithNewFindings.js";
import type { OctokitResponse } from "@octokit/types";
const OctokitWithThrottling = Octokit.plugin(throttling);

export default async function () {
core.info("Started 'file' action");
const findings: Finding[] = JSON.parse(
core.getInput("findings", { required: true })
core.getInput("findings", { required: true }),
);
const repoWithOwner = core.getInput("repository", { required: true });
const token = core.getInput("token", { required: true });
const cachedFilings: (ResolvedFiling | RepeatedFiling)[] = JSON.parse(
core.getInput("cached_filings", { required: false }) || "[]"
core.getInput("cached_filings", { required: false }) || "[]",
);
const shouldOpenGroupedIssues = core.getBooleanInput("open_grouped_issues");
core.debug(`Input: 'findings: ${JSON.stringify(findings)}'`);
core.debug(`Input: 'repository: ${repoWithOwner}'`);
core.debug(`Input: 'cached_filings: ${JSON.stringify(cachedFilings)}'`);
Expand All @@ -32,7 +40,7 @@ export default async function () {
throttle: {
onRateLimit: (retryAfter, options, octokit, retryCount) => {
octokit.log.warn(
`Request quota exhausted for request ${options.method} ${options.url}`
`Request quota exhausted for request ${options.method} ${options.url}`,
);
if (retryCount < 3) {
octokit.log.info(`Retrying after ${retryAfter} seconds!`);
Expand All @@ -41,7 +49,7 @@ export default async function () {
},
onSecondaryRateLimit: (retryAfter, options, octokit, retryCount) => {
octokit.log.warn(
`Secondary rate limit hit for request ${options.method} ${options.url}`
`Secondary rate limit hit for request ${options.method} ${options.url}`,
);
if (retryCount < 3) {
octokit.log.info(`Retrying after ${retryAfter} seconds!`);
Expand All @@ -52,8 +60,12 @@ export default async function () {
});
const filings = updateFilingsWithNewFindings(cachedFilings, findings);

// Track new issues for grouping
const newIssuesByProblemShort: Record<string, FindingGroupIssue[]> = {};
const trackingIssueUrls: Record<string, string> = {};

for (const filing of filings) {
let response;
let response: OctokitResponse<any> | undefined;
try {
if (isResolvedFiling(filing)) {
// Close the filing’s issue (if necessary)
Expand All @@ -62,7 +74,18 @@ export default async function () {
} else if (isNewFiling(filing)) {
// Open a new issue for the filing
response = await openIssue(octokit, repoWithOwner, filing.findings[0]);
(filing as any).issue = { state: "open" } as Issue;
(filing as Filing).issue = { state: "open" } as Issue;
// Track for grouping
if (shouldOpenGroupedIssues) {
const problemShort: string = filing.findings[0].problemShort;
if (!newIssuesByProblemShort[problemShort]) {
newIssuesByProblemShort[problemShort] = [];
}
newIssuesByProblemShort[problemShort].push({
url: response.data.html_url,
id: response.data.number,
});
Comment on lines +84 to +87
Copy link

Copilot AI Feb 12, 2026

Choose a reason for hiding this comment

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

The collected issue object uses { url, id: response.data.number }, but response.data.number is the issue number (not the GitHub issue id). Rename this field to something like issueNumber to avoid confusion with response.data.id (which you later store into filing.issue.id).

Copilot uses AI. Check for mistakes.
}
} else if (isRepeatedFiling(filing)) {
// Reopen the filing’s issue (if necessary)
response = await reopenIssue(octokit, new Issue(filing.issue));
Expand All @@ -75,7 +98,7 @@ export default async function () {
filing.issue.url = response.data.html_url;
filing.issue.title = response.data.title;
core.info(
`Set issue ${response.data.title} (${repoWithOwner}#${response.data.number}) state to ${filing.issue.state}`
`Set issue ${response.data.title} (${repoWithOwner}#${response.data.number}) state to ${filing.issue.state}`,
);
}
} catch (error) {
Expand All @@ -84,6 +107,40 @@ export default async function () {
}
}

// Open tracking issues for each root cause and link back from each newly-created issue
if (shouldOpenGroupedIssues) {
for (const [problemShort, issues] of Object.entries(
newIssuesByProblemShort,
)) {
if (issues.length > 1) {
const title: string = `Accessibility tracking issue for all ${problemShort} issues`;
const body: string =
`# ${problemShort} issues\n\n` +
issues.map((issue) => `- [ ] ${issue.url}`).join("\n");
Copy link
Contributor

Choose a reason for hiding this comment

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

Nice! I am sure we can convert this to subissues eventually if we want, but I think this is a great start.

try {
const trackingResponse = await octokit.request(
`POST /repos/${repoWithOwner}/issues`,
{
owner: repoWithOwner.split("/")[0],
repo: repoWithOwner.split("/")[1],
title,
body,
},
);
const trackingUrl: string = trackingResponse.data.html_url;
Copy link

@abdulahmad307 abdulahmad307 Feb 12, 2026

Choose a reason for hiding this comment

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

I think we will want to wrap this in a model in the near future (especially if we start working with other issue types), specifically this part: trackingResponse.data.html_url;. and some of the code around interfacing with octokit (something like this for models, and something like this for a 'client' of some kind - these are just examples, I'm open to other approaches too).

Copy link
Contributor

Choose a reason for hiding this comment

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

It seems like the rest of the scanner codebase doesn't use models and we typically haven't in our other code (other than the examples you've linked), so I'm not sure if we want to have different architecture in different places.

trackingIssueUrls[problemShort] = trackingUrl;
core.info(
`Opened tracking issue for '${problemShort}': ${issues.length} issues.`,
);
} catch (error) {
core.warning(
`Failed to open grouped issue for '${problemShort}': ${error}`,
);
}
}
}
Comment on lines 110 to 141
Copy link

Copilot AI Feb 12, 2026

Choose a reason for hiding this comment

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

New tracking-issue behavior is behind a flag but has no test coverage. Consider adding/adjusting a Vitest test to assert that when open_tracking_issues is enabled and multiple new issues share the same problemShort, exactly one tracking issue is created and includes links to those issues (can be gated behind describe.runIf(!!process.env.GITHUB_TOKEN) like existing GitHub API tests).

Copilot uses AI. Check for mistakes.
Copy link
Contributor

Choose a reason for hiding this comment

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

Agree with adding tests 🙏🏻

}
Comment on lines 110 to 142
Copy link

Copilot AI Feb 12, 2026

Choose a reason for hiding this comment

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

core.setOutput("filings", ...) (and the "Finished 'file' action" log) now run only when shouldOpenTrackingIssues is true. When the flag is false, the action produces no filings output at all, which will break downstream steps that rely on steps.file.outputs.filings (and also changes behavior from before this PR). Move the output/log statements outside the conditional so they always run, and keep the tracking-issue logic inside the flag guard.

Copilot uses AI. Check for mistakes.

core.setOutput("filings", JSON.stringify(filings));
core.debug(`Output: 'filings: ${JSON.stringify(filings)}'`);
core.info("Finished 'file' action");
Expand Down
5 changes: 5 additions & 0 deletions .github/actions/file/src/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,8 @@ export type RepeatedFiling = {
};

export type Filing = ResolvedFiling | NewFiling | RepeatedFiling;

export type FindingGroupIssue = {
url: string;
id: number;
};
5 changes: 5 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@ inputs:
description: "Whether to skip assigning filed issues to Copilot"
required: false
default: "false"
open_grouped_issues:
description: "In the 'file' step, also open grouped issues which link to all issues with the same problem"
required: false
default: "false"

outputs:
results:
Expand Down Expand Up @@ -88,6 +92,7 @@ runs:
repository: ${{ inputs.repository }}
token: ${{ inputs.token }}
cached_filings: ${{ steps.normalize_cache.outputs.value }}
open_grouped_issues: ${{ inputs.open_grouped_issues }}
- if: ${{ steps.file.outputs.filings }}
name: Get issues from filings
id: get_issues_from_filings
Expand Down