Skip to content
Open
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
190 changes: 190 additions & 0 deletions .github/workflows/jira-advance-to-qa.yml
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

Copy link
Copy Markdown
Collaborator

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: read almost certainly can't authorize getCollaboratorPermissionLevel, which would make this job a permanent no-op.

GET /repos/{owner}/{repo}/collaborators/{username}/permission is documented as requiring push access for the authenticated user (fine-grained: Administration + Metadata read) — a scope the workflow permissions: block can't grant GITHUB_TOKEN beyond what contents: write implies.

If that's right: the call 403s, the catch at 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.listCollaborators with permission=push, or leaning on the author_association pre-filter plus a repo-scoped PAT.


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)
Comment thread
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;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@hal-eisen-adfa LOW — no check that the PR is still open.

pull_request_review fires for approvals submitted against closed and merged PRs too. A late approval on an already-merged PR whose ticket was deliberately bounced back (QA found a regression and reopened work) would silently walk it forward to QA again.

Guard with pr.state === 'open' here, or add github.event.pull_request.state == 'open' to the if: at L24.

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();
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.

try {
const issue = await jira(`/issue/${key}?fields=status`);
const startedAt = issue.fields.status.name;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@hal-eisen-adfa LOW — startedAt is assigned and never read.

current carries the value from L119 onward, and walked[0] already preserves the origin status. Presumably this was meant for the log line or the Jira comment — either use it there or drop it.

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) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@hal-eisen-adfa MEDIUM — no concurrency guard; simultaneous approvals can strand the ticket mid-walk.

Two reviewers approving within seconds start two runs. Both read status To Do and both fetch transitions. Run A executes To Do -> In Progress; run B then POSTs its now-stale transition id, gets a 4xx, and drops into the catch. Run A continues, but its next find may miss because B already moved things.

Observable result: a ticket parked at In Progress or Code review, only warnings in the log, and no Jira comment.

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`, {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The 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 To Do and the 30s AbortSignal.timeout fires on hop 2, the ticket is left at In Progress — advanced, but not to QA, and with no Jira comment recording it, so the log warning is the only trace.

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 walked in the warning at L189 so the trail is recoverable, or correct the description to state the real behavior.

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}`);
}
Loading