Skip to content

Commit 45c9a39

Browse files
authored
feat: Add results output (#1955)
Fixes github/continuous-ai-for-accessibility#68 (Hubber access only) Fixes github/continuous-ai-for-accessibility#32 (Hubber access only) This PR: - Adds a `results` output to the main scanner action, which includes all the action’s issues and pull requests and their associated findings. - Caches pull request data, so it doesn’t have to be re-fetched in other workflows.
2 parents 3beb05d + f8b20e2 commit 45c9a39

File tree

12 files changed

+201
-86
lines changed

12 files changed

+201
-86
lines changed

.github/actions/fix/README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,9 @@ Attempts to fix issues with Copilot.
1717
#### `token`
1818

1919
**Required** Personal access token (PAT) with fine-grained permissions 'issues: write' and 'pull_requests: write'.
20+
21+
### Outputs
22+
23+
#### `fixings`
24+
25+
List of pull requests filed (and their associated issues), as stringified JSON. For example: `'[{"issue":{"id":1,"nodeId":"SXNzdWU6MQ==","url":"https://github.com/github/docs/issues/123","title":"Accessibility issue: 1"},"pullRequest":{"url":"https://github.com/github/docs/pulls/124"}}]'`

.github/actions/fix/action.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@ inputs:
1212
description: "Personal access token (PAT) with fine-grained permissions 'issues: write' and 'pull_requests: write'"
1313
required: true
1414

15+
outputs:
16+
fixings:
17+
description: "List of pull requests filed (and their associated issues), as stringified JSON"
18+
1519
runs:
1620
using: "node24"
1721
main: "bootstrap.js"

.github/actions/fix/src/Issue.ts

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,6 @@
1-
import { IssueInput } from "./types.d.js";
1+
import { Issue as IssueInput } from "./types.d.js";
22

3-
interface IIssue extends IssueInput{
4-
owner: string;
5-
repository: string;
6-
issueNumber: number;
7-
}
8-
9-
export class Issue implements IIssue {
3+
export class Issue implements IssueInput {
104
/**
115
* Extracts owner, repository, and issue number from a GitHub issue URL.
126
* @param issueUrl A GitHub issue URL (e.g. `https://github.com/owner/repo/issues/42`).
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,18 @@
1-
import type { Octokit } from '@octokit/core';
2-
import { Issue } from './Issue.js';
1+
import type { Octokit } from "@octokit/core";
2+
import { Issue } from "./Issue.js";
33

44
// https://docs.github.com/en/enterprise-cloud@latest/copilot/how-tos/use-copilot-agents/coding-agent/assign-copilot-to-an-issue#assigning-an-existing-issue
5-
export async function fixIssue(octokit: Octokit, { owner, repository, issueNumber, nodeId }: Issue) {
5+
export async function assignIssue(
6+
octokit: Octokit,
7+
{ owner, repository, issueNumber, nodeId }: Issue
8+
) {
69
// Check whether issues can be assigned to Copilot
710
const suggestedActorsResponse = await octokit.graphql<{
811
repository: {
912
suggestedActors: {
10-
nodes: { login: string, id: string }[]
11-
}
12-
}
13+
nodes: { login: string; id: string }[];
14+
};
15+
};
1316
}>(
1417
`query ($owner: String!, $repository: String!) {
1518
repository(owner: $owner, name: $repository) {
@@ -23,19 +26,24 @@ export async function fixIssue(octokit: Octokit, { owner, repository, issueNumbe
2326
}
2427
}
2528
}`,
26-
{ owner, repository },
29+
{ owner, repository }
2730
);
28-
if (suggestedActorsResponse?.repository?.suggestedActors?.nodes[0]?.login !== "copilot-swe-agent") {
31+
if (
32+
suggestedActorsResponse?.repository?.suggestedActors?.nodes[0]?.login !==
33+
"copilot-swe-agent"
34+
) {
2935
return;
3036
}
3137
// Get GraphQL identifier for issue (unless already provided)
3238
let issueId = nodeId;
3339
if (!issueId) {
34-
console.debug(`Fetching identifier for issue ${owner}/${repository}#${issueNumber}`);
40+
console.debug(
41+
`Fetching identifier for issue ${owner}/${repository}#${issueNumber}`
42+
);
3543
const issueResponse = await octokit.graphql<{
3644
repository: {
37-
issue: { id: string }
38-
}
45+
issue: { id: string };
46+
};
3947
}>(
4048
`query($owner: String!, $repository: String!, $issueNumber: Int!) {
4149
repository(owner: $owner, name: $repository) {
@@ -45,26 +53,31 @@ export async function fixIssue(octokit: Octokit, { owner, repository, issueNumbe
4553
{ owner, repository, issueNumber }
4654
);
4755
issueId = issueResponse?.repository?.issue?.id;
48-
console.debug(`Fetched identifier for issue ${owner}/${repository}#${issueNumber}: ${issueId}`);
56+
console.debug(
57+
`Fetched identifier for issue ${owner}/${repository}#${issueNumber}: ${issueId}`
58+
);
4959
} else {
50-
console.debug(`Using provided identifier for issue ${owner}/${repository}#${issueNumber}: ${issueId}`);
60+
console.debug(
61+
`Using provided identifier for issue ${owner}/${repository}#${issueNumber}: ${issueId}`
62+
);
5163
}
5264
if (!issueId) {
53-
console.warn(`Couldn’t get identifier for issue ${owner}/${repository}#${issueNumber}. Skipping assignment to Copilot.`);
65+
console.warn(
66+
`Couldn’t get identifier for issue ${owner}/${repository}#${issueNumber}. Skipping assignment to Copilot.`
67+
);
5468
return;
5569
}
5670
// Assign issue to Copilot
5771
await octokit.graphql<{
5872
replaceActorsForAssignable: {
5973
assignable: {
6074
id: string;
61-
url: string;
6275
title: string;
6376
assignees: {
64-
nodes: { login: string }[]
65-
}
66-
}
67-
}
77+
nodes: { login: string }[];
78+
};
79+
};
80+
};
6881
}>(
6982
`mutation($issueId: ID!, $assigneeId: ID!) {
7083
replaceActorsForAssignable(input: {assignableId: $issueId, actorIds: [$assigneeId]}) {
@@ -81,6 +94,10 @@ export async function fixIssue(octokit: Octokit, { owner, repository, issueNumbe
8194
}
8295
}
8396
}`,
84-
{ issueId, assigneeId: suggestedActorsResponse?.repository?.suggestedActors?.nodes[0]?.id }
97+
{
98+
issueId,
99+
assigneeId:
100+
suggestedActorsResponse?.repository?.suggestedActors?.nodes[0]?.id,
101+
}
85102
);
86-
}
103+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import type { Octokit } from "@octokit/core";
2+
import { Issue } from "./Issue.js";
3+
4+
export async function getLinkedPR(
5+
octokit: Octokit,
6+
{ owner, repository, issueNumber }: Issue
7+
) {
8+
// Check whether issues can be assigned to Copilot
9+
const response = await octokit.graphql<{
10+
repository?: {
11+
issue?: {
12+
timelineItems?: {
13+
nodes: (
14+
| { source: { id: string; url: string; title: string } }
15+
| { subject: { id: string; url: string; title: string } }
16+
)[];
17+
};
18+
};
19+
};
20+
}>(
21+
`query($owner: String!, $repository: String!, $issueNumber: Int!) {
22+
repository(owner: $owner, name: $repository) {
23+
issue(number: $issueNumber) {
24+
timelineItems(first: 100, itemTypes: [CONNECTED_EVENT, CROSS_REFERENCED_EVENT]) {
25+
nodes {
26+
... on CrossReferencedEvent { source { ... on PullRequest { id url title } } }
27+
... on ConnectedEvent { subject { ... on PullRequest { id url title } } }
28+
}
29+
}
30+
}
31+
}
32+
}`,
33+
{ owner, repository, issueNumber }
34+
);
35+
const timelineNodes = response?.repository?.issue?.timelineItems?.nodes || [];
36+
const pullRequest: { id: string; url: string; title: string } | undefined =
37+
timelineNodes
38+
.map((node) => {
39+
if ("source" in node && node.source?.url) return node.source;
40+
if ("subject" in node && node.subject?.url) return node.subject;
41+
return undefined;
42+
})
43+
.find((pr) => !!pr);
44+
return pullRequest;
45+
}

.github/actions/fix/src/index.ts

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
1-
import type { IssueInput } from "./types.d.js";
1+
import type { Issue as IssueInput, Fixing } from "./types.d.js";
22
import process from "node:process";
33
import core from "@actions/core";
44
import { Octokit } from "@octokit/core";
55
import { throttling } from "@octokit/plugin-throttling";
6-
import { fixIssue } from "./fixIssue.js";
6+
import { assignIssue } from "./assignIssue.js";
7+
import { getLinkedPR } from "./getLinkedPR.js";
8+
import { retry } from "./retry.js";
79
import { Issue } from "./Issue.js";
810
const OctokitWithThrottling = Octokit.plugin(throttling);
911

@@ -40,17 +42,35 @@ export default async function () {
4042
},
4143
},
4244
});
43-
for (const issueInput of issues) {
45+
const fixings: Fixing[] = issues.map((issue) => ({ issue })) as Fixing[];
46+
47+
for (const fixing of fixings) {
4448
try {
45-
const issue = new Issue(issueInput);
46-
await fixIssue(octokit, issue);
49+
const issue = new Issue(fixing.issue);
50+
await assignIssue(octokit, issue);
4751
core.info(
4852
`Assigned ${issue.owner}/${issue.repository}#${issue.issueNumber} to Copilot!`
4953
);
54+
const pullRequest = await retry(() => getLinkedPR(octokit, issue));
55+
if (pullRequest) {
56+
fixing.pullRequest = pullRequest;
57+
core.info(
58+
`Found linked PR for ${issue.owner}/${issue.repository}#${issue.issueNumber}: ${pullRequest.url}`
59+
);
60+
} else {
61+
core.info(
62+
`No linked PR was found for ${issue.owner}/${issue.repository}#${issue.issueNumber}`
63+
);
64+
}
5065
} catch (error) {
51-
core.setFailed(`Failed to assign ${issueInput.url} to Copilot: ${error}`);
66+
core.setFailed(
67+
`Failed to assign ${fixing.issue.url} to Copilot: ${error}`
68+
);
5269
process.exit(1);
5370
}
5471
}
72+
73+
core.setOutput("fixings", JSON.stringify(fixings));
74+
core.debug(`Output: 'fixings: ${JSON.stringify(fixings)}'`);
5575
core.info("Finished 'fix' action");
5676
}

.github/actions/fix/src/retry.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
/**
2+
* Sleep for a given number of milliseconds.
3+
* @param ms Time to sleep, in milliseconds.
4+
*/
5+
function sleep(ms: number): Promise<void> {
6+
return new Promise((resolve) => setTimeout(() => resolve(), ms));
7+
}
8+
9+
/**
10+
* Retry a function with exponential backoff.
11+
* @param fn The function to retry.
12+
* @param maxAttempts The maximum number of retry attempts.
13+
* @param baseDelay The base delay between attempts.
14+
* @param attempt The current attempt number.
15+
* @returns The result of the function or undefined if all attempts fail.
16+
*/
17+
export async function retry<T>(
18+
fn: () => Promise<T | null | undefined> | T | null | undefined,
19+
maxAttempts = 6,
20+
baseDelay = 2000,
21+
attempt = 1
22+
): Promise<T | undefined> {
23+
const value = await fn();
24+
if (value != null) return value;
25+
if (attempt >= maxAttempts) return undefined;
26+
/** Exponential backoff, capped at 30s */
27+
const delay = Math.min(30000, baseDelay * 2 ** (attempt - 1));
28+
/** ±10% jitter */
29+
const jitter = 1 + (Math.random() - 0.5) * 0.2;
30+
await sleep(Math.round(delay * jitter));
31+
return retry(fn, maxAttempts, baseDelay, attempt + 1);
32+
}

.github/actions/fix/src/types.d.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,14 @@
1-
export type IssueInput = {
1+
export type Issue = {
22
url: string;
33
nodeId?: string;
4-
};
4+
};
5+
6+
export type PullRequest = {
7+
url: string;
8+
nodeId?: string;
9+
};
10+
11+
export type Fixing = {
12+
issue: Issue;
13+
pullRequest: PullRequest;
14+
};

.github/workflows/test.yml

Lines changed: 4 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ jobs:
6161
id: cache_key
6262
shell: bash
6363
run: |
64-
echo "cache_key=$(printf 'cached_filings-%s-%s.json' "${{ matrix.site }}" "${{ github.ref_name }}" | tr -cs 'A-Za-z0-9._-' '_')" >> $GITHUB_OUTPUT
64+
echo "cache_key=$(printf 'cached_results-%s-%s.json' "${{ matrix.site }}" "${{ github.ref_name }}" | tr -cs 'A-Za-z0-9._-' '_')" >> $GITHUB_OUTPUT
6565
6666
- name: Scan site (${{ matrix.site }})
6767
uses: ./
@@ -78,54 +78,12 @@ jobs:
7878
token: ${{ secrets.GH_TOKEN }}
7979
cache_key: ${{ steps.cache_key.outputs.cache_key }}
8080

81-
- name: Retrieve cached filings
81+
- name: Retrieve cached results
8282
uses: ./.github/actions/gh-cache/restore
8383
with:
8484
path: ${{ steps.cache_key.outputs.cache_key }}
8585
token: ${{ secrets.GITHUB_TOKEN }}
8686

87-
- name: Add PR URLs to filings
88-
uses: actions/github-script@v8
89-
with:
90-
github-token: ${{ secrets.GH_TOKEN }}
91-
script: |
92-
const fs = require('fs');
93-
if (!process.env.CACHE_PATH || !fs.existsSync(process.env.CACHE_PATH)) {
94-
core.info("Skipping 'Add PR URLs to filings' (no cached filings).");
95-
return;
96-
}
97-
const filings = JSON.parse(fs.readFileSync(process.env.CACHE_PATH, 'utf-8'));
98-
for (const filing of filings) {
99-
if (!filing?.issue.url) {
100-
continue;
101-
}
102-
const { owner, repo, issueNumber } = /https:\/\/github\.com\/(?<owner>[^/]+)\/(?<repo>[^/]+)\/issues\/(?<issueNumber>\d+)/.exec(filing.issue.url).groups;
103-
const query = `query($owner: String!, $repo: String!, $issueNumber: Int!) {
104-
repository(owner: $owner, name: $repo) {
105-
issue(number: $issueNumber) {
106-
timelineItems(first: 100, itemTypes: [CONNECTED_EVENT, CROSS_REFERENCED_EVENT]) {
107-
nodes {
108-
... on CrossReferencedEvent { source { ... on PullRequest { url } } }
109-
... on ConnectedEvent { subject { ... on PullRequest { url } } }
110-
}
111-
}
112-
}
113-
}
114-
}`;
115-
const variables = { owner, repo, issueNumber: parseInt(issueNumber, 10) }
116-
const result = await github.graphql(query, variables)
117-
const timelineNodes = result?.repository?.issue?.timelineItems?.nodes || [];
118-
const pullRequestNode = timelineNodes.find(n => n?.source?.url || n?.subject?.url);
119-
if (pullRequestNode) {
120-
filing.pullRequest = { url: pullRequestNode.source?.url || pullRequestNode.subject?.url };
121-
} else {
122-
core.info(`No pull request found for issue: ${filing.issue.url}`);
123-
}
124-
}
125-
fs.writeFileSync(process.env.CACHE_PATH, JSON.stringify(filings));
126-
env:
127-
CACHE_PATH: ${{ steps.cache_key.outputs.cache_key }}
128-
12987
- name: Validate scan results (${{ matrix.site }})
13088
run: |
13189
npm ci
@@ -140,7 +98,7 @@ jobs:
14098
run: |
14199
set -euo pipefail
142100
if [[ ! -f "${{ steps.cache_key.outputs.cache_key }}" ]]; then
143-
echo "Skipping 'Clean up issues and pull requests' (no cached filings)."
101+
echo "Skipping 'Clean up issues and pull requests' (no cached results)."
144102
exit 0
145103
fi
146104
jq -r '
@@ -162,7 +120,7 @@ jobs:
162120
env:
163121
GITHUB_TOKEN: ${{ secrets.GH_TOKEN }}
164122

165-
- name: Clean up cached filings
123+
- name: Clean up cached results
166124
if: ${{ always() }}
167125
uses: ./.github/actions/gh-cache/delete
168126
with:

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ Trigger the workflow manually or automatically based on your configuration. The
8585
| `urls` | Yes | Newline-delimited list of URLs to scan | `https://primer.style`<br>`https://primer.style/octicons` |
8686
| `repository` | Yes | Repository (with owner) for issues and PRs | `primer/primer-docs` |
8787
| `token` | Yes | PAT with write permissions (see above) | `${{ secrets.GH_TOKEN }}` |
88-
| `cache_key` | Yes | Key for caching findings across runs<br>Allowed: `A-Za-z0-9._/-` | `cached_filings-main-primer.style.json` |
88+
| `cache_key` | Yes | Key for caching results across runs<br>Allowed: `A-Za-z0-9._/-` | `cached_results-primer.style-main.json` |
8989
| `login_url` | No | If scanned pages require authentication, the URL of the login page | `https://github.com/login` |
9090
| `username` | No | If scanned pages require authentication, the username to use for login | `some-user` |
9191
| `password` | No | If scanned pages require authentication, the password to use for login | `correct-horse-battery-staple` |

0 commit comments

Comments
 (0)