Skip to content

[main] test

[main] test #8

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."