Skip to content
Merged
Show file tree
Hide file tree
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
5 changes: 5 additions & 0 deletions .github/labels.yml
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,11 @@
color: "c5def5"
description: "State management and persistence"

# ── Meta ─────────────────────────────────────────────────────────────────────
- name: "contributors"
color: "5319e7"
description: "Contributor recognition & governance automation (all-contributors audit, etc.)"

# ── Status ───────────────────────────────────────────────────────────────────
- name: "good first issue"
color: "7057ff"
Expand Down
111 changes: 111 additions & 0 deletions .github/workflows/contributors-audit.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
# Weekly audit of .all-contributorsrc against current reality.
#
# Runs scripts/check_contributors.sh, which checks for:
# 1. Profile URL drift between README.md and .all-contributorsrc
# (failure mode that hit us via #616 → #617 → #620).
# 2. Contributors with merged PRs missing from .all-contributorsrc
# (failure mode that hit us via @yodakanohoshi / @Photon101 /
# @kiwamizamurai being absent at the time of the v0.7.8 cut).
# 3. Contributors eligible for Triage Collaborator per GOVERNANCE.md
# criteria (5+ merged PRs, active last 30 days) who are not yet
# invited (failure mode that hit us when masukai initially
# thought Muawiya-contact still needed elevating — they didn't,
# but the only way to confirm that confidently was a manual
# collaborators check).
#
# When the script reports a mismatch, this workflow opens (or updates)
# a GitHub issue with the detailed report so the gap is acted on
# rather than silently accumulating.

name: contributors-audit

on:
schedule:
# Monday 00:30 UTC — slightly offset from the existing nightly /
# weekly schedules in ci.yml / codeql.yml so they don't all queue
# at the same minute.
- cron: '30 0 * * 1'
workflow_dispatch:

permissions:
contents: read
issues: write

jobs:
audit:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v6

- name: Run contributors audit
id: audit
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set +e
OUTPUT=$(bash scripts/check_contributors.sh 2>&1)
EXIT_CODE=$?
set -e
{
echo "exit_code=$EXIT_CODE"
echo "report<<EOF"
echo "$OUTPUT"
echo "EOF"
} >> "$GITHUB_OUTPUT"
# Also dump to the Action log for quick inspection.
echo "$OUTPUT"

- name: Open / update tracking issue when audit finds gaps
if: steps.audit.outputs.exit_code != '0'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
TITLE="chore(contributors): weekly audit found gaps in .all-contributorsrc"
BODY=$(cat <<EOF
The weekly \`contributors-audit\` workflow flagged one or more issues with the \`.all-contributorsrc\` file. The detailed report from \`scripts/check_contributors.sh\` follows; please address the gaps either by editing \`.all-contributorsrc\` directly, requesting the \`@all-contributors\` bot to add missing contributors, or opening a Discussion to propose any overdue Triage Collaborator invitations.

See \`scripts/check_contributors.sh\` and [GOVERNANCE.md](../blob/main/GOVERNANCE.md) for the remediation paths.

<details>
<summary>Full report</summary>

\`\`\`
${{ steps.audit.outputs.report }}
\`\`\`

</details>

---

*This issue was opened automatically by the \`contributors-audit\` workflow. It will be updated rather than duplicated on subsequent runs.*
EOF
)

# Reuse an existing open issue if one already exists; otherwise create.
EXISTING=$(gh issue list \
--search "in:title \"chore(contributors): weekly audit found gaps\"" \
--state open \
--json number \
-q '.[0].number // empty')

if [[ -n "$EXISTING" ]]; then
echo "Updating existing audit issue #$EXISTING"
gh issue comment "$EXISTING" --body "$BODY"
else
echo "Opening new audit issue"
# Ensure the labels exist before the issue create — otherwise
# `gh issue create --label` hard-fails if a label is missing
# (e.g. on the very first run, before `make sync-labels` has
# materialised .github/labels.yml). `--force` makes this
# idempotent (update-or-create); .github/labels.yml stays the
# source of truth and sync-labels reconciles colour/description.
gh label create contributors \
--color "5319e7" \
--description "Contributor recognition & governance automation (all-contributors audit, etc.)" \
--force || true
gh issue create \
--title "$TITLE" \
--body "$BODY" \
--label "chore,contributors"
fi
3 changes: 3 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,9 @@ check-skills: ## Verify .claude/commands/ matches skills (CI gate)
check-i18n: ## Check if translated *.{lang}.md files are in sync with English base
@bash scripts/check-i18n-sync.sh

check-contributors: ## Audit .all-contributorsrc for drift / missing entries / overdue Triage invitations
@bash scripts/check_contributors.sh

check-changelog: ## Verify every drt-core v* tag has a ## [X.Y.Z] section in CHANGELOG.md
@python3 scripts/check_changelog_monotonic.py

Expand Down
199 changes: 199 additions & 0 deletions scripts/check_contributors.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
#!/usr/bin/env bash
#
# check_contributors.sh — audit .all-contributorsrc against current reality.
#
# Three checks:
# 1. URL drift — profile URL in .all-contributorsrc differs from the one
# rendered in README.md (typically because the README was hand-edited
# without syncing the source-of-truth). Surfaces the failure mode that
# caused PR #616 → #617 / #620.
# 2. Missing entry — a contributor with at least one merged PR is absent
# from .all-contributorsrc.
# 3. Triage eligibility — a contributor with 5+ merged PRs, active in the
# last 30 days, who is NOT yet a Triage Collaborator per GOVERNANCE.md
# criteria. Surfaces forgotten elevations.
#
# Exit codes:
# 0 — no issues, .all-contributorsrc is in sync
# 1 — one or more issues found; details on stdout
# 2 — script error (gh / jq missing, unauthenticated, etc.)
#
# Local usage:
# make check-contributors
# ./scripts/check_contributors.sh
#
# CI usage (.github/workflows/contributors-audit.yml runs this weekly).

set -euo pipefail

REPO="drt-hub/drt"
ALL_CONTRIBUTORS_FILE=".all-contributorsrc"
README_FILE="README.md"
ACTIVE_WINDOW_DAYS=30
TRIAGE_THRESHOLD=5

# --- prerequisites -----------------------------------------------------------

require() {
if ! command -v "$1" > /dev/null 2>&1; then
echo "error: '$1' is required but not installed" >&2
exit 2
fi
}

require gh
require jq

if ! gh auth status > /dev/null 2>&1; then
echo "error: gh CLI not authenticated (run 'gh auth login' or set GITHUB_TOKEN)" >&2
exit 2
fi

if [[ ! -f "$ALL_CONTRIBUTORS_FILE" ]]; then
echo "error: $ALL_CONTRIBUTORS_FILE not found (run from repo root)" >&2
exit 2
fi

# --- helpers -----------------------------------------------------------------

header() {
echo
echo "=== $1 ==="
}

ISSUES=0

flag_issue() {
ISSUES=$((ISSUES + 1))
}

# --- data: parse .all-contributorsrc ----------------------------------------

contributors_json=$(jq -c '.contributors' "$ALL_CONTRIBUTORS_FILE")
logins=$(echo "$contributors_json" | jq -r '.[].login')

# --- Check 1: URL drift ------------------------------------------------------

header "Check 1: profile URL drift (.all-contributorsrc vs README.md)"

drift_count=0
while IFS=$'\t' read -r login profile; do
# Find the contributor's row in README.md by matching the avatar+login
# signature and pull the outer <a href="..."> of the SAME <td>.
readme_url=$(grep -oE "<a href=\"[^\"]+\"><img src=\"https://avatars.githubusercontent.com/[^\"]+\"[^>]*alt=\"[^\"]*\"/><br /><sub><b>[^<]+</b></sub></a><br /><a href=\"https://github.com/${REPO}/[^\"]*author=${login}\"" "$README_FILE" | head -1 | sed -E 's|^<a href="([^"]+)".*|\1|' || true)

if [[ -z "$readme_url" ]]; then
echo " ? @${login}: not found in $README_FILE (handled by Check 2 below if applicable)"
continue
fi

if [[ "$readme_url" != "$profile" ]]; then
echo " ✗ @${login}: drift"
echo " .all-contributorsrc: $profile"
echo " $README_FILE: $readme_url"
drift_count=$((drift_count + 1))
flag_issue
fi
done < <(echo "$contributors_json" | jq -r '.[] | [.login, .profile] | @tsv')

if [[ "$drift_count" -eq 0 ]]; then
echo " ✓ no profile URL drift between $README_FILE and $ALL_CONTRIBUTORS_FILE"
fi

# --- Check 2: missing entries (merged-PR author not in .all-contributorsrc) --

header "Check 2: contributors with merged PRs missing from $ALL_CONTRIBUTORS_FILE"

# Filter out bot accounts (allcontributors, dependabot, etc.) — they don't
# need entries in .all-contributorsrc since their contributions are automated.
merged_authors=$(
gh search prs --repo="$REPO" --merged --json author -L 200 \
-q '.[] | .author.login // empty' \
| sort -u \
| grep -vE '\[bot\]$|^dependabot$|^renovate$|^github-actions$' \
|| true
)

missing=()
while read -r author; do
[[ -z "$author" ]] && continue
if ! echo "$logins" | grep -qix -- "$author"; then
missing+=("$author")
fi
done <<< "$merged_authors"

if [[ "${#missing[@]}" -eq 0 ]]; then
echo " ✓ every merged-PR author has an entry in $ALL_CONTRIBUTORS_FILE"
else
echo " ✗ missing entries (these accounts have merged PRs but no entry):"
for m in "${missing[@]}"; do
pr_count=$(gh search prs --repo="$REPO" --author="$m" --merged --json number -L 100 -q "length")
echo " @${m}: $pr_count merged PRs"
flag_issue
done
fi

# --- Check 3: Triage Collaborator eligibility -------------------------------

header "Check 3: GOVERNANCE.md Triage Collaborator eligibility (${TRIAGE_THRESHOLD}+ merged PRs, active last ${ACTIVE_WINDOW_DAYS} days)"

triage_logins=$(
gh api "repos/${REPO}/collaborators?affiliation=all&per_page=100" \
--jq '.[] | select(.permissions.triage == true) | .login' \
| sort -u
)

all_recent_prs=$(
gh search prs --repo="$REPO" --merged --json author,closedAt -L 200
)

cutoff=$(python3 -c "
import datetime
print((datetime.datetime.now(datetime.timezone.utc)
- datetime.timedelta(days=${ACTIVE_WINDOW_DAYS})).date().isoformat())
")

eligible_count=0
while IFS=$'\t' read -r author count latest; do
[[ -z "$author" ]] && continue
[[ "$count" -lt "$TRIAGE_THRESHOLD" ]] && continue
[[ "$latest" < "$cutoff" ]] && continue

if echo "$triage_logins" | grep -qix -- "$author"; then
continue
fi

echo " ✗ @${author}: $count merged PRs, last activity $latest — NOT yet Triage Collaborator"
echo " GOVERNANCE.md path: open a Discussion proposing the invitation"
eligible_count=$((eligible_count + 1))
flag_issue
done < <(
echo "$all_recent_prs" \
| jq -r 'group_by(.author.login)
| .[]
| select(.[0].author.login != null)
| [.[0].author.login,
length,
(max_by(.closedAt).closedAt[0:10])]
| @tsv'
)

if [[ "$eligible_count" -eq 0 ]]; then
echo " ✓ no overdue Triage Collaborator invitations (everyone meeting the bar is already invited)"
fi

# --- summary -----------------------------------------------------------------

echo
if [[ "$ISSUES" -eq 0 ]]; then
echo "✓ All checks passed — $ALL_CONTRIBUTORS_FILE is in sync."
exit 0
else
echo "✗ $ISSUES issue(s) found. See above for details."
echo
echo "Remediation:"
echo " - URL drift → edit $ALL_CONTRIBUTORS_FILE to match $README_FILE (the README is hand-fixed more often)"
echo " - missing → @all-contributors add @user for code (in any PR comment) OR edit $ALL_CONTRIBUTORS_FILE directly"
echo " - Triage → open a Discussion proposing the invitation per GOVERNANCE.md"
exit 1
fi
Loading