Skip to content

Commit 27cc8a1

Browse files
authored
Async standup v2 (#1387)
2 parents dd21835 + c10e987 commit 27cc8a1

File tree

6 files changed

+445
-191
lines changed

6 files changed

+445
-191
lines changed

.github/scripts/compile_standup.py

Lines changed: 119 additions & 113 deletions
Original file line numberDiff line numberDiff line change
@@ -1,153 +1,159 @@
11
#!/usr/bin/env python3
2-
"""Compile standup responses into a GitHub Discussion and close the issue."""
2+
"""Update the latest Weekly Check-in Discussion body with participation summary."""
33

44
import os
5-
from datetime import datetime, timezone, timedelta
5+
from pathlib import Path
66

77
import requests
8+
import yaml
89

910
REPO = os.environ["GITHUB_REPOSITORY"]
1011
TOKEN = os.environ["STANDUP_TOKEN"]
11-
CATEGORY_NODE_ID = os.environ["DISCUSSION_CATEGORY_NODE_ID"]
12-
API = "https://api.github.com"
1312
GRAPHQL = "https://api.github.com/graphql"
1413
HEADERS = {
1514
"Authorization": f"token {TOKEN}",
1615
"Accept": "application/vnd.github+json",
1716
}
1817

18+
_CONFIG_PATH = Path(__file__).resolve().parent.parent / "standup-contributors.yml"
19+
with open(_CONFIG_PATH) as _f:
20+
CONTRIBUTORS = [c["username"] for c in yaml.safe_load(_f)["contributors"]]
1921

20-
def find_standup_issue():
21-
"""Find the most recent open standup-input issue from the last 7 days."""
22-
since = (datetime.now(timezone.utc) - timedelta(days=7)).isoformat()
23-
resp = requests.get(
24-
f"{API}/repos/{REPO}/issues",
25-
headers=HEADERS,
26-
params={
27-
"labels": "standup-input",
28-
"state": "open",
29-
"since": since,
30-
"sort": "created",
31-
"direction": "desc",
32-
"per_page": 1,
33-
},
34-
)
35-
resp.raise_for_status()
36-
issues = resp.json()
37-
if not issues:
38-
print("No standup-input issue found in the last 7 days.")
39-
return None
40-
return issues[0]
41-
42-
43-
def fetch_comments(issue_number):
44-
"""Fetch all comments on an issue."""
45-
comments = []
46-
page = 1
47-
while True:
48-
resp = requests.get(
49-
f"{API}/repos/{REPO}/issues/{issue_number}/comments",
50-
headers=HEADERS,
51-
params={"per_page": 100, "page": page},
52-
)
53-
resp.raise_for_status()
54-
batch = resp.json()
55-
if not batch:
56-
break
57-
comments.extend(batch)
58-
page += 1
59-
return comments
60-
61-
62-
def get_repo_node_id():
63-
"""Get the repository node ID for the GraphQL mutation."""
64-
resp = requests.get(f"{API}/repos/{REPO}", headers=HEADERS)
65-
resp.raise_for_status()
66-
return resp.json()["node_id"]
67-
68-
69-
def create_discussion(title, body, repo_node_id):
70-
"""Create a GitHub Discussion via GraphQL."""
71-
mutation = """
72-
mutation($repoId: ID!, $categoryId: ID!, $title: String!, $body: String!) {
73-
createDiscussion(input: {
74-
repositoryId: $repoId,
75-
categoryId: $categoryId,
76-
title: $title,
77-
body: $body
78-
}) {
79-
discussion {
80-
url
81-
}
82-
}
83-
}
84-
"""
22+
23+
def graphql(query, variables=None):
24+
"""Run a GraphQL query and return the data."""
8525
resp = requests.post(
8626
GRAPHQL,
8727
headers=HEADERS,
88-
json={
89-
"query": mutation,
90-
"variables": {
91-
"repoId": repo_node_id,
92-
"categoryId": CATEGORY_NODE_ID,
93-
"title": title,
94-
"body": body,
95-
},
96-
},
28+
json={"query": query, "variables": variables or {}},
9729
)
9830
resp.raise_for_status()
9931
data = resp.json()
10032
if "errors" in data:
10133
raise RuntimeError(f"GraphQL errors: {data['errors']}")
102-
return data["data"]["createDiscussion"]["discussion"]["url"]
34+
return data["data"]
35+
36+
37+
def find_latest_checkin():
38+
"""Find the most recent Weekly Check-in Discussion."""
39+
owner, name = REPO.split("/")
40+
data = graphql(
41+
"""
42+
query($owner: String!, $name: String!) {
43+
repository(owner: $owner, name: $name) {
44+
discussions(first: 10, orderBy: {field: CREATED_AT, direction: DESC}) {
45+
nodes {
46+
id
47+
title
48+
url
49+
body
50+
}
51+
}
52+
}
53+
}
54+
""",
55+
{"owner": owner, "name": name},
56+
)
57+
for d in data["repository"]["discussions"]["nodes"]:
58+
if d["title"].startswith("Weekly Check-in:"):
59+
return d
60+
return None
61+
62+
63+
def get_discussion_comments(discussion_id):
64+
"""Fetch top-level comments and their replies for a Discussion."""
65+
data = graphql(
66+
"""
67+
query($id: ID!) {
68+
node(id: $id) {
69+
... on Discussion {
70+
comments(first: 50) {
71+
nodes {
72+
body
73+
replies(first: 50) {
74+
nodes {
75+
author { login }
76+
}
77+
}
78+
}
79+
}
80+
}
81+
}
82+
}
83+
""",
84+
{"id": discussion_id},
85+
)
86+
return data["node"]["comments"]["nodes"]
10387

10488

105-
def close_issue(issue_number, discussion_url):
106-
"""Close the standup issue with a link to the compiled discussion."""
107-
requests.post(
108-
f"{API}/repos/{REPO}/issues/{issue_number}/comments",
109-
headers=HEADERS,
110-
json={"body": f"Compiled into discussion: {discussion_url}"},
111-
)
112-
requests.patch(
113-
f"{API}/repos/{REPO}/issues/{issue_number}",
114-
headers=HEADERS,
115-
json={"state": "closed"},
89+
def check_participation(comments):
90+
"""Return list of contributors who replied to their thread."""
91+
participated = []
92+
for comment in comments:
93+
body = comment["body"]
94+
for user in CONTRIBUTORS:
95+
if f"@{user}" not in body:
96+
continue
97+
reply_authors = {
98+
r["author"]["login"] for r in comment["replies"]["nodes"] if r["author"]
99+
}
100+
if user in reply_authors:
101+
participated.append(user)
102+
return participated
103+
104+
105+
def update_discussion_body(discussion_id, new_body):
106+
"""Edit the Discussion body via GraphQL."""
107+
graphql(
108+
"""
109+
mutation($discussionId: ID!, $body: String!) {
110+
updateDiscussion(input: {
111+
discussionId: $discussionId,
112+
body: $body
113+
}) {
114+
discussion { id }
115+
}
116+
}
117+
""",
118+
{"discussionId": discussion_id, "body": new_body},
116119
)
117120

118121

122+
PARTICIPATION_MARKER = "<!-- participation -->"
123+
124+
119125
def main():
120-
issue = find_standup_issue()
121-
if not issue:
126+
dry_run = os.environ.get("DRY_RUN")
127+
128+
discussion = find_latest_checkin()
129+
if not discussion:
130+
print("No Weekly Check-in discussion found.")
122131
return
123132

124-
issue_number = issue["number"]
125-
# Extract the week label from the issue title
126-
title_suffix = issue["title"].removeprefix("Standup Input: ")
127-
week_label = title_suffix or datetime.now(timezone.utc).strftime("Week of %Y-%m-%d")
133+
print(f"Found: {discussion['url']}")
128134

129-
comments = fetch_comments(issue_number)
135+
comments = get_discussion_comments(discussion["id"])
136+
participated = check_participation(comments)
130137

131-
# Build sections per contributor
132-
sections = []
133-
for comment in comments:
134-
user = comment["user"]["login"]
135-
if comment["user"]["type"] == "Bot":
136-
continue
137-
body = comment["body"].strip()
138-
sections.append(f"### @{user}\n{body}")
138+
if participated:
139+
names = ", ".join(f"@{u}" for u in participated)
140+
participation_line = f"**Participated:** {names}"
141+
else:
142+
participation_line = "**Participated:** _(none yet)_"
139143

140-
updates = "\n\n".join(sections) if sections else "_No responses._"
144+
# Strip any previous participation section, then append
145+
body = discussion["body"]
146+
if PARTICIPATION_MARKER in body:
147+
body = body[: body.index(PARTICIPATION_MARKER)].rstrip()
141148

142-
discussion_title = f"Weekly Check-in: {week_label}"
143-
discussion_body = updates
149+
new_body = f"{body}\n\n{PARTICIPATION_MARKER}\n{participation_line}"
144150

145-
repo_node_id = get_repo_node_id()
146-
discussion_url = create_discussion(discussion_title, discussion_body, repo_node_id)
147-
print(f"Created discussion: {discussion_url}")
151+
if dry_run:
152+
print(f"Would update body to:\n---\n{new_body}\n---")
153+
return
148154

149-
close_issue(issue_number, discussion_url)
150-
print(f"Closed issue #{issue_number}")
155+
update_discussion_body(discussion["id"], new_body)
156+
print(f"Updated discussion: {participation_line}")
151157

152158

153159
if __name__ == "__main__":

0 commit comments

Comments
 (0)