diff --git a/.github/workflows/guild_assignment_status.yml b/.github/workflows/guild_assignment_status.yml new file mode 100644 index 00000000000..77f03168c4e --- /dev/null +++ b/.github/workflows/guild_assignment_status.yml @@ -0,0 +1,59 @@ +# Guild board (https://github.com/orgs/zed-industries/projects/74) reactions to issue events: +# assigned guild member -> Status "In Progress" (or Slack if off-board) +# unassigned guild member -> move back to a To-Do column by Type + Slack +# commented guild assignee comments after a check-in -> Slack (each comment) + +name: Guild Assignment Status + +on: + issues: + types: [assigned, unassigned] + issue_comment: + types: [created] + +permissions: + contents: read + +concurrency: + group: guild-assignment-status-${{ github.event.issue.number || github.run_id }} + cancel-in-progress: false + +jobs: + handle-event: + if: >- + github.repository == 'zed-industries/zed' && + (github.event_name != 'issue_comment' || github.event.issue.pull_request == null) + 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 + + - name: Checkout repository + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + with: + sparse-checkout: | + script/github-guild-board.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: Handle issue event + 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/.github/workflows/guild_new_pr_notify.yml b/.github/workflows/guild_new_pr_notify.yml new file mode 100644 index 00000000000..babab1c8b9c --- /dev/null +++ b/.github/workflows/guild_new_pr_notify.yml @@ -0,0 +1,115 @@ +# 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". + +name: Guild New PR Notification + +on: + pull_request_target: + types: [opened] + +permissions: + contents: read + +concurrency: + group: guild-new-pr-notify-${{ github.event.pull_request.number }} + cancel-in-progress: false + +env: + CUTOFF_DATE: "2026-07-01" + +jobs: + notify-slack: + if: github.repository == 'zed-industries/zed' + 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 + + - 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 }} + with: + github-token: ${{ steps.app-token.outputs.token }} + script: | + const GUILD_TEAM_SLUG = 'guild-cohort-2'; + const pr = context.payload.pull_request; + const author = pr.user.login; + + const isGuildMember = async (username) => { + try { + const response = await github.rest.teams.getMembershipForUserInOrg({ + org: 'zed-industries', + team_slug: GUILD_TEAM_SLUG, + username + }); + return response.data.state === 'active'; + } catch (error) { + if (error.status === 404) { + return false; + } + throw error; + } + }; + + if (!(await isGuildMember(author))) { + console.log(`${author} is not a guild member, skipping`); + return; + } + + 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)`); diff --git a/.github/workflows/guild_stale_assignments.yml b/.github/workflows/guild_stale_assignments.yml new file mode 100644 index 00000000000..90b47988c3f --- /dev/null +++ b/.github/workflows/guild_stale_assignments.yml @@ -0,0 +1,57 @@ +# Scheduled sweep of the Guild board (https://github.com/orgs/zed-industries/projects/74). +# For an "In Progress" issue assigned to a guild member with no linked PR: post a +# check-in comment once the assignee goes quiet, re-nudging after any renewed +# silence, and clear the assignment (moving the issue back to a To-Do column by +# Type) if a check-in goes unanswered. The "guild hold" label pauses the sweep. + +name: Guild Stale Assignments + +on: + schedule: + - cron: "0 8 * * *" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: guild-stale-assignments + cancel-in-progress: true + +jobs: + sweep: + if: github.repository == 'zed-industries/zed' + runs-on: namespace-profile-2x4-ubuntu-2404 + timeout-minutes: 15 + + 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 + + - name: Checkout repository + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + with: + sparse-checkout: | + script/github-guild-board.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: Sweep stale assignments + env: + GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} + PROJECT_NUMBER: "74" + GUILD_MODE: stale + SLACK_WEBHOOK_GUILD_INTERNAL: ${{ secrets.SLACK_WEBHOOK_GUILD_INTERNAL }} + run: python script/github-guild-board.py diff --git a/.github/workflows/guild_weekly_shipped.yml b/.github/workflows/guild_weekly_shipped.yml new file mode 100644 index 00000000000..9f2a7fb4c8c --- /dev/null +++ b/.github/workflows/guild_weekly_shipped.yml @@ -0,0 +1,55 @@ +# Scheduled Slack digest of Guild board +# (https://github.com/orgs/zed-industries/projects/74) issues recently closed by +# a merged PR authored by a guild member. + +name: Guild Weekly Shipped + +on: + schedule: + - cron: "0 7 * * 3" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: guild-weekly-shipped + cancel-in-progress: true + +jobs: + digest: + if: github.repository == 'zed-industries/zed' + runs-on: namespace-profile-2x4-ubuntu-2404 + timeout-minutes: 15 + + 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 + + - name: Checkout repository + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + with: + sparse-checkout: | + script/github-guild-board.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: Build and send digest + env: + GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} + PROJECT_NUMBER: "74" + GUILD_MODE: weekly + SLACK_WEBHOOK_GUILD_INTERNAL: ${{ secrets.SLACK_WEBHOOK_GUILD_INTERNAL }} + run: python script/github-guild-board.py diff --git a/.github/workflows/pr_issue_labeler.yml b/.github/workflows/pr_issue_labeler.yml index fbba166a244..f295dcb4f9a 100644 --- a/.github/workflows/pr_issue_labeler.yml +++ b/.github/workflows/pr_issue_labeler.yml @@ -41,47 +41,7 @@ jobs: const STAFF_TEAM_SLUG = 'staff'; const FIRST_CONTRIBUTION_LABEL = 'first contribution'; const GUILD_LABEL = 'guild'; - const GUILD_MEMBERS = [ - '11happy', - 'AidanV', - 'alanpjohn', - 'AmaanBilwar', - 'arjunkomath', - 'austincummings', - 'ayushk-1801', - 'criticic', - 'dongdong867', - 'emamulandalib', - 'eureka928', - 'feitreim', - 'iam-liam', - 'iksuddle', - 'ishaksebsib', - 'lingyaochu', - 'loadingalias', - 'marcocondrache', - 'mchisolm0', - 'MostlyKIGuess', - 'nairadithya', - 'nihalxkumar', - 'notJoon', - 'OmChillure', - 'Palanikannan1437', - 'polyesterswing', - 'prayanshchh', - 'razeghi71', - 'sarmadgulzar', - 'seanstrom', - 'Shivansh-25', - 'SkandaBhat', - 'th0jensen', - 'tommyming', - 'transitoryangel', - 'TwistingTwists', - 'virajbhartiya', - 'YEDASAVG', - 'Ziqi-Yang', - ]; + const GUILD_TEAM_SLUG = 'guild-cohort-2'; const COMMUNITY_CHAMPION_LABEL = 'community champion'; const COMMUNITY_CHAMPIONS = [ '0x2CA', @@ -161,11 +121,11 @@ jobs: return members.some((member) => member.toLowerCase() === authorLower); }; - const isStaffMember = async (author) => { + const isTeamMember = async (teamSlug, author) => { try { const response = await github.rest.teams.getMembershipForUserInOrg({ org: 'zed-industries', - team_slug: STAFF_TEAM_SLUG, + team_slug: teamSlug, username: author }); return response.data.state === 'active'; @@ -177,6 +137,9 @@ jobs: } }; + const isStaffMember = (author) => isTeamMember(STAFF_TEAM_SLUG, author); + const isGuildMember = (author) => isTeamMember(GUILD_TEAM_SLUG, author); + const getIssueLabels = () => { if (listIncludesAuthor(COMMUNITY_CHAMPIONS, author)) { return [COMMUNITY_CHAMPION_LABEL]; @@ -202,7 +165,7 @@ jobs: labelsToAdd.push(COMMUNITY_CHAMPION_LABEL); } - if (listIncludesAuthor(GUILD_MEMBERS, author)) { + if (await isGuildMember(author)) { labelsToAdd.push(GUILD_LABEL); } diff --git a/.github/workflows/slack_notify_community_automation_failure.yml b/.github/workflows/slack_notify_community_automation_failure.yml index 688cfdded83..ae58a67868d 100644 --- a/.github/workflows/slack_notify_community_automation_failure.yml +++ b/.github/workflows/slack_notify_community_automation_failure.yml @@ -12,6 +12,10 @@ on: - "Community PR Board" - "PR Board Meta Fields Refresh" - "PR Issue Labeler" + - "Guild Assignment Status" + - "Guild Stale Assignments" + - "Guild Weekly Shipped" + - "Guild New PR Notification" types: [completed] jobs: diff --git a/script/github-community-pr-board.py b/script/github-community-pr-board.py index a182de8ac65..92724e5722f 100644 --- a/script/github-community-pr-board.py +++ b/script/github-community-pr-board.py @@ -155,11 +155,17 @@ def compute_size_bucket(total_changes): def compute_contributor(pr_labels): """Return the Contributor value derived from the PR's labels. - The auto-labeler is responsible for applying `community champion` and - `first contribution` based on the author's history; anything else on - the board is treated as a returning contributor (PRs from staff/bots + The auto-labeler is responsible for applying `guild`, `community champion` + and `first contribution` based on the author's membership/history; anything + else on the board is treated as a returning contributor (PRs from staff/bots don't reach this function because they're filtered upstream). + + Guild takes precedence over Champion because current-cohort guild members + are frequently also community champions, and their guild status is the more + relevant signal while the cohort is active. """ + if "guild" in pr_labels: + return "Guild" if "community champion" in pr_labels: return "Champion" if "first contribution" in pr_labels: diff --git a/script/github-guild-board.py b/script/github-guild-board.py new file mode 100644 index 00000000000..1fe4c34baaf --- /dev/null +++ b/script/github-guild-board.py @@ -0,0 +1,634 @@ +#!/usr/bin/env python3 +""" +Automation for the Guild project board (#74). + +GUILD_MODE selects behavior: + +- event: react to a single issue 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 + 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. +- weekly: Slack digest of board issues recently closed by a merged PR authored + by a guild member. + +Requires: requests +""" + +import html +import json +import os +import random +import time +from datetime import datetime, timedelta, timezone +from functools import lru_cache + +import requests + +RETRYABLE_STATUS_CODES = {502, 503, 504} +MAX_RETRIES = 3 +RETRY_DELAY_SECONDS = 5 + +GITHUB_API_URL = "https://api.github.com" +REPO_OWNER = "zed-industries" +REPO_NAME = "zed" +GUILD_TEAM_SLUG = "guild-cohort-2" + +STATUS_FIELD = "Status" +STATUS_IN_PROGRESS = "In Progress" +STATUS_BUG_BASHERS = "Bug Bashers" +STATUS_SHIP_FEATURE = "Ship a New Feature" + +# Which to-do column to move issues of a given type to. +STATUS_FOR_TYPE = { + "Feature": STATUS_SHIP_FEATURE, + "Bug": STATUS_BUG_BASHERS, + "Docs": STATUS_BUG_BASHERS, + "Crash": STATUS_BUG_BASHERS, +} + +# How many days to wait after assignment before posting a check-in comment. +CHECK_IN_AFTER_DAYS = 14 +# Clear the assignment after this many days of no reply to check-in. +AUTO_CLEAR_AFTER_DAYS = 7 +# For the slack summaries of what's been recently shipped by the Guild. +SHIPPED_WINDOW_DAYS = 7 + +# Applying this label to an issue pauses the stale sweep for it (no nudges, no +# auto-clear of the assignee) until a human removes the label. +NUDGE_HOLD_LABEL = "guild hold" + +# Hidden in the rendered issue; lets later runs find the bot's own check-in. +CHECK_IN_MARKER = "" + +CHECK_IN_BODY = ( + "{marker}\n" + "Hey @{assignee}, checking in to see if you're still actively working on " + "this issue. No worries if you no longer have the time. If that's the case, " + "do you mind unassigning yourself from the issue so another contributor can " + "work on it? If you are still working on it, drop a comment and let us know " + "how we can help! Otherwise, the bot will clear the assignment in " + "{clear_days} days from now." +) + +ZEDGAR_QUIPS = [ + "Deep into the backlog peering...", + "Once upon a board so dreary...", + "A tell-tale ping beneath the board.", +] + + +def github_graphql(query, variables): + for attempt in range(MAX_RETRIES + 1): + response = requests.post( + f"{GITHUB_API_URL}/graphql", + headers=GITHUB_HEADERS, + json={"query": query, "variables": variables}, + timeout=30, + ) + if response.status_code in RETRYABLE_STATUS_CODES and attempt < MAX_RETRIES: + time.sleep(RETRY_DELAY_SECONDS) + continue + response.raise_for_status() + result = response.json() + if "errors" in result: + raise RuntimeError(f"GraphQL error: {result['errors']}") + return result["data"] + raise RuntimeError("github_graphql: retry loop exited without return") + + +def github_rest_request(method, path, body=None): + url = f"{GITHUB_API_URL}/{path}" + for attempt in range(MAX_RETRIES + 1): + response = requests.request( + method, url, headers=GITHUB_HEADERS, json=body, timeout=30 + ) + if response.status_code in RETRYABLE_STATUS_CODES and attempt < MAX_RETRIES: + time.sleep(RETRY_DELAY_SECONDS) + continue + response.raise_for_status() + if response.status_code == 204 or not response.content: + return None + return response.json() + raise RuntimeError("github_rest_request: retry loop exited without return") + + +def github_rest_get_paginated(path): + results = [] + page = 1 + while True: + separator = "&" if "?" in path else "?" + batch = github_rest_request("GET", f"{path}{separator}per_page=100&page={page}") + if not batch: + break + results.extend(batch) + if len(batch) < 100: + break + page += 1 + return results + + +@lru_cache(maxsize=None) +def is_guild_member(username): + response = requests.get( + f"{GITHUB_API_URL}/orgs/{REPO_OWNER}/teams/{GUILD_TEAM_SLUG}/memberships/{username}", + headers=GITHUB_HEADERS, + timeout=30, + ) + if response.status_code == 404: + return False + response.raise_for_status() + # A pending invitation reports state "pending"; only active members count. + return response.json().get("state") == "active" + + +def issue_comments(issue_number): + return github_rest_get_paginated( + f"repos/{REPO_OWNER}/{REPO_NAME}/issues/{issue_number}/comments" + ) + + +def latest_assignment_time(issue_number, assignee): + events = github_rest_get_paginated( + f"repos/{REPO_OWNER}/{REPO_NAME}/issues/{issue_number}/timeline" + ) + times = [ + parse_dt(event["created_at"]) + for event in events + if event.get("event") == "assigned" + and (event.get("assignee") or {}).get("login") == assignee + ] + if not times: + raise RuntimeError( + f"No assignment event for {assignee} on issue #{issue_number}" + ) + return max(times) + + +def issue_closing_prs(issue_node_id, include_closed_prs=False): + # Caps at the first 20 closing PRs: callers only test presence or scan for a + # single guild-authored merge, so an issue exceeding this bound is not worth + # paginating. + data = github_graphql( + """ + query($issueId: ID!, $includeClosedPrs: Boolean!) { + node(id: $issueId) { + ... on Issue { + closedByPullRequestsReferences(first: 20, includeClosedPrs: $includeClosedPrs) { + nodes { merged author { login } } + } + } + } + } + """, + {"issueId": issue_node_id, "includeClosedPrs": include_closed_prs}, + ) + node = data.get("node") or {} + refs = node.get("closedByPullRequestsReferences") or {} + return [ + {"merged": pr["merged"], "author": (pr.get("author") or {}).get("login")} + for pr in refs.get("nodes", []) + ] + + +def fetch_project(project_number): + data = github_graphql( + """ + query($owner: String!, $number: Int!) { + organization(login: $owner) { + projectV2(number: $number) { + id + fields(first: 50) { + nodes { + ... on ProjectV2Field { id name dataType } + ... on ProjectV2SingleSelectField { id name options { id name } } + } + } + } + } + } + """, + {"owner": REPO_OWNER, "number": project_number}, + ) + project = data["organization"]["projectV2"] + if not project: + raise RuntimeError(f"Project #{project_number} not found in {REPO_OWNER}") + return project + + +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. + data = github_graphql( + """ + query($contentId: ID!) { + node(id: $contentId) { + ... on Issue { + projectItems(first: 50) { + nodes { + id + project { id } + fieldValues(first: 20) { + nodes { + ... on ProjectV2ItemFieldSingleSelectValue { + field { ... on ProjectV2SingleSelectField { name } } + name + } + } + } + } + } + } + } + } + """, + {"contentId": content_node_id}, + ) + node = data.get("node") or {} + for item in (node.get("projectItems") or {}).get("nodes", []): + if item["project"]["id"] == project_id: + return item + return None + + +def set_project_field(project, item_id, field_name, option_name): + field = next( + (f for f in project["fields"]["nodes"] if f.get("name") == field_name), None + ) + if not field: + raise RuntimeError(f"Field '{field_name}' not found on board") + option_id = next( + (o["id"] for o in field.get("options", []) if o["name"] == option_name), None + ) + if not option_id: + raise RuntimeError(f"Option '{option_name}' not found in field '{field_name}'") + github_graphql( + """ + mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) { + updateProjectV2ItemFieldValue(input: { + projectId: $projectId, itemId: $itemId, fieldId: $fieldId, + value: { singleSelectOptionId: $optionId } + }) { projectV2Item { id } } + } + """, + { + "projectId": project["id"], + "itemId": item_id, + "fieldId": field["id"], + "optionId": option_id, + }, + ) + + +def list_project_issues(project_id): + cursor = None + while True: + data = github_graphql( + """ + query($projectId: ID!, $cursor: String) { + node(id: $projectId) { + ... on ProjectV2 { + items(first: 100, after: $cursor) { + pageInfo { hasNextPage endCursor } + nodes { + id + isArchived + fieldValues(first: 20) { + nodes { + ... on ProjectV2ItemFieldSingleSelectValue { + field { ... on ProjectV2SingleSelectField { name } } + name + } + } + } + content { + __typename + ... on Issue { + id number title url state closedAt + issueType { name } + assignees(first: 10) { nodes { login } } + labels(first: 20) { nodes { name } } + } + } + } + } + } + } + } + """, + {"projectId": project_id, "cursor": cursor}, + ) + page = data["node"]["items"] + yield from page["nodes"] + if not page["pageInfo"]["hasNextPage"]: + return + cursor = page["pageInfo"]["endCursor"] + + +def post_comment(issue_number, body): + github_rest_request( + "POST", f"repos/{REPO_OWNER}/{REPO_NAME}/issues/{issue_number}/comments", {"body": body} + ) + + +def remove_assignees(issue_number, assignees): + github_rest_request( + "DELETE", + f"repos/{REPO_OWNER}/{REPO_NAME}/issues/{issue_number}/assignees", + {"assignees": assignees}, + ) + + +def escape_slack(text): + # Escape Slack's control characters (&, <, >) so free text like issue/PR + # titles and comment bodies renders literally instead of being interpreted + # as a link or @-mention. send_slack can't do this wholesale because its own + # messages legitimately contain links and *bold* markup. + # quote=False keeps it to Slack's three characters (no " or ' escaping). + return html.escape(text or "", quote=False) + + +def slack_link(url, text): + # The single way to embed a titled link, so the title is always escaped + # without every caller having to remember to do it. + return f"<{url}|{escape_slack(text)}>" + + +def send_slack(text): + webhook = os.environ.get("SLACK_WEBHOOK_GUILD_INTERNAL") + if not webhook: + raise RuntimeError("SLACK_WEBHOOK_GUILD_INTERNAL is not set") + message = f"{random.choice(ZEDGAR_QUIPS)} {text}" + response = requests.post( + webhook, + json={ + "text": message, + "blocks": [{"type": "section", "text": {"type": "mrkdwn", "text": message}}], + }, + timeout=30, + ) + if response.status_code != 200: + raise RuntimeError( + f"Slack webhook returned {response.status_code}: {response.text[:200]}" + ) + + +def parse_dt(value): + return datetime.fromisoformat(value.replace("Z", "+00:00")) + + +def item_status(item): + for field_value in (item.get("fieldValues") or {}).get("nodes", []): + if field_value.get("field", {}).get("name") == STATUS_FIELD: + return field_value.get("name") + return None + + +def latest_check_in_time(comments): + times = [ + parse_dt(comment["created_at"]) + for comment in comments + if CHECK_IN_MARKER in (comment.get("body") or "") + ] + return max(times, default=None) + + +def handle_assignment(issue, assignee_login, project_number): + if not is_guild_member(assignee_login): + return + + project = fetch_project(project_number) + item = find_project_item(project["id"], issue["node_id"]) + if not item: + send_slack( + f"Heads-up: @{assignee_login} was assigned to issue #{issue['number']} " + f"{slack_link(issue['html_url'], issue['title'])}, which isn't on the Guild board. " + "Possible mistake — if it's meant to be Guild work, add it to Bug Bashers " + "or Ship a New Feature; otherwise no action needed." + ) + return + + set_project_field(project, item["id"], STATUS_FIELD, STATUS_IN_PROGRESS) + + +def handle_unassignment(issue, removed_login, sender, project_number): + # Our own auto-clear also unassigns; don't double-handle that. + if (sender or {}).get("type") == "Bot": + return + if not is_guild_member(removed_login): + return + + project = fetch_project(project_number) + item = find_project_item(project["id"], issue["node_id"]) + if not item or item_status(item) != STATUS_IN_PROGRESS: + return + + sender_login = (sender or {}).get("login") + who = ( + "unassigned themselves from" + if sender_login == removed_login + else f"was unassigned (by @{sender_login}) from" + ) + status = STATUS_FOR_TYPE.get((issue.get("type") or {}).get("name") or "") + link = slack_link(issue["html_url"], issue["title"]) + if status: + set_project_field(project, item["id"], STATUS_FIELD, status) + send_slack( + f"@{removed_login} {who} In Progress issue #{issue['number']} " + f"{link}. I moved it back to *{status}*, " + "so it's up for grabs again — no action needed." + ) + else: + send_slack( + f"@{removed_login} {who} In Progress issue #{issue['number']} " + f"{link}. I couldn't tell its Type, so it's " + "still In Progress — 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." + ) + + +def handle_comment(issue, comment): + commenter = (comment.get("user") or {}).get("login") + if not commenter or CHECK_IN_MARKER in (comment.get("body") or ""): + return + + assignees = [a["login"] for a in (issue.get("assignees") or [])] + if commenter not in assignees or not is_guild_member(commenter): + return + + check_in_at = latest_check_in_time(issue_comments(issue["number"])) + if check_in_at is None or parse_dt(comment["created_at"]) <= check_in_at: + return + + reply = " ".join((comment.get("body") or "").split()) + if len(reply) > 500: + reply = reply[:500] + "…" + send_slack( + f"@{commenter} replied to the check-in on In Progress issue #{issue['number']} " + f"{slack_link(issue['html_url'], issue['title'])}:\n" + f"> {escape_slack(reply)}\n" + f"Follow up if they need help, or add the `{NUDGE_HOLD_LABEL}` label to the " + "issue to pause check-ins on it." + ) + + +def run_event(project_number): + event_name = os.environ.get("GITHUB_EVENT_NAME", "") + with open(os.environ["GITHUB_EVENT_PATH"]) as f: + event = json.load(f) + + if event_name == "issues": + action = event["action"] + issue = event["issue"] + if action == "assigned": + handle_assignment(issue, event["assignee"]["login"], project_number) + elif action == "unassigned": + handle_unassignment( + issue, event["assignee"]["login"], event.get("sender"), project_number + ) + elif event_name == "issue_comment" and "pull_request" not in event["issue"]: + handle_comment(event["issue"], event["comment"]) + + +def run_stale(project_number): + project = fetch_project(project_number) + checked_in = cleared = 0 + + for item in list_project_issues(project["id"]): + if item.get("isArchived"): + continue + content = item.get("content") or {} + if content.get("__typename") != "Issue": + continue + if item_status(item) != STATUS_IN_PROGRESS: + continue + + assignees = [a["login"] for a in content["assignees"]["nodes"]] + guild_assignees = [a for a in assignees if is_guild_member(a)] + if not guild_assignees: + continue + + labels = [label["name"] for label in (content.get("labels") or {}).get("nodes", [])] + if NUDGE_HOLD_LABEL in labels: + continue + + issue_number = content["number"] + if issue_closing_prs(content["id"]): + continue + + comments = issue_comments(issue_number) + assignee = guild_assignees[0] + + # One pass over the comments for both the latest check-in and the + # assignee's latest comment. Measuring from the assignee's last activity + # (their assignment or a later comment) means a reply resets the clock, so + # a nudge only recurs after renewed silence (and a check-in from a prior + # assignment stint predates this, so it is naturally ignored). + last_activity = latest_assignment_time(issue_number, assignee) + check_in_at = None + for comment in comments: + created_at = parse_dt(comment["created_at"]) + if CHECK_IN_MARKER in (comment.get("body") or ""): + if check_in_at is None or created_at > check_in_at: + check_in_at = created_at + elif (comment.get("user") or {}).get("login") == assignee: + if created_at > last_activity: + last_activity = created_at + + if check_in_at is None or check_in_at < last_activity: + # No outstanding nudge since the assignee last engaged; nudge once + # they have stayed quiet for the check-in window. + if (NOW - last_activity).days < CHECK_IN_AFTER_DAYS: + continue + post_comment( + issue_number, + CHECK_IN_BODY.format( + marker=CHECK_IN_MARKER, + assignee=assignee, + clear_days=AUTO_CLEAR_AFTER_DAYS, + ), + ) + checked_in += 1 + continue + + # We nudged and the assignee stayed quiet; clear once the grace period + # lapses, otherwise leave them the rest of it. + if (NOW - check_in_at).days < AUTO_CLEAR_AFTER_DAYS: + continue + + remove_assignees(issue_number, guild_assignees) + status = STATUS_FOR_TYPE.get((content.get("issueType") or {}).get("name") or "") + link = slack_link(content["url"], content["title"]) + if status: + 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 " + 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 " + "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." + ) + cleared += 1 + + print(f"Stale sweep: {checked_in} checked in, {cleared} cleared") + + +def run_weekly(project_number): + project = fetch_project(project_number) + cutoff = NOW - timedelta(days=SHIPPED_WINDOW_DAYS) + shipped = [] + + for item in list_project_issues(project["id"]): + content = item.get("content") or {} + if content.get("__typename") != "Issue" or content.get("state") != "CLOSED": + continue + if parse_dt(content["closedAt"]) < cutoff: + continue + for pr in issue_closing_prs(content["id"], include_closed_prs=True): + if pr["merged"] and pr["author"] and is_guild_member(pr["author"]): + shipped.append((pr["author"], content)) + break + + if not shipped: + send_slack("A quiet week on the Guild board — nothing shipped. Onward.") + return + + lines = "\n".join( + f"• @{author} — {slack_link(content['url'], content['title'])} (#{content['number']})" + for author, content in shipped + ) + send_slack(f"Here's what the Guild shipped this week:\n{lines}") + + +if __name__ == "__main__": + GITHUB_HEADERS = { + "Authorization": f"Bearer {os.environ['GITHUB_TOKEN']}", + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + } + + NOW = datetime.now(timezone.utc) + + project_number = int(os.environ["PROJECT_NUMBER"]) + mode = os.environ["GUILD_MODE"] + + if mode == "event": + run_event(project_number) + elif mode == "stale": + run_stale(project_number) + elif mode == "weekly": + run_weekly(project_number) + else: + raise RuntimeError(f"Unknown GUILD_MODE: {mode}")