diff --git a/.github/workflows/guild_new_pr_notify.yml b/.github/workflows/guild_new_pr_notify.yml index 8aad061e5f..afa9b07d87 100644 --- a/.github/workflows/guild_new_pr_notify.yml +++ b/.github/workflows/guild_new_pr_notify.yml @@ -1,6 +1,8 @@ -# When a guild member opens a PR while already having another open PR in -# zed-industries/zed, post a heads-up to #zed-guild-internal. PRs opened before -# CUTOFF_DATE (the cohort start) don't count as "already open". +# When a guild member opens a PR, react on the Guild board (#74): +# - if the PR will close an open board issue, set that issue to In Progress +# (covers starting work without self-assigning), and +# - if they already have another open PR opened since the cohort started, post +# a gentle heads-up to #zed-guild-internal. name: Guild New PR Notification @@ -15,11 +17,8 @@ concurrency: group: guild-new-pr-notify-${{ github.event.pull_request.number }} cancel-in-progress: false -env: - CUTOFF_DATE: "2026-07-01" - jobs: - notify-slack: + react: if: github.repository == 'zed-industries/zed' runs-on: namespace-profile-2x4-ubuntu-2404 timeout-minutes: 5 @@ -33,88 +32,25 @@ jobs: private-key: ${{ secrets.ZED_COMMUNITY_BOT_PRIVATE_KEY }} owner: zed-industries - - name: Notify on an additional open PR by a guild member - uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 - env: - SLACK_WEBHOOK_GUILD_INTERNAL: ${{ secrets.SLACK_WEBHOOK_GUILD_INTERNAL }} + - name: Checkout repository + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: - github-token: ${{ steps.app-token.outputs.token }} - script: | - // Guild cohort members are outside collaborators holding this custom - // repository role, not members of an org team. - const GUILD_ROLE_NAME = 'Guild Assign issues/PRs'; - const pr = context.payload.pull_request; - const author = pr.user.login; + sparse-checkout: | + script/github-guild-board.py + sparse-checkout-cone-mode: false - const isGuildMember = async (username) => { - try { - const response = await github.rest.repos.getCollaboratorPermissionLevel({ - owner: 'zed-industries', - repo: 'zed', - username - }); - // role_name is the effective (highest) role; for cohort outside - // collaborators that is the custom role. Built-in roles come back - // lowercased and won't match. - return (response.data.role_name || '').toLowerCase() === GUILD_ROLE_NAME.toLowerCase(); - } catch (error) { - if (error.status === 404) { - return false; - } - throw error; - } - }; + - name: Set up Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: "3.12" - if (!(await isGuildMember(author))) { - console.log(`${author} is not a guild member, skipping`); - return; - } + - name: Install dependencies + run: pip install requests - const cutoff = process.env.CUTOFF_DATE; - const query = `repo:zed-industries/zed is:pr is:open author:${author} created:>=${cutoff}`; - const result = await github.rest.search.issuesAndPullRequests({ q: query, per_page: 100 }); - const others = result.data.items.filter((item) => item.number !== pr.number); - - if (others.length === 0) { - console.log(`${author} has no other qualifying open PRs, skipping`); - return; - } - - const webhook = process.env.SLACK_WEBHOOK_GUILD_INTERNAL; - if (!webhook) { - core.setFailed('SLACK_WEBHOOK_GUILD_INTERNAL secret is not set'); - return; - } - - // Escape Slack's control characters so PR titles render literally - // instead of being read as links or @-mentions. - const esc = (text) => - (text || '').replace(/&/g, '&').replace(//g, '>'); - - const quips = [ - 'Deep into the queue they wander.', - 'Nevermore just one open PR.', - ]; - const quip = quips[Math.floor(Math.random() * quips.length)]; - const otherList = others - .map((item) => `<${item.html_url}|#${item.number}> ${esc(item.title)}`) - .join('\n• '); - const message = - `${quip} @${author} just opened <${pr.html_url}|${esc(pr.title)}> ` + - `while still having open PR(s):\n• ${otherList}\n` + - 'A gentle nudge to help them land one before spreading thin might be in order.'; - - const response = await fetch(webhook, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - text: `${author} opened another PR while others are still open`, - blocks: [{ type: 'section', text: { type: 'mrkdwn', text: message } }], - }), - }); - if (!response.ok) { - const body = await response.text(); - core.setFailed(`Slack notification failed with status ${response.status}: ${body}`); - return; - } - console.log(`Notified: ${author} has ${others.length} other open PR(s)`); + - name: React to the new PR + env: + GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} + PROJECT_NUMBER: "74" + GUILD_MODE: event + SLACK_WEBHOOK_GUILD_INTERNAL: ${{ secrets.SLACK_WEBHOOK_GUILD_INTERNAL }} + run: python script/github-guild-board.py diff --git a/script/github-guild-board.py b/script/github-guild-board.py index 7bdd8ab3a2..2635ac9b09 100644 --- a/script/github-guild-board.py +++ b/script/github-guild-board.py @@ -4,14 +4,18 @@ Automation for the Guild project board (#74). GUILD_MODE selects behavior: -- event: react to a single issue webhook. +- event: react to a single issue or pull request webhook. assigned guild member -> Status "In Progress", or a Slack heads-up if the issue isn't on the board. unassigned guild member off an "In Progress" issue -> move back to a To-Do column by Type (e.g. Bugs go back to Bug Bashers column) + Slack. created (issue_comment) guild assignee comments on an issue that has a pending check-in -> Slack (fires on each such comment). -- stale: nudge issues assigned to a guild member with no linked PR once the + pull request guild member opens a PR -> set any open board issue it will + close to "In Progress" (covers starting work without self- + assigning), plus a Slack heads-up if they already have another + open PR. +- stale: nudge issues assigned to a guild member with no open PR once the assignee goes quiet; a reply resets the clock, so nudges recur after renewed silence, and the assignment is cleared after a further grace period without a reply. The "guild hold" label pauses both nudging and clearing. @@ -26,6 +30,7 @@ import json import os import random import time +import urllib.parse from datetime import datetime, timedelta, timezone from functools import lru_cache @@ -43,6 +48,9 @@ REPO_NAME = "zed" # then just adding/removing collaborators, with no org seats involved. GUILD_ROLE_NAME = "Guild Assign issues/PRs" +# PRs opened before the cohort started don't count toward the open-PR heads-up. +COHORT_START_DATE = "2026-07-01" + STATUS_FIELD = "Status" STATUS_IN_PROGRESS = "In Progress" STATUS_BUG_BASHERS = "Bug Bashers" @@ -204,6 +212,25 @@ def issue_closing_prs(issue_node_id, include_closed_prs=False): ] +def pr_closing_issues(pr_node_id): + data = github_graphql( + """ + query($prId: ID!) { + node(id: $prId) { + ... on PullRequest { + closingIssuesReferences(first: 10) { + nodes { id state } + } + } + } + } + """, + {"prId": pr_node_id}, + ) + node = data.get("node") or {} + return (node.get("closingIssuesReferences") or {}).get("nodes", []) + + def fetch_project(project_number): data = github_graphql( """ @@ -230,8 +257,8 @@ def fetch_project(project_number): def find_project_item(project_id, content_node_id): - # Also fetches each item's single-select values, so callers that need the - # issue's Status (the unassignment handler) don't have to re-query the item. + # Includes each item's single-select values so callers can read Status + # without a second query. data = github_graphql( """ query($contentId: ID!) { @@ -407,6 +434,10 @@ def latest_check_in_time(comments): def handle_assignment(issue, assignee_login, project_number): + # A late assignment on an already-closed issue shouldn't drag it back into + # In Progress (or flag it as off-board); the work is done. + if issue.get("state") == "closed": + return if not is_guild_member(assignee_login): return @@ -460,6 +491,58 @@ def handle_unassignment(issue, removed_login, sender, project_number): ) +def handle_pull_request(pull_request, project_number): + author = (pull_request.get("user") or {}).get("login") + if not author or not is_guild_member(author): + return + set_linked_issues_in_progress(pull_request, project_number) + warn_about_extra_open_prs(pull_request, author) + + +def set_linked_issues_in_progress(pull_request, project_number): + # Only open issues: a stale closed or merged PR shouldn't drag its issue back + # to In Progress. + open_issues = [ + issue + for issue in pr_closing_issues(pull_request["node_id"]) + if issue.get("state") == "OPEN" + ] + if not open_issues: + return + project = fetch_project(project_number) + for issue in open_issues: + item = find_project_item(project["id"], issue["id"]) + if item: + set_project_field(project, item["id"], STATUS_FIELD, STATUS_IN_PROGRESS) + + +def warn_about_extra_open_prs(pull_request, author): + query = ( + f"repo:{REPO_OWNER}/{REPO_NAME} is:pr is:open " + f"author:{author} created:>={COHORT_START_DATE}" + ) + result = github_rest_request( + "GET", f"search/issues?q={urllib.parse.quote(query)}&per_page=100" + ) + others = [ + pr + for pr in (result or {}).get("items", []) + if pr["number"] != pull_request["number"] + ] + if not others: + return + lines = "\n".join( + f"• {slack_link(pr['html_url'], '#' + str(pr['number']))} {escape_slack(pr['title'])}" + for pr in others + ) + send_slack( + f"@{author} just opened {slack_link(pull_request['html_url'], pull_request['title'])} " + "while still having open PR(s):\n" + f"{lines}\n" + "A gentle nudge to help them land one before spreading thin might be in order." + ) + + def handle_comment(issue, comment): commenter = (comment.get("user") or {}).get("login") if not commenter or CHECK_IN_MARKER in (comment.get("body") or ""): @@ -501,6 +584,8 @@ def run_event(project_number): ) elif event_name == "issue_comment" and "pull_request" not in event["issue"]: handle_comment(event["issue"], event["comment"]) + elif event_name == "pull_request_target": + handle_pull_request(event["pull_request"], project_number) def run_stale(project_number): @@ -576,13 +661,13 @@ def run_stale(project_number): set_project_field(project, item["id"], STATUS_FIELD, status) send_slack( f"Issue #{issue_number} {link} went quiet with @{assignee} " - "(no linked PR, no reply to my check-in), so I cleared the assignment and " + "(no open PR, no reply to my check-in), so I cleared the assignment and " f"moved it back to *{status}*. It's up for grabs again — no action needed." ) else: send_slack( f"Issue #{issue_number} {link} went quiet with @{assignee} " - "(no linked PR, no reply to my check-in), so I cleared the assignment. I " + "(no open PR, no reply to my check-in), so I cleared the assignment. I " "couldn't tell its Type — please move it to Bug Bashers or Ship a New " "Feature so it can be picked up again, then set the :done-checkmark: emoji " "on this message when done."