Skip to content

ADR-311: PR event detector and watch_pr_poll.py - #82

Merged
jodavis merged 5 commits into
feature/ADR-296-concurrencyfrom
dev/claude/ADR-311
Jul 23, 2026
Merged

ADR-311: PR event detector and watch_pr_poll.py#82
jodavis merged 5 commits into
feature/ADR-296-concurrencyfrom
dev/claude/ADR-311

Conversation

@jodavis-claude

Copy link
Copy Markdown
Collaborator

Work item

ADR-311: Implement the PR event detector (detect_pr_events) and the bounded, self-looping poll script watch_pr_poll.py — the detection half of the post-hand-off PR monitor (dev-team:watch-pr, ADR-313) — so a future monitor can learn what changed on a task's PR (new review comment, CI failure, base branch moved, a dependency's PR merged, or the task's own PR merged) without needing to poll GitHub directly itself.

Changes

  • New: plugins/dev-team/skills/workflow-orchestrate/scripts/pr_event_detector.py — implements detect_pr_events(task_work_item_id) -> list[Literal["review_comment", "ci_failure", "base_updated", "dependency_merged", "task_merged"]]. Loads the task's context file via PipelineContext.load(); returns [] immediately with no gh calls if the context file doesn't exist or pr_url is empty. Detects all five event types by shelling out to gh/git via subprocess.run (never MCP), updating base_branch_sha, last_seen_review_comment_id, and last_seen_ci_conclusion only for events actually returned.
  • dependency_merged reports the actual branch a dependency's PR merged into (not a hardcoded feature branch), covering the stacked/out-of-order case where a dependency's PR merges into another still-open task's branch.
  • New: plugins/dev-team/skills/workflow-orchestrate/scripts/watch_pr_poll.py — implements poll(task_work_item_id, max_seconds=480, sleep=time.sleep, clock=time.monotonic) -> list[str] | Literal["no_change"], looping the detector on a 30s interval with an injectable sleep/clock for fully simulated unit tests. Returns as soon as any event fires; returns "no_change" once the bounded window elapses with nothing new. Includes a thin CLI main() (argparse, JSON to stdout, Error: ... + exit 1 on failure).
  • New tests: test_pr_event_detector.py (20 tests) and test_watch_pr_poll.py (8 tests), covering each event type individually and in combination, no-refire guards, early-return guards, and boundary/error inputs — all via mocked subprocess.run / real tmp_path-backed context files, no real GitHub or wall-clock calls.

Design decisions

  • "Review comment" resolved to GitHub's inline review-comments endpoint (gh api repos/<owner>/<repo>/pulls/<number>/comments), the semantically precise match for last_seen_review_comment_id and for actual reviewer feedback — not general conversation comments.
  • last_seen_ci_conclusion resolved to an aggregate value ("failing"/"pending"/"passing"/"") derived from gh pr checks --json bucket; ci_failure fires only on a transition into "failing". Documented limitation: a failing→passing→failing (different check) sequence won't re-fire, since the field is only updated when an event fires — out of scope for this task's exit criteria.
  • "The dependency" for dependency_merged is resolved via the spec's Depends on: graph (reusing task_dependencies.parse_task_dependencies() and each dependency's own context file), not by reverse-matching base_branch against another task's working_branch.
  • Idempotency for dependency_merged derives entirely from the existing base_branch field (no new context-file field): once fired, base_branch is updated to the merge target, so the next call's comparison naturally stops matching.
  • First-ever base_branch_sha observation records a baseline without firing base_updated (no prior baseline to diff against).

Testing completed

  • Full directory regression: python3 -m pytest in plugins/dev-team/skills/workflow-orchestrate/scripts/ — 193 passed, no existing tests broken.
  • Both components are Testable-tier per the task brief; no new externally observable entry point was introduced, so no E2E scenarios were in scope (the reacting component, dev-team:watch-pr, is ADR-313, out of scope here).

@github-actions

Copy link
Copy Markdown

build-and-test: Python test results

Status: ✅ Passed

Test log

@jodavis-claude jodavis-claude left a comment

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.

Reviewed against ADR-311's exit criteria in _spec_ConcurrentDevelopment.md. All six checklist items are met: detect_pr_events covers all five event types with correct no-refire guards, dependency_merged reports the actual merge target (including the stacked/out-of-order case), watch_pr_poll.py's loop/timeout mechanics are fully unit-tested with an injectable clock/sleep and no real wall-clock delay, and multiple simultaneous events are returned together. 193 tests pass in the scripts directory. Two fault-tolerance gaps below, both around unhandled gh output — worth addressing before merge since detect_pr_events is meant to run unattended inside watch_pr_poll.py's bounded polling loop, where a single transient gh hiccup shouldn't hard-crash the whole window.

Comment thread plugins/dev-team/skills/workflow-orchestrate/scripts/pr_event_detector.py Outdated
Comment thread plugins/dev-team/skills/workflow-orchestrate/scripts/pr_event_detector.py Outdated
jodavis-claude pushed a commit that referenced this pull request Jul 22, 2026
Address PR #82 review comment: _pr_state_and_base()'s `gh pr view` call and
the review-comments `gh api` call both parsed result.stdout with a bare
json.loads(...), unlike the ci_failure check right below which already wraps
the same class of gh parse in try/except json.JSONDecodeError. A transient gh
failure (rate limit, network blip, auth hiccup) raised an unhandled
JSONDecodeError out of detect_pr_events, which would kill watch_pr_poll.py's
whole bounded polling window instead of skipping that one check and retrying
next iteration. Wrap both parses the same way the CI-checks call already is,
so a flaky gh call degrades to "no signal this round" rather than a crash.

Added tests: review comments and gh pr view (both the task's own call and a
dependency's) returning non-JSON no longer raise, and the corresponding
signal is simply skipped for that call.
jodavis-claude pushed a commit that referenced this pull request Jul 22, 2026
Address PR #82 review comment: `_PR_URL_RE.match(ctx.pr_url).groups()` raised
a confusing `AttributeError: 'NoneType' object has no attribute 'groups'`
when ctx.pr_url didn't match the expected
`https://github.com/<owner>/<repo>/pull/<number>` shape, instead of a clear
diagnostic naming the offending value. Extracted the match into a
`_parse_pr_url()` helper that raises `ValueError(f"pr_url does not match
expected GitHub PR URL format: {pr_url!r}")` on no match, so detect_pr_events
still fails loudly (this isn't a transient gh flake, unlike the JSON parse
issue fixed in the previous commit) but with a message a human can act on
when this runs unattended inside watch_pr_poll.py's polling loop.

Added a test asserting a malformed pr_url raises ValueError naming the URL.

@jodavis-claude jodavis-claude left a comment

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.

Sign-off review

Both prior review threads are addressed and resolved:

  1. Unhandled json.JSONDecodeError on gh parses (_pr_state_and_base, review-comments
    gh api call) — fixed in 9fbf869. Both call sites now degrade to "no signal this round"
    on a transient gh failure instead of raising and killing watch_pr_poll.py's whole
    bounded polling window, matching the existing CI-checks pattern. Covered by 3 new tests
    (own pr-view, dependency pr-view, review-comments, each returning non-JSON output).
  2. Unguarded .match(...).groups() on ctx.pr_url — fixed in e477f16. Extracted a
    _parse_pr_url() helper that raises a clear ValueError naming the malformed pr_url
    instead of a confusing AttributeError on None. Intentionally still raises (a malformed
    pr_url is a control-plane bug, not a transient flake), just with an actionable message.
    Covered by TestDetectPrEventsMalformedPrUrl.

Confirmed both fix commits are minimal and scoped exactly to the two flagged lines (diff
1916e24..e477f16 touches only pr_event_detector.py and its test file — watch_pr_poll.py
untouched, as the fix summary claims). Full regression suite is green: 197 passed
(python3 -m pytest in plugins/dev-team/skills/workflow-orchestrate/scripts/).

No new Priority 1-5 issues found in the files modified since the last review pass.

Approved.

@jodavis-claude jodavis-claude left a comment

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.

Sign-off review — ADR-311 (fix cycle 2)

Decision: Approved.

Prior review threads

Both PR review threads are confirmed isResolved: true on GitHub:

  • Unhandled json.JSONDecodeError on gh parses (pr_event_detector.py L31/L67) — fixed in 9fbf869, verified with 3 new regression tests.
  • Unguarded _PR_URL_RE.match(...).groups() on malformed pr_url — fixed in e477f16 via a new _parse_pr_url() helper raising a clear ValueError.

No further action needed on either thread.

This cycle's change (commit 2271a2a)

Closed the sign-off validation gap flagged in the prior cycle: the "no false re-fires on an already-seen item" exit criterion was only demonstrated for review_comment, ci_failure, and dependency_merged, not base_updated/task_merged.

  • Added base_sha_unchanged_no_refire to TestDetectPrEventsBaseUpdated's parametrize list — asserts base_updated does not fire when git ls-remote reports the same sha already recorded in base_branch_sha. Verified this correctly exercises the existing elif current_sha != stored_sha: guard in pr_event_detector.py.
  • Added TestDetectPrEventsTaskMergedCalledAgain — documents (rather than changes) the accepted design limitation that task_merged has no persisted "already reported" field and fires on every call while the PR stays merged, since the brief's extra_frontmatter field list doesn't name one for it. Reasonable call — inventing a new field here would be scope creep for a signoff-driven fix cycle.
  • Both new tests pass against the unmodified implementation; no production code was touched this cycle (test-only diff, 53 lines added).
  • Full regression: 199 passed locally, matching the commit's own report. Re-ran independently: 199 passed, 0 failed.
  • gh pr checks 82: build-and-test: pass, gate: pass.

No new Priority 1–4 issues found in the modified file. All five exit-criteria-mapped event types now have explicit individual-firing, multi-firing, and no-refire test coverage. Nice work closing this out cleanly with a minimal, test-only diff.

@jodavis-claude
jodavis-claude marked this pull request as ready for review July 22, 2026 23:11
@jodavis-claude
jodavis-claude requested a review from jodavis July 22, 2026 23:12
…y covered — all five event types (review_comment, ci_failure, base_updated, dependency_merged, task_merged) firing individually and in combination, no-refire guards for review_comment/ci_failure/dependency_merged, the quiet-call no-op case (with unchanged saved context), the stacked out-of-order dependency_merged branch-reporting case, early-return guards (no context file / no pr_url), and all boundary/error inputs from the exit criteria (non-JSON checks output, missing base_branch, missing/unparseable spec_path, dependency-less task, dependency with no context file or no pr_url, and a failed git ls-remote lookup))
… covered — `poll()`'s immediate-fire branch (single and multiple simultaneous events, no sleep), the loop-until-fired branch (sleeps between empty checks, exits before `max_seconds`), the `max_seconds`-elapsed → `"no_change"` branch, and the `max_seconds=0` boundary (zero sleeps); `main()`'s CLI wrapper for both success shapes (fired-event-list and `"no_change"` JSON) and the exception → `Error: ...`/exit-1 path. All 8 tests green.)
Address PR #82 review comment: _pr_state_and_base()'s `gh pr view` call and
the review-comments `gh api` call both parsed result.stdout with a bare
json.loads(...), unlike the ci_failure check right below which already wraps
the same class of gh parse in try/except json.JSONDecodeError. A transient gh
failure (rate limit, network blip, auth hiccup) raised an unhandled
JSONDecodeError out of detect_pr_events, which would kill watch_pr_poll.py's
whole bounded polling window instead of skipping that one check and retrying
next iteration. Wrap both parses the same way the CI-checks call already is,
so a flaky gh call degrades to "no signal this round" rather than a crash.

Added tests: review comments and gh pr view (both the task's own call and a
dependency's) returning non-JSON no longer raise, and the corresponding
signal is simply skipped for that call.
Address PR #82 review comment: `_PR_URL_RE.match(ctx.pr_url).groups()` raised
a confusing `AttributeError: 'NoneType' object has no attribute 'groups'`
when ctx.pr_url didn't match the expected
`https://github.com/<owner>/<repo>/pull/<number>` shape, instead of a clear
diagnostic naming the offending value. Extracted the match into a
`_parse_pr_url()` helper that raises `ValueError(f"pr_url does not match
expected GitHub PR URL format: {pr_url!r}")` on no match, so detect_pr_events
still fails loudly (this isn't a transient gh flake, unlike the JSON parse
issue fixed in the previous commit) but with a message a human can act on
when this runs unattended inside watch_pr_poll.py's polling loop.

Added a test asserting a malformed pr_url raises ValueError naming the URL.
Sign-off validation flagged one exit criterion as only partially covered:
review_comment, ci_failure, and dependency_merged each had an explicit
already-seen/no-refire test, but base_updated and task_merged did not.

- Added a base_sha_unchanged_no_refire case to
  TestDetectPrEventsBaseUpdated: asserts base_updated does not fire when
  git ls-remote reports the same sha already recorded in base_branch_sha.
- Added TestDetectPrEventsTaskMergedCalledAgain: documents that
  task_merged fires on every call for as long as the PR stays merged,
  since (per the task brief's stated context-file fields) there is no
  persisted "already reported" field for it, unlike the other four
  events. This is an accepted design limitation, not a defect — adding
  a new field would be scope creep beyond the brief's named
  extra_frontmatter fields (base_branch_sha, last_seen_review_comment_id,
  last_seen_ci_conclusion, base_branch).

Both new tests pass against the existing implementation unmodified —
pr_event_detector.py already behaves correctly for both cases; this
commit only closes the test-coverage gap the signoff review found.

Full regression: 199 passed (197 pre-existing + 2 new).
@jodavis
jodavis force-pushed the dev/claude/ADR-311 branch from 2271a2a to 5d4c6b4 Compare July 23, 2026 12:43
@jodavis
jodavis merged commit 3457a57 into feature/ADR-296-concurrency Jul 23, 2026
3 checks passed
@jodavis
jodavis deleted the dev/claude/ADR-311 branch July 23, 2026 12:43
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.

3 participants