Skip to content

feat: daily digest of contributor pull requests - #59

Closed
Bilb wants to merge 10 commits into
mainfrom
feat/contributor-pr-digest
Closed

Bilb wants to merge 10 commits into
mainfrom
feat/contributor-pr-digest

Conversation

@Bilb

@Bilb Bilb commented Sep 24, 2026 •

Copy link
Copy Markdown
Collaborator

One Discord message each weekday morning listing the open PRs across session-foundation's own repositories whose author is not a maintainer and which have moved in the last three days — the ones nobody on the team has a reason to already know about.

**Contributor pull requests** · last 3 days
🟢 **2** new · ✏️ **1** updated
**36** open from contributors across the org.

**session-desktop**
🟢 [#1958](…) @KyWB · 12h · 💬1 · Fix issue #563
✏️ [#1904](…) @scrense-hash · 3h · 💬2 · feat: add SOCKS5 proxy support

🟢 is a PR the digest has never reported; ✏️ is one it has, which has moved since. A PR that hasn't moved is dropped from the message entirely while still counting in the backlog line.

Weekdays, and the state file

Mon..Fri 09:30 Australia/Melbourne, so Monday's run has to cover the weekend — hence a 72h window, which then overlaps itself by two days on every run. --state absorbs the overlap: it records which PRs reached Discord and each one's updated_at at the time. Same mechanism as zendesk-digest, whose --window-hours 72 --state seen.json solved this first — and now literally the same code, see below.

Dedup is keyed on updated_at alone. That is a deliberate trade: updated_at moves on any change, so an edit touching several PRs at once resurfaces all of them — three session-ios PRs in the org currently share an updated_at to the second, from a label sweep. The accurate alternative (head SHA + comment counts) isn't in the search result and costs a request per PR that moved. Both halves are written down in activity_key and the README.

Only what Discord accepted is recorded, so a run failing on its second message re-reports that message's PRs rather than losing them. Every way of failing to read the state file — missing, unreadable, wrong version — treats the window as new: noisy once, never wrong.

How it decides what to show

One search fetches every open PR in the org and the window is applied to the result, which buys the backlog line for the cost of a single query. Past GitHub's 1000-result ceiling it reports the counts as a floor rather than failing.

Maintainers are a hand-written list. Neither signal GitHub offers works: org membership covers six accounts, two of which aren't in the review loop; push access is held by a dozen more as outside collaborators, several of them contractors whose PRs are exactly what this is for. Both would get it wrong in both directions.

Bots are dropped on GitHub's own account type, so a renamed Dependabot stays out without an entry. Forks, archived and private repos are excluded by checking search results against the org's repository list rather than by name, so a repo created today is covered today. Private repositories are never reported whatever the token can see: the digest posts to Discord, and nothing about them belongs there.

shared/: one copy of the plumbing

The first draft of this digest carried its own copy of the retry loop, the Discord Components V2 posting, the dedup state file and get_env, on the theory that a shared module would tie the two jobs' dependencies together. That theory was already false: deploy/alert.py imported triage across directories, so the github-prs venv had to satisfy triage's imports anyway — the standard library plus requests. And the crowdin report carried a third copy of the retry and posting code.

So the plumbing moves to a shared/ package at the repo root, imported the way alert.py already did it (the script inserts the repo root on sys.path; every job runs out of the same clone, so there is no install step):

  • shared.retry — request_with_retry with backoff on 429/5xx, Retry-After in both forms RFC 9110 allows (seconds or HTTP-date), GitHub's x-ratelimit-reset when there is no Retry-After, and a timeout argument.
  • shared.discord — the Components V2 constants, the chunker, a header-plus-entries message builder, delivered_ids for what a partial post covered, the with_components webhook fix-up and post_to_discord, which returns the accepted count rather than raising so a caller can record what landed.
  • shared.state — the dedup file, and a Tracker that binds a digest's schema version, item key, activity field and retention once, so the new/changed/unchanged partition and the record building live in one place.
  • shared.text — clip, squash, window_label.
  • shared.env — get_env, now with a default for settings that are optional with a fallback.
  • shared.testing — the fakes every suite used to define for itself.

What each script keeps is its own contract. The Zendesk digest keeps requester_updated_at as its activity key and the header-coverage rule. This digest keeps the record it stores per PR, its per-message block cap and the rendering. The crowdin report keeps ten attempts at a 60s timeout, a 4xx that raises so callers can read the body unchecked, and the plain-text warning it posts before exiting on a rejected embed. note_reply, resolve_reviews, relay and the alert import shared directly instead of reaching the helpers as attributes of a 1900-line classifier.

The rest of the way out of triage.py

note_reply, resolve_reviews and the alert reached the Zendesk fetches, the marker format and the CLI's name by importing the 1900-line classifier. Three modules take that over, and nothing outside triage.py imports it any more:

  • zendesk_triage/zendesk.py — the API session, search and its 1000-result ceiling, one ticket, its comments and users, the one PUT every write is, the comment markers, who the customer is on a channel integration, and what an imported store review looks like. The three comment fetchers triage.py carried differed only in page size, order and whether a failure exits or skips the ticket; those are parameters of one fetch_comments.
  • zendesk_triage/claude_cli.py — run_json, and try_run_json for an enrichment that hands the failure back instead of exiting, in place of three copies of the catch-SystemExit idiom. note_reply --model goes through resolve_api_model like the triage's flag does, so an alias works for both.
  • zendesk_triage/transcript.py — the English transcript: translate, render, write to the field, attach before the post.

triage.py keeps the queries, the taxonomy, the dedup, the review filter, the analysis and the rendering: 1091 lines, from 1913. undash_english is pure text and moves to shared.text.

download_translations_from_crowdin.py goes through shared.retry too. Its own loop retried 429 only, crashed on a Retry-After given as a date, and re-sent the bearer token per call with no session. It now uses one session for the API and a second, unauthenticated one for the export downloads, which are served from a signed URL on another host. Arguments parse in main rather than at import, which is what let it get tests; the workflow's invocation is unchanged.

One small fix on the way

claude_cli.run_json (claude_cli_json at the time) reports the CLI's reason when it exits 1 (#60), but the exit-0 is_error branch still reported only the subtype and status code. It now appends the envelope's result as well.

Deployment

The box that already runs the Zendesk digest, with its own user, environment file and venv — the venv because the two jobs pin requests differently, and a shared one would silently be whichever was installed last. StateDirectory=github-prs creates the state directory on first start. shared/ ships with the clone and needs nothing installed.

deploy/alert.py gains an ALERT_DISCORD_WEBHOOK_URL override so this job's failures land in its own channel rather than the triage one.

Testing

Six suites, each python -m unittest discover from its directory. .github/workflows/tests.yml runs them all on every pull request, one job per directory because two requirements files pin requests differently; nothing ran them before. sogs_moderation needs python3-session-util, a deb built against the system interpreter, so its job installs it from deb.session.foundation and runs on that interpreter.

suite tests
shared 63 — retry and backoff, both Retry-After forms and the reset epoch, chunking and message assembly, webhook posting and partial delivery, the state file and the Tracker, text helpers, get_env
zendesk_triage 444
github_prs 41 — selection, the window boundary, the partition, the PR record, rendering, search pagination at the result ceiling
crowdin 23 — including the report's retry contract and its posting fallback, and the download script's client; neither had tests before
deploy 17
sogs_moderation 43 — in CI; locally it needs the apt package

Verified against the live org and a test webhook rather than only mocked:

106 open PRs in session-foundation, 36 from contributors across 34 repos
run 1  no state          31 new,  0 changed,  0 unchanged   → posted, 31 recorded
run 2  state present      0 new,  0 changed, 31 unchanged   → header only
run 3  one entry rewound  0 new,  1 changed, 30 unchanged   → one ✏️ line

A busy-day render split into 2 messages (3830 + 743 chars) on a repo boundary, confirming the chunker against Discord's real 4000-char Components V2 ceiling and that a plain incoming webhook accepts the container. After the move to shared/, a read-only --dry-run against the org rendered the same shape through the shared builder, both for a quiet 3-day window and a 30-day one with repository blocks.

One message each weekday morning listing the open PRs across session-foundation's
own repositories whose author is not a maintainer and which have moved in the
last three days.

A single search fetches every open PR in the org and the window is applied to
the result, so the header can carry the total contributor backlog for the cost
of one query. Forks and archived repos are excluded by checking results against
the org's repository list rather than by name, and bot accounts on GitHub's own
account type.

Weekdays means Monday has to cover the weekend, so the window is 72h and
overlaps itself by two days on every run. A state file absorbs the overlap: 🟢
is a PR never reported, ✏️ one that has moved since it was, and a PR that has
not moved is left out. Only what Discord accepted is recorded, so a run that
fails partway re-reports that message's PRs rather than losing them, and every
way of failing to read the file treats the window as new — noisy once, never
wrong.

Dedup is keyed on updated_at, which moves on any change at all, so an edit
touching several PRs at once resurfaces all of them. The accurate alternative
needs a request per PR that moved; this is the cheaper half of that trade.

Maintainers are a hand-written list. Org membership covers six accounts, two of
which are not in the review loop, and push access is held by a dozen more as
outside collaborators — several of them contractors whose PRs are the point of
the digest. Both signals would get it wrong in both directions.

Runs on the box that already hosts the Zendesk digest, under its own user, env
file and venv — the venv because the two jobs pin requests differently.
@Bilb
Bilb force-pushed the feat/contributor-pr-digest branch from 5131343 to ae60e0d Compare September 24, 2026 02:08
@Bilb
Bilb changed the base branch from main to fix/report-claude-cli-failures September 24, 2026 02:08
A block over the text budget was posted whole and rejected, and since nothing in
a rejected message is recorded, the same block was rebuilt on every run until the
PRs aged out of the window. Blocks are now split under a repeated heading, each
sized to fit beside the header.
…s in the docs

Exclusion is by repository property, so nothing private is named anywhere in the
repo. The token guidance drops the option of a `repo` scope with it: the digest
posts to Discord, and nothing about a private repository belongs there.
…eads

Forks, archived and private repositories are always left out; there was no run
that wanted them back in.
Base automatically changed from fix/report-claude-cli-failures to main September 24, 2026 05:34
The envelope's `result` carries the reason whichever way the CLI exits, and
the exit-1 path already reads it. The exit-0 path reported only the subtype
and the status code, which does not say what went wrong.
…ers into shared/

The pull request digest was about to carry its own copy of each; crowdin's
report already does. One copy, imported by every script out of the same clone.

triage.py keeps get_env, request_with_retry, clip and post_to_discord in its
namespace: note_reply, resolve_reviews and the alert reach them there, and the
tests patch them there.
The digest carried its own copy of the retry loop, the dedup state file, the
Components V2 constants, the chunker and the webhook posting. It now imports
them from shared/, and keeps only what is its own: the record it stores per
PR, its per-message block cap, and the rendering.

The tests that covered the copies move with the code; what stays here covers
the wrappers and the rendering.
…imeout

RFC 9110 allows Retry-After in either form. Crowdin's report already parsed
the date form, so the shared loop has to before it can replace that copy; a
date already gone reads as no header, since time.sleep() rejects a negative.

The timeout was fixed at 30s; Crowdin's scan runs on 60.
…k code

The third copy of the retry loop and the Discord posting. What stays is
Crowdin's own contract: ten attempts at a 60s timeout, a 4xx that raises so
callers can read the body unchecked, and the plain-text warning posted before
the run exits on a rejected embed.
Comment thread README.md
Comment on lines +669 to +670
🟢 [#1958](…) @KyWB · 12h · 💬1 · Fix issue #563
✏️ [#1904](…) @scrense-hash · 3h · 💬2 · feat: add SOCKS5 proxy support

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

don't use real users here, or use Bilb

@Bilb

Bilb commented Sep 26, 2026

Copy link
Copy Markdown
Collaborator Author

Folded into #65, which now targets main and carries every commit from this branch. #65 replaces most of this PR's scaffolding (the shared/ retry, the per-job units and venv, the alert wiring), so the two review better as one. The README comment here is addressed there: the example digest names no real contributor.

@Bilb Bilb closed this Sep 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant