Skip to content

One Slack thread per breakage episode with live status digest - #84

Merged
saturnflyer merged 20 commits into
mainfrom
thread-persistence
Jul 24, 2026
Merged

One Slack thread per breakage episode with live status digest#84
saturnflyer merged 20 commits into
mainfrom
thread-persistence

Conversation

@saturnflyer

Copy link
Copy Markdown
Member

Summary

Implements Phase 1's remaining threading work: one Slack thread per breakage episode on a branch/PR, shared across parallel CI jobs, with the parent message maintained as a live status digest that resolves automatically when the branch goes green.

Design doc and implementation plan were developed and reviewed incrementally; every change was test-driven.

How it works

  • ThreadStore strategy (DI, like every collaborator): maps a thread key (app#pr-ref-or-branch) to a thread. Null (default, zero-config) and SlackMetadata — which uses Slack itself as the store: parents carry message metadata, jobs find the episode via conversations.history. No shared storage or infrastructure.
  • Episodes: a resolved thread is treated as a miss — break → fix → break again yields two clean threads. Matrix jobs join the same episode instead of posting three parents.
  • Live digest: each job appends a machine-readable status reply; the parent is recomputed from the full thread log via chat.update (test ruby-3.2 ❌ 12 · test ruby-3.3 ✅). Concurrent writers converge — the digest is a pure function of the reports (property-tested across writers × orderings).
  • Resolution: passing runs post nothing unless they close a known open episode (noise discipline preserved). GITHUB_RUN_ATTEMPT is folded into run keys so "Re-run failed jobs" supersedes earlier attempts.
  • Degradation ladder: missing scopes, non-member bot, channel-name-instead-of-ID, transport errors, rate limits — every failure logs and lands on pre-feature behavior (per-job threads). Verified live against a real workspace.

Also fixes a silent production bug

Messenger delegates branch/sha to app, which ChatNotifier.call passed as a plain String — every real notification raised NoMethodError, swallowed by the rescue-and-log in call. New ChatNotifier::App value object resolves branch/sha from NOTIFY_BRANCH/GITHUB_* env or git, with a regression test through the real factories.

Configuration

NOTIFY_THREAD_STORE=none opt out of episode threading
NOTIFY_JOB_NAME job identity override (default GITHUB_JOB + ruby version)
ChatNotifier.call(thread_store:) inject a custom store (two-method duck type + shared contract test)
New bot scopes channels:history (+ groups:history for private channels); channel ID required

Test plan

  • rake test — 108 runs, 209 assertions, 0 failures (67 new tests)
  • standardrb — no new offenses (7 pre-existing on main untouched)
  • rake reissue:preview — 0.4.0, changelog entries from commit trailers
  • Live debug session against a real Slack workspace: episode creation, cross-job thread joining, digest updates, resolution, and the missing-scope/channel-name degradation paths

After posting failure replies and the status reply, fetch the thread's
status replies (conversations.replies) and recompute the parent message
text via chat.update, refreshing the parent metadata status
(failing/resolved) so later finds see episode state.

- Messenger#digest renders latest-run job statuses deterministically
  for any report ordering; Messenger#resolved? reports all-green runs
- Reports without run_id sort as the oldest run
- TestEnvironment::Github#run_id falls back to GITHUB_RUN_ID and no
  longer raises when NOTIFY_TEST_RUN_ID is unset
GitHub's re-run failed jobs reuses GITHUB_RUN_ID, so a re-run pass
posted the same run_id and lost to the original failure in the digest,
leaving the episode unresolvable. Add TestEnvironment#run_key (run id
plus GITHUB_RUN_ATTEMPT when present) and use it for the status
payload's run_id field. test_run_url keeps the bare run_id, and the
persisted payload field name is unchanged so old threads still parse.
- Raise the conversations.replies limit to 1000 (API max) and warn via
  the logger when has_more indicates truncation, since the digest may
  then be stale for very long threads
- Cover the non-ok replies fetch: no chat.update is attempted
- Document the accepted premature-resolve race on update_parent
  (fetch/update are not atomic; the next event repairs a lost write)
NOTIFY_THREAD_STORE=none forces the Null store even when a bot token
would otherwise select SlackMetadata. ChatNotifier.call accepts a
thread_store: kwarg and assigns it onto each chatter via the existing
writer, leaving Chatter.handling's signature unchanged.

Also pins two Task 8 review carry-forwards: webhook-only chatters stay
silent on non-verbose success, and the success-silence paths actually
consult the store.
Added: One Slack thread per breakage episode on a branch/PR, shared across parallel CI jobs, with the parent message maintained as a live status digest
Added: Passing runs resolve open failure episodes, updating the thread parent to resolved
Version: minor
The digest embedded writer-dependent state: identifier carried the
writing job's ruby version, and the unresolved prefix used the writer's
message_prefix, so concurrent writers produced different parent texts.
Build the digest header from shared state only (app, sha, branch) and
derive the prefix from the reports, so any writer converges on
identical text. The convergence test now permutes writers (success and
failure messengers with different ruby versions) as well as report
order. Also document the two-frameworks-in-one-job caveat in the
README (set NOTIFY_JOB_NAME per framework).
Fixed: Notifications no longer fail silently: branch and sha now resolve from CI env or git instead of raising on the app name
Version: minor
@saturnflyer
saturnflyer requested a review from a team as a code owner July 24, 2026 18:58
CI checks out a detached HEAD, so `git branch --show-current` returns
empty and the ambient-git test failed. App now takes an injectable
runner (defaulting to backticks) and the fallback tests script it.
Also capture the intentionally-logged Slack error in the parent-failure
test so suite output stays pristine.
@saturnflyer
saturnflyer merged commit d0491db into main Jul 24, 2026
3 checks passed
@saturnflyer
saturnflyer deleted the thread-persistence branch July 24, 2026 20:23
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