feat(app/action): improved setup error handling for exceeded rate-limit (#1008) [skip ci]

This commit is contained in:
Simon Lecoq
2022-04-23 20:36:26 +02:00
committed by GitHub
parent 5eef11b5c7
commit c3ea358446

View File

@@ -68,6 +68,12 @@ async function retry(func, {retries = 1, delay = 0} = {}) {
return null return null
} }
//Process exit
function quit(reason) {
const code = {success:0, skipped:0, failed:1}[reason] ?? 0
process.exit(code)
}
//===================================================================================================== //=====================================================================================================
//Runner //Runner
@@ -81,11 +87,11 @@ async function retry(func, {retries = 1, delay = 0} = {}) {
if ((github.context.eventName === "push") && (github.context.payload?.head_commit)) { if ((github.context.eventName === "push") && (github.context.payload?.head_commit)) {
if (/\[Skip GitHub Action\]/.test(github.context.payload.head_commit.message)) { if (/\[Skip GitHub Action\]/.test(github.context.payload.head_commit.message)) {
console.log("Skipped because [Skip GitHub Action] is in commit message") console.log("Skipped because [Skip GitHub Action] is in commit message")
process.exit(0) quit("skipped")
} }
if (/Auto-generated metrics for run #\d+/.test(github.context.payload.head_commit.message)) { if (/Auto-generated metrics for run #\d+/.test(github.context.payload.head_commit.message)) {
console.log("Skipped because this seems to be an automated pull request merge") console.log("Skipped because this seems to be an automated pull request merge")
process.exit(0) quit("skipped")
} }
} }
@@ -163,6 +169,7 @@ async function retry(func, {retries = 1, delay = 0} = {}) {
throw new Error("You must provide a valid GitHub personal token to gather your metrics (see https://github.com/lowlighter/metrics/blob/master/.github/readme/partials/documentation/setup/action.md for more informations)") throw new Error("You must provide a valid GitHub personal token to gather your metrics (see https://github.com/lowlighter/metrics/blob/master/.github/readme/partials/documentation/setup/action.md for more informations)")
conf.settings.token = token conf.settings.token = token
const api = {} const api = {}
const resources = {}
api.graphql = octokit.graphql.defaults({headers:{authorization:`token ${token}`}}) api.graphql = octokit.graphql.defaults({headers:{authorization:`token ${token}`}})
info("Github GraphQL API", "ok") info("Github GraphQL API", "ok")
const octoraw = github.getOctokit(token) const octoraw = github.getOctokit(token)
@@ -174,15 +181,35 @@ async function retry(func, {retries = 1, delay = 0} = {}) {
Object.assign(api, await mocks(api)) Object.assign(api, await mocks(api))
info("Use mocked API", true) info("Use mocked API", true)
} }
//Test token validity //Test token validity and requests count
else if (!/^NOT_NEEDED$/.test(token)) { else if (!/^NOT_NEEDED$/.test(token)) {
const {headers} = await api.rest.request("HEAD /") //Check rate limit
if (!("x-oauth-scopes" in headers)) { const {data} = await api.rest.rateLimit.get().catch(() => ({data:{resources:{}}}))
throw new Error( Object.assign(resources, data.resources)
'GitHub API did not send any "x-oauth-scopes" header back from provided "token". It means that your token may not be valid or you\'re using GITHUB_TOKEN which cannot be used since metrics will fetch data outside of this repository scope. Use a personal access token instead (see https://github.com/lowlighter/metrics/blob/master/.github/readme/partials/documentation/setup/action.md for more informations).', info("API requests (REST)", resources.core ? `${resources.core.remaining}/${resources.core.limit}` : "(unknown)")
) info("API requests (GraphQL)", resources.graphql ? `${resources.graphql.remaining}/${resources.graphql.limit}` : "(unknown)")
info("API requests (search)", resources.search ? `${resources.search.remaining}/${resources.search.limit}` : "(unknown)")
if ((!resources.core.remaining)||(!resources.graphql.remaining)) {
console.warn("::warning::It seems you have reached your API requests limit. Please retry later.")
info.break()
console.log("Nothing can be done currently, thanks for using metrics!")
quit("skipped")
}
if (!resources.search.remaining)
console.warn("::warning::It seems you have reached your Search API requests limit. Some plugins may return less accurate results.")
//Check scopes
try {
const {headers} = await api.rest.request("HEAD /")
if (!("x-oauth-scopes" in headers)) {
throw new Error(
'GitHub API did not send any "x-oauth-scopes" header back from provided "token". It means that your token may not be valid or you\'re using GITHUB_TOKEN which cannot be used since metrics will fetch data outside of this repository scope. Use a personal access token instead (see https://github.com/lowlighter/metrics/blob/master/.github/readme/partials/documentation/setup/action.md for more informations).',
)
}
info("Token validity", "seems ok")
}
catch {
info("Token validity", "(could not verify)")
} }
info("Token validity", "seems ok")
} }
//Extract octokits //Extract octokits
const {graphql, rest} = api const {graphql, rest} = api
@@ -404,157 +431,170 @@ async function retry(func, {retries = 1, delay = 0} = {}) {
if (_action === "none") { if (_action === "none") {
info.break() info.break()
console.log("Success, thanks for using metrics!") console.log("Success, thanks for using metrics!")
process.exit(0)
} }
//Perform output action
//Cache embed svg for markdown outputs else {
if (/markdown/.test(convert)) { //Cache embed svg for markdown outputs
const regex = /(?<match><img class="metrics-cachable" data-name="(?<name>[\s\S]+?)" src="data:image[/](?<format>(?:svg[+]xml)|jpeg|png);base64,(?<content>[/+=\w]+?)">)/ if (/markdown/.test(convert)) {
let matched = null const regex = /(?<match><img class="metrics-cachable" data-name="(?<name>[\s\S]+?)" src="data:image[/](?<format>(?:svg[+]xml)|jpeg|png);base64,(?<content>[/+=\w]+?)">)/
while (matched = regex.exec(rendered)?.groups) { //eslint-disable-line no-cond-assign let matched = null
await retry(async () => { while (matched = regex.exec(rendered)?.groups) { //eslint-disable-line no-cond-assign
const {match, name, format, content} = matched
let path = `${_markdown_cache}/${name}.${format.replace(/[+].*$/g, "")}`
console.debug(`Processing ${path}`)
let sha = null
try {
const {repository:{object:{oid}}} = await graphql(
`
query Sha {
repository(owner: "${github.context.repo.owner}", name: "${github.context.repo.repo}") {
object(expression: "${committer.head}:${path}") { ... on Blob { oid } }
}
}
`,
{headers:{authorization:`token ${committer.token}`}},
)
sha = oid
}
catch (error) {
console.debug(error)
}
finally {
await committer.rest.repos.createOrUpdateFileContents({
...github.context.repo,
path,
content,
message:`${committer.message} (cache)`,
...(sha ? {sha} : {}),
branch:committer.pr ? committer.head : committer.branch,
})
rendered = rendered.replace(match, `<img src="https://github.com/${github.context.repo.owner}/${github.context.repo.repo}/blob/${committer.branch}/${path}">`)
info(`Saving ${path}`, "ok")
}
}, {retries:retries_output_action, delay:retries_delay_output_action})
}
}
//Check editions
if ((committer.commit) || (committer.pr)) {
const git = sgit()
const sha = await git.hashObject(paths.join("/renders", filename))
info("Current render sha", sha)
if (committer.sha === sha) {
info(`Commit to branch ${committer.branch}`, "(no changes)")
committer.commit = false
}
}
//Upload to gist (this is done as user since committer_token may not have gist rights)
if (committer.gist) {
await retry(async () => {
await rest.gists.update({gist_id:committer.gist, files:{[filename]:{content:rendered}}})
info(`Upload to gist ${committer.gist}`, "ok")
committer.commit = false
}, {retries:retries_output_action, delay:retries_delay_output_action})
}
//Commit metrics
if (committer.commit) {
await retry(async () => {
await committer.rest.repos.createOrUpdateFileContents({
...github.context.repo,
path:filename,
message:committer.message,
content:Buffer.from(typeof rendered === "object" ? JSON.stringify(rendered) : `${rendered}`).toString("base64"),
branch:committer.pr ? committer.head : committer.branch,
...(committer.sha ? {sha:committer.sha} : {}),
})
info(`Commit to branch ${committer.branch}`, "ok")
}, {retries:retries_output_action, delay:retries_delay_output_action})
}
//Pull request
if (committer.pr) {
//Create pull request
let number = null
await retry(async () => {
try {
({data:{number}} = await committer.rest.pulls.create({...github.context.repo, head:committer.head, base:committer.branch, title:`Auto-generated metrics for run #${github.context.runId}`, body:" ", maintainer_can_modify:true}))
info(`Pull request from ${committer.head} to ${committer.branch}`, "(created)")
}
catch (error) {
console.debug(error)
//Check if pull request has already been created previously
if (/A pull request already exists/.test(error)) {
info(`Pull request from ${committer.head} to ${committer.branch}`, "(already existing)")
const q = `repo:${github.context.repo.owner}/${github.context.repo.repo}+type:pr+state:open+Auto-generated metrics for run #${github.context.runId}+in:title`
const prs = (await committer.rest.search.issuesAndPullRequests({q})).data.items.filter(({user:{login}}) => login === "github-actions[bot]")
if (prs.length < 1)
throw new Error("0 matching prs. Cannot proceed.")
if (prs.length > 1)
throw new Error(`Found more than one matching prs: ${prs.map(({number}) => `#${number}`).join(", ")}. Cannot proceed.`)
;({number} = prs.shift())
}
//Check if pull request could not been created because there are no diff between head and base
else if (/No commits between/.test(error)) {
info(`Pull request from ${committer.head} to ${committer.branch}`, "(no diff)")
committer.merge = false
number = "(none)"
}
else
throw error
}
info("Pull request number", number)
}, {retries:retries_output_action, delay:retries_delay_output_action})
//Merge pull request
if (committer.merge) {
info("Merge method", committer.merge)
let attempts = 240
do {
const success = await retry(async () => {
//Check pull request mergeability (https://octokit.github.io/rest.js/v18#pulls-get)
const {data:{mergeable, mergeable_state:state}} = await committer.rest.pulls.get({...github.context.repo, pull_number:number})
console.debug(`Pull request #${number} mergeable state is "${state}"`)
if (mergeable === null) {
await wait(15)
return false
}
if (!mergeable)
throw new Error(`Pull request #${number} is not mergeable (state is "${state}")`)
//Merge pull request
await committer.rest.pulls.merge({...github.context.repo, pull_number:number, merge_method:committer.merge})
info(`Merge #${number} to ${committer.branch}`, "ok")
return true
}, {retries:retries_output_action, delay:retries_delay_output_action})
if (!success)
continue
//Delete head branch
await retry(async () => { await retry(async () => {
const {match, name, format, content} = matched
let path = `${_markdown_cache}/${name}.${format.replace(/[+].*$/g, "")}`
console.debug(`Processing ${path}`)
let sha = null
try { try {
await wait(15) const {repository:{object:{oid}}} = await graphql(
await committer.rest.git.deleteRef({...github.context.repo, ref:`heads/${committer.head}`}) `
query Sha {
repository(owner: "${github.context.repo.owner}", name: "${github.context.repo.repo}") {
object(expression: "${committer.head}:${path}") { ... on Blob { oid } }
}
}
`,
{headers:{authorization:`token ${committer.token}`}},
)
sha = oid
} }
catch (error) { catch (error) {
console.debug(error) console.debug(error)
if (!/reference does not exist/i.test(`${error}`))
throw error
} }
info(`Branch ${committer.head}`, "(deleted)") finally {
await committer.rest.repos.createOrUpdateFileContents({
...github.context.repo,
path,
content,
message:`${committer.message} (cache)`,
...(sha ? {sha} : {}),
branch:committer.pr ? committer.head : committer.branch,
})
rendered = rendered.replace(match, `<img src="https://github.com/${github.context.repo.owner}/${github.context.repo.repo}/blob/${committer.branch}/${path}">`)
info(`Saving ${path}`, "ok")
}
}, {retries:retries_output_action, delay:retries_delay_output_action}) }, {retries:retries_output_action, delay:retries_delay_output_action})
break }
} while (--attempts) }
//Check editions
if ((committer.commit) || (committer.pr)) {
const git = sgit()
const sha = await git.hashObject(paths.join("/renders", filename))
info("Current render sha", sha)
if (committer.sha === sha) {
info(`Commit to branch ${committer.branch}`, "(no changes)")
committer.commit = false
}
}
//Upload to gist (this is done as user since committer_token may not have gist rights)
if (committer.gist) {
await retry(async () => {
await rest.gists.update({gist_id:committer.gist, files:{[filename]:{content:rendered}}})
info(`Upload to gist ${committer.gist}`, "ok")
committer.commit = false
}, {retries:retries_output_action, delay:retries_delay_output_action})
}
//Commit metrics
if (committer.commit) {
await retry(async () => {
await committer.rest.repos.createOrUpdateFileContents({
...github.context.repo,
path:filename,
message:committer.message,
content:Buffer.from(typeof rendered === "object" ? JSON.stringify(rendered) : `${rendered}`).toString("base64"),
branch:committer.pr ? committer.head : committer.branch,
...(committer.sha ? {sha:committer.sha} : {}),
})
info(`Commit to branch ${committer.branch}`, "ok")
}, {retries:retries_output_action, delay:retries_delay_output_action})
}
//Pull request
if (committer.pr) {
//Create pull request
let number = null
await retry(async () => {
try {
({data:{number}} = await committer.rest.pulls.create({...github.context.repo, head:committer.head, base:committer.branch, title:`Auto-generated metrics for run #${github.context.runId}`, body:" ", maintainer_can_modify:true}))
info(`Pull request from ${committer.head} to ${committer.branch}`, "(created)")
}
catch (error) {
console.debug(error)
//Check if pull request has already been created previously
if (/A pull request already exists/.test(error)) {
info(`Pull request from ${committer.head} to ${committer.branch}`, "(already existing)")
const q = `repo:${github.context.repo.owner}/${github.context.repo.repo}+type:pr+state:open+Auto-generated metrics for run #${github.context.runId}+in:title`
const prs = (await committer.rest.search.issuesAndPullRequests({q})).data.items.filter(({user:{login}}) => login === "github-actions[bot]")
if (prs.length < 1)
throw new Error("0 matching prs. Cannot proceed.")
if (prs.length > 1)
throw new Error(`Found more than one matching prs: ${prs.map(({number}) => `#${number}`).join(", ")}. Cannot proceed.`)
;({number} = prs.shift())
}
//Check if pull request could not been created because there are no diff between head and base
else if (/No commits between/.test(error)) {
info(`Pull request from ${committer.head} to ${committer.branch}`, "(no diff)")
committer.merge = false
number = "(none)"
}
else
throw error
}
info("Pull request number", number)
}, {retries:retries_output_action, delay:retries_delay_output_action})
//Merge pull request
if (committer.merge) {
info("Merge method", committer.merge)
let attempts = 240
do {
const success = await retry(async () => {
//Check pull request mergeability (https://octokit.github.io/rest.js/v18#pulls-get)
const {data:{mergeable, mergeable_state:state}} = await committer.rest.pulls.get({...github.context.repo, pull_number:number})
console.debug(`Pull request #${number} mergeable state is "${state}"`)
if (mergeable === null) {
await wait(15)
return false
}
if (!mergeable)
throw new Error(`Pull request #${number} is not mergeable (state is "${state}")`)
//Merge pull request
await committer.rest.pulls.merge({...github.context.repo, pull_number:number, merge_method:committer.merge})
info(`Merge #${number} to ${committer.branch}`, "ok")
return true
}, {retries:retries_output_action, delay:retries_delay_output_action})
if (!success)
continue
//Delete head branch
await retry(async () => {
try {
await wait(15)
await committer.rest.git.deleteRef({...github.context.repo, ref:`heads/${committer.head}`})
}
catch (error) {
console.debug(error)
if (!/reference does not exist/i.test(`${error}`))
throw error
}
info(`Branch ${committer.head}`, "(deleted)")
}, {retries:retries_output_action, delay:retries_delay_output_action})
break
} while (--attempts)
}
}
}
//Consumed API requests
{
info.break()
info.section("Consumed API requests")
info(" * provided that no other app used your quota during execution", "")
const {data:current} = await api.rest.rateLimit.get().catch(() => ({data:{resources:{}}}))
for (const type of ["core", "graphql", "search"]) {
const used = resources[type].remaining - current.resources[type].remaining
info({core:"REST API", graphql:"GraphQL API", search:"Search API"}[type], (Number.isFinite(used)&&(used >= 0)) ? used : "(unknown)")
} }
} }
@@ -568,7 +608,7 @@ async function retry(func, {retries = 1, delay = 0} = {}) {
//Success //Success
info.break() info.break()
console.log("Success, thanks for using metrics!") console.log("Success, thanks for using metrics!")
process.exit(0) quit("success")
} }
//Errors //Errors
catch (error) { catch (error) {
@@ -579,6 +619,6 @@ async function retry(func, {retries = 1, delay = 0} = {}) {
console.log(log) console.log(log)
} }
core.setFailed(error.message) core.setFailed(error.message)
process.exit(1) quit("failed")
} }
})() })()