[main] test #8
Workflow file for this run
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
| name: Backport Bug Linker | |
| # ────────────────────────────────────────────────────────────────────── | |
| # STEP 1 — Trigger and idempotency guard | |
| # ────────────────────────────────────────────────────────────────────── | |
| on: { pull_request_target: | |
| { types: [labeled] }, | |
| pull_request: | |
| { types: [opened, synchronize] } } | |
| permissions: | |
| pull-requests: write # Required to edit PR description (Step 3) and post comments (Step 7) | |
| issues: write # GitHub uses the Issues API endpoint for PR comments | |
| jobs: | |
| link-bug: | |
| # Only run when the "Linked" label is applied AND no AB# reference exists yet | |
| if: >- | |
| github.event.label.name == 'Linked' && | |
| !contains(github.event.pull_request.body, 'AB#') | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Log trigger details | |
| run: | | |
| echo "PR #${{ github.event.pull_request.number }} received 'Linked' label." | |
| echo "Target branch: ${{ github.event.pull_request.base.ref }}" | |
| echo "Head branch: ${{ github.event.pull_request.head.ref }}" | |
| echo "No AB# reference found in PR description. Starting automation..." | |
| # ────────────────────────────────────────────────────────────── | |
| # STEP 2 — Poll Azure DevOps for the newly created bug | |
| # ────────────────────────────────────────────────────────────── | |
| - name: Poll ADO for newly created bug | |
| id: find-bug | |
| env: | |
| ADO_PAT: ${{ secrets.ADO_PAT }} | |
| PR_NUMBER: ${{ github.event.pull_request.number }} | |
| run: | | |
| # Construct auth header (Basic auth with PAT, no username) | |
| AUTH_HEADER=$(printf ":%s" "$ADO_PAT" | base64) | |
| # ADO WIQL endpoint for the Dynamics SMB project | |
| WIQL_ENDPOINT="https://dev.azure.com/dynamicssmb2/Dynamics%20SMB/_apis/wit/wiql?api-version=7.0" | |
| # WIQL query: find the bug by title pattern and creator | |
| WIQL_JSON=$(cat <<EOF | |
| { | |
| "query": "SELECT [System.Id] FROM WorkItems WHERE [System.TeamProject] = 'Dynamics SMB' AND [System.WorkItemType] = 'Bug' AND [System.Title] CONTAINS '[BCApps #${PR_NUMBER}]' AND [System.CreatedBy] = 'bcbuild-dmedaemon-agent' ORDER BY [System.CreatedDate] DESC" | |
| } | |
| EOF | |
| ) | |
| # Poll with exponential backoff (max ~3 minutes) | |
| MAX_TRIES=5 | |
| DELAY=10 | |
| bugId="" | |
| for ((i=1; i<=MAX_TRIES; i++)); do | |
| echo "Polling ADO for Bug (attempt $i of $MAX_TRIES)..." | |
| sleep $DELAY | |
| RESPONSE=$(curl -s -X POST \ | |
| -H "Authorization: Basic $AUTH_HEADER" \ | |
| -H "Content-Type: application/json" \ | |
| -d "$WIQL_JSON" \ | |
| "$WIQL_ENDPOINT") | |
| COUNT=$(echo "$RESPONSE" | jq ".workItems | length") | |
| if [[ "$COUNT" -gt 0 ]]; then | |
| bugId=$(echo "$RESPONSE" | jq -r ".workItems[0].id") | |
| echo "✅ Found ADO Bug ID: $bugId" | |
| break | |
| fi | |
| echo "Bug not found yet. Will retry in ${DELAY}s..." | |
| DELAY=$(( DELAY * 2 )) | |
| done | |
| if [[ -z "$bugId" ]]; then | |
| echo "##[error] ADO bug for PR #${PR_NUMBER} not found within 3 minutes." | |
| exit 1 | |
| fi | |
| # Export Bug ID and browser URL for use in later steps | |
| echo "BUG_ID=$bugId" >> $GITHUB_ENV | |
| echo "BUG_URL=https://dev.azure.com/dynamicssmb2/Dynamics%20SMB/_workitems/edit/$bugId" >> $GITHUB_ENV | |
| # ────────────────────────────────────────────────────────────── | |
| # STEP 3: Update PR description with AB#<BUG_ID> | |
| # ────────────────────────────────────────────────────────────── | |
| - name: Append AB# reference to PR description | |
| env: | |
| GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| PR_NUMBER: ${{ github.event.pull_request.number }} | |
| REPO: ${{ github.repository }} | |
| run: | | |
| CURRENT_BODY=$(gh api "/repos/$REPO/pulls/$PR_NUMBER" --jq '.body') | |
| # Double-check idempotency (body may have changed since trigger) | |
| if echo "$CURRENT_BODY" | grep -qP "AB#${BUG_ID}\\b"; then | |
| echo "PR body already contains AB#${BUG_ID}. Skipping." | |
| else | |
| UPDATED_BODY="${CURRENT_BODY} | |
| Fixes AB#${BUG_ID}" | |
| gh api "/repos/$REPO/pulls/$PR_NUMBER" \ | |
| --method PATCH \ | |
| -f body="$UPDATED_BODY" \ | |
| --silent | |
| echo "✅ Appended 'Fixes AB#${BUG_ID}' to PR description." | |
| fi | |
| # ────────────────────────────────────────────────────────────── | |
| # STEP 4: Identify master ADO bug via original PR | |
| # Backport PRs created by CrossBranchPorting.psm1 have the | |
| # body pattern: "This pull request backports #<NNN> to ..." | |
| # Fetch the original PR and extract its AB# reference. | |
| # ────────────────────────────────────────────────────────────── | |
| - name: Resolve master (parent) work item ID | |
| id: resolve-parent | |
| env: | |
| GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| PR_NUMBER: ${{ github.event.pull_request.number }} | |
| REPO: ${{ github.repository }} | |
| run: | | |
| PR_BODY=$(gh api "/repos/$REPO/pulls/$PR_NUMBER" --jq '.body') | |
| # Extract original PR number from "backports #NNN" or "backport of #NNN" | |
| ORIGINAL_PR=$(echo "$PR_BODY" | grep -oP '(?i)backports?\s+(of\s+)?#\K\d+' | head -1) | |
| if [[ -z "$ORIGINAL_PR" ]]; then | |
| echo "⚠️ This PR does not reference an original PR via 'backports #NNN'. Skipping parent link." | |
| echo "MASTER_BUG_ID=" >> "$GITHUB_OUTPUT" | |
| exit 0 | |
| fi | |
| echo "Original (master) PR: #$ORIGINAL_PR" | |
| # Fetch the original PR description and extract its AB# reference(s) | |
| ORIGINAL_BODY=$(gh api "/repos/$REPO/pulls/$ORIGINAL_PR" --jq '.body') | |
| MASTER_BUG_ID=$(echo "$ORIGINAL_BODY" | grep -oP 'AB#\K\d+' | head -1) | |
| if [[ -z "$MASTER_BUG_ID" ]]; then | |
| echo "⚠️ Original PR #$ORIGINAL_PR has no AB# reference. Cannot determine parent." | |
| echo "MASTER_BUG_ID=" >> "$GITHUB_OUTPUT" | |
| exit 0 | |
| fi | |
| # Guard: child and parent must differ | |
| if [[ "$MASTER_BUG_ID" == "$BUG_ID" ]]; then | |
| echo "⚠️ Master bug ID ($MASTER_BUG_ID) is the same as the backport bug. Skipping." | |
| echo "MASTER_BUG_ID=" >> "$GITHUB_OUTPUT" | |
| exit 0 | |
| fi | |
| echo "✅ Master (parent) ADO Bug ID: $MASTER_BUG_ID" | |
| echo "MASTER_BUG_ID=$MASTER_BUG_ID" >> "$GITHUB_OUTPUT" | |
| # ────────────────────────────────────────────────────────────── | |
| # STEP 5: Link backport bug → master bug (Parent-Child) | |
| # PATCH the child work item to add a Hierarchy-Reverse | |
| # (Parent) relation pointing to the master work item. | |
| # ────────────────────────────────────────────────────────────── | |
| - name: Create parent-child link in ADO | |
| if: steps.resolve-parent.outputs.MASTER_BUG_ID != '' | |
| env: | |
| ADO_PAT: ${{ secrets.ADO_PAT }} | |
| CHILD_ID: ${{ env.BUG_ID }} | |
| PARENT_ID: ${{ steps.resolve-parent.outputs.MASTER_BUG_ID }} | |
| ADO_ORG: dynamicssmb2 | |
| ADO_PROJECT: Dynamics%20SMB | |
| run: | | |
| AUTH_HEADER=$(printf ":%s" "$ADO_PAT" | base64) | |
| ADO_BASE="https://dev.azure.com/${ADO_ORG}/${ADO_PROJECT}" | |
| # First verify the parent work item exists | |
| HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" \ | |
| -H "Authorization: Basic $AUTH_HEADER" \ | |
| "${ADO_BASE}/_apis/wit/workitems/${PARENT_ID}?api-version=7.1") | |
| if [[ "$HTTP_CODE" != "200" ]]; then | |
| echo "##[error] Parent work item $PARENT_ID not found (HTTP $HTTP_CODE). Skipping link." | |
| exit 1 | |
| fi | |
| # Check if the parent link already exists on the child | |
| EXISTING_RELS=$(curl -s \ | |
| -H "Authorization: Basic $AUTH_HEADER" \ | |
| "${ADO_BASE}/_apis/wit/workitems/${CHILD_ID}?\$expand=relations&api-version=7.1") | |
| ALREADY_LINKED=$(echo "$EXISTING_RELS" | jq -r \ | |
| --arg parentUrl "${ADO_BASE}/_apis/wit/workItems/${PARENT_ID}" \ | |
| '.relations // [] | map(select(.rel == "System.LinkTypes.Hierarchy-Reverse" and .url == $parentUrl)) | length') | |
| if [[ "$ALREADY_LINKED" -gt 0 ]]; then | |
| echo "Parent link already exists. Skipping." | |
| exit 0 | |
| fi | |
| # Add Hierarchy-Reverse (Parent) relation on the child work item | |
| PATCH_BODY=$(cat <<EOF | |
| [ | |
| { | |
| "op": "add", | |
| "path": "/relations/-", | |
| "value": { | |
| "rel": "System.LinkTypes.Hierarchy-Reverse", | |
| "url": "${ADO_BASE}/_apis/wit/workItems/${PARENT_ID}", | |
| "attributes": { | |
| "comment": "Auto-linked by backport-bug-linker: release bug #${CHILD_ID} → master bug #${PARENT_ID}" | |
| } | |
| } | |
| } | |
| ] | |
| EOF | |
| ) | |
| RESPONSE=$(curl -s -w "\n%{http_code}" \ | |
| -X PATCH \ | |
| -H "Authorization: Basic $AUTH_HEADER" \ | |
| -H "Content-Type: application/json-patch+json" \ | |
| -d "$PATCH_BODY" \ | |
| "${ADO_BASE}/_apis/wit/workitems/${CHILD_ID}?api-version=7.1") | |
| HTTP_CODE=$(echo "$RESPONSE" | tail -1) | |
| BODY=$(echo "$RESPONSE" | sed '$d') | |
| if [[ "$HTTP_CODE" == "200" ]]; then | |
| echo "✅ Linked ADO #${CHILD_ID} as child of #${PARENT_ID}." | |
| else | |
| echo "##[error] Failed to create parent link (HTTP $HTTP_CODE)." | |
| echo "$BODY" | jq . 2>/dev/null || echo "$BODY" | |
| exit 1 | |
| fi | |
| # ────────────────────────────────────────────────────────────── | |
| # STEP 6: Copy key fields from master bug to backport bug | |
| # Copies Area Path, Iteration Path, Priority, and Severity | |
| # so the backport bug inherits the master's triage metadata. | |
| # ────────────────────────────────────────────────────────────── | |
| - name: Copy fields from master bug to backport bug | |
| if: steps.resolve-parent.outputs.MASTER_BUG_ID != '' | |
| env: | |
| ADO_PAT: ${{ secrets.ADO_PAT }} | |
| CHILD_ID: ${{ env.BUG_ID }} | |
| PARENT_ID: ${{ steps.resolve-parent.outputs.MASTER_BUG_ID }} | |
| ADO_ORG: dynamicssmb2 | |
| ADO_PROJECT: Dynamics%20SMB | |
| run: | | |
| AUTH_HEADER=$(printf ":%s" "$ADO_PAT" | base64) | |
| ADO_BASE="https://dev.azure.com/${ADO_ORG}/${ADO_PROJECT}" | |
| # Fetch master work item fields | |
| MASTER_WI=$(curl -s \ | |
| -H "Authorization: Basic $AUTH_HEADER" \ | |
| "${ADO_BASE}/_apis/wit/workitems/${PARENT_ID}?api-version=7.1") | |
| # Extract fields to copy (skip nulls/empty) | |
| AREA_PATH=$(echo "$MASTER_WI" | jq -r '.fields["System.AreaPath"] // empty') | |
| PRIORITY=$(echo "$MASTER_WI" | jq -r '.fields["Microsoft.VSTS.Common.Priority"] // empty') | |
| SEVERITY=$(echo "$MASTER_WI" | jq -r '.fields["Microsoft.VSTS.Common.Severity"] // empty') | |
| # Build JSON Patch array with non-empty fields only | |
| PATCH_OPS="[]" | |
| if [[ -n "$AREA_PATH" ]]; then | |
| PATCH_OPS=$(echo "$PATCH_OPS" | jq \ | |
| --arg v "$AREA_PATH" \ | |
| '. + [{"op":"replace","path":"/fields/System.AreaPath","value":$v}]') | |
| fi | |
| if [[ -n "$PRIORITY" ]]; then | |
| PATCH_OPS=$(echo "$PATCH_OPS" | jq \ | |
| --arg v "$PRIORITY" \ | |
| '. + [{"op":"replace","path":"/fields/Microsoft.VSTS.Common.Priority","value":($v | tonumber)}]') | |
| fi | |
| if [[ -n "$SEVERITY" ]]; then | |
| PATCH_OPS=$(echo "$PATCH_OPS" | jq \ | |
| --arg v "$SEVERITY" \ | |
| '. + [{"op":"replace","path":"/fields/Microsoft.VSTS.Common.Severity","value":$v}]') | |
| fi | |
| OP_COUNT=$(echo "$PATCH_OPS" | jq 'length') | |
| if [[ "$OP_COUNT" == "0" ]]; then | |
| echo "No fields to copy. Skipping." | |
| exit 0 | |
| fi | |
| RESPONSE=$(curl -s -w "\n%{http_code}" \ | |
| -X PATCH \ | |
| -H "Authorization: Basic $AUTH_HEADER" \ | |
| -H "Content-Type: application/json-patch+json" \ | |
| -d "$PATCH_OPS" \ | |
| "${ADO_BASE}/_apis/wit/workitems/${CHILD_ID}?api-version=7.1") | |
| HTTP_CODE=$(echo "$RESPONSE" | tail -1) | |
| BODY=$(echo "$RESPONSE" | sed '$d') | |
| if [[ "$HTTP_CODE" == "200" ]]; then | |
| echo "✅ Copied fields (Area Path, Priority, Severity) from #${PARENT_ID} to #${CHILD_ID}." | |
| else | |
| echo "##[warning] Failed to copy fields (HTTP $HTTP_CODE). This is non-fatal." | |
| echo "$BODY" | jq . 2>/dev/null || echo "$BODY" | |
| fi | |
| # ────────────────────────────────────────────────────────────── | |
| # STEP 7: Post confirmation comment on PR | |
| # ────────────────────────────────────────────────────────────── | |
| - name: Post summary comment on PR | |
| env: | |
| GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| PR_NUMBER: ${{ github.event.pull_request.number }} | |
| REPO: ${{ github.repository }} | |
| PARENT_ID: ${{ steps.resolve-parent.outputs.MASTER_BUG_ID }} | |
| run: | | |
| ADO_EDIT_URL="https://dev.azure.com/dynamicssmb2/Dynamics%20SMB/_workitems/edit" | |
| if [[ -n "$PARENT_ID" ]]; then | |
| COMMENT_BODY="🔗 **Backport Bug Linker — Summary** | |
| - **Backport bug**: [AB#${BUG_ID}](${ADO_EDIT_URL}/${BUG_ID}) | |
| - **Master (parent) bug**: [AB#${PARENT_ID}](${ADO_EDIT_URL}/${PARENT_ID}) | |
| - **Parent-child link**: ✅ Created (release → master)" | |
| else | |
| COMMENT_BODY="🔗 **Backport Bug Linker — Summary** | |
| - **Backport bug**: [AB#${BUG_ID}](${ADO_EDIT_URL}/${BUG_ID}) | |
| - **Master (parent) bug**: ⚠️ Could not be determined (no backport reference found in PR description) | |
| - **Parent-child link**: ⏭️ Skipped" | |
| fi | |
| # Idempotent: delete previous bot comment before posting | |
| EXISTING_ID=$(gh api "/repos/$REPO/issues/$PR_NUMBER/comments" \ | |
| --jq '.[] | select(.body | startswith("🔗 **Backport Bug Linker")) | .id' \ | |
| | head -1) | |
| if [[ -n "$EXISTING_ID" ]]; then | |
| gh api "/repos/$REPO/issues/comments/$EXISTING_ID" -X DELETE --silent | |
| fi | |
| gh api "/repos/$REPO/issues/$PR_NUMBER/comments" \ | |
| -f body="$COMMENT_BODY" \ | |
| --silent | |
| echo "✅ Posted summary comment on PR #$PR_NUMBER." |