|
| 1 | +#!/usr/bin/env bash |
| 2 | +# Contributor recognition helpers (experiment E5). |
| 3 | +# |
| 4 | +# contributors.sh thanks <pr-number> # post (or dry-run) the thank-you comment |
| 5 | +# contributors.sh release <prev-tag> <ref> # print a "Contributors" markdown section |
| 6 | +# |
| 7 | +# Environment: GH_TOKEN, GITHUB_REPOSITORY. DRY_RUN (thanks only) defaults to |
| 8 | +# "true": the comment is printed, never posted. Set DRY_RUN=false to post. |
| 9 | +# |
| 10 | +# Safety: reads the GitHub API only. It never checks out, builds or executes |
| 11 | +# anything from a pull request, so it is safe under pull_request_target. |
| 12 | +# Untrusted PR text (title, login) is handled as data and never reaches eval. |
| 13 | +set -euo pipefail |
| 14 | + |
| 15 | +REPO="${GITHUB_REPOSITORY:-smart-mcp-proxy/mcpproxy-go}" |
| 16 | +MARKER='<!-- contributor-thanks:v1 -->' |
| 17 | +MAINTAINERS='Dumbris algis-dumbris' |
| 18 | + |
| 19 | +# is_external <login> <user-type> <author-association> |
| 20 | +# Succeeds when the PR author is an outside contributor worth thanking. |
| 21 | +is_external() { |
| 22 | + local login="$1" utype="$2" assoc="$3" m |
| 23 | + [[ -n "$login" ]] || return 1 |
| 24 | + [[ "$utype" != "Bot" && "$login" != *"[bot]" ]] || return 1 |
| 25 | + local l; l="$(printf '%s' "$login" | tr '[:upper:]' '[:lower:]')" |
| 26 | + for m in $MAINTAINERS; do |
| 27 | + [[ "$l" != "$(printf '%s' "$m" | tr '[:upper:]' '[:lower:]')" ]] || return 1 |
| 28 | + done |
| 29 | + [[ "$assoc" != "OWNER" && "$assoc" != "MEMBER" ]] || return 1 |
| 30 | + return 0 |
| 31 | +} |
| 32 | + |
| 33 | +# neutralize <text>: one line, no @mentions, no backticks/markdown links (PR titles are untrusted). |
| 34 | +neutralize() { |
| 35 | + printf '%s' "$1" | tr '\r\n\t' ' ' | sed -e 's/@/@\xE2\x80\x8B/g' -e 's/[]`<>[]//g' | cut -c1-120 |
| 36 | +} |
| 37 | + |
| 38 | +# render_comment <login> <title> <pr-url> <first-time:true|false> |
| 39 | +render_comment() { |
| 40 | + local login="$1" title url="$3" first="$4" |
| 41 | + title="$(neutralize "$2")" |
| 42 | + printf '%s\n' "$MARKER" |
| 43 | + printf 'Thank you @%s! Your PR "%s" is merged and will ship in the next release.\n\n' "$login" "$title" |
| 44 | + if [[ "$first" == "true" ]]; then |
| 45 | + printf 'This is your first merged contribution to MCPProxy. Welcome, and congratulations!\n\n' |
| 46 | + fi |
| 47 | + printf 'If you would like to mention it, here is a snippet you can copy and edit:\n\n' |
| 48 | + printf '```text\n' |
| 49 | + printf 'My PR to MCPProxy, an open-source MCP proxy for Claude Code, Codex and Cursor, just got merged: %s\n' "$url" |
| 50 | + printf 'Repo: https://github.com/%s\n' "$REPO" |
| 51 | + printf '```\n\n' |
| 52 | + printf 'Sharing genuinely helps a small open-source project get found. It is entirely optional, and we are grateful either way.\n' |
| 53 | +} |
| 54 | + |
| 55 | +cmd_thanks() { |
| 56 | + local pr="$1" dry="${DRY_RUN:-true}" info login utype assoc title url merged others first body existing |
| 57 | + info="$(gh api "repos/$REPO/pulls/$pr")" |
| 58 | + merged="$(jq -r '.merged' <<<"$info")" |
| 59 | + login="$(jq -r '.user.login' <<<"$info")" |
| 60 | + utype="$(jq -r '.user.type' <<<"$info")" |
| 61 | + assoc="$(jq -r '.author_association' <<<"$info")" |
| 62 | + title="$(jq -r '.title' <<<"$info")" |
| 63 | + url="$(jq -r '.html_url' <<<"$info")" |
| 64 | + |
| 65 | + [[ "$merged" == "true" ]] || { echo "skip: PR #$pr is not merged"; return 0; } |
| 66 | + is_external "$login" "$utype" "$assoc" || { echo "skip: $login ($utype, $assoc) is not an external contributor"; return 0; } |
| 67 | + |
| 68 | + # Only the bot's own comments count (a commenter cannot suppress the thanks by |
| 69 | + # pasting the marker). Read all comments first: piping into `grep -q` can SIGPIPE gh and, under |
| 70 | + # pipefail, look like "marker not found" (a duplicate comment). |
| 71 | + existing="$(gh api --paginate "repos/$REPO/issues/$pr/comments" --jq '.[] | select(.user.login == "github-actions[bot]") | .body')" |
| 72 | + if grep -qF "$MARKER" <<<"$existing"; then |
| 73 | + echo "skip: PR #$pr already has a thank-you comment"; return 0 |
| 74 | + fi |
| 75 | + |
| 76 | + # First contribution = no OTHER merged PR by this author. Counting "others" |
| 77 | + # (instead of total == 1) keeps this right while the search index lags behind |
| 78 | + # the merge we are reacting to. A failed or incomplete search degrades to "not first". Known |
| 79 | + # limit: two first-timer PRs merged within the index lag may both claim "first". |
| 80 | + others="$(gh api -X GET search/issues -f q="repo:$REPO is:pr is:merged author:$login" -f per_page=5 \ |
| 81 | + --jq "if .incomplete_results then -1 else [.items[].number | select(. != $pr)] | length end" 2>/dev/null)" || others=-1 |
| 82 | + first=false; [[ "$others" == "0" ]] && first=true |
| 83 | + |
| 84 | + body="$(render_comment "$login" "$title" "$url" "$first")" |
| 85 | + echo "--- comment for PR #$pr (author=$login assoc=$assoc other_merged_prs=$others first=$first, ${#body} chars) ---" |
| 86 | + printf '%s\n' "$body" |
| 87 | + if [[ "$dry" == "true" ]]; then |
| 88 | + echo "--- DRY_RUN=true: nothing posted ---" |
| 89 | + return 0 |
| 90 | + fi |
| 91 | + printf '%s\n' "$body" | gh api -X POST "repos/$REPO/issues/$pr/comments" -F body=@- >/dev/null |
| 92 | + echo "--- posted ---" |
| 93 | +} |
| 94 | + |
| 95 | +cmd_release() { |
| 96 | + local prev="$1" ref="$2" range nums n info login utype assoc seen=' ' list= |
| 97 | + range="${prev:+$prev..}$ref" |
| 98 | + # Squash-merge subjects end in "(#N)"; one PR number per commit. |
| 99 | + nums="$(git log "$range" --no-merges --pretty=format:%s | sed -n 's/.*(#\([0-9][0-9]*\))$/\1/p' | sort -un)" |
| 100 | + for n in $nums; do |
| 101 | + info="$(gh api "repos/$REPO/pulls/$n" 2>/dev/null)" || continue |
| 102 | + login="$(jq -r '.user.login' <<<"$info")" |
| 103 | + utype="$(jq -r '.user.type' <<<"$info")" |
| 104 | + assoc="$(jq -r '.author_association' <<<"$info")" |
| 105 | + is_external "$login" "$utype" "$assoc" || continue |
| 106 | + [[ "$seen" == *" $login "* ]] && continue |
| 107 | + seen+="$login "; list="${list:+$list, }@$login" |
| 108 | + done |
| 109 | + [[ -n "$list" ]] || return 0 |
| 110 | + printf '## Contributors\n\nThank you to our external contributors for this release: %s\n' "$list" |
| 111 | +} |
| 112 | + |
| 113 | +if [[ "${BASH_SOURCE[0]:-$0}" == "$0" ]]; then |
| 114 | + case "${1:-}" in |
| 115 | + thanks) cmd_thanks "${2:?pr number}" ;; |
| 116 | + release) cmd_release "${2-}" "${3:?ref}" ;; |
| 117 | + *) echo "usage: $0 thanks <pr> | release <prev-tag> <ref>" >&2; exit 2 ;; |
| 118 | + esac |
| 119 | +fi |
0 commit comments