Skip to content

Commit bd6508a

Browse files
committed
gh-actions/retest: Code cleanups and tests
Signed-off-by: Ryan Northey <[email protected]>
1 parent ab09173 commit bd6508a

File tree

11 files changed

+327
-304
lines changed

11 files changed

+327
-304
lines changed

gh-actions/retest/__tests__/retest.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import * as github from '@actions/github'
22
import * as core from '@actions/core'
33
import {WebhookPayload} from '@actions/github/lib/interfaces'
44
import nock from 'nock'
5-
import run from '../retest'
5+
import run from '../main'
66

77
beforeEach(() => {
88
jest.resetModules()

gh-actions/retest/azp.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import RetestCommand from './base'
2+
import * as types from './types'
3+
4+
class AZPRetestCommand extends RetestCommand {
5+
name = 'AZP'
6+
7+
constructor(env: types.Env) {
8+
super(env)
9+
}
10+
11+
getRetestables = async (pr: types.PR): Promise<Array<types.Retest>> => {
12+
const response: types.ListChecksType['response'] = await this.env.octokit.checks.listForRef({
13+
owner: this.env.owner,
14+
repo: this.env.repo,
15+
ref: pr.commit,
16+
})
17+
// add err handling
18+
const retestables: Array<types.Retest> = []
19+
const azpRuns: types.ListChecksType['response']['data']['check_runs'] = response.data.check_runs.filter((run: any) => {
20+
return run.app.slug === 'azure-pipelines'
21+
})
22+
const checks: Array<any> = []
23+
const checkIds: Set<string> = new Set()
24+
for (const check of azpRuns) {
25+
if (!check.external_id) {
26+
continue
27+
}
28+
checkIds.add(check.external_id)
29+
if (check.name.endsWith(')')) {
30+
checks.push(check)
31+
}
32+
}
33+
34+
for (const checkId of checkIds) {
35+
const subchecks = checks.filter((c: any) => {
36+
return c.external_id === checkId
37+
})
38+
if (Object.keys(subchecks).length === 0) {
39+
continue
40+
}
41+
const subcheck = subchecks[0].name.split(' ')[0]
42+
const link = this.getAZPLink(checkId)
43+
const name = `[${subcheck}](${link})`
44+
const config = {
45+
headers: {
46+
authorization: `basic ${this.env.azpToken}`,
47+
'content-type': 'application/json;odata=verbose',
48+
},
49+
}
50+
for (const check of subchecks) {
51+
if (check.conclusion && check.conclusion !== 'success') {
52+
const [_, buildId, project] = checkId.split('|')
53+
const url = `https://dev.azure.com/${this.env.azpOrg}/${project}/_apis/build/builds/${buildId}?retry=true&api-version=6.0`
54+
retestables.push({
55+
url,
56+
name,
57+
config,
58+
method: 'patch',
59+
octokit: false,
60+
})
61+
}
62+
}
63+
}
64+
return retestables
65+
}
66+
67+
getAZPLink = (checkId: string): string => {
68+
const [_, buildId, project] = checkId.split('|')
69+
return `https://dev.azure.com/${this.env.azpOrg}/${project}/_build/results?buildId=${buildId}&view=results`
70+
}
71+
}
72+
73+
export default AZPRetestCommand

gh-actions/retest/base.ts

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import {OctokitResponse} from '@octokit/types'
2+
import axios, {AxiosResponse} from 'axios'
3+
import cachedProperty from './cached-property'
4+
import * as types from './types'
5+
6+
7+
class RetestCommand {
8+
public env: types.Env
9+
public name = ''
10+
11+
constructor(env: types.Env) {
12+
this.env = env
13+
}
14+
15+
getPR = async (): Promise<types.PR | void> => {
16+
if (!this.env.pr || !this.env.pr.url) {
17+
return
18+
}
19+
const response: OctokitResponse<any> = await this.env.octokit.request(this.env.pr.url)
20+
const data = response.data
21+
if (!data) return
22+
return {
23+
number: data.number,
24+
branch: data.head.ref,
25+
commit: data.head.sha,
26+
}
27+
}
28+
29+
@cachedProperty
30+
get isRetest(): boolean {
31+
let isRetest = false
32+
this.env.comment.body.split('\r\n').forEach((line: string) => {
33+
if (line.startsWith('/retest')) {
34+
if (!line.startsWith('/retest envoy')) {
35+
isRetest = true
36+
return
37+
}
38+
}
39+
})
40+
return isRetest
41+
}
42+
43+
retest = async (): Promise<number> => {
44+
if (!this.env) {
45+
console.error(`Failed parsing env`)
46+
return 0
47+
}
48+
if (!this.isRetest) return 0
49+
const pr = await this.getPR()
50+
if (!pr) {
51+
return 0
52+
}
53+
const retestables = await this.getRetestables(pr)
54+
if (Object.keys(retestables).length === 0) {
55+
return 0
56+
}
57+
await this.retestRuns(pr, retestables)
58+
return Object.keys(retestables).length
59+
}
60+
61+
retestExternal = async (check: types.Retest): Promise<any | void> => {
62+
let response: AxiosResponse
63+
if (check.method == 'patch') {
64+
try {
65+
response = await axios.patch(check.url, {}, check.config)
66+
/* eslint-disable prettier/prettier */
67+
} catch (error: any) {
68+
if (!axios.isAxiosError(error) || !error.response) {
69+
console.error('No response received')
70+
return
71+
}
72+
console.error(`External API call failed: ${check.url}`)
73+
console.error(error.response.status)
74+
console.error(error.response.data)
75+
return
76+
}
77+
} else {
78+
return
79+
}
80+
return response.data
81+
}
82+
83+
retestOctokit = async (check: types.Retest): Promise<void> => {
84+
const rerunURL = `POST ${check.url}/rerun-failed-jobs`
85+
const rerunResponse = await this.env.octokit.request(rerunURL)
86+
if ([200, 201].includes(rerunResponse.status)) {
87+
console.debug(`Retry success: (${check.name})`)
88+
} else {
89+
console.error(`Retry failed: (${check.name}) ... ${rerunResponse.status}`)
90+
}
91+
}
92+
93+
retestRuns = async (pr: types.PR, retestables: Array<types.Retest>): Promise<void> => {
94+
console.debug(`Running /retest command for PR #${pr.number}`)
95+
console.debug(`PR branch: ${pr.branch}`)
96+
console.debug(`Latest PR commit: ${pr.commit}`)
97+
for (const check of retestables) {
98+
console.debug(`Retesting failed job: ${check.name}`)
99+
if (!check.octokit) {
100+
await this.retestExternal(check)
101+
} else {
102+
await this.retestOctokit(check)
103+
}
104+
}
105+
}
106+
107+
getRetestables = async (_: types.PR): Promise<Array<types.Retest>> => {
108+
return []
109+
}
110+
111+
listWorkflowRunsForPR = async (pr: types.PR): Promise<types.WorkflowRunsType['data']['workflow_runs'] | void> => {
112+
const response: types.WorkflowRunsType = await this.env.octokit.actions.listWorkflowRunsForRepo({
113+
owner: this.env.owner,
114+
repo: this.env.repo,
115+
branch: pr.branch,
116+
})
117+
118+
const workflowRuns = response.data
119+
if (!workflowRuns) return
120+
121+
const runs = workflowRuns.workflow_runs
122+
if (!runs) return
123+
124+
return runs.filter((run: any) => {
125+
return run.head_sha === pr.commit
126+
})
127+
}
128+
}
129+
130+
export default RetestCommand
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
2+
function cachedProperty(_: unknown, key: string, descriptor: PropertyDescriptor): PropertyDescriptor {
3+
const originalGetter = descriptor.get
4+
if (!originalGetter) {
5+
throw new Error('The decorated property must have a getter.')
6+
}
7+
8+
// Use a Symbol for storing the cached value on the instance
9+
const cachedValueKey = Symbol(`__cached_${key}`)
10+
/* eslint-disable @typescript-eslint/no-explicit-any */
11+
descriptor.get = function(this: any): any {
12+
if (!this[cachedValueKey]) {
13+
this[cachedValueKey] = originalGetter.call(this)
14+
}
15+
return this[cachedValueKey]
16+
}
17+
return descriptor
18+
}
19+
20+
export default cachedProperty

gh-actions/retest/dist/index.js

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

gh-actions/retest/github.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import RetestCommand from './base'
2+
import * as types from './types'
3+
4+
class GithubRetestCommand extends RetestCommand {
5+
name = 'Github'
6+
7+
constructor(env: types.Env) {
8+
super(env)
9+
}
10+
11+
getRetestables = async (pr: types.PR): Promise<Array<types.Retest>> => {
12+
// Get failed Workflow runs for the latest PR commit
13+
const runs = await this.listWorkflowRunsForPR(pr)
14+
if (!runs) return []
15+
const failedRuns = runs.filter((run: any) => {
16+
return run.conclusion === 'cancelled' || run.conclusion === 'failure' || run.conclusion === 'timed_out'
17+
})
18+
const retestables: Array<types.Retest> = []
19+
for (const run of failedRuns) {
20+
retestables.push({
21+
name: run.name || 'unknown',
22+
url: run.url,
23+
octokit: true,
24+
})
25+
}
26+
return retestables
27+
}
28+
}
29+
30+
export default GithubRetestCommand

gh-actions/retest/jest.config.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,5 +17,5 @@ module.exports = {
1717
transform: {
1818
'^.+\\.ts$': 'ts-jest',
1919
},
20-
verbose: true,
20+
verbose: false,
2121
}

gh-actions/retest/main.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import * as core from '@actions/core'
2+
import RetestCommands from './retest'
3+
4+
const run = async (): Promise<void> => {
5+
try {
6+
const retesters = new RetestCommands()
7+
await retesters.retest()
8+
} catch (error) {
9+
if (error instanceof Error) {
10+
console.error(error.message)
11+
}
12+
core.setFailed(`retest-action failure: ${error}`)
13+
}
14+
}
15+
16+
// Don't auto-execute in the test environment
17+
if (process.env['NODE_ENV'] !== 'test') {
18+
run()
19+
}
20+
21+
export {RetestCommands}
22+
export default run

gh-actions/retest/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
},
2121
"homepage": "https://github.com/envoyproxy/toolshed",
2222
"scripts": {
23-
"build": "tsc --noEmit && ncc build retest.ts -o dist -m",
23+
"build": "tsc --noEmit && ncc build main.ts -o dist -m",
2424
"test": "tsc --noEmit && jest",
2525
"lint": "eslint . --ext .ts",
2626
"lint-fix": "eslint . --ext .ts --fix"

0 commit comments

Comments
 (0)