Skip to content

Commit 8717df3

Browse files
authored
feat(get-latest-workflow-artifact): consider runs triggered by comments (#1099)
If a workflow is triggered by a comment, we have no clear way to associate it with a PR through the head_sha. Instead we have to look at the time and title of the run. This is not completely reliable but should be "good enough" for most cases. Due to that it is disabled by default.
1 parent 712c599 commit 8717df3

File tree

5 files changed

+179
-12
lines changed

5 files changed

+179
-12
lines changed

actions/get-latest-workflow-artifact/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ Required permissions:
1717
| `path` | Directory to store the artifact in | no | `${{ github.workspace }}` |
1818
| `github-token` | GitHub token | no | `${{ github.token }}` |
1919
| `consider-inprogress` | Not only consider completed but also in-progress workflow runs | no | `false` |
20+
| `consider-comments` | Also look for workflow runs triggered by comments | no | `false` |
2021

2122
| Output | Description |
2223
| ------------------------ | ------------------------------------------------------ |

actions/get-latest-workflow-artifact/action.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ inputs:
1010
consider-inprogress:
1111
description: Allow to also return artifacts from in-progress runs
1212
required: false
13+
consider-comments:
14+
description: Also look for workflow runs triggered by comments
15+
required: false
1316
repository:
1417
description: Repository of the target workflow (e.g. `grafana/grafana`)
1518
required: false
@@ -64,6 +67,7 @@ runs:
6467
INPUT_ARTIFACT-NAME: ${{ inputs.artifact-name }}
6568
INPUT_WORKFLOW-ID: ${{ inputs.workflow-id }}
6669
INPUT_CONSIDER-INPROGRESS: ${{ inputs.consider-inprogress }}
70+
INPUT_CONSIDER-COMMENTS: ${{ inputs.consider-comments }}
6771
INPUT_REPOSITORY: ${{ inputs.repository }}
6872
INPUT_PR-NUMBER: ${{ inputs.pr-number }}
6973
INPUT_PATH: ${{ inputs.path }}

actions/get-latest-workflow-artifact/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@
1010
"dependencies": {
1111
"@actions/artifact": "2.3.2",
1212
"@actions/core": "1.11.1",
13-
"@actions/github": "6.0.1"
13+
"@actions/github": "6.0.1",
14+
"@js-temporal/polyfill": "0.5.1"
1415
},
1516
"devDependencies": {
1617
"@octokit/openapi-types": "25.1.0",

actions/get-latest-workflow-artifact/src/main.ts

Lines changed: 167 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
import { Temporal, toTemporalInstant } from "@js-temporal/polyfill";
2+
Date.prototype.toTemporalInstant = toTemporalInstant;
3+
14
import * as core from "@actions/core";
25
import { getOctokit } from "@actions/github";
36
import { DefaultArtifactClient } from "@actions/artifact";
@@ -9,10 +12,12 @@ type WorkflowRunStatus = "in_progress" | "completed";
912
type Artifact = components["schemas"]["artifact"];
1013
type GitHubClient = ReturnType<typeof getOctokit>;
1114
type WorkflowRun = components["schemas"]["workflow-run"];
15+
type IssueComment = components["schemas"]["issue-comment"];
16+
type PullRequest = components["schemas"]["pull-request-simple"];
1217
type GetLatestArtifactResponse = {
1318
artifact: Artifact;
1419
run: WorkflowRun;
15-
} | null;
20+
};
1621

1722
async function main() {
1823
const ghToken = core.getInput("github-token", { required: true });
@@ -23,6 +28,7 @@ async function main() {
2328
const prNumber = parseInt(core.getInput("pr-number", { required: true }), 10);
2429
const artifactName = core.getInput("artifact-name", { required: true });
2530
const considerInProgress = core.getInput("consider-inprogress") === "true";
31+
const considerComments = core.getInput("consider-comments") === "true";
2632
let path = core.getInput("path", { required: false });
2733

2834
if (!path) {
@@ -47,7 +53,55 @@ async function main() {
4753

4854
// Now let's get all workflows associated with this sha:
4955
// We start with in-progress ones if those are to be considered.
50-
let found: GetLatestArtifactResponse = null;
56+
let found: GetLatestArtifactResponse | null = null;
57+
let foundForComment: GetLatestArtifactResponse | null = null;
58+
59+
if (considerComments) {
60+
// If we need to consider workflow runs triggered by comments, then we
61+
// cannot filter by head_sha but need to really go through all the runs of
62+
// a particular workflow and hope it's still available through paging.
63+
// Otherwise, we can only fallback and potentially get an outdated
64+
// artifact:
65+
//
66+
// First we need to get a list of all the comments inside a PR but only
67+
// those that happened before the PR was merged:
68+
const mergedAt = data.merged_at;
69+
const comment = await getRelevantComment(
70+
client,
71+
repoOwner,
72+
repoName,
73+
prNumber,
74+
mergedAt,
75+
);
76+
if (comment) {
77+
// Now that we have a comment, we need to find workflows triggered by it.
78+
// The problem is, that there is no clear association between a workflow
79+
// run and a comment triggering it available through the API. For this we
80+
// need to look at workflows runs with the title of the PR (ideally at the
81+
// time of the comment) and filter for all workflow runs that were trigger
82+
// between [comment_time, comment_time+delta]. This is based on the
83+
// assumption, that the title of a PR is stable around the time the fetch
84+
// is made and the comment is created
85+
const workflowRun = await getWorkflowRunForComment(
86+
client,
87+
repoOwner,
88+
repoName,
89+
workflowId,
90+
data,
91+
comment,
92+
considerInProgress,
93+
);
94+
if (workflowRun) {
95+
foundForComment = await getWorkflowRunArtifact(
96+
client,
97+
repoOwner,
98+
repoName,
99+
workflowRun,
100+
artifactName,
101+
);
102+
}
103+
}
104+
}
51105

52106
if (considerInProgress) {
53107
found = await getLatestArtifact(
@@ -59,7 +113,6 @@ async function main() {
59113
"in_progress",
60114
artifactName,
61115
);
62-
core.setOutput("workflow-run-status", "in_progress");
63116
}
64117

65118
if (!found) {
@@ -72,12 +125,22 @@ async function main() {
72125
"completed",
73126
artifactName,
74127
);
75-
core.setOutput("workflow-run-status", "completed");
128+
}
129+
130+
if (
131+
found &&
132+
foundForComment &&
133+
found.run.created_at < foundForComment.run.created_at
134+
) {
135+
found = foundForComment;
76136
}
77137

78138
if (!found) {
79139
throw new Error(`No artifacts found with name ${artifactName}`);
80140
}
141+
142+
core.setOutput("workflow-run-status", found.run.status);
143+
81144
const { artifact: foundArtifact, run } = found;
82145
const artifact = new DefaultArtifactClient();
83146
const response = await artifact.downloadArtifact(foundArtifact.id, {
@@ -99,10 +162,10 @@ async function getLatestArtifact(
99162
repoOwner: string,
100163
repoName: string,
101164
workflowId: string,
102-
headSha: string,
165+
headSha?: string,
103166
status: WorkflowRunStatus,
104167
artifactName: string,
105-
): Promise<GetLatestArtifactResponse> {
168+
): Promise<GetLatestArtifactResponse | null> {
106169
const {
107170
data: { workflow_runs: workflowRuns },
108171
}: { data: { workflow_runs: WorkflowRun[] } } =
@@ -113,25 +176,117 @@ async function getLatestArtifact(
113176
workflow_id: workflowId,
114177
status: status,
115178
});
179+
116180
if (workflowRuns.length === 0) {
117181
console.log(`No ${status} runs found`);
118182
return null;
119183
}
120184
const run = workflowRuns[0];
121-
// For in-progress workflows the artifact might not be available yet. For this scenario we do a bit of retrying:
122-
const maxAttempts = status === "in_progress" ? 5 : 1;
185+
return getWorkflowRunArtifact(client, repoOwner, repoName, run, artifactName);
186+
}
187+
188+
async function getRelevantComment(
189+
client: GitHubClient,
190+
owner: string,
191+
repo: string,
192+
prNumber: number,
193+
mergedAt: string | null,
194+
): Promise<IssueComment | null> {
195+
const comments: IssueComment[] = await client.paginate(
196+
client.rest.issues.listComments,
197+
{
198+
owner,
199+
repo,
200+
issue_number: prNumber,
201+
per_page: 100,
202+
},
203+
);
204+
comments.reverse();
205+
// Now drop all that are made *after* the PR was merged
206+
for (const comment of comments) {
207+
if (mergedAt && comment.created_at >= mergedAt) {
208+
continue;
209+
}
210+
return comment;
211+
}
212+
return null;
213+
}
214+
215+
async function getWorkflowRunForComment(
216+
client: GitHubClient,
217+
owner: string,
218+
repo: string,
219+
workflowId: string,
220+
pr: PullRequest,
221+
comment: IssueComment,
222+
considerInProgress: boolean,
223+
): Promise<WorkflowRun | null> {
224+
console.log(
225+
"Searching for workflows connected to comments. This can take a while…",
226+
);
227+
const commentCreatedAt = Temporal.Instant.from(comment.created_at);
228+
const runsIterator = client.paginate.iterator(
229+
client.rest.actions.listWorkflowRuns,
230+
{
231+
repo,
232+
owner,
233+
workflow_id: workflowId,
234+
per_page: 100,
235+
created: `${commentCreatedAt.toString()}..${commentCreatedAt.add({ minutes: 1 }).toString()}`,
236+
},
237+
);
238+
239+
let abort = false;
240+
let page = 0;
241+
242+
const allowedStatus = ["completed", "success"];
243+
if (considerInProgress) {
244+
allowedStatus.push("in_progress");
245+
}
246+
247+
for await (const runs of runsIterator) {
248+
for (const run of runs.data) {
249+
if (run.event !== "issue_comment") {
250+
continue;
251+
}
252+
if (!allowedStatus.includes(run.status)) {
253+
continue;
254+
}
255+
if (run.display_title !== pr.title) {
256+
continue;
257+
}
258+
return run;
259+
}
260+
if (++page > 100) {
261+
console.log("Aborting search after 100 pages");
262+
abort = true;
263+
}
264+
if (abort) {
265+
break;
266+
}
267+
}
268+
}
269+
270+
async function getWorkflowRunArtifact(
271+
client: GitHubClient,
272+
owner: string,
273+
repo: string,
274+
run: WorkflowRun,
275+
artifactName: string,
276+
): Promise<GetLatestArtifactResponse | null> {
277+
const maxAttempts = run.status === "in_progress" ? 5 : 1;
123278
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
124279
const {
125280
data: { artifacts },
126281
}: { data: { artifacts: Artifact[] } } =
127282
await client.rest.actions.listWorkflowRunArtifacts({
128-
owner: repoOwner,
129-
repo: repoName,
283+
owner,
284+
repo,
130285
run_id: run.id,
131286
});
132287
const artifact = artifacts.find((art) => art.name == artifactName);
133288
if (artifact) {
134-
console.log(`Found ${status} artifact`);
289+
console.log(`Found ${run.status} artifact`);
135290
return { artifact, run };
136291
}
137292
if (attempt < maxAttempts) {
@@ -142,4 +297,5 @@ async function getLatestArtifact(
142297
console.log("No artifact found");
143298
return null;
144299
}
300+
145301
await main();

bun.lock

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
"@actions/artifact": "2.3.2",
4343
"@actions/core": "1.11.1",
4444
"@actions/github": "6.0.1",
45+
"@js-temporal/polyfill": "0.5.1",
4546
},
4647
"devDependencies": {
4748
"@octokit/openapi-types": "25.1.0",
@@ -188,6 +189,8 @@
188189

189190
"@isaacs/cliui": ["@isaacs/[email protected]", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="],
190191

192+
"@js-temporal/polyfill": ["@js-temporal/[email protected]", "", { "dependencies": { "jsbi": "^4.3.0" } }, "sha512-hloP58zRVCRSpgDxmqCWJNlizAlUgJFqG2ypq79DCvyv9tHjRYMDOcPFjzfl/A1/YxDvRCZz8wvZvmapQnKwFQ=="],
193+
191194
"@nodelib/fs.scandir": ["@nodelib/[email protected]", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
192195

193196
"@nodelib/fs.stat": ["@nodelib/[email protected]", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="],
@@ -504,6 +507,8 @@
504507

505508
"js-yaml": ["[email protected]", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA=="],
506509

510+
"jsbi": ["[email protected]", "", {}, "sha512-9fqMSQbhJykSeii05nxKl4m6Eqn2P6rOlYiS+C5Dr/HPIU/7yZxu5qzbs40tgaFORiw2Amd0mirjxatXYMkIew=="],
511+
507512
"json-buffer": ["[email protected]", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="],
508513

509514
"json-parse-even-better-errors": ["[email protected]", "", {}, "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w=="],

0 commit comments

Comments
 (0)