Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-28T00:29:27.254Z Public web read
NIP-34 coordinate30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omega
MaintainersHidden in public view
References2 branches · 1 tag
Read-only clonegit clone https://openagents.com/git/tenant.openagents/omega.git
Browse files

github-guild-board.py

727 lines · 25.9 KB · python
1#!/usr/bin/env python3
2"""
3Automation for the Guild project board (#74).
4
5GUILD_MODE selects behavior:
6
7- event: react to a single issue or pull request webhook.
8    assigned    guild member -> Status "In Progress", or a Slack heads-up if
9                the issue isn't on the board.
10    unassigned  guild member off an "In Progress" issue -> move back to a
11                To-Do column by Type (e.g. Bugs go back to Bug Bashers column) + Slack.
12    created     (issue_comment) guild assignee comments on an issue that has a
13                pending check-in -> Slack (fires on each such comment).
14    pull request  guild member opens a PR -> set any open board issue it will
15                close to "In Progress" (covers starting work without self-
16                assigning), plus a Slack heads-up if they already have another
17                open PR.
18- stale: nudge issues assigned to a guild member with no open PR once the
19    assignee goes quiet; a reply resets the clock, so nudges recur after renewed
20    silence, and the assignment is cleared after a further grace period without a
21    reply. The "guild hold" label pauses both nudging and clearing.
22- weekly: Slack digest of board issues recently closed by a merged PR authored
23    by a guild member.
24
25Requires: requests
26"""
27
28import html
29import json
30import os
31import random
32import time
33import urllib.parse
34from datetime import datetime, timedelta, timezone
35from functools import lru_cache
36
37import requests
38
39RETRYABLE_STATUS_CODES = {502, 503, 504}
40MAX_RETRIES = 3
41RETRY_DELAY_SECONDS = 5
42
43GITHUB_API_URL = "https://api.github.com"
44REPO_OWNER = "zed-industries"
45REPO_NAME = "zed"
46# Cohort members are outside collaborators on the repo holding this custom
47# repository role, rather than members of an org team. Rotating the cohort is
48# then just adding/removing collaborators, with no org seats involved.
49GUILD_ROLE_NAME = "Guild Assign issues/PRs"
50
51# PRs opened before the cohort started don't count toward the open-PR heads-up.
52COHORT_START_DATE = "2026-07-01"
53
54STATUS_FIELD = "Status"
55STATUS_IN_PROGRESS = "In Progress"
56STATUS_BUG_BASHERS = "Bug Bashers"
57STATUS_SHIP_FEATURE = "Ship a New Feature"
58
59# Which to-do column to move issues of a given type to.
60STATUS_FOR_TYPE = {
61    "Feature": STATUS_SHIP_FEATURE,
62    "Bug": STATUS_BUG_BASHERS,
63    "Docs": STATUS_BUG_BASHERS,
64    "Crash": STATUS_BUG_BASHERS,
65}
66
67# How many days to wait after assignment before posting a check-in comment.
68CHECK_IN_AFTER_DAYS = 14
69# Clear the assignment after this many days of no reply to check-in.
70AUTO_CLEAR_AFTER_DAYS = 7
71# For the slack summaries of what's been recently shipped by the Guild.
72SHIPPED_WINDOW_DAYS = 7
73
74# Applying this label to an issue pauses the stale sweep for it (no nudges, no
75# auto-clear of the assignee) until a human removes the label.
76NUDGE_HOLD_LABEL = "guild hold"
77
78# Hidden in the rendered issue; lets later runs find the bot's own check-in.
79CHECK_IN_MARKER = "<!-- zedgar:guild-check-in -->"
80
81CHECK_IN_BODY = (
82    "{marker}\n"
83    "Hey @{assignee}, checking in to see if you're still actively working on "
84    "this issue. No worries if you no longer have the time. If that's the case, "
85    "do you mind unassigning yourself from the issue so another contributor can "
86    "work on it? If you are still working on it, drop a comment and let us know "
87    "how we can help! Otherwise, the bot will clear the assignment in "
88    "{clear_days} days from now."
89)
90
91ZEDGAR_QUIPS = [
92    "Deep into the backlog peering...",
93    "Once upon a board so dreary...",
94    "A tell-tale ping beneath the board.",
95]
96
97
98def github_graphql(query, variables):
99    for attempt in range(MAX_RETRIES + 1):
100        response = requests.post(
101            f"{GITHUB_API_URL}/graphql",
102            headers=GITHUB_HEADERS,
103            json={"query": query, "variables": variables},
104            timeout=30,
105        )
106        if response.status_code in RETRYABLE_STATUS_CODES and attempt < MAX_RETRIES:
107            time.sleep(RETRY_DELAY_SECONDS)
108            continue
109        response.raise_for_status()
110        result = response.json()
111        if "errors" in result:
112            raise RuntimeError(f"GraphQL error: {result['errors']}")
113        return result["data"]
114    raise RuntimeError("github_graphql: retry loop exited without return")
115
116
117def github_rest_request(method, path, body=None):
118    url = f"{GITHUB_API_URL}/{path}"
119    for attempt in range(MAX_RETRIES + 1):
120        response = requests.request(
121            method, url, headers=GITHUB_HEADERS, json=body, timeout=30
122        )
123        if response.status_code in RETRYABLE_STATUS_CODES and attempt < MAX_RETRIES:
124            time.sleep(RETRY_DELAY_SECONDS)
125            continue
126        response.raise_for_status()
127        if response.status_code == 204 or not response.content:
128            return None
129        return response.json()
130    raise RuntimeError("github_rest_request: retry loop exited without return")
131
132
133def github_rest_get_paginated(path):
134    results = []
135    page = 1
136    while True:
137        separator = "&" if "?" in path else "?"
138        batch = github_rest_request("GET", f"{path}{separator}per_page=100&page={page}")
139        if not batch:
140            break
141        results.extend(batch)
142        if len(batch) < 100:
143            break
144        page += 1
145    return results
146
147
148@lru_cache(maxsize=None)
149def is_guild_member(username):
150    response = requests.get(
151        f"{GITHUB_API_URL}/repos/{REPO_OWNER}/{REPO_NAME}/collaborators/{username}/permission",
152        headers=GITHUB_HEADERS,
153        timeout=30,
154    )
155    # 404 means the user isn't a collaborator on the repo at all.
156    if response.status_code == 404:
157        return False
158    response.raise_for_status()
159    # role_name is the effective (highest) role for the user. For a cohort of
160    # outside collaborators whose only grant is this custom role, that is the
161    # custom role's name; built-in roles come back lowercased and won't match.
162    role_name = response.json().get("role_name") or ""
163    return role_name.lower() == GUILD_ROLE_NAME.lower()
164
165
166def issue_comments(issue_number):
167    return github_rest_get_paginated(
168        f"repos/{REPO_OWNER}/{REPO_NAME}/issues/{issue_number}/comments"
169    )
170
171
172def latest_assignment_time(issue_number, assignee):
173    events = github_rest_get_paginated(
174        f"repos/{REPO_OWNER}/{REPO_NAME}/issues/{issue_number}/timeline"
175    )
176    times = [
177        parse_dt(event["created_at"])
178        for event in events
179        if event.get("event") == "assigned"
180        and (event.get("assignee") or {}).get("login") == assignee
181    ]
182    if not times:
183        raise RuntimeError(
184            f"No assignment event for {assignee} on issue #{issue_number}"
185        )
186    return max(times)
187
188
189def issue_closing_prs(issue_node_id, include_closed_prs=False):
190    # Caps at the first 20 closing PRs: callers only test presence or scan for a
191    # single guild-authored merge, so an issue exceeding this bound is not worth
192    # paginating.
193    data = github_graphql(
194        """
195        query($issueId: ID!, $includeClosedPrs: Boolean!) {
196          node(id: $issueId) {
197            ... on Issue {
198              closedByPullRequestsReferences(first: 20, includeClosedPrs: $includeClosedPrs) {
199                nodes { merged author { login } }
200              }
201            }
202          }
203        }
204        """,
205        {"issueId": issue_node_id, "includeClosedPrs": include_closed_prs},
206    )
207    node = data.get("node") or {}
208    refs = node.get("closedByPullRequestsReferences") or {}
209    return [
210        {"merged": pr["merged"], "author": (pr.get("author") or {}).get("login")}
211        for pr in refs.get("nodes", [])
212    ]
213
214
215def pr_closing_issues(pr_node_id):
216    data = github_graphql(
217        """
218        query($prId: ID!) {
219          node(id: $prId) {
220            ... on PullRequest {
221              closingIssuesReferences(first: 10) {
222                nodes { id state }
223              }
224            }
225          }
226        }
227        """,
228        {"prId": pr_node_id},
229    )
230    node = data.get("node") or {}
231    return (node.get("closingIssuesReferences") or {}).get("nodes", [])
232
233
234def fetch_project(project_number):
235    data = github_graphql(
236        """
237        query($owner: String!, $number: Int!) {
238          organization(login: $owner) {
239            projectV2(number: $number) {
240              id
241              fields(first: 50) {
242                nodes {
243                  ... on ProjectV2Field { id name dataType }
244                  ... on ProjectV2SingleSelectField { id name options { id name } }
245                }
246              }
247            }
248          }
249        }
250        """,
251        {"owner": REPO_OWNER, "number": project_number},
252    )
253    project = data["organization"]["projectV2"]
254    if not project:
255        raise RuntimeError(f"Project #{project_number} not found in {REPO_OWNER}")
256    return project
257
258
259def find_project_item(project_id, content_node_id):
260    # Includes each item's single-select values so callers can read Status
261    # without a second query.
262    data = github_graphql(
263        """
264        query($contentId: ID!) {
265          node(id: $contentId) {
266            ... on Issue {
267              projectItems(first: 50) {
268                nodes {
269                  id
270                  project { id }
271                  fieldValues(first: 20) {
272                    nodes {
273                      ... on ProjectV2ItemFieldSingleSelectValue {
274                        field { ... on ProjectV2SingleSelectField { name } }
275                        name
276                      }
277                    }
278                  }
279                }
280              }
281            }
282          }
283        }
284        """,
285        {"contentId": content_node_id},
286    )
287    node = data.get("node") or {}
288    for item in (node.get("projectItems") or {}).get("nodes", []):
289        if item["project"]["id"] == project_id:
290            return item
291    return None
292
293
294def set_project_field(project, item_id, field_name, option_name):
295    field = next(
296        (f for f in project["fields"]["nodes"] if f.get("name") == field_name), None
297    )
298    if not field:
299        raise RuntimeError(f"Field '{field_name}' not found on board")
300    option_id = next(
301        (o["id"] for o in field.get("options", []) if o["name"] == option_name), None
302    )
303    if not option_id:
304        raise RuntimeError(f"Option '{option_name}' not found in field '{field_name}'")
305    github_graphql(
306        """
307        mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) {
308          updateProjectV2ItemFieldValue(input: {
309            projectId: $projectId, itemId: $itemId, fieldId: $fieldId,
310            value: { singleSelectOptionId: $optionId }
311          }) { projectV2Item { id } }
312        }
313        """,
314        {
315            "projectId": project["id"],
316            "itemId": item_id,
317            "fieldId": field["id"],
318            "optionId": option_id,
319        },
320    )
321
322
323def list_project_issues(project_id):
324    cursor = None
325    while True:
326        data = github_graphql(
327            """
328            query($projectId: ID!, $cursor: String) {
329              node(id: $projectId) {
330                ... on ProjectV2 {
331                  items(first: 100, after: $cursor) {
332                    pageInfo { hasNextPage endCursor }
333                    nodes {
334                      id
335                      isArchived
336                      fieldValues(first: 20) {
337                        nodes {
338                          ... on ProjectV2ItemFieldSingleSelectValue {
339                            field { ... on ProjectV2SingleSelectField { name } }
340                            name
341                          }
342                        }
343                      }
344                      content {
345                        __typename
346                        ... on Issue {
347                          id number title url state closedAt
348                          issueType { name }
349                          assignees(first: 10) { nodes { login } }
350                          labels(first: 20) { nodes { name } }
351                        }
352                      }
353                    }
354                  }
355                }
356              }
357            }
358            """,
359            {"projectId": project_id, "cursor": cursor},
360        )
361        page = data["node"]["items"]
362        yield from page["nodes"]
363        if not page["pageInfo"]["hasNextPage"]:
364            return
365        cursor = page["pageInfo"]["endCursor"]
366
367
368def post_comment(issue_number, body):
369    github_rest_request(
370        "POST", f"repos/{REPO_OWNER}/{REPO_NAME}/issues/{issue_number}/comments", {"body": body}
371    )
372
373
374def remove_assignees(issue_number, assignees):
375    github_rest_request(
376        "DELETE",
377        f"repos/{REPO_OWNER}/{REPO_NAME}/issues/{issue_number}/assignees",
378        {"assignees": assignees},
379    )
380
381
382def escape_slack(text):
383    # Escape Slack's control characters (&, <, >) so free text like issue/PR
384    # titles and comment bodies renders literally instead of being interpreted
385    # as a link or @-mention. send_slack can't do this wholesale because its own
386    # messages legitimately contain <url|text> links and *bold* markup.
387    # quote=False keeps it to Slack's three characters (no " or ' escaping).
388    return html.escape(text or "", quote=False)
389
390
391def slack_link(url, text):
392    # The single way to embed a titled link, so the title is always escaped
393    # without every caller having to remember to do it.
394    return f"<{url}|{escape_slack(text)}>"
395
396
397def send_slack(text):
398    webhook = os.environ.get("SLACK_WEBHOOK_GUILD_INTERNAL")
399    if not webhook:
400        raise RuntimeError("SLACK_WEBHOOK_GUILD_INTERNAL is not set")
401    message = f"{random.choice(ZEDGAR_QUIPS)} {text}"
402    response = requests.post(
403        webhook,
404        json={
405            "text": message,
406            "blocks": [{"type": "section", "text": {"type": "mrkdwn", "text": message}}],
407        },
408        timeout=30,
409    )
410    if response.status_code != 200:
411        raise RuntimeError(
412            f"Slack webhook returned {response.status_code}: {response.text[:200]}"
413        )
414
415
416def parse_dt(value):
417    return datetime.fromisoformat(value.replace("Z", "+00:00"))
418
419
420def item_status(item):
421    for field_value in (item.get("fieldValues") or {}).get("nodes", []):
422        if field_value.get("field", {}).get("name") == STATUS_FIELD:
423            return field_value.get("name")
424    return None
425
426
427def latest_check_in_time(comments):
428    times = [
429        parse_dt(comment["created_at"])
430        for comment in comments
431        if CHECK_IN_MARKER in (comment.get("body") or "")
432    ]
433    return max(times, default=None)
434
435
436def handle_assignment(issue, assignee_login, project_number):
437    # A late assignment on an already-closed issue shouldn't drag it back into
438    # In Progress (or flag it as off-board); the work is done.
439    if issue.get("state") == "closed":
440        return
441    if not is_guild_member(assignee_login):
442        return
443
444    project = fetch_project(project_number)
445    item = find_project_item(project["id"], issue["node_id"])
446    if not item:
447        send_slack(
448            f"Heads-up: @{assignee_login} was assigned to issue #{issue['number']} "
449            f"{slack_link(issue['html_url'], issue['title'])}, which isn't on the Guild board. "
450            "Possible mistake — if it's meant to be Guild work, add it to Bug Bashers "
451            "or Ship a New Feature; otherwise no action needed."
452        )
453        return
454
455    set_project_field(project, item["id"], STATUS_FIELD, STATUS_IN_PROGRESS)
456
457
458def handle_unassignment(issue, removed_login, sender, project_number):
459    # Our own auto-clear also unassigns; don't double-handle that.
460    if (sender or {}).get("type") == "Bot":
461        return
462    if not is_guild_member(removed_login):
463        return
464
465    project = fetch_project(project_number)
466    item = find_project_item(project["id"], issue["node_id"])
467    if not item or item_status(item) != STATUS_IN_PROGRESS:
468        return
469
470    sender_login = (sender or {}).get("login")
471    who = (
472        "unassigned themselves from"
473        if sender_login == removed_login
474        else f"was unassigned (by @{sender_login}) from"
475    )
476    status = STATUS_FOR_TYPE.get((issue.get("type") or {}).get("name") or "")
477    link = slack_link(issue["html_url"], issue["title"])
478    if status:
479        set_project_field(project, item["id"], STATUS_FIELD, status)
480        send_slack(
481            f"@{removed_login} {who} In Progress issue #{issue['number']} "
482            f"{link}. I moved it back to *{status}*, "
483            "so it's up for grabs again — no action needed."
484        )
485    else:
486        send_slack(
487            f"@{removed_login} {who} In Progress issue #{issue['number']} "
488            f"{link}. I couldn't tell its Type, so it's "
489            "still In Progress — please move it to Bug Bashers or Ship a New Feature so it "
490            "can be picked up again, then set the :done-checkmark: emoji on this message when done."
491        )
492
493
494def handle_pull_request(pull_request, project_number):
495    author = (pull_request.get("user") or {}).get("login")
496    if not author or not is_guild_member(author):
497        return
498    set_linked_issues_in_progress(pull_request, project_number)
499    warn_about_extra_open_prs(pull_request, author)
500
501
502def set_linked_issues_in_progress(pull_request, project_number):
503    # Only open issues: a stale closed or merged PR shouldn't drag its issue back
504    # to In Progress.
505    open_issues = [
506        issue
507        for issue in pr_closing_issues(pull_request["node_id"])
508        if issue.get("state") == "OPEN"
509    ]
510    if not open_issues:
511        return
512    project = fetch_project(project_number)
513    for issue in open_issues:
514        item = find_project_item(project["id"], issue["id"])
515        if item:
516            set_project_field(project, item["id"], STATUS_FIELD, STATUS_IN_PROGRESS)
517
518
519def warn_about_extra_open_prs(pull_request, author):
520    query = (
521        f"repo:{REPO_OWNER}/{REPO_NAME} is:pr is:open "
522        f"author:{author} created:>={COHORT_START_DATE}"
523    )
524    result = github_rest_request(
525        "GET", f"search/issues?q={urllib.parse.quote(query)}&per_page=100"
526    )
527    others = [
528        pr
529        for pr in (result or {}).get("items", [])
530        if pr["number"] != pull_request["number"]
531    ]
532    if not others:
533        return
534    lines = "\n".join(
535        f"• {slack_link(pr['html_url'], '#' + str(pr['number']))} {escape_slack(pr['title'])}"
536        for pr in others
537    )
538    send_slack(
539        f"@{author} just opened {slack_link(pull_request['html_url'], pull_request['title'])} "
540        "while still having open PR(s):\n"
541        f"{lines}\n"
542        "A gentle nudge to help them land one before spreading thin might be in order."
543    )
544
545
546def handle_comment(issue, comment):
547    commenter = (comment.get("user") or {}).get("login")
548    if not commenter or CHECK_IN_MARKER in (comment.get("body") or ""):
549        return
550
551    assignees = [a["login"] for a in (issue.get("assignees") or [])]
552    if commenter not in assignees or not is_guild_member(commenter):
553        return
554
555    check_in_at = latest_check_in_time(issue_comments(issue["number"]))
556    if check_in_at is None or parse_dt(comment["created_at"]) <= check_in_at:
557        return
558
559    reply = " ".join((comment.get("body") or "").split())
560    if len(reply) > 500:
561        reply = reply[:500] + "…"
562    send_slack(
563        f"@{commenter} replied to the check-in on In Progress issue #{issue['number']} "
564        f"{slack_link(issue['html_url'], issue['title'])}:\n"
565        f"> {escape_slack(reply)}\n"
566        f"Follow up if they need help, or add the `{NUDGE_HOLD_LABEL}` label to the "
567        "issue to pause check-ins on it."
568    )
569
570
571def run_event(project_number):
572    event_name = os.environ.get("GITHUB_EVENT_NAME", "")
573    with open(os.environ["GITHUB_EVENT_PATH"]) as f:
574        event = json.load(f)
575
576    if event_name == "issues":
577        action = event["action"]
578        issue = event["issue"]
579        if action == "assigned":
580            handle_assignment(issue, event["assignee"]["login"], project_number)
581        elif action == "unassigned":
582            handle_unassignment(
583                issue, event["assignee"]["login"], event.get("sender"), project_number
584            )
585    elif event_name == "issue_comment" and "pull_request" not in event["issue"]:
586        handle_comment(event["issue"], event["comment"])
587    elif event_name == "pull_request_target":
588        handle_pull_request(event["pull_request"], project_number)
589
590
591def run_stale(project_number):
592    project = fetch_project(project_number)
593    checked_in = cleared = 0
594
595    for item in list_project_issues(project["id"]):
596        if item.get("isArchived"):
597            continue
598        content = item.get("content") or {}
599        if content.get("__typename") != "Issue":
600            continue
601        if item_status(item) != STATUS_IN_PROGRESS:
602            continue
603
604        assignees = [a["login"] for a in content["assignees"]["nodes"]]
605        guild_assignees = [a for a in assignees if is_guild_member(a)]
606        if not guild_assignees:
607            continue
608
609        labels = [label["name"] for label in (content.get("labels") or {}).get("nodes", [])]
610        if NUDGE_HOLD_LABEL in labels:
611            continue
612
613        issue_number = content["number"]
614        if issue_closing_prs(content["id"]):
615            continue
616
617        comments = issue_comments(issue_number)
618        assignee = guild_assignees[0]
619
620        # One pass over the comments for both the latest check-in and the
621        # assignee's latest comment. Measuring from the assignee's last activity
622        # (their assignment or a later comment) means a reply resets the clock, so
623        # a nudge only recurs after renewed silence (and a check-in from a prior
624        # assignment stint predates this, so it is naturally ignored).
625        last_activity = latest_assignment_time(issue_number, assignee)
626        check_in_at = None
627        for comment in comments:
628            created_at = parse_dt(comment["created_at"])
629            if CHECK_IN_MARKER in (comment.get("body") or ""):
630                if check_in_at is None or created_at > check_in_at:
631                    check_in_at = created_at
632            elif (comment.get("user") or {}).get("login") == assignee:
633                if created_at > last_activity:
634                    last_activity = created_at
635
636        if check_in_at is None or check_in_at < last_activity:
637            # No outstanding nudge since the assignee last engaged; nudge once
638            # they have stayed quiet for the check-in window.
639            if (NOW - last_activity).days < CHECK_IN_AFTER_DAYS:
640                continue
641            post_comment(
642                issue_number,
643                CHECK_IN_BODY.format(
644                    marker=CHECK_IN_MARKER,
645                    assignee=assignee,
646                    clear_days=AUTO_CLEAR_AFTER_DAYS,
647                ),
648            )
649            checked_in += 1
650            continue
651
652        # We nudged and the assignee stayed quiet; clear once the grace period
653        # lapses, otherwise leave them the rest of it.
654        if (NOW - check_in_at).days < AUTO_CLEAR_AFTER_DAYS:
655            continue
656
657        remove_assignees(issue_number, guild_assignees)
658        status = STATUS_FOR_TYPE.get((content.get("issueType") or {}).get("name") or "")
659        link = slack_link(content["url"], content["title"])
660        if status:
661            set_project_field(project, item["id"], STATUS_FIELD, status)
662            send_slack(
663                f"Issue #{issue_number} {link} went quiet with @{assignee} "
664                "(no open PR, no reply to my check-in), so I cleared the assignment and "
665                f"moved it back to *{status}*. It's up for grabs again — no action needed."
666            )
667        else:
668            send_slack(
669                f"Issue #{issue_number} {link} went quiet with @{assignee} "
670                "(no open PR, no reply to my check-in), so I cleared the assignment. I "
671                "couldn't tell its Type — please move it to Bug Bashers or Ship a New "
672                "Feature so it can be picked up again, then set the :done-checkmark: emoji "
673                "on this message when done."
674            )
675        cleared += 1
676
677    print(f"Stale sweep: {checked_in} checked in, {cleared} cleared")
678
679
680def run_weekly(project_number):
681    project = fetch_project(project_number)
682    cutoff = NOW - timedelta(days=SHIPPED_WINDOW_DAYS)
683    shipped = []
684
685    for item in list_project_issues(project["id"]):
686        content = item.get("content") or {}
687        if content.get("__typename") != "Issue" or content.get("state") != "CLOSED":
688            continue
689        if parse_dt(content["closedAt"]) < cutoff:
690            continue
691        for pr in issue_closing_prs(content["id"], include_closed_prs=True):
692            if pr["merged"] and pr["author"] and is_guild_member(pr["author"]):
693                shipped.append((pr["author"], content))
694                break
695
696    if not shipped:
697        send_slack("A quiet week on the Guild board — nothing shipped. Onward.")
698        return
699
700    lines = "\n".join(
701        f"• @{author}{slack_link(content['url'], content['title'])} (#{content['number']})"
702        for author, content in shipped
703    )
704    send_slack(f"Here's what the Guild shipped this week:\n{lines}")
705
706
707if __name__ == "__main__":
708    GITHUB_HEADERS = {
709        "Authorization": f"Bearer {os.environ['GITHUB_TOKEN']}",
710        "Accept": "application/vnd.github+json",
711        "X-GitHub-Api-Version": "2022-11-28",
712    }
713
714    NOW = datetime.now(timezone.utc)
715
716    project_number = int(os.environ["PROJECT_NUMBER"])
717    mode = os.environ["GUILD_MODE"]
718
719    if mode == "event":
720        run_event(project_number)
721    elif mode == "stale":
722        run_stale(project_number)
723    elif mode == "weekly":
724        run_weekly(project_number)
725    else:
726        raise RuntimeError(f"Unknown GUILD_MODE: {mode}")
727
Served at tenant.openagents/omega Member data and write actions are omitted.