diff --git a/.github/workflows/jira-advance-to-qa.yml b/.github/workflows/jira-advance-to-qa.yml new file mode 100644 index 0000000000..f6fb4c7dad --- /dev/null +++ b/.github/workflows/jira-advance-to-qa.yml @@ -0,0 +1,363 @@ +name: Advance Jira Ticket to QA + +# The event is only a wake-up signal: every decision is taken from the review +# state this workflow reads back from GitHub, never from the payload that woke +# it. It must never check out or execute code from a pull request. All pull +# request data is read through github-script's `context` and the GraphQL API +# rather than `${{ }}` interpolation, so a crafted branch name or title is never +# parsed as source. +on: + pull_request_review: + types: [ submitted, dismissed ] + # The events that can *unblock* a ticket without anyone submitting a review: a + # sibling closing as abandoned, and a sibling leaving draft. Without these the + # gate below can hold a ticket for good, since nothing else re-runs this job. + # `converted_to_draft` and `reopened` are absent on purpose - both can only add + # a blocker, so re-running for them could never advance anything. + pull_request: + types: [ closed, ready_for_review ] + +# No `contents` grant: this workflow never reads the repository, only its pull +# requests. It runs in the base-repo context with access to the Jira secrets even +# for pull requests from forks, so it holds the narrowest set that works. +permissions: + pull-requests: read + +jobs: + resolve: + name: Decide whether the ticket may advance + runs-on: ubuntu-latest + # A comment-only review is the one event that cannot change any pull + # request's review decision, so it is worth dropping before a runner starts. + # Nothing else is pre-filtered: a drive-by approval from a stranger can start + # a runner, but it cannot move a ticket, because the gate below asks GitHub + # for its own review decision rather than counting reviews itself. + if: github.event_name == 'pull_request' || github.event.review.state != 'commented' + outputs: + key: ${{ steps.resolve.outputs.key }} + pulls: ${{ steps.resolve.outputs.pulls }} + steps: + - name: Resolve the ticket key + id: resolve + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0 + with: + # The catch below is for a broken automation, and it fails the run to + # say so. A transient 5xx is not that, and without retries it would + # land in the same place and turn an unrelated pull request red. + retries: 3 + script: | + const KEY_PATTERN = /ADFA-\d+/i; + + /* + * A `community/` branch owns no ticket of its own, so it neither + * names the ticket that moves nor counts towards one that does. + */ + const keyOf = ref => { + if (ref.startsWith('community/')) return null; + const match = ref.match(KEY_PATTERN); + return match ? match[0].toUpperCase() : null; + }; + + const pr = context.payload.pull_request; + + /* + * A fork's head branch is named inside the author's own repository, + * so its ADFA key is evidence of nothing: a fork branch called + * "ADFA-1234-crash" would bind an unrelated ticket that a + * maintainer's approval then walks to QA. lint-branch-name.yml + * already refuses the ADFA- prefix on an external branch. + */ + if (pr.head.repo?.id !== context.payload.repository.id) { + core.info(`Pull request #${pr.number} is from a fork; no Jira ticket to advance.`); + return; + } + + // The key comes from the branch rather than the title: a title + // reading "fixes the ADFA-1234 crash" would otherwise pick the ticket + // that moves. debug.yml extracts from the branch alone for the same + // reason. + const key = keyOf(pr.head.ref); + if (!key) { + core.info(`No ADFA ticket referenced by branch "${pr.head.ref}"; nothing to do.`); + return; + } + + /* + * One ticket can own a stack of pull requests. Moving it on the first + * approval hands QA a ticket whose code is several branches from + * landing, so it advances only once every open pull request carrying + * the key is ready. + * + * `reviewDecision` is GitHub's own verdict on a pull request. It + * counts only opinionated reviews from people who can write to the + * repository and it honours dismissals, which is what this job would + * otherwise have to reconstruct by reading every review and looking + * up every reviewer's permission. + */ + const query = ` + query($owner: String!, $repo: String!, $cursor: String) { + repository(owner: $owner, name: $repo) { + pullRequests(states: OPEN, first: 100, after: $cursor) { + pageInfo { hasNextPage endCursor } + nodes { number isDraft isCrossRepository headRefName reviewDecision } + } + } + }`; + + const stack = []; + try { + let cursor = null; + for (;;) { + const { repository } = await github.graphql(query, { + owner: context.repo.owner, + repo: context.repo.repo, + cursor, + }); + const { nodes, pageInfo } = repository.pullRequests; + /* + * Fork heads are excluded for the reason given above, and because + * a stranger could otherwise hold the board still by opening a + * fork branch named after someone else's ticket. + */ + stack.push(...nodes.filter(node => + !node.isCrossRepository && keyOf(node.headRefName) === key)); + if (!pageInfo.hasNextPage) break; + cursor = pageInfo.endCursor; + } + } catch (error) { + core.setFailed(`Cannot tell whether ${key} may advance: ${error.message}. Leaving it untouched.`); + return; + } + + /* + * Nothing open means nothing to hand QA: every pull request for the + * ticket has already been merged or closed. A late approval on one of + * them must not walk a ticket that QA deliberately bounced back. + */ + if (!stack.length) { + core.info(`${key} has no open pull request; leaving the ticket where it is.`); + return; + } + + const blocker = member => member.isDraft ? 'a draft' + : member.reviewDecision === 'CHANGES_REQUESTED' ? 'awaiting changes' + : 'not approved'; + const blocked = stack.filter(member => + member.isDraft || member.reviewDecision !== 'APPROVED'); + + if (blocked.length) { + /* + * A warning, not info: a ticket held back by a sibling is the one + * outcome nobody is watching for, and a hold that says nothing is + * the same silent drift this job exists to remove. + */ + core.warning( + `${key} stays where it is: ` + + blocked.map(member => `#${member.number} is ${blocker(member)}`).join(', ') + '.' + ); + return; + } + + core.setOutput('key', key); + core.setOutput('pulls', stack.map(member => member.number).join(',')); + + advance: + name: Move linked Jira ticket to QA + runs-on: ubuntu-latest + needs: resolve + if: needs.resolve.outputs.key != '' + # Two events landing seconds apart otherwise run two walks over one ticket. + # Backward transitions are global in this Jira workflow, so a stale walk can + # pull a ticket back out of QA. The group has to key on the ticket, not the + # pull request: a stack shares one ticket across several of them. + concurrency: + group: jira-advance-${{ needs.resolve.outputs.key }} + cancel-in-progress: false + steps: + - name: Walk the ticket forward to QA + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0 + env: + JIRA_EMAIL: ${{ secrets.JIRA_EMAIL }} + JIRA_API_TOKEN: ${{ secrets.JIRA_API_TOKEN }} + # Both are derived values, not payload: the key matched /ADFA-\d+/ and + # the list is pull request numbers. + TICKET_KEY: ${{ needs.resolve.outputs.key }} + TICKET_PULLS: ${{ needs.resolve.outputs.pulls }} + with: + script: | + const JIRA_BASE = 'https://appdevforall.atlassian.net'; + + // Node's fetch imposes no deadline on a response, so a hung or + // half-delivered reply would stall the job rather than fail it. + // Aborting routes it into the catch below, where it stays a warning. + const JIRA_REQUEST_TIMEOUT_MS = 30000; + + // Case-sensitive, in board order. Note the lowercase "review" and + // "merge" -- Jira matches these exactly. + const STATUSES = ['To Do', 'In Progress', 'Code review', 'QA', 'Ready to merge', 'Done']; + const TARGET = 'QA'; + const TARGET_INDEX = STATUSES.indexOf(TARGET); + + const key = process.env.TICKET_KEY; + const pulls = process.env.TICKET_PULLS.split(','); + const repoUrl = context.payload.repository.html_url; + + const email = process.env.JIRA_EMAIL; + const token = process.env.JIRA_API_TOKEN; + if (!email || !token) { + core.warning(`Jira credentials are not configured; leaving ${key} untouched.`); + return; + } + const auth = 'Basic ' + Buffer.from(`${email}:${token}`).toString('base64'); + + const jira = async (path, init = {}) => { + const response = await fetch(`${JIRA_BASE}/rest/api/3${path}`, { + ...init, + headers: { + Authorization: auth, + Accept: 'application/json', + ...(init.body ? { 'Content-Type': 'application/json' } : {}), + }, + signal: AbortSignal.timeout(JIRA_REQUEST_TIMEOUT_MS), + }); + if (!response.ok) { + throw new Error(`${init.method || 'GET'} ${path} returned HTTP ${response.status}`); + } + return response.status === 204 ? null : response.json(); + }; + + const statusOf = async () => (await jira(`/issue/${key}?fields=status`)).fields.status.name; + + /* + * Declared outside the try so the catch can report how far the walk + * got. A hop is committed as it is made and there is no rollback, so + * a failure part-way leaves the ticket somewhere new, and a log line + * claiming it did not move sends whoever repairs the board to the + * wrong place. + */ + let current = null; + const walked = []; + + const trail = () => walked.join(' -> '); + const trailSuffix = () => { + if (walked.length <= 1) return ' The ticket was not moved.'; + return ` It was moved ${trail()} and is now at "${current}"; finish it by hand.`; + }; + + try { + current = await statusOf(); + walked.push(current); + let index = STATUSES.indexOf(current); + + if (index === -1) { + core.warning(`${key} is in unrecognized status "${current}"; leaving it untouched.`); + return; + } + if (index >= TARGET_INDEX) { + core.info(`${key} is already at "${current}", which is at or past ${TARGET}; nothing to do.`); + return; + } + + /* + * There is no direct edge from "To Do" or "In Progress" to QA, so a + * ticket left behind has to be walked forward one hop at a time. + * Each hop resolves its transition by target status name from the + * live endpoint, which doubles as the freshness check: if someone + * else moved the ticket, the transition this run wants is no longer + * on offer and the walk stops instead of guessing. + */ + while (index < TARGET_INDEX) { + const next = STATUSES[index + 1]; + const { transitions } = await jira(`/issue/${key}/transitions`); + const hop = transitions.find(transition => transition.to && transition.to.name === next); + + if (!hop) { + const offered = transitions + .map(transition => transition.to && transition.to.name) + .filter(Boolean) + .join(', '); + core.warning( + `${key} is in "${current}" and offers no transition to "${next}" (available: ${offered}). ` + + `Stopping here.${trailSuffix()}` + ); + return; + } + + await jira(`/issue/${key}/transitions`, { + method: 'POST', + body: JSON.stringify({ transition: { id: hop.id } }), + }); + + current = next; + index += 1; + walked.push(current); + + /* + * Backward transitions are global from every status, so another + * run or a person can pull the ticket back down the board mid + * walk. Re-read after each hop, and report where the ticket + * actually is rather than where this run left it. + */ + if (index < TARGET_INDEX) { + const observed = await statusOf(); + if (observed !== current) { + current = observed; + core.warning( + `${key} is at "${observed}", not where this run left it; something else is ` + + `moving the same ticket. Stopping.${trailSuffix()}` + ); + return; + } + } + } + + /* + * The walk is already committed by this point, so a comment that + * fails to post must not be reported as a failure to advance: that + * sends whoever reads the log to a ticket already sitting where it + * belongs. + */ + const link = number => ({ + type: 'text', + text: `#${number}`, + marks: [{ type: 'link', attrs: { href: `${repoUrl}/pull/${number}` } }], + }); + const sentence = [{ type: 'text', text: `Automatically moved to ${TARGET} (${trail()}): ` }]; + pulls.forEach((number, position) => { + sentence.push({ type: 'text', text: position === 0 ? '' : ', ' }, link(number)); + }); + sentence.push({ + type: 'text', + text: pulls.length > 1 + ? ' are all approved, with nothing else open for this ticket.' + : ' is approved, and is the only pull request open for this ticket.', + }); + + try { + await jira(`/issue/${key}/comment`, { + method: 'POST', + body: JSON.stringify({ + body: { + type: 'doc', + version: 1, + content: [{ type: 'paragraph', content: sentence }], + }, + }), + }); + } catch (error) { + core.warning(`${key} reached ${TARGET} (${trail()}), but the Jira comment recording why could not be posted: ${error.message}.`); + } + + core.info(`${key}: ${trail()}`); + core.summary.addRaw(`Moved [${key}](${JIRA_BASE}/browse/${key}) to ${TARGET}: ${trail()}`); + await core.summary.write(); + } catch (error) { + /* + * A Jira outage or auth problem must never turn a pull request red. + * Anything thrown once the ticket has reached QA is a reporting + * failure, not a failure to advance, and must not say otherwise. + */ + core.warning(current === TARGET + ? `${key} reached ${TARGET} (${trail()}), but recording it failed: ${error.message}.` + : `Could not advance ${key} to ${TARGET}: ${error.message}.${trailSuffix()}`); + }