Skip to content
Merged
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
314 changes: 314 additions & 0 deletions .github/workflows/link-pr-to-issue.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,314 @@
name: PR → Issue Linker (smart)

on:
workflow_call:
secrets:
CROSS_REPO_TOKEN:
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<<EOF" >> $GITHUB_OUTPUT
echo "$ISSUES" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT

- name: Cleanup stale issue links
if: github.event.action == 'edited'
env:
GH_TOKEN: ${{ secrets.CROSS_REPO_TOKEN }}
PR_URL: ${{ github.event.pull_request.html_url }}
SOURCE_REPO: ${{ github.repository }}
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)
Comment thread
Ivanmeneges marked this conversation as resolved.

echo "${{ env.LOG_PREFIX }} Current referenced issues:"
echo "$CURRENT_ISSUES"

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

echo "${{ env.LOG_PREFIX }} Checking existing linked issue: $ISSUE_REF"

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.CROSS_REPO_TOKEN }}
PR_URL: ${{ github.event.pull_request.html_url }}
PR_TITLE: ${{ github.event.pull_request.title }}
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="<!-- pr-linker:${PR_URL} -->"

PROCESSED=0
FAILED=0

echo "${{ env.LOG_PREFIX }} Processing issues..."

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)"
echo "$COMMENTS_BODY"
((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

# ✅ Proper multiline formatting (FIXED)
BODY_TEXT="🔗 Linked PR

- PR: ${PR_URL}
- Source: ${SOURCE_REPO}

${MARKER}"

# 🧹 DELETE if PR closed and NOT merged
if [[ "$PR_ACTION" == "closed" && "$PR_MERGED" == "false" ]]; then
if [ -n "$COMMENT_ID" ]; then
echo "${{ env.LOG_PREFIX }} Deleting comment $COMMENT_ID"

DELETE_RESPONSE=$(curl -s -w "\n%{http_code}" -X DELETE \
-H "Authorization: Bearer $GH_TOKEN" \
"https://api.github.com/repos/$REPO/issues/comments/$COMMENT_ID")

DELETE_CODE=$(echo "$DELETE_RESPONSE" | tail -n1)

if [[ "$DELETE_CODE" =~ ^(200|204)$ ]]; then
((PROCESSED++))
else
echo "${{ env.LOG_PREFIX }} Delete failed (HTTP $DELETE_CODE)"
echo "$DELETE_RESPONSE"
((FAILED++))
fi
else
echo "${{ env.LOG_PREFIX }} No comment to delete"
((PROCESSED++))
fi
continue
fi

# 🔁 UPDATE
if [ -n "$COMMENT_ID" ]; 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)"
echo "$UPDATE_RESPONSE"
((FAILED++))
fi

# ➕ CREATE
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)"
echo "$CREATE_RESPONSE"
((FAILED++))
fi
fi

done <<< "$ISSUES"

echo "${{ env.LOG_PREFIX }} Done. Processed: $PROCESSED | Failed: $FAILED"

# ✅ DO NOT fail workflow
exit 0

- name: Success summary
if: success()
run: |
echo "${{ env.LOG_PREFIX }} ✅ Workflow completed successfully"