diff --git a/.github/workflows/slack_notify_community_automation_failure.yml b/.github/workflows/slack_notify_community_automation_failure.yml index 00efd431b7c..c1abf7a4986 100644 --- a/.github/workflows/slack_notify_community_automation_failure.yml +++ b/.github/workflows/slack_notify_community_automation_failure.yml @@ -17,6 +17,7 @@ on: - "Maintainer Edits Nudge" - "PR Board Meta Fields Refresh" - "PR Issue Labeler" + - "Welcome First Contribution" - "Guild Assignment Status" - "Guild Stale Assignments" - "Guild Weekly Shipped" diff --git a/.github/workflows/welcome_first_contribution.yml b/.github/workflows/welcome_first_contribution.yml new file mode 100644 index 00000000000..2d2740ee4a3 --- /dev/null +++ b/.github/workflows/welcome_first_contribution.yml @@ -0,0 +1,72 @@ +# Welcome first-time contributors with a short summary of CONTRIBUTING.md, once +# per author, when the 'first contribution' label is added to their PR. + +name: Welcome First Contribution + +on: + # zizmor: ignore[dangerous-triggers] + # Fork PRs must be supported, but this workflow only reads event metadata and + # checks out scripts from the trusted default branch; it never executes PR code. + pull_request_target: + types: [labeled] + workflow_dispatch: + inputs: + pr_number: + description: "PR number to welcome (must have the 'first contribution' label)" + required: true + type: number + dry-run: + description: "Print the comment instead of posting it" + type: boolean + default: true + +permissions: + contents: read + +jobs: + welcome: + if: >- + github.repository == 'zed-industries/zed' && + (github.event_name == 'workflow_dispatch' || + github.event.label.name == 'first contribution') + # Per author, so two PRs labeled at once can't both be welcomed. Job-level, + # so runs for other labels don't take the pending slot. + concurrency: + group: welcome-first-contribution-${{ github.event.pull_request.user.login || inputs.pr_number }} + cancel-in-progress: false + runs-on: namespace-profile-2x4-ubuntu-2404 + timeout-minutes: 5 + + steps: + - name: Generate app token + id: app-token + uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0 + with: + app-id: ${{ secrets.ZED_COMMUNITY_BOT_APP_ID }} + private-key: ${{ secrets.ZED_COMMUNITY_BOT_PRIVATE_KEY }} + owner: zed-industries + repositories: zed + permission-issues: write + permission-pull-requests: read + + - name: Checkout repository + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + with: + sparse-checkout: | + script/github-welcome-first-contribution.py + script/github_helpers.py + sparse-checkout-cone-mode: false + + - name: Set up Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: "3.12" + + - name: Install dependencies + run: pip install requests + + - name: Post welcome comment + env: + GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} + PR_NUMBER: ${{ github.event.pull_request.number || inputs.pr_number }} + run: python script/github-welcome-first-contribution.py "$PR_NUMBER" ${{ inputs.dry-run && '--dry-run' || '' }} diff --git a/script/github-welcome-first-contribution.py b/script/github-welcome-first-contribution.py new file mode 100644 index 00000000000..3856979a322 --- /dev/null +++ b/script/github-welcome-first-contribution.py @@ -0,0 +1,119 @@ +#!/usr/bin/env python3 +""" +Post a welcome comment on a pull request labeled `first contribution`, unless +the community bot has already welcomed its author on any of their pull requests. + +Requires: + requests (pip install requests) + +Usage: + python github-welcome-first-contribution.py [--dry-run] + +Environment variables: + GITHUB_TOKEN - GitHub token (issues: write, pull requests: read) +""" + +import argparse + +from github_helpers import ( + github_rest_api, + github_rest_get_paginated, + post_github_comment, +) + +REPO_OWNER = "zed-industries" +REPO_NAME = "zed" +FIRST_CONTRIBUTION_LABEL = "first contribution" +COMMUNITY_BOT_LOGIN = "zed-community-bot[bot]" +WELCOME_MARKER = "" +SEARCH_PAGE_SIZE = 100 +# The search API returns at most 1000 results which should never be relevant here but hey. +MAX_SEARCH_PAGES = 10 + +WELCOME_COMMENT = f"""{WELCOME_MARKER} + +Thanks for your first contribution to Zed! For the best chances of merge, please read our [contributing guide](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md) if you haven't already. The key points for a first PR: + +- **Show how you tested it.** Add evidence that fits the change: screenshots/video for UI changes, benchmarks for performance work, etc. Using LLMs or other help is fine, but you must understand and test the changes yourself. **Write review replies in your own words;** a human is reading them. +- **One PR at a time.** Things go smoother once your first PR lands; several open at once tend to go stale and collect the same feedback. +- **Discuss features first.** If this adds a feature that staff haven't agreed to in an issue, please start a [GitHub discussion](https://github.com/zed-industries/zed/discussions) instead of a PR. +- **Don't ping staff by username** unless they've told you that's okay.""" + + +def search_pull_requests_by_author(author): + pull_requests = [] + for page in range(1, MAX_SEARCH_PAGES + 1): + result = github_rest_api( + "GET", + "search/issues", + params={ + "q": f"repo:{REPO_OWNER}/{REPO_NAME} is:pr author:{author}", + "sort": "created", + "order": "asc", + "per_page": SEARCH_PAGE_SIZE, + "page": page, + }, + ) + pull_requests.extend(result["items"]) + if len(result["items"]) < SEARCH_PAGE_SIZE: + break + return pull_requests + + +def find_welcomed_pull_request(pull_requests): + for pull_request in pull_requests: + comments = github_rest_get_paginated( + f"repos/{REPO_OWNER}/{REPO_NAME}/issues/{pull_request['number']}/comments" + ) + if any( + (comment["user"] or {}).get("login") == COMMUNITY_BOT_LOGIN + and WELCOME_MARKER in (comment["body"] or "") + for comment in comments + ): + return pull_request + return None + + +def welcome_first_time_contributor(pr_number, dry_run): + pull_request = github_rest_api( + "GET", f"repos/{REPO_OWNER}/{REPO_NAME}/pulls/{pr_number}" + ) + author = pull_request["user"]["login"] + + if not any( + label["name"] == FIRST_CONTRIBUTION_LABEL for label in pull_request["labels"] + ): + print(f"PR #{pr_number} has no '{FIRST_CONTRIBUTION_LABEL}' label") + return + + # Search results can lag behind a just-opened PR, so check it explicitly. + welcomed_pull_request = find_welcomed_pull_request([pull_request]) + if welcomed_pull_request is None: + other_pull_requests = [ + other_pull_request + for other_pull_request in search_pull_requests_by_author(author) + if other_pull_request["number"] != pr_number + ] + welcomed_pull_request = find_welcomed_pull_request(other_pull_requests) + if welcomed_pull_request is not None: + print( + f"{author} was already welcomed on PR #{welcomed_pull_request['number']}" + ) + return + + if dry_run: + print(f"Would post on PR #{pr_number}:\n\n{WELCOME_COMMENT}") + else: + post_github_comment(REPO_OWNER, REPO_NAME, pr_number, WELCOME_COMMENT) + + +if __name__ == "__main__": + argument_parser = argparse.ArgumentParser() + argument_parser.add_argument("pr_number", type=int) + argument_parser.add_argument( + "--dry-run", + action="store_true", + help="Print the comment instead of posting it", + ) + arguments = argument_parser.parse_args() + welcome_first_time_contributor(arguments.pr_number, arguments.dry_run)