-
-
Notifications
You must be signed in to change notification settings - Fork 56
ADFA-5317: Advance the linked Jira ticket to QA when a PR is approved #1755
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
hal-eisen-adfa
wants to merge
6
commits into
stage
Choose a base branch
from
task/ADFA-5317-jira-qa-on-pr-approval
base: stage
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+363
−0
Open
Changes from all commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
b529a90
ADFA-5317: Advance the linked Jira ticket to QA when a PR is approved
hal-eisen-adfa 9a17bb8
ADFA-5317: Bound Jira requests and authorize reviewers by permission
hal-eisen-adfa 9cbf27e
Move the ticket only when the whole stack is approved, and serialize …
itsaky-adfa c86b233
Merge branch 'stage' into task/ADFA-5317-jira-qa-on-pr-approval
itsaky-adfa 85ed7d6
Refactor Jira advancement logic in workflow
itsaky-adfa 194d339
Fix review comments and simplify workflow
itsaky-adfa File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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) { | ||
|
hal-eisen-adfa marked this conversation as resolved.
|
||
| 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`, { | ||
|
hal-eisen-adfa marked this conversation as resolved.
|
||
| 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()}`); | ||
| } | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.