From cf83fc6e70141419f406a382bd0965c5b96bee18 Mon Sep 17 00:00:00 2001 From: Lauren Tan Date: Tue, 8 Apr 2025 10:40:27 -0400 Subject: [PATCH] [ci] Add ghstack /land bot Summary: Test Plan: Reviewers: Subscribers: Tasks: Tags: --- .../workflows/scripts/ghstack-perm-check.py | 109 +++++++++++++++++ .github/workflows/shared_ghstack_land.yml | 113 ++++++++++++++++++ 2 files changed, 222 insertions(+) create mode 100644 .github/workflows/scripts/ghstack-perm-check.py create mode 100644 .github/workflows/shared_ghstack_land.yml diff --git a/.github/workflows/scripts/ghstack-perm-check.py b/.github/workflows/scripts/ghstack-perm-check.py new file mode 100644 index 0000000000..5e84800d61 --- /dev/null +++ b/.github/workflows/scripts/ghstack-perm-check.py @@ -0,0 +1,109 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +# https://github.com/Chillee/ghstack_land_example/blob/main/.github/workflows/scripts/ghstack-perm-check.py + +import json +import os +import re +import subprocess +import sys + +import requests + + +def main(): + gh = requests.Session() + gh.headers.update( + { + "Authorization": f'Bearer {os.environ["GITHUB_TOKEN"]}', + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + } + ) + NUMBER, head_ref = int(sys.argv[1]), sys.argv[2] + EV = json.loads(sys.stdin.read()) + REPO = EV["repository"] + + def must(cond, msg): + if not cond: + print(msg) + gh.post( + f"https://api.github.com/repos/{REPO}/issues/{NUMBER}/comments", + json={ + "body": f"ghstack bot failed: {msg}", + }, + ) + exit(1) + + print(head_ref) + must( + head_ref and re.match(r"^gh/[A-Za-z0-9-]+/[0-9]+/head$", head_ref), + "Not a ghstack PR", + ) + orig_ref = head_ref.replace("/head", "/orig") + print(":: Fetching newest main...") + must(os.system("git fetch origin main") == 0, "Can't fetch main") + print(":: Fetching orig branch...") + must(os.system(f"git fetch origin {orig_ref}") == 0, "Can't fetch orig branch") + + proc = subprocess.Popen( + "git log FETCH_HEAD...$(git merge-base FETCH_HEAD origin/main)", + stdout=subprocess.PIPE, + shell=True, + ) + out, _ = proc.communicate() + must(proc.wait() == 0, "`git log` command failed!") + + pr_numbers = re.findall( + r"Pull Request resolved: https://github.com/.*?/pull/([0-9]+)", + out.decode("utf-8"), + ) + pr_numbers = list(map(int, pr_numbers)) + print(pr_numbers) + must(pr_numbers and pr_numbers[0] == NUMBER, "Extracted PR numbers not seems right!") + + for n in pr_numbers: + print(f":: Checking PR status #{n}... ", end="") + resp = gh.get(f"https://api.github.com/repos/{REPO}/pulls/{n}") + must(resp.ok, "Error Getting PR Object!") + pr_obj = resp.json() + + resp = gh.get(f"https://api.github.com/repos/{REPO}/pulls/{NUMBER}/reviews") + must(resp.ok, "Error Getting PR Reviews!") + reviews = resp.json() + idmap = {} + approved = True # TODO: REMOVE + for r in reviews: + s = r["state"] + if s not in ("COMMENTED",): + idmap[r["user"]["login"]] = r["state"] + + for u, cc in idmap.items(): + approved = approved or cc == "APPROVED" + must( + cc in ("APPROVED", "DISMISSED"), + f"@{u} has stamped PR #{n} `{cc}`, please resolve it first!", + ) + + must(approved, f"PR #{n} is not approved yet!") + + resp = gh.get(f'https://api.github.com/repos/{REPO}/commits/{pr_obj["head"]["sha"]}/check-runs') + must(resp.ok, "Error getting check runs status!") + checkruns = resp.json() + for cr in checkruns["check_runs"]: + status = cr.get("conclusion", cr["status"]) + name = cr["name"] + if name == "Copilot for PRs": + continue + must( + status in ("success", "neutral"), + f"PR #{n} check-run `{name}`'s status `{status}` is not success!", + ) + print("SUCCESS!") + + print(":: All PRs are ready to be landed!") + + +if __name__ == "__main__": + main() diff --git a/.github/workflows/shared_ghstack_land.yml b/.github/workflows/shared_ghstack_land.yml new file mode 100644 index 0000000000..a33d1ce0ed --- /dev/null +++ b/.github/workflows/shared_ghstack_land.yml @@ -0,0 +1,113 @@ +name: (Shared) ghstack land + +on: + issue_comment: + types: [created] + +permissions: {} + +env: + TZ: /usr/share/zoneinfo/America/Los_Angeles + # https://github.com/actions/cache/blob/main/tips-and-workarounds.md#cache-segment-restore-timeout + SEGMENT_DOWNLOAD_TIMEOUT_MINS: 1 + +jobs: + check_access: + runs-on: ubuntu-latest + outputs: + is_member_or_collaborator: ${{ steps.check_access.outputs.result }} + steps: + - name: Check access + id: check_access + if: ${{ github.event.comment.author_association == 'MEMBER' || github.event.comment.author_association == 'COLLABORATOR' }} + run: echo "is_member_or_collaborator=true" >> "$GITHUB_OUTPUT" + + check_maintainer: + if: ${{ needs.check_access.outputs.is_member_or_collaborator == 'true' }} + needs: [check_access] + uses: facebook/react/.github/workflows/shared_check_maintainer.yml@main + permissions: + # Used by check_maintainer + contents: read + with: + actor: ${{ github.event.comment.user.login }} + + ghstack_land: + if: ${{ needs.check_maintainer.outputs.is_core_team == 'true' && github.event.issue.pull_request && contains(github.event.comment.body, '/land') }} + needs: [check_maintainer] + runs-on: ubuntu-latest + steps: + - name: Add reaction to comment + uses: actions/github-script@v7 + with: + script: | + const owner = context.repo.owner; + const repo = context.repo.repo; + const comment_id = "${{ github.event.comment.id }}" + + await github.rest.reactions.createForCommitComment({ + owner, + repo, + comment_id, + content: "rocket", + }); + - name: Get PR details + id: get-pr + run: | + PR_NUMBER=${{ github.event.issue.number }} + echo "PR number is $PR_NUMBER" + echo "pr_number=$PR_NUMBER" >> $GITHUB_OUTPUT + + # Get PR details using GitHub API + PR_DATA=$(curl -s \ + -H "Authorization: token ${{ github.token }}" \ + -H "Accept: application/vnd.github.v3+json" \ + "${{ github.api_url }}/repos/${{ github.repository }}/pulls/$PR_NUMBER") + + # Extract useful information + PR_HEAD_REF=$(echo "$PR_DATA" | jq -r .head.ref) + PR_HEAD_SHA=$(echo "$PR_DATA" | jq -r .head.sha) + PR_URL="${{ github.server_url }}/${{ github.repository }}/pull/$PR_NUMBER" + + echo "pr_branch=$PR_HEAD_REF" >> $GITHUB_OUTPUT + echo "pr_sha=$PR_HEAD_SHA" >> $GITHUB_OUTPUT + echo "pr_url=$PR_URL" >> $GITHUB_OUTPUT + echo "pr_branch=$PR_HEAD_REF" + echo "pr_sha=$PR_HEAD_SHA" + echo "pr_url=$PR_URL" + - uses: actions/checkout@v4 + - uses: actions/cache@v4 + with: + path: ~/.cache/pip + key: ghstack-pip-${{ runner.arch }}-${{ runner.os }}-${{ hashFiles('**/requirements.txt') }} + restore-keys: | + ghstack-pip-${{ runner.arch }}-${{ runner.os }}- + ghstack-pip- + - uses: actions/setup-python@v4 + with: + python-version: 3.9 + - name: Install ghstack + run: pip install requests ghstack + - name: Check Current CI Status + run: | + echo ${{ github.event.issue.number }} + .github/workflows/scripts/ghstack-perm-check.py ${{ github.event.issue.number }} ${{steps.get-pr.outputs.pr_branch}} <<'EOF' + ${{ toJson(github) }} + EOF + env: + GITHUB_TOKEN: ${{ github.token }} + - name: Land It! + run: | + git config --global user.email "bot@react.dev" + git config --global user.name "react bot" + cat < ~/.ghstackrc + [ghstack] + github_url = github.com + github_oauth = $GITHUB_TOKEN + github_username = foo + remote_name = origin + EOF + # ghstack land "${{ steps.get-pr.outputs.pr_url }}" + echo "${{ steps.get-pr.outputs.pr_url }}" + env: + GITHUB_TOKEN: ${{ github.token }}