CQL Engine v5 #190
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: PR-to-Issue Matcher | |
| on: | |
| pull_request: | |
| types: [opened, edited, synchronize] | |
| permissions: | |
| pull-requests: write | |
| issues: read | |
| concurrency: | |
| group: issue-matcher-${{ github.event.pull_request.number }} | |
| cancel-in-progress: true | |
| jobs: | |
| match-issues: | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Find related issues | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| GH_REPO: ${{ github.repository }} | |
| PR_NUMBER: ${{ github.event.pull_request.number }} | |
| run: | | |
| set -euo pipefail | |
| # ── Fetch PR data ────────────────────────────────────────────── | |
| pr_json=$(gh pr view "$PR_NUMBER" --json title,body,files) | |
| pr_title=$(echo "$pr_json" | jq -r '.title // ""') | |
| pr_body=$(echo "$pr_json" | jq -r '.body // ""') | |
| pr_files=$(echo "$pr_json" | jq -r '.files[].path // empty') | |
| # ── Collect issue numbers already referenced in the PR body ─── | |
| declare -A referenced_issues | |
| while IFS= read -r num; do | |
| [[ -n "$num" ]] && referenced_issues["$num"]=1 | |
| done < <(echo "$pr_body" | grep -oP '(?<=#)\d+' || true) | |
| # ── Build PR tokens ──────────────────────────────────────────── | |
| # Stop words to filter out | |
| stop_words="the and for are but not you all any can had her was one our out day get has him his how its may new now old see way who did" | |
| stop_words+=" also been from have just more most only than that them then this very what when will with does done each even goes into" | |
| stop_words+=" like make many must over same some such take than that these they this those used were which would your about after being" | |
| stop_words+=" could every first found given great into known large later least never other place point right since small still their there" | |
| stop_words+=" think using where which while close closes closed refs ref fix fixes fixed should issue pull request merge merged" | |
| declare -A stop_map | |
| for w in $stop_words; do stop_map["$w"]=1; done | |
| # Split camelCase / PascalCase into individual words | |
| split_camel() { | |
| echo "$1" | sed -E 's/([a-z])([A-Z])/\1 \2/g; s/([A-Z]+)([A-Z][a-z])/\1 \2/g' | tr '[:upper:]' '[:lower:]' | |
| } | |
| # Tokenize text: lowercase, strip non-alpha, filter stop words and short tokens | |
| tokenize() { | |
| local text="$1" | |
| echo "$text" | tr '[:upper:]' '[:lower:]' | tr -cs 'a-z0-9' ' ' | tr ' ' '\n' | while read -r word; do | |
| [[ ${#word} -ge 3 && -z "${stop_map[$word]:-}" ]] && echo "$word" | |
| done | |
| } | |
| # Extract path components from file paths, splitting camelCase | |
| path_tokens() { | |
| echo "$1" | tr '/' '\n' | sed 's/\.[^.]*$//' | while read -r component; do | |
| split_camel "$component" | tr ' ' '\n' | while read -r word; do | |
| word=$(echo "$word" | tr -cd 'a-z0-9') | |
| [[ ${#word} -ge 3 && -z "${stop_map[$word]:-}" ]] && echo "$word" | |
| done | |
| done | |
| } | |
| # Collect all PR tokens | |
| declare -A pr_keywords | |
| while IFS= read -r token; do | |
| [[ -n "$token" ]] && pr_keywords["$token"]=1 | |
| done < <(tokenize "$pr_title"; tokenize "$pr_body") | |
| # Collect path component tokens separately | |
| declare -A pr_path_tokens | |
| while IFS= read -r token; do | |
| [[ -n "$token" ]] && pr_path_tokens["$token"]=1 | |
| done < <(echo "$pr_files" | while IFS= read -r f; do [[ -n "$f" ]] && path_tokens "$f"; done) | |
| # Also add path tokens to keywords for bigram matching | |
| for token in "${!pr_path_tokens[@]}"; do | |
| pr_keywords["$token"]=1 | |
| done | |
| # Build PR bigrams from title + body | |
| pr_text_tokens=() | |
| while IFS= read -r token; do | |
| [[ -n "$token" ]] && pr_text_tokens+=("$token") | |
| done < <(tokenize "$pr_title"; tokenize "$pr_body") | |
| declare -A pr_bigrams | |
| for ((i=0; i<${#pr_text_tokens[@]}-1; i++)); do | |
| bigram="${pr_text_tokens[$i]}_${pr_text_tokens[$((i+1))]}" | |
| pr_bigrams["$bigram"]=1 | |
| done | |
| # Exit early if PR has almost no content to match on | |
| total_tokens=$(( ${#pr_keywords[@]} + ${#pr_bigrams[@]} )) | |
| if [[ $total_tokens -lt 2 ]]; then | |
| echo "PR has insufficient content for matching. Skipping." | |
| exit 0 | |
| fi | |
| # ── Fetch open issues ────────────────────────────────────────── | |
| issues_json=$(gh issue list --state open --limit 500 --json number,title,body,labels) | |
| issue_count=$(echo "$issues_json" | jq 'length') | |
| if [[ "$issue_count" -eq 0 ]]; then | |
| echo "No open issues found. Skipping." | |
| exit 0 | |
| fi | |
| # ── Score each issue ─────────────────────────────────────────── | |
| declare -A issue_scores | |
| declare -A issue_titles | |
| declare -A issue_matched_terms | |
| for ((idx=0; idx<issue_count; idx++)); do | |
| issue_num=$(echo "$issues_json" | jq -r ".[$idx].number") | |
| issue_title=$(echo "$issues_json" | jq -r ".[$idx].title // \"\"") | |
| issue_body=$(echo "$issues_json" | jq -r ".[$idx].body // \"\" | .[:2000]") | |
| issue_labels=$(echo "$issues_json" | jq -r ".[$idx].labels[].name // empty" 2>/dev/null || true) | |
| # Skip if already referenced | |
| [[ -n "${referenced_issues[$issue_num]:-}" ]] && continue | |
| issue_titles["$issue_num"]="$issue_title" | |
| # Tokenize issue content | |
| issue_tokens=() | |
| while IFS= read -r token; do | |
| [[ -n "$token" ]] && issue_tokens+=("$token") | |
| done < <(tokenize "$issue_title"; tokenize "$issue_body"; tokenize "$issue_labels") | |
| # Build issue bigrams | |
| declare -A issue_bigram_map | |
| for ((i=0; i<${#issue_tokens[@]}-1; i++)); do | |
| bigram="${issue_tokens[$i]}_${issue_tokens[$((i+1))]}" | |
| issue_bigram_map["$bigram"]=1 | |
| done | |
| # Build issue keyword set | |
| declare -A issue_kw_map | |
| for token in "${issue_tokens[@]}"; do | |
| issue_kw_map["$token"]=1 | |
| done | |
| score=0 | |
| matched="" | |
| # Bigram matches (×3) | |
| for bigram in "${!pr_bigrams[@]}"; do | |
| if [[ -n "${issue_bigram_map[$bigram]:-}" ]]; then | |
| score=$((score + 3)) | |
| display_bigram="${bigram/_/ }" | |
| matched="${matched:+$matched, }\"$display_bigram\"" | |
| fi | |
| done | |
| # Keyword matches (×1) | |
| for kw in "${!pr_keywords[@]}"; do | |
| if [[ -n "${issue_kw_map[$kw]:-}" && -z "${pr_path_tokens[$kw]:-}" ]]; then | |
| score=$((score + 1)) | |
| matched="${matched:+$matched, }$kw" | |
| fi | |
| done | |
| # Path component matches (×0.5 → use integer math: count and add half at end) | |
| path_match_count=0 | |
| for pt in "${!pr_path_tokens[@]}"; do | |
| if [[ -n "${issue_kw_map[$pt]:-}" ]]; then | |
| path_match_count=$((path_match_count + 1)) | |
| matched="${matched:+$matched, }$pt (path)" | |
| fi | |
| done | |
| # Multiply by 0.5 using integer math (round down, add 1 per 2 matches) | |
| score=$((score + path_match_count / 2)) | |
| # Handle odd counts: use a flag to track half-points | |
| if [[ $((path_match_count % 2)) -eq 1 ]]; then | |
| # Store as score*10 to handle decimal, then divide at the end | |
| score=$((score * 10 + 5)) | |
| else | |
| score=$((score * 10)) | |
| fi | |
| unset issue_bigram_map | |
| unset issue_kw_map | |
| if [[ $score -ge 30 ]]; then # threshold is 3.0 → 30 in ×10 scale | |
| issue_scores["$issue_num"]=$score | |
| issue_matched_terms["$issue_num"]="$matched" | |
| fi | |
| done | |
| # ── Sort and pick top 5 ──────────────────────────────────────── | |
| sorted_issues=() | |
| while IFS= read -r line; do | |
| [[ -n "$line" ]] && sorted_issues+=("$line") | |
| done < <( | |
| for num in "${!issue_scores[@]}"; do | |
| echo "${issue_scores[$num]} $num" | |
| done | sort -rn | head -5 | |
| ) | |
| # ── Build comment body ───────────────────────────────────────── | |
| marker="<!-- pr-issue-matcher -->" | |
| if [[ ${#sorted_issues[@]} -eq 0 ]]; then | |
| comment_body="${marker} | |
| ### Related Issues | |
| No strongly related open issues were found for this PR. | |
| > **Tip:** If this PR addresses an existing issue, please link it using \`Closes #NNN\` or \`Refs #NNN\` in the PR description." | |
| else | |
| table_rows="" | |
| for entry in "${sorted_issues[@]}"; do | |
| score_raw=$(echo "$entry" | awk '{print $1}') | |
| num=$(echo "$entry" | awk '{print $2}') | |
| # Convert ×10 score back to display value | |
| score_int=$((score_raw / 10)) | |
| score_frac=$((score_raw % 10)) | |
| if [[ $score_frac -gt 0 ]]; then | |
| display_score="${score_int}.${score_frac}" | |
| else | |
| display_score="${score_int}" | |
| fi | |
| title="${issue_titles[$num]}" | |
| # Escape pipe characters in title | |
| title="${title//|/\\|}" | |
| matched="${issue_matched_terms[$num]}" | |
| table_rows="${table_rows} | |
| | #${num} | ${title} | ${display_score} | ${matched} |" | |
| done | |
| comment_body="${marker} | |
| ### Related Issues | |
| The following open issues may be related to this PR: | |
| | Issue | Title | Score | Matched Terms | | |
| |-------|-------|------:|---------------|${table_rows} | |
| > **Tip:** If this PR addresses any of these issues, please link them using \`Closes #NNN\` or \`Refs #NNN\` in the PR description." | |
| fi | |
| # ── Upsert comment ───────────────────────────────────────────── | |
| existing_comment_id=$(gh api \ | |
| "repos/${GH_REPO}/issues/${PR_NUMBER}/comments" \ | |
| --paginate \ | |
| --jq "[.[] | select(.body | contains(\"$marker\")) | .id] | first // empty" \ | |
| 2>/dev/null || true) | |
| if [[ -n "$existing_comment_id" ]]; then | |
| gh api \ | |
| --method PATCH \ | |
| "repos/${GH_REPO}/issues/comments/${existing_comment_id}" \ | |
| -f body="$comment_body" \ | |
| --silent | |
| echo "Updated existing comment $existing_comment_id" | |
| else | |
| gh pr comment "$PR_NUMBER" --body "$comment_body" | |
| echo "Posted new comment" | |
| fi |