Guild board automation: fix In Progress status moves (#61115)
Release Notes: - N/A
This commit is contained in:
1 parent
18cdb6904d
commit
01e3b3305a
2 files changed
+115
-94
No files matched your search
@@ -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, '<').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
|
||||
@@ -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."
|
||||
|
||||
Reference in new issue
Block a user