name: PR → Issue Linker (smart) on: workflow_call: secrets: ACTION_PAT: required: true permissions: contents: read issues: write pull-requests: read env: LOG_PREFIX: "[PR-Linker]" jobs: link: runs-on: ubuntu-latest timeout-minutes: 10 steps: - name: Validate inputs id: validate env: PR_TITLE: ${{ github.event.pull_request.title }} PR_BODY: ${{ github.event.pull_request.body }} run: | echo "${{ env.LOG_PREFIX }} Starting validation..." BODY="${PR_TITLE} ${PR_BODY}" if [ -z "$BODY" ] || [ "$BODY" == "null" ]; then echo "${{ env.LOG_PREFIX }} ℹ️ No PR content found" echo "has_issues=false" >> $GITHUB_OUTPUT exit 0 fi echo "has_issues=true" >> $GITHUB_OUTPUT - name: Extract issue references id: extract if: steps.validate.outputs.has_issues == 'true' env: PR_TITLE: ${{ github.event.pull_request.title }} PR_BODY: ${{ github.event.pull_request.body }} run: | echo "${{ env.LOG_PREFIX }} Extracting issue references..." BODY="${PR_TITLE} ${PR_BODY}" ISSUES=$(echo "$BODY" | \ grep -Eo '([a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+)?#[0-9]+' | \ sort -u) if [ -z "$ISSUES" ]; then echo "${{ env.LOG_PREFIX }} No issues found" echo "count=0" >> $GITHUB_OUTPUT exit 0 fi ISSUE_COUNT=$(echo "$ISSUES" | wc -l) echo "${{ env.LOG_PREFIX }} Found $ISSUE_COUNT issue(s)" echo "count=$ISSUE_COUNT" >> $GITHUB_OUTPUT echo "issues<> $GITHUB_OUTPUT echo "$ISSUES" >> $GITHUB_OUTPUT echo "EOF" >> $GITHUB_OUTPUT - name: Cleanup stale issue links if: github.event.action == 'edited' env: GH_TOKEN: ${{ secrets.ACTION_PAT }} PR_URL: ${{ github.event.pull_request.html_url }} PR_TITLE: ${{ github.event.pull_request.title }} PR_BODY: ${{ github.event.pull_request.body }} run: | set +e set +o pipefail echo "${{ env.LOG_PREFIX }} Cleaning stale issue links..." CURRENT_BODY="${PR_TITLE} ${PR_BODY}" CURRENT_ISSUES=$(echo "$CURRENT_BODY" | \ grep -Eo '([a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+)?#[0-9]+' | \ sort -u || true) MARKER="pr-linker:${PR_URL}" SEARCH_RESPONSE=$(curl -s \ -H "Authorization: Bearer $GH_TOKEN" \ -H "Accept: application/vnd.github+json" \ "https://api.github.com/search/issues?q=${MARKER}+in:comments") COMMENT_URLS=$(echo "$SEARCH_RESPONSE" | \ jq -r '.items[].comments_url' 2>/dev/null || true) while IFS= read -r COMMENTS_URL; do [ -z "$COMMENTS_URL" ] && continue ISSUE_API=$(echo "$COMMENTS_URL" | sed 's|/comments||') ISSUE_DATA=$(curl -s \ -H "Authorization: Bearer $GH_TOKEN" \ -H "Accept: application/vnd.github+json" \ "$ISSUE_API") ISSUE_NUMBER=$(echo "$ISSUE_DATA" | jq -r '.number') ISSUE_REPO=$(echo "$ISSUE_DATA" | \ jq -r '.repository_url' | \ sed 's|https://api.github.com/repos/||') ISSUE_REF="${ISSUE_REPO}#${ISSUE_NUMBER}" if ! echo "$CURRENT_ISSUES" | grep -q "$ISSUE_REF"; then echo "${{ env.LOG_PREFIX }} Removing stale link from $ISSUE_REF" COMMENT_ID="" CLEANUP_PAGE=1 while :; do COMMENTS_DATA=$(curl -s \ -H "Authorization: Bearer $GH_TOKEN" \ -H "Accept: application/vnd.github+json" \ "${COMMENTS_URL}?per_page=100&page=$CLEANUP_PAGE") COMMENT_ID=$(echo "$COMMENTS_DATA" | jq -r \ --arg marker "$MARKER" \ '.[] | select(.body | contains($marker)) | .id' \ 2>/dev/null | head -n1 || true) [ -n "$COMMENT_ID" ] && break CLEANUP_PAGE_SIZE=$(echo "$COMMENTS_DATA" | jq 'length') [ "$CLEANUP_PAGE_SIZE" -lt 100 ] && break CLEANUP_PAGE=$((CLEANUP_PAGE + 1)) done if [ -n "$COMMENT_ID" ] && [ "$COMMENT_ID" != "null" ]; then curl -s -X DELETE \ -H "Authorization: Bearer $GH_TOKEN" \ -H "Accept: application/vnd.github+json" \ "https://api.github.com/repos/$ISSUE_REPO/issues/comments/$COMMENT_ID" echo "${{ env.LOG_PREFIX }} Deleted stale comment from $ISSUE_REF" fi fi done <<< "$COMMENT_URLS" - name: Process issue links if: steps.extract.outputs.count > 0 env: GH_TOKEN: ${{ secrets.ACTION_PAT }} PR_URL: ${{ github.event.pull_request.html_url }} SOURCE_REPO: ${{ github.repository }} PR_ACTION: ${{ github.event.action }} PR_MERGED: ${{ github.event.pull_request.merged }} run: | set +e set +o pipefail ISSUES="${{ steps.extract.outputs.issues }}" MARKER="" PROCESSED=0 FAILED=0 echo "${{ env.LOG_PREFIX }} Processing issues..." if [[ "$PR_ACTION" == "closed" && "$PR_MERGED" == "true" ]]; then STATUS="MERGED ✅" elif [[ "$PR_ACTION" == "closed" && "$PR_MERGED" == "false" ]]; then STATUS="CLOSED ❌" else STATUS="OPEN 🔄" fi while IFS= read -r ISSUE; do [ -z "$ISSUE" ] && continue if [[ "$ISSUE" == *"/"* ]]; then REPO=$(echo "$ISSUE" | cut -d'#' -f1) NUMBER=$(echo "$ISSUE" | cut -d'#' -f2) else REPO="$SOURCE_REPO" NUMBER=$(echo "$ISSUE" | cut -d'#' -f2) fi if ! [[ "$NUMBER" =~ ^[0-9]+$ ]]; then echo "${{ env.LOG_PREFIX }} Invalid issue number: $NUMBER" ((FAILED++)) continue fi echo "${{ env.LOG_PREFIX }} → Processing $REPO#$NUMBER" COMMENT_ID="" PAGE=1 while :; do COMMENTS_RESPONSE=$(curl -s -w "\n%{http_code}" \ -H "Authorization: Bearer $GH_TOKEN" \ -H "Accept: application/vnd.github+json" \ "https://api.github.com/repos/$REPO/issues/$NUMBER/comments?per_page=100&page=$PAGE") HTTP_CODE=$(echo "$COMMENTS_RESPONSE" | tail -n1) COMMENTS_BODY=$(echo "$COMMENTS_RESPONSE" | head -n-1) if [ "$HTTP_CODE" != "200" ]; then echo "${{ env.LOG_PREFIX }} Failed to fetch comments (HTTP $HTTP_CODE)" ((FAILED++)) continue 2 fi COMMENT_ID=$(echo "$COMMENTS_BODY" | jq -r \ --arg marker "$MARKER" \ '.[] | select(.body | contains($marker)) | .id' \ 2>/dev/null | head -n1 || true) [ -n "$COMMENT_ID" ] && break PAGE_SIZE=$(echo "$COMMENTS_BODY" | jq 'length') [ "$PAGE_SIZE" -lt 100 ] && break PAGE=$((PAGE + 1)) done BODY_TEXT="🔗 Linked PR - PR: ${PR_URL} - Source: ${SOURCE_REPO} - Status: ${STATUS} - Updated: $(date -u +"%Y-%m-%d %H:%M:%S UTC") ${MARKER}" if [ -n "$COMMENT_ID" ] && [ "$COMMENT_ID" != "null" ]; then echo "${{ env.LOG_PREFIX }} Updating comment $COMMENT_ID" UPDATE_RESPONSE=$(curl -s -w "\n%{http_code}" -X PATCH \ -H "Authorization: Bearer $GH_TOKEN" \ -H "Accept: application/vnd.github+json" \ -H "Content-Type: application/json" \ "https://api.github.com/repos/$REPO/issues/comments/$COMMENT_ID" \ -d "$(jq -n --arg body "$BODY_TEXT" '{body: $body}')") UPDATE_CODE=$(echo "$UPDATE_RESPONSE" | tail -n1) if [[ "$UPDATE_CODE" =~ ^(200|201)$ ]]; then ((PROCESSED++)) else echo "${{ env.LOG_PREFIX }} Update failed (HTTP $UPDATE_CODE)" ((FAILED++)) fi else echo "${{ env.LOG_PREFIX }} Creating new comment" CREATE_RESPONSE=$(curl -s -w "\n%{http_code}" -X POST \ -H "Authorization: Bearer $GH_TOKEN" \ -H "Accept: application/vnd.github+json" \ -H "Content-Type: application/json" \ "https://api.github.com/repos/$REPO/issues/$NUMBER/comments" \ -d "$(jq -n --arg body "$BODY_TEXT" '{body: $body}')") CREATE_CODE=$(echo "$CREATE_RESPONSE" | tail -n1) if [[ "$CREATE_CODE" =~ ^(200|201)$ ]]; then ((PROCESSED++)) else echo "${{ env.LOG_PREFIX }} Create failed (HTTP $CREATE_CODE)" ((FAILED++)) fi fi done <<< "$ISSUES" echo "${{ env.LOG_PREFIX }} Done. Processed: $PROCESSED | Failed: $FAILED" exit 0 - name: Success summary if: success() run: | echo "${{ env.LOG_PREFIX }} ✅ Workflow completed successfully"