diff --git a/.github/workflows/pr-comment.yml b/.github/workflows/pr-comment.yml index fdc5980..bbfb86e 100644 --- a/.github/workflows/pr-comment.yml +++ b/.github/workflows/pr-comment.yml @@ -4,9 +4,12 @@ on: workflow_run: workflows: ["PR Check"] types: [completed] + pull_request_review: + types: [submitted] permissions: actions: read + checks: read contents: read pull-requests: write @@ -132,3 +135,87 @@ jobs: }); console.log('Updated bot comment.'); } + + review-notify: + name: Notify maintainer + runs-on: ubuntu-latest + if: github.event_name == 'pull_request_review' + + steps: + - uses: actions/checkout@v4 + + - name: Fetch review and CI context + id: context + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.BOT_TOKEN || secrets.GITHUB_TOKEN }} + script: | + const fs = require('fs'); + const pr = context.payload.pull_request; + const { owner, repo } = context.repo; + + // Fetch reviews — extract user login and state + const { data: reviews } = await github.rest.pulls.listReviews({ + owner, repo, pull_number: pr.number, per_page: 100, + }); + const reviewData = reviews.map(r => ({ + user: r.user.login, + state: r.state, + })); + + // Fetch check runs for the PR head SHA + const { data: { check_runs } } = await github.rest.checks.listForRef({ + owner, repo, ref: pr.head.sha, per_page: 100, + }); + const checkData = check_runs.map(cr => ({ + status: cr.status, + conclusion: cr.conclusion, + })); + + // Check if we already posted the notification + const marker = ''; + const { data: comments } = await github.rest.issues.listComments({ + owner, repo, issue_number: pr.number, per_page: 100, + }); + const alreadyNotified = comments.some(c => c.body.includes(marker)); + + // Write data for Python + fs.mkdirSync('pr-meta', { recursive: true }); + fs.writeFileSync('pr-meta/reviews.json', JSON.stringify(reviewData)); + fs.writeFileSync('pr-meta/check-runs.json', JSON.stringify(checkData)); + fs.writeFileSync('pr-meta/already-notified.txt', String(alreadyNotified)); + + core.setOutput('pr_number', pr.number); + + - name: Check review readiness + run: python lib/checks/check-review-ready.py + + - name: Post notification + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.BOT_TOKEN || secrets.GITHUB_TOKEN }} + script: | + const fs = require('fs'); + const actionFile = 'pr-meta/action.txt'; + if (!fs.existsSync(actionFile)) return; + + const action = fs.readFileSync(actionFile, 'utf8').trim(); + if (action !== 'notify') { + console.log('Not ready for review — skipping.'); + return; + } + + const body = [ + '', + 'This PR is now ready to be merged, pending maintainer review. All checks are passing and it has been peer-reviewed.', + '', + '@Lissy93 - Please evaluate, and either merge or leave feedback.', + ].join('\n'); + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: parseInt('${{ steps.context.outputs.pr_number }}'), + body, + }); + console.log('Posted maintainer notification.'); diff --git a/lib/checks/check-review-ready.py b/lib/checks/check-review-ready.py new file mode 100644 index 0000000..8437e5d --- /dev/null +++ b/lib/checks/check-review-ready.py @@ -0,0 +1,90 @@ +"""Decides whether a PR is ready for maintainer review. + +Conditions: 2+ approvals from external contributors AND all CI checks passing. + +Reads: + pr-meta/reviews.json — array of {user, state} from GitHub API + pr-meta/check-runs.json — array of {status, conclusion} from GitHub API + pr-meta/already-notified.txt — "true" if notification comment already exists + +Writes: + pr-meta/action.txt — "notify" or "skip" +""" + +import json +import os + +WORK_DIR = "pr-meta" +MAINTAINER = "Lissy93" +REQUIRED_APPROVALS = 2 +PASSING_CONCLUSIONS = {"success", "skipped", "neutral"} + + +def read_json(filename): + """Load a JSON file from the work directory, or empty list on error.""" + try: + with open(os.path.join(WORK_DIR, filename)) as f: + return json.load(f) + except Exception: + return [] + + +def count_external_approvals(reviews): + """Count unique non-maintainer users who approved.""" + approvers = { + r["user"] + for r in reviews + if r.get("state") == "APPROVED" + and r.get("user", "").lower() != MAINTAINER.lower() + } + return len(approvers) + + +def all_checks_passing(check_runs): + """Return True if every check run completed successfully.""" + if not check_runs: + return False + return all( + cr.get("status") == "completed" + and cr.get("conclusion") in PASSING_CONCLUSIONS + for cr in check_runs + ) + + +def already_notified(): + """Return True if the notification comment already exists on the PR.""" + try: + with open(os.path.join(WORK_DIR, "already-notified.txt")) as f: + return f.read().strip().lower() == "true" + except Exception: + return False + + +def write_action(action): + os.makedirs(WORK_DIR, exist_ok=True) + with open(os.path.join(WORK_DIR, "action.txt"), "w") as f: + f.write(action) + + +def main(): + if already_notified(): + write_action("skip") + return + + reviews = read_json("reviews.json") + approvals = count_external_approvals(reviews) + + if approvals < REQUIRED_APPROVALS: + write_action("skip") + return + + check_runs = read_json("check-runs.json") + if not all_checks_passing(check_runs): + write_action("skip") + return + + write_action("notify") + + +if __name__ == "__main__": + main()