mirror of
https://github.com/zed-industries/zed.git
synced 2026-07-09 16:00:35 +00:00
Add Guild board automation (#60266)
Automates the Guild contributor program on project board #74 so the cohort is recognized and kept unblocked without manual babysitting. - Move a board issue to In Progress when a Guild member self-assigns it, and post a friendly heads-up when they are assigned an issue that is not on the board. - Flag when a Guild member opens a new PR while another of theirs is still open, to help them land work before spreading thin. - Check in on quiet assignments and, if an assignee stays silent, free the issue back to a to-do column so others can pick it up; a "guild hold" label lets maintainers pause check-ins after they have followed up. - Share a weekly digest of what the Guild shipped. - Label PRs from Guild members and recognize a Guild contributor tier on the community PR board. Release Notes: - N/A
This commit is contained in:
parent
9cb3140ea1
commit
b3d5ead59f
8 changed files with 940 additions and 47 deletions
59
.github/workflows/guild_assignment_status.yml
vendored
Normal file
59
.github/workflows/guild_assignment_status.yml
vendored
Normal file
|
|
@ -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
|
||||||
115
.github/workflows/guild_new_pr_notify.yml
vendored
Normal file
115
.github/workflows/guild_new_pr_notify.yml
vendored
Normal file
|
|
@ -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, '<').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)`);
|
||||||
57
.github/workflows/guild_stale_assignments.yml
vendored
Normal file
57
.github/workflows/guild_stale_assignments.yml
vendored
Normal file
|
|
@ -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
|
||||||
55
.github/workflows/guild_weekly_shipped.yml
vendored
Normal file
55
.github/workflows/guild_weekly_shipped.yml
vendored
Normal file
|
|
@ -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
|
||||||
51
.github/workflows/pr_issue_labeler.yml
vendored
51
.github/workflows/pr_issue_labeler.yml
vendored
|
|
@ -41,47 +41,7 @@ jobs:
|
||||||
const STAFF_TEAM_SLUG = 'staff';
|
const STAFF_TEAM_SLUG = 'staff';
|
||||||
const FIRST_CONTRIBUTION_LABEL = 'first contribution';
|
const FIRST_CONTRIBUTION_LABEL = 'first contribution';
|
||||||
const GUILD_LABEL = 'guild';
|
const GUILD_LABEL = 'guild';
|
||||||
const GUILD_MEMBERS = [
|
const GUILD_TEAM_SLUG = 'guild-cohort-2';
|
||||||
'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 COMMUNITY_CHAMPION_LABEL = 'community champion';
|
const COMMUNITY_CHAMPION_LABEL = 'community champion';
|
||||||
const COMMUNITY_CHAMPIONS = [
|
const COMMUNITY_CHAMPIONS = [
|
||||||
'0x2CA',
|
'0x2CA',
|
||||||
|
|
@ -161,11 +121,11 @@ jobs:
|
||||||
return members.some((member) => member.toLowerCase() === authorLower);
|
return members.some((member) => member.toLowerCase() === authorLower);
|
||||||
};
|
};
|
||||||
|
|
||||||
const isStaffMember = async (author) => {
|
const isTeamMember = async (teamSlug, author) => {
|
||||||
try {
|
try {
|
||||||
const response = await github.rest.teams.getMembershipForUserInOrg({
|
const response = await github.rest.teams.getMembershipForUserInOrg({
|
||||||
org: 'zed-industries',
|
org: 'zed-industries',
|
||||||
team_slug: STAFF_TEAM_SLUG,
|
team_slug: teamSlug,
|
||||||
username: author
|
username: author
|
||||||
});
|
});
|
||||||
return response.data.state === 'active';
|
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 = () => {
|
const getIssueLabels = () => {
|
||||||
if (listIncludesAuthor(COMMUNITY_CHAMPIONS, author)) {
|
if (listIncludesAuthor(COMMUNITY_CHAMPIONS, author)) {
|
||||||
return [COMMUNITY_CHAMPION_LABEL];
|
return [COMMUNITY_CHAMPION_LABEL];
|
||||||
|
|
@ -202,7 +165,7 @@ jobs:
|
||||||
labelsToAdd.push(COMMUNITY_CHAMPION_LABEL);
|
labelsToAdd.push(COMMUNITY_CHAMPION_LABEL);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (listIncludesAuthor(GUILD_MEMBERS, author)) {
|
if (await isGuildMember(author)) {
|
||||||
labelsToAdd.push(GUILD_LABEL);
|
labelsToAdd.push(GUILD_LABEL);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,10 @@ on:
|
||||||
- "Community PR Board"
|
- "Community PR Board"
|
||||||
- "PR Board Meta Fields Refresh"
|
- "PR Board Meta Fields Refresh"
|
||||||
- "PR Issue Labeler"
|
- "PR Issue Labeler"
|
||||||
|
- "Guild Assignment Status"
|
||||||
|
- "Guild Stale Assignments"
|
||||||
|
- "Guild Weekly Shipped"
|
||||||
|
- "Guild New PR Notification"
|
||||||
types: [completed]
|
types: [completed]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
|
|
||||||
|
|
@ -155,11 +155,17 @@ def compute_size_bucket(total_changes):
|
||||||
def compute_contributor(pr_labels):
|
def compute_contributor(pr_labels):
|
||||||
"""Return the Contributor value derived from the PR's labels.
|
"""Return the Contributor value derived from the PR's labels.
|
||||||
|
|
||||||
The auto-labeler is responsible for applying `community champion` and
|
The auto-labeler is responsible for applying `guild`, `community champion`
|
||||||
`first contribution` based on the author's history; anything else on
|
and `first contribution` based on the author's membership/history; anything
|
||||||
the board is treated as a returning contributor (PRs from staff/bots
|
else on the board is treated as a returning contributor (PRs from staff/bots
|
||||||
don't reach this function because they're filtered upstream).
|
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:
|
if "community champion" in pr_labels:
|
||||||
return "Champion"
|
return "Champion"
|
||||||
if "first contribution" in pr_labels:
|
if "first contribution" in pr_labels:
|
||||||
|
|
|
||||||
634
script/github-guild-board.py
Normal file
634
script/github-guild-board.py
Normal file
|
|
@ -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 = "<!-- zedgar:guild-check-in -->"
|
||||||
|
|
||||||
|
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 <url|text> 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}")
|
||||||
Loading…
Add table
Add a link
Reference in a new issue