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
2 changes: 1 addition & 1 deletion gh-actions/retest/__tests__/retest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import * as github from '@actions/github'
import * as core from '@actions/core'
import {WebhookPayload} from '@actions/github/lib/interfaces'
import nock from 'nock'
import run from '../retest'
import run from '../main'

beforeEach(() => {
jest.resetModules()
Expand Down
73 changes: 73 additions & 0 deletions gh-actions/retest/azp.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import RetestCommand from './base'
import * as types from './types'

class AZPRetestCommand extends RetestCommand {
name = 'AZP'

constructor(env: types.Env) {
super(env)
}

getRetestables = async (pr: types.PR): Promise<Array<types.Retest>> => {
const response: types.ListChecksType['response'] = await this.env.octokit.checks.listForRef({
owner: this.env.owner,
repo: this.env.repo,
ref: pr.commit,
})
// add err handling
const retestables: Array<types.Retest> = []
const azpRuns: types.ListChecksType['response']['data']['check_runs'] = response.data.check_runs.filter((run: any) => {
return run.app.slug === 'azure-pipelines'
})
const checks: Array<any> = []
const checkIds: Set<string> = new Set()
for (const check of azpRuns) {
if (!check.external_id) {
continue
}
checkIds.add(check.external_id)
if (check.name.endsWith(')')) {
checks.push(check)
}
}

for (const checkId of checkIds) {
const subchecks = checks.filter((c: any) => {
return c.external_id === checkId
})
if (Object.keys(subchecks).length === 0) {
continue
}
const subcheck = subchecks[0].name.split(' ')[0]
const link = this.getAZPLink(checkId)
const name = `[${subcheck}](${link})`
const config = {
headers: {
authorization: `basic ${this.env.azpToken}`,
'content-type': 'application/json;odata=verbose',
},
}
for (const check of subchecks) {
if (check.conclusion && check.conclusion !== 'success') {
const [_, buildId, project] = checkId.split('|')
const url = `https://dev.azure.com/${this.env.azpOrg}/${project}/_apis/build/builds/${buildId}?retry=true&api-version=6.0`
retestables.push({
url,
name,
config,
method: 'patch',
octokit: false,
})
}
}
}
return retestables
}

getAZPLink = (checkId: string): string => {
const [_, buildId, project] = checkId.split('|')
return `https://dev.azure.com/${this.env.azpOrg}/${project}/_build/results?buildId=${buildId}&view=results`
}
}

export default AZPRetestCommand
130 changes: 130 additions & 0 deletions gh-actions/retest/base.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import {OctokitResponse} from '@octokit/types'
import axios, {AxiosResponse} from 'axios'
import cachedProperty from './cached-property'
import * as types from './types'


class RetestCommand {
public env: types.Env
public name = ''

constructor(env: types.Env) {
this.env = env
}

getPR = async (): Promise<types.PR | void> => {
if (!this.env.pr || !this.env.pr.url) {
return
}
const response: OctokitResponse<any> = await this.env.octokit.request(this.env.pr.url)
const data = response.data
if (!data) return
return {
number: data.number,
branch: data.head.ref,
commit: data.head.sha,
}
}

@cachedProperty
get isRetest(): boolean {
let isRetest = false
this.env.comment.body.split('\r\n').forEach((line: string) => {
if (line.startsWith('/retest')) {
if (!line.startsWith('/retest envoy')) {
isRetest = true
return
}
}
})
return isRetest
}

retest = async (): Promise<number> => {
if (!this.env) {
console.error(`Failed parsing env`)
return 0
}
if (!this.isRetest) return 0
const pr = await this.getPR()
if (!pr) {
return 0
}
const retestables = await this.getRetestables(pr)
if (Object.keys(retestables).length === 0) {
return 0
}
await this.retestRuns(pr, retestables)
return Object.keys(retestables).length
}

retestExternal = async (check: types.Retest): Promise<any | void> => {
let response: AxiosResponse
if (check.method == 'patch') {
try {
response = await axios.patch(check.url, {}, check.config)
/* eslint-disable prettier/prettier */
} catch (error: any) {
if (!axios.isAxiosError(error) || !error.response) {
console.error('No response received')
return
}
console.error(`External API call failed: ${check.url}`)
console.error(error.response.status)
console.error(error.response.data)
return
}
} else {
return
}
return response.data
}

retestOctokit = async (check: types.Retest): Promise<void> => {
const rerunURL = `POST ${check.url}/rerun-failed-jobs`
const rerunResponse = await this.env.octokit.request(rerunURL)
if ([200, 201].includes(rerunResponse.status)) {
console.debug(`Retry success: (${check.name})`)
} else {
console.error(`Retry failed: (${check.name}) ... ${rerunResponse.status}`)
}
}

retestRuns = async (pr: types.PR, retestables: Array<types.Retest>): Promise<void> => {
console.debug(`Running /retest command for PR #${pr.number}`)
console.debug(`PR branch: ${pr.branch}`)
console.debug(`Latest PR commit: ${pr.commit}`)
for (const check of retestables) {
console.debug(`Retesting failed job: ${check.name}`)
if (!check.octokit) {
await this.retestExternal(check)
} else {
await this.retestOctokit(check)
}
}
}

getRetestables = async (_: types.PR): Promise<Array<types.Retest>> => {
return []
}

listWorkflowRunsForPR = async (pr: types.PR): Promise<types.WorkflowRunsType['data']['workflow_runs'] | void> => {
const response: types.WorkflowRunsType = await this.env.octokit.actions.listWorkflowRunsForRepo({
owner: this.env.owner,
repo: this.env.repo,
branch: pr.branch,
})

const workflowRuns = response.data
if (!workflowRuns) return

const runs = workflowRuns.workflow_runs
if (!runs) return

return runs.filter((run: any) => {
return run.head_sha === pr.commit
})
}
}

export default RetestCommand
20 changes: 20 additions & 0 deletions gh-actions/retest/cached-property.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@

function cachedProperty(_: unknown, key: string, descriptor: PropertyDescriptor): PropertyDescriptor {
const originalGetter = descriptor.get
if (!originalGetter) {
throw new Error('The decorated property must have a getter.')
}

// Use a Symbol for storing the cached value on the instance
const cachedValueKey = Symbol(`__cached_${key}`)
/* eslint-disable @typescript-eslint/no-explicit-any */
descriptor.get = function(this: any): any {
if (!this[cachedValueKey]) {
this[cachedValueKey] = originalGetter.call(this)
}
return this[cachedValueKey]
}
return descriptor
}

export default cachedProperty
2 changes: 1 addition & 1 deletion gh-actions/retest/dist/index.js

Large diffs are not rendered by default.

30 changes: 30 additions & 0 deletions gh-actions/retest/github.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import RetestCommand from './base'
import * as types from './types'

class GithubRetestCommand extends RetestCommand {
name = 'Github'

constructor(env: types.Env) {
super(env)
}

getRetestables = async (pr: types.PR): Promise<Array<types.Retest>> => {
// Get failed Workflow runs for the latest PR commit
const runs = await this.listWorkflowRunsForPR(pr)
if (!runs) return []
const failedRuns = runs.filter((run: any) => {
return run.conclusion === 'cancelled' || run.conclusion === 'failure' || run.conclusion === 'timed_out'
})
const retestables: Array<types.Retest> = []
for (const run of failedRuns) {
retestables.push({
name: run.name || 'unknown',
url: run.url,
octokit: true,
})
}
return retestables
}
}

export default GithubRetestCommand
2 changes: 1 addition & 1 deletion gh-actions/retest/jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,5 +17,5 @@ module.exports = {
transform: {
'^.+\\.ts$': 'ts-jest',
},
verbose: true,
verbose: false,
}
22 changes: 22 additions & 0 deletions gh-actions/retest/main.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import * as core from '@actions/core'
import RetestCommands from './retest'

const run = async (): Promise<void> => {
try {
const retesters = new RetestCommands()
await retesters.retest()
} catch (error) {
if (error instanceof Error) {
console.error(error.message)
}
core.setFailed(`retest-action failure: ${error}`)
}
}

// Don't auto-execute in the test environment
if (process.env['NODE_ENV'] !== 'test') {
run()
}

export {RetestCommands}
export default run
2 changes: 1 addition & 1 deletion gh-actions/retest/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
},
"homepage": "https://github.com/envoyproxy/toolshed",
"scripts": {
"build": "tsc --noEmit && ncc build retest.ts -o dist -m",
"build": "tsc --noEmit && ncc build main.ts -o dist -m",
"test": "tsc --noEmit && jest",
"lint": "eslint . --ext .ts",
"lint-fix": "eslint . --ext .ts --fix"
Expand Down
Loading