|
1 | 1 | #!/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.""" |
3 | 3 |
|
4 | 4 | import os |
5 | | -from datetime import datetime, timezone, timedelta |
| 5 | +from pathlib import Path |
6 | 6 |
|
7 | 7 | import requests |
| 8 | +import yaml |
8 | 9 |
|
9 | 10 | REPO = os.environ["GITHUB_REPOSITORY"] |
10 | 11 | TOKEN = os.environ["STANDUP_TOKEN"] |
11 | | -CATEGORY_NODE_ID = os.environ["DISCUSSION_CATEGORY_NODE_ID"] |
12 | | -API = "https://api.github.com" |
13 | 12 | GRAPHQL = "https://api.github.com/graphql" |
14 | 13 | HEADERS = { |
15 | 14 | "Authorization": f"token {TOKEN}", |
16 | 15 | "Accept": "application/vnd.github+json", |
17 | 16 | } |
18 | 17 |
|
| 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"]] |
19 | 21 |
|
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.""" |
85 | 25 | resp = requests.post( |
86 | 26 | GRAPHQL, |
87 | 27 | 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 {}}, |
97 | 29 | ) |
98 | 30 | resp.raise_for_status() |
99 | 31 | data = resp.json() |
100 | 32 | if "errors" in data: |
101 | 33 | 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"] |
103 | 87 |
|
104 | 88 |
|
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}, |
116 | 119 | ) |
117 | 120 |
|
118 | 121 |
|
| 122 | +PARTICIPATION_MARKER = "<!-- participation -->" |
| 123 | + |
| 124 | + |
119 | 125 | 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.") |
122 | 131 | return |
123 | 132 |
|
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']}") |
128 | 134 |
|
129 | | - comments = fetch_comments(issue_number) |
| 135 | + comments = get_discussion_comments(discussion["id"]) |
| 136 | + participated = check_participation(comments) |
130 | 137 |
|
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)_" |
139 | 143 |
|
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() |
141 | 148 |
|
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}" |
144 | 150 |
|
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 |
148 | 154 |
|
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}") |
151 | 157 |
|
152 | 158 |
|
153 | 159 | if __name__ == "__main__": |
|
0 commit comments