Skip to content

feat(gate): event protocol, SSE adapter, sticky comments, slash commands, notification routing, report CLI#29

Merged
openlawbot merged 4 commits into
mainfrom
feat/dashboard-phase-0-1
May 4, 2026
Merged

feat(gate): event protocol, SSE adapter, sticky comments, slash commands, notification routing, report CLI#29
openlawbot merged 4 commits into
mainfrom
feat/dashboard-phase-0-1

Conversation

@openlawbot
Copy link
Copy Markdown
Collaborator

Summary

Implements Phase 0 (Event Protocol & SSE Adapter) and Phase 1 (PR UX & Operator Tooling) of the Gate dashboard plan.

Phase 0 — Event Protocol & SSE Adapter

  • Formalized broadcast envelope (v, id, ts, type) with monotonic event IDs and forward-compat rules, documented in docs/protocol.md
  • Internal pub/sub on GateServer (subscribe / unsubscribe) for in-process consumers (dashboard, SSE adapter)
  • HTTP/SSE adapter (gate/web_events.py) — FastAPI app with bearer-token auth, ring-buffer replay via Last-Event-ID, async disconnect handling; wired into gate up --serve-events
  • Added fastapi + uvicorn[standard] optional [web] extra

Phase 1 — PR UX & Operator Tooling

  • Sticky PR review summary (gate/sticky.py) — single editable GitHub comment updated each review cycle, rendered via Jinja2 with .gate/templates search-path override
  • Slash commands (gate/slash_commands.py) — /gate rerun|skip|status|explain|bypass with comment polling, org-member auth gate, and action audit log (gate/actions.py)
  • Per-author notification routing (gate/notify.py refactor) — Target / route_for(event, author, repo) with notification_routes.toml schema for per-author and per-event overrides
  • gate report CLI (gate/reports.py) — review volume, verdict, and fix-pipeline metrics from reviews.jsonl with --since / --repo / --json flags

Fixes

  • Fixed pre-existing test_config.py timeout assertions (900→2400, 120→600) to match current gate.toml values

Test plan

  • All 1232 tests pass (python -m pytest -q --timeout=60)
  • New test files: test_protocol.py, test_sse_adapter.py, test_multi_subscriber.py, test_sticky_comment.py, test_slash_parse.py, test_notification_routes.py, test_reports.py
  • Pre-existing test failure in test_config.py fixed
  • Smoke test gate up --serve-events 127.0.0.1:9090 after merge on the live server
  • Verify sticky comment renders on a real PR review cycle

Made with Cursor

…nds, notification routing, report CLI

Phase 0 — Event Protocol & SSE Adapter:
- Formalize broadcast envelope (v, id, ts, type) with monotonic event IDs
- Add internal pub/sub (subscribe/unsubscribe) to GateServer for in-process consumers
- Implement FastAPI HTTP/SSE adapter (gate/web_events.py) with bearer-token auth,
  ring-buffer replay via Last-Event-ID, and async disconnect handling
- Wire --serve-events / --cors-origin flags into `gate up`
- Add fastapi + uvicorn[standard] optional `web` extra
- Document protocol in docs/protocol.md

Phase 1 — PR UX & Operator Tooling:
- Sticky PR review summary comment (gate/sticky.py) with Jinja2 template and
  .gate/templates search-path override
- Slash commands (/gate rerun|skip|status|explain|bypass) with comment polling,
  auth gating, and action audit log (gate/slash_commands.py, gate/actions.py)
- Per-author notification routing via Target/route_for with
  notification_routes.toml schema (gate/notify.py refactor)
- `gate report` CLI for review volume, verdict, and fix-pipeline metrics
  from reviews.jsonl (gate/reports.py)

Fixes:
- Fix pre-existing test_config.py timeout assertions (900→2400, 120→600) to
  match current gate.toml values

Tests: 1232 passed, 0 failures.
Co-authored-by: Cursor <cursoragent@cursor.com>
@openlawbot
Copy link
Copy Markdown
Collaborator Author

Gate (error) — review failed: Command 'gh' returned non-zero exit status 1.. Auto-approving.

…string)

Auto-fixed by `ruff check --fix` to pass CI lint step.

Co-authored-by: Cursor <cursoragent@cursor.com>
@openlawbot
Copy link
Copy Markdown
Collaborator Author

Gate (error) — review failed: Command 'gh' returned non-zero exit status 1.. Auto-approving.

GitHub rejects formal request-changes reviews when Gate is reviewing a PR authored by the same bot. Post the full review body as a PR comment instead so the verdict can still be surfaced and the run can finish cleanly.

Co-authored-by: Cursor <cursoragent@cursor.com>
@openlawbot
Copy link
Copy Markdown
Collaborator Author

Gate Review ❌

Changes requested — This PR delivers a substantial and well-structured feature set — versioned event protocol, HTTP/SSE adapter, sticky comments, slash commands, per-author notification routing, and a report CLI — all of which pass build, lint, and the 1,227-test suite. However, one confirmed correctness bug blocks merge: the /gate rerun slash-command handler always sends an empty head_sha and empty branch to the review queue because it reads from a config key (default_branch_head_sha) that is never written anywhere in the codebase. The logic stage confirmed this with a mutation-checked gate test. In practice, every /gate rerun invocation fails silently: create_check_run 422s on an empty SHA, the orchestrator falls into its fail-open except Exception path, and the user receives an auto-approval with a misleading success reply instead of a real re-review. The remaining findings are all warning-level (non-constant-time bearer token comparison, three architecture convention violations, one test gap) and do not individually block merge, but the rerun correctness error does.

Errors

  • gate/slash_commands.py:290 (test confirmed) — /gate rerun always enqueues a review_request with empty head_sha and empty branch. The handler reads head_sha = str(repo_config.get('default_branch_head_sha', '')), but default_branch_head_sha is never written by any code path in this PR or elsewhere in the codebase (confirmed by grep). The empty SHA causes github.create_check_run(repo, '') to 422, then create_worktree fails on git fetch origin '', the orchestrator falls into its fail-open except Exception path, and the PR is auto-approved. The operator sees :arrows_counterclockwise: Re-running review for PR #N but no real review runs. Confirmed by a mutation-checked gate test (mutation result: fail, test intent_type: confirmed_bug).

    Fix: Before sending review_request, look up the PR's actual head commit: pr_info = github.get_pr_info(cmd.repo, cmd.pr_number); head_sha = pr_info.get('headRefOid', ''); branch = pr_info.get('headRefName', ''). Reject the command (return an error reply) if either field is empty, e.g. if the PR is closed.

Warnings

  • gate/web_events.py:298 (pattern match) — Bearer-token comparison uses Python's short-circuiting != operator, which is not constant-time. This introduces a timing oracle on the new public-facing SSE endpoint. Low practical risk against a 32-byte random token over a noisy internet link, but a clear best-practice violation on a brand-new auth code path.

    Fix: Replace if provided != token: with if not secrets.compare_digest(provided, token): (add import secrets at the top of the module). Optionally enforce a minimum token length (e.g. 32 bytes) in cmd_up at startup.

  • gate/slash_commands.py:160 (pattern match) — _latest_verdict_for_pr constructs the per-PR state path directly as state_dir() / repo_slug(repo) / f'pr{pr_number}' instead of using the canonical state.get_pr_state_dir(pr_number, repo) helper, bypassing the single source of truth for state directory layout.

    Fix: Replace the inline path construction with state.get_pr_state_dir(pr_number, repo) and import get_pr_state_dir from gate.state.

  • gate/slash_commands.py:441 (pattern match) — CommentPoller.__init__ calls get_all_repos() without a config argument, which internally calls load_config(). This violates the config-propagation rule that inner functions must receive config: dict rather than loading config themselves.

    Fix: Accept an optional config: dict | None = None parameter in CommentPoller.__init__ and pass it through to get_all_repos(config). The caller (cmd_up) already has the loaded config available.

  • gate/actions.py:127 (pattern match) — read_actions opens the actions log with open(path, 'r') without wrapping the call in try/except OSError. The preceding path.exists() check does not eliminate the TOCTOU window (file could be deleted or become unreadable between the check and the open).

    Fix: Wrap the open() and loop body in a try/except OSError block, logging at DEBUG level and returning early, consistent with the function's best-effort contract.

  • tests/test_slash_parse.py:146 (pattern match) — test_authorised_commenter_proceeds asserts only outcome == 'ok' and reply text contains 'Re-running'; it never inspects the message dict passed to gate_client.send_message. Because the test discards call_args, the confirmed empty-head_sha bug was not caught by the test suite.

    Fix: Capture the message argument and assert head_sha != '' and branch != '' once the rerun handler is fixed to fetch real PR head info. Add: msg = mock_send_message.call_args.args[1]; assert msg['head_sha'] != ''.

Notes

  • gate/notify.py:146_section_extras(section, key) never reads the key parameter; both call sites pass a literal string that the function body discards. Likely a refactor artifact.

Build Results

  • ruff: ✅ (0 warnings)
  • pytest: ✅ (1227/1233 passed)

1 error, 5 warnings, 1 note across 6 stages (1266s, confidence: high)

@openlawbot
Copy link
Copy Markdown
Collaborator Author

openlawbot commented May 4, 2026

Gate Review Summary

Verdict:Approved  ·  confidence high  ·  440s

This is a re-review after the fix commit fa0284e. All six prior blocking and warning findings have been resolved: the critical empty-head_sha bug in /gate rerun is fixed by fetching the real PR head via get_pr_info; bearer-token comparison now uses secrets.compare_digest; _latest_verdict_for_pr now uses the canonical state.get_pr_state_dir helper; CommentPoller.__init__ now receives config propagated from cmd_up; read_actions now wraps the file open in try/except OSError; and the test for the authorized-commenter path now asserts the full message dict including head_sha and branch. The build is clean (1227 passed, 6 skipped, 0 failed; lint and typecheck pass). Architecture and security stages returned no findings. Per the re-review graduation rule, with all prior errors and warnings resolved and only a pre-existing info-level finding remaining in unchanged code, the decision is approve.

Non-blocking notes (1)
  • INFO  gate/notify.py:L146  ·  fb921542da
    _section_extras(section, key) never reads the key parameter; both call sites pass a literal string that the function body discards. Likely a refactor artifact.

Build

typecheck ✅  ·  lint ✅  ·  tests

Stats

0 blocking  ·  1 other  ·  6 stages run


Next steps

  • This PR is approved by Gate. Non-blocking notes above are advisory.
  • A human reviewer should still sign off if your branch protection requires it.

How to override

You want to… Add this label, or comment
Skip Gate entirely gate-skip  or  /gate skip <reason>
Re-run the review gate-rerun  or  /gate rerun
Bypass blocking findings (audited) gate-emergency-bypass  or  /gate bypass <incident-link>
Disable auto-fix for this PR gate-no-fix

Updated 2026-05-04 17:28 UTC  ·  sticky comment, replaced in-place on each review cycle.

Address blocking findings from review.

Logic
* `/gate rerun` previously read `default_branch_head_sha` from repo
  config, a key never written anywhere. The empty SHA caused
  `create_check_run` to 422, `git fetch origin ''` to fail, and the
  fail-open path to auto-approve while the operator saw a "rerunning"
  reply. Now fetches `headRefOid`/`headRefName` via `github.get_pr_info`
  and rejects with an audit-logged `error` reply when either is empty
  (closed/detached PR).
* Strengthen `test_authorised_commenter_proceeds` to capture the
  `send_message` payload and assert `head_sha` and `branch` are
  populated from the mocked PR info — the original assertion only
  checked the reply text and missed the empty-SHA bug.

Security
* `web_events._check_auth` now uses `secrets.compare_digest` for the
  bearer-token comparison instead of `!=` so the SSE auth path is not
  a timing oracle.

Architecture
* `actions.read_actions` wraps the file open and read loop in
  `try/except OSError` (best-effort generator, debug-logs on failure).
  The pre-existing `path.exists()` check cannot eliminate the TOCTOU
  window.
* `slash_commands._latest_verdict_for_pr` now uses
  `state.get_pr_state_dir(..., create=False)` instead of constructing
  the per-PR state path inline, restoring the single source of truth
  for state directory layout.
* `slash_commands.CommentPoller.__init__` accepts an optional
  `config: dict | None` and threads it into `get_all_repos(config)` so
  it no longer triggers an inner `load_config()`. `cmd_up` (a CLI
  entry point) loads the config and passes it through.
@openlawbot
Copy link
Copy Markdown
Collaborator Author

Gate Auto-Fix Applied

Fixed 6/6 findings in 1 iteration(s) (fa0284e).

Fixed:

  • gate/slash_commands.py — Replaced the empty-SHA lookup with a lazy-imported call to github.get_pr_info(cmd.repo, cmd.pr_number); use headRefOid/headRefName for head_sha and branch in the review_request message; on get_pr_info CalledProcessError/JSONDecodeError record an error action and return an error reply; when headRefOid or headRefName is missing (closed/detached PR) record a rejected action and return a no_entry_sign reply.
  • gate/web_events.py — Added import secrets (alphabetised among stdlib imports) and replaced if provided != token: with if not secrets.compare_digest(provided, token): in _check_auth so token comparison is constant-time.
  • gate/slash_commands.py — Replaced state_dir() / repo_slug(repo) / f'pr{pr_number}' with get_pr_state_dir(pr_number, repo, create=False); added from gate.state import get_pr_state_dir and removed the now-unused state_dir from the gate.config import (kept repo_slug because _last_seen_path still needs it). create=False preserves the read-only contract.
  • gate/slash_commands.py — Added config: dict | None = None keyword parameter to CommentPoller.init and pass it through to get_all_repos(config) when repos is not supplied. Updated cmd_up in gate/cli.py (a CLI entry point) to call load_config() and pass the result via CommentPoller(socket_path=_sp(), config=load_config()).
  • gate/actions.py — Wrapped the with open(path, 'r', ...) block and read loop in try/except OSError; on OSError logs at DEBUG via logger.debug('read_actions: failed to read %s', path, exc_info=True) and returns (in a generator this just ends iteration), keeping the best-effort contract. record_action was already correctly handled and was not modified.
  • tests/test_slash_parse.py — Strengthened the test to mock gate.github.get_pr_info returning {'headRefOid': 'abc1234567890def', 'headRefName': 'feature/x'}, capture the send_message call via as mock_send, and assert that msg['head_sha'] and msg['branch'] equal the mocked values and are non-empty. Future regressions of the empty-SHA pattern will now fail this test.

@openlawbot
Copy link
Copy Markdown
Collaborator Author

Gate Review ✅

Approved — This is a re-review after the fix commit fa0284e. All six prior blocking and warning findings have been resolved: the critical empty-head_sha bug in /gate rerun is fixed by fetching the real PR head via get_pr_info; bearer-token comparison now uses secrets.compare_digest; _latest_verdict_for_pr now uses the canonical state.get_pr_state_dir helper; CommentPoller.__init__ now receives config propagated from cmd_up; read_actions now wraps the file open in try/except OSError; and the test for the authorized-commenter path now asserts the full message dict including head_sha and branch. The build is clean (1227 passed, 6 skipped, 0 failed; lint and typecheck pass). Architecture and security stages returned no findings. Per the re-review graduation rule, with all prior errors and warnings resolved and only a pre-existing info-level finding remaining in unchanged code, the decision is approve.

Notes

  • gate/notify.py:146_section_extras(section, key) never reads the key parameter; both call sites pass a literal string that the function body discards. Likely a refactor artifact.

Resolved since last review

  • gate/slash_commands.py/gate rerun always enqueued a review_request with empty head_sha and branch, causing create_check_run to 422 and falling into fail-open auto-approve. (fixed)
  • gate/web_events.py — Bearer-token comparison used Python's short-circuiting != operator, introducing a timing oracle on the public-facing SSE endpoint. (fixed)
  • gate/slash_commands.py_latest_verdict_for_pr constructed the per-PR state path directly instead of using the canonical state.get_pr_state_dir helper. (fixed)
  • gate/slash_commands.pyCommentPoller.__init__ called get_all_repos() without a config argument, violating the config-propagation rule. (fixed)
  • gate/actions.pyread_actions opened the actions log with open(path, 'r') without a try/except OSError, leaving a TOCTOU window. (fixed)
  • tests/test_slash_parse.pytest_authorised_commenter_proceeds only asserted outcome and reply text, discarding the message dict and failing to catch the empty-head_sha regression. (fixed)

Build Results

  • ruff: ✅ (0 warnings)
  • pytest: ✅ (1227/1233 passed)

1 note across 6 stages (440s, confidence: high)

@openlawbot openlawbot merged commit 6969472 into main May 4, 2026
5 checks passed
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