-
-
Notifications
You must be signed in to change notification settings - Fork 55
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
base: stage
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,190 @@ | ||
| name: Advance Jira Ticket to QA | ||
|
|
||
| # Runs on pull_request_review so it can reach the Jira secrets, including for | ||
| # pull requests from forks. It must never check out or execute code from the | ||
| # pull request. All pull request data is read through github-script's `context` | ||
| # rather than `${{ }}` interpolation, so a crafted branch name or title is never | ||
| # parsed as source. | ||
| on: | ||
| pull_request_review: | ||
| types: [ submitted ] | ||
|
|
||
| permissions: | ||
| contents: read | ||
|
|
||
| jobs: | ||
| advance_to_qa: | ||
| name: Move linked Jira ticket to QA | ||
| runs-on: ubuntu-latest | ||
| # Anyone with read access to a public repo can submit an approving review. | ||
| # Such a review does not satisfy branch protection, but it does fire this | ||
| # event. This condition is only a cheap pre-filter to avoid starting a | ||
| # runner for a drive-by approval; the authorization decision is the | ||
| # effective-permission check in the script, not the association. | ||
| if: >- | ||
| github.event.review.state == 'approved' && | ||
| contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.review.author_association) | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| steps: | ||
| - name: Walk the ticket forward to QA | ||
| uses: actions/github-script@v7 | ||
| env: | ||
| JIRA_EMAIL: ${{ secrets.JIRA_EMAIL }} | ||
| JIRA_API_TOKEN: ${{ secrets.JIRA_API_TOKEN }} | ||
| 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 pr = context.payload.pull_request; | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @hal-eisen-adfa LOW — no check that the PR is still open.
Guard with |
||
| const review = context.payload.review; | ||
|
|
||
| const branch = pr.head.ref; | ||
| if (branch.startsWith('community/')) { | ||
| core.info(`Branch "${branch}" is a community contribution; no Jira ticket to advance.`); | ||
| return; | ||
| } | ||
|
|
||
| // The key appears in the branch name and the PR title with equal | ||
| // reliability and never disagrees between them, so either source | ||
| // works; the branch is the more structured of the two. | ||
| const match = branch.match(/ADFA-\d+/i) || pr.title.match(/ADFA-\d+/i); | ||
| if (!match) { | ||
| core.info(`No ADFA ticket referenced by branch "${branch}" or the pull request title; nothing to do.`); | ||
| return; | ||
| } | ||
| const key = match[0].toUpperCase(); | ||
|
|
||
| // author_association does not prove write access: an org member may | ||
| // have no access to this repo, and a collaborator may be read-only. | ||
| // Ask for the reviewer's effective permission instead. The legacy | ||
| // `permission` field reports "maintain" as "write", so those two | ||
| // values cover admin, maintain, and write. | ||
| const reviewer = review.user.login; | ||
| let permission; | ||
| try { | ||
| const { data } = await github.rest.repos.getCollaboratorPermissionLevel({ | ||
| owner: context.repo.owner, | ||
| repo: context.repo.repo, | ||
| username: reviewer, | ||
| }); | ||
| permission = data.permission; | ||
| } catch (error) { | ||
| // Fail closed: without a permission answer, do not touch the board. | ||
| core.warning(`Could not read ${reviewer}'s permission on this repository (${error.message}); leaving ${key} untouched.`); | ||
| return; | ||
| } | ||
|
|
||
| if (!['admin', 'write'].includes(permission)) { | ||
| core.info(`${reviewer} has "${permission}" permission and cannot merge; leaving ${key} untouched.`); | ||
| return; | ||
| } | ||
|
|
||
| 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(); | ||
| }; | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
| try { | ||
| const issue = await jira(`/issue/${key}?fields=status`); | ||
| const startedAt = issue.fields.status.name; | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @hal-eisen-adfa LOW —
|
||
| let current = startedAt; | ||
| 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; | ||
| } | ||
|
|
||
| // Jira's transitions are gated and linear, so a ticket left behind | ||
| // in "To Do" or "In Progress" cannot jump straight to QA. Walking | ||
| // it forward one hop at a time is the whole point of this job: | ||
| // the ticket that is behind belongs to the person who forgot. | ||
| const walked = [current]; | ||
| while (index < TARGET_INDEX) { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @hal-eisen-adfa MEDIUM — no Two reviewers approving within seconds start two runs. Both read status Observable result: a ticket parked at Suggested fix at the top level: concurrency:
group: jira-advance-${{ github.event.pull_request.number }}
cancel-in-progress: false |
||
| 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).join(', '); | ||
| core.warning( | ||
| `${key} is in "${current}" and offers no transition to "${next}" (available: ${offered}). ` + | ||
| `Stopping here; the ticket needs to be moved by hand.` | ||
| ); | ||
| return; | ||
| } | ||
|
|
||
| await jira(`/issue/${key}/transitions`, { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @hal-eisen-adfa MEDIUM — a failure or timeout after the first hop leaves a partial move, contrary to the PR description. Hops are committed one at a time with no rollback. If the walk starts at The test table in the PR body claims "no partial move" for the timeout case; that only holds when the timeout hits the very first request. Either include |
||
| method: 'POST', | ||
| body: JSON.stringify({ transition: { id: hop.id } }), | ||
| }); | ||
|
|
||
| current = next; | ||
| index += 1; | ||
| walked.push(current); | ||
| } | ||
|
|
||
| const trail = walked.join(' -> '); | ||
|
|
||
| await jira(`/issue/${key}/comment`, { | ||
| method: 'POST', | ||
| body: JSON.stringify({ | ||
| body: { | ||
| type: 'doc', | ||
| version: 1, | ||
| content: [{ | ||
| type: 'paragraph', | ||
| content: [ | ||
| { type: 'text', text: `Automatically moved to ${TARGET} (${trail}): ` }, | ||
| { | ||
| type: 'text', | ||
| text: `pull request #${pr.number}`, | ||
| marks: [{ type: 'link', attrs: { href: pr.html_url } }], | ||
| }, | ||
| { type: 'text', text: ` was approved by ${reviewer}.` }, | ||
| ], | ||
| }], | ||
| }, | ||
| }), | ||
| }); | ||
|
|
||
| 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. | ||
| core.warning(`Could not advance ${key} to ${TARGET}: ${error.message}`); | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@hal-eisen-adfa HIGH —
contents: readalmost certainly can't authorizegetCollaboratorPermissionLevel, which would make this job a permanent no-op.GET /repos/{owner}/{repo}/collaborators/{username}/permissionis documented as requiring push access for the authenticated user (fine-grained: Administration + Metadata read) — a scope the workflowpermissions:block can't grantGITHUB_TOKENbeyond whatcontents: writeimplies.If that's right: the call 403s, the
catchat L81 fires, and every approval ends as a::warning::with the ticket untouched. Because it's a warning and not a red check, nobody notices — which reproduces exactly the drift ADFA-5317 exists to fix.Worth confirming against a real run before merging. If it does 403, alternatives that don't need the extra grant:
github.rest.repos.listCollaboratorswithpermission=push, or leaning on theauthor_associationpre-filter plus a repo-scoped PAT.