feat(core): opt-in gh CLI tracer + scm/tracker migration (Phase A1a) (#1238)
* feat(core): add opt-in gh CLI tracer and migrate scm/tracker plugins
Introduces execGhObserved() in @aoagents/ao-core: a thin wrapper around
execFile("gh", ...) that writes a JSONL trace row to $AO_GH_TRACE_FILE
on both success and failure. Captures status line, HTTP status, ETag,
rate-limit headers, duration, stdout/stderr byte counts, exit code, and
signal. No-op when the env var is unset, so default behavior is
unchanged.
Migrates three call sites to the observer:
- scm-github/graphql-batch.ts — PR-list guard, commit-status guard,
GraphQL batch query
- scm-github/index.ts — gh() and ghInDir() helpers
- tracker-github/index.ts — internal gh() helper
This is Phase A1a of experiments/PLAN.md: tracer infrastructure +
migration. The full GhRunner contract (Promise<GhResult>,
GhRunnerError.ghResult on reject, body capture, redaction, 64 KB cap)
lands in A1b along with the scorecard baseline.
Also adds experiments/ reference docs: the v2.3 plan, the gh-CLI call
catalog, two ETag verification writeups, and a trace harness + summary
script.
* docs(experiments): add A1a validation status and A1b blockers
Record the five A1b pre-freeze blockers surfaced by Adil's 1,487-row
baseline and an independent drill run: graphql-batch missing -i,
extractOperation flag mis-bucketing, analyzer not segmenting burn by
reset window, CLI-subcommand opacity (GH_DEBUG=api stderr vs coarse
/rate_limit bracket — not equivalent), and sessionId/projectId not
threaded through plugin callsites. Note bare gh() helper cleanup as
known-open follow-up.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(tracer): close A1b blockers 1-4 — graphql-batch visibility, operation naming, analyzer segmentation
- Add -i flag to executeBatchQuery in graphql-batch.ts and split HTTP
headers from JSON body before parsing, making all gh.api.graphql-batch
rows visible to status and rate-limit analysis (was 186 invisible rows)
- Fix extractOperation() in gh-trace.ts to walk past -* flags before
picking the operation segment, eliminating the gh.api.--method bucket
- Add per-reset-window burn segmentation to both analyzers so runs
straddling a reset boundary produce per-window deltas instead of a
single invalid cross-reset delta
- Add experiment scripts: analyze-trace.mjs (deep trace analysis) and
drill-tracer.mjs (standalone tracer exerciser)
- Document Gap 1 decision in PLAN.md: accept CLI subcommands as opaque
for A1, bracket A2 runs with /rate_limit snapshots for coarse burn
- Add progress timeline to PLAN.md showing A→B→C track dependencies
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* docs(experiments): add A2 baseline matrix runbook
Practical execution plan for the Phase A2 scenario x scale x topology
matrix: 7 priority cells, per-cell procedure, /rate_limit bracketing
for Gap 1 subcommand burn, output format for baseline.md, and the
scorecard that gates Track B.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(tracer): guard stderr/stdout against undefined, bound operation cardinality
Addresses code review findings:
1. Guard Buffer.byteLength and parseIncludedHttpResponse against
undefined stderr/stdout — fixes 48 SCM test regressions where
mocked execFile paths don't populate stderr
2. extractOperation() now takes only the first path segment of REST
URLs (e.g. "repos" from "repos/acme/repo/pulls/123/...") to keep
operation bucket cardinality bounded and stable across runs
3. Fix A2 runbook /rate_limit snapshots to produce valid JSON using
jq's now|todate instead of appending raw timestamp
4. Add blocker 5 dependency to runbook prereqs and per-session cells
All 140 SCM tests pass (0 failures).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* docs(experiments): add rate-limiting research artifacts
Baseline measurements, discussion notes, benchmark harness spec,
and updated master plan from two independent trace runs at 5-6 sessions.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat(experiments): add benchmark harness for GH rate-limit measurement
Three modes: setup (spawn sessions, wait for PRs), measure (trace API
calls over a fixed window, produce scorecard), report (recompute from
existing trace). Node.js stdlib only, shells out to ao CLI and gh CLI.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(scm-github): handle 304 Not Modified in ETag guard catch blocks (B1)
`gh api -i` exits code 1 on HTTP 304 responses, causing the catch blocks
in checkPRListETag and checkCommitStatusETag to assume the resource changed
and trigger unnecessary GraphQL batch queries every poll cycle.
Fix: inspect stdout/stderr in the catch block for the 304 status line before
falling back to "assume changed". Also unifies the 304 detection regex to
handle HTTP/1.1, HTTP/2, and HTTP/2.0 status lines, and adds rateLimit
introspection to the batch GraphQL query.
Benchmark result (quiet-steady, 5 sessions, 15 min):
- GraphQL points/hr: 260/5,000 (5%) — down from 820–1,416 pre-fix
- ETag guard 304 rate: 100%
- GraphQL batch calls during measurement: 0
Also fixes the benchmark harness to create placeholder tmux sessions with a
claude symlink so the lifecycle actually polls sessions instead of
short-circuiting to "killed".
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* docs(experiments): update plan and notes with B1 benchmark results
B1 fix validated at 5, 10, and 20 sessions in quiet-steady state:
- 5 sessions: 260 GraphQL pts/hr (5% budget)
- 10 sessions: 640 pts/hr (13%)
- 20 sessions: 680 pts/hr (14%) — sub-linear scaling confirmed
- 50-session projection: ~800-1000 pts/hr (16-20%)
- ETag guard 304 rate: 100% at all scale points
- graphql-batch calls: 0 during measurement at all scale points
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat(core): log gh wrapper invocations for D1
* fix(core): preserve wrapper logging for dash-prefixed gh args
* feat(core): add gh wrapper cache for PR discovery and issue context (D4)
Add read-through caching to the ~/.ao/bin/gh wrapper, targeting the two
largest agent-side waste buckets identified in D4 analysis:
1. PR discovery (gh pr list --head): infinite TTL for positive results.
598 calls → ~10 per 10-session run (98% reduction).
2. Issue context (gh issue view): 300s TTL.
75 calls → ~20 per 10-session run (73% reduction).
The wrapper now caches successful read-only responses in
$AO_DATA_DIR/.ghcache/$AO_SESSION/ and serves them on subsequent
identical calls. Negative results (empty []) are never cached.
gh pr create populates the PR discovery cache immediately.
Also lifts PATH wrapper installation from individual agent plugins into
session-manager, making it universal for all agents including Claude Code:
- session-manager injects PATH + GH_PATH into every runtime.create()
- session-manager calls setupPathWrapperWorkspace() for all agents
- Removes duplicate buildAgentPath/setupPathWrapperWorkspace boilerplate
from codex, aider, opencode, and cursor plugins
Includes D4 implementation plans in experiments/.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* docs(experiments): add full capacity discovery (5→50 sessions) and CI churn results
Complete scaling curve measured: 50 sessions uses only ~28% of GraphQL
budget with 100% ETag guard hit rate at every scale. Poll cycle lag
identified as first bottleneck (66s at 50 sessions vs 30s target).
CI churn benchmark shows ETag invalidation is a latency problem, not
a rate-limit problem (+9% GraphQL, +4.4x p50 latency).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* docs(experiments): record real-agent catastrophe and Track D handoff
5-real-agent run on todo-app exhausted GraphQL bucket in 31 min (~9572 pts/hr,
~37x quiet-steady at the same session count). AO polling consumed ~10 calls;
the rest came from agents themselves via the metadata-only ~/.ao/bin/gh
wrapper, which has no tracing. Captures findings, adds Track D (agent-side
gh consumption) plus B5 (migrate remaining bare gh callsites to
execGhObserved), and includes the runbook + benchmark scripts Adil will
build on for the cross-machine reproduction.
* feat(core): add cache-hit/miss tracing to gh wrapper (D4)
The wrapper trace now logs a cacheResult entry for every cacheable
command: hit, miss-stored, miss-negative, or miss-error. This makes
benchmark runs conclusive — you can count cache hits vs real gh calls
directly from the JSONL trace instead of inferring from rate-limit
deltas.
Bump wrapper version to 0.4.1.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat(scm-github): replace repo-scoped Guard 1 with PR-scoped ETag checks (D4)
Guard 1 now checks GET /repos/{owner}/{repo}/pulls/{number} per PR
instead of GET /repos/{owner}/{repo}/pulls?... per repo. This means:
- Only changed PRs flow into the GraphQL batch
- Unchanged PRs are served directly from the enrichment cache
- shouldRefreshPREnrichment returns a refresh plan (prsToRefresh +
cachedResults) instead of a boolean
When 1 of 10 PRs changes, the old guard refreshed all 10 via GraphQL.
Now only the 1 changed PR is fetched; the other 9 are served from cache
at zero GraphQL cost.
Trade-off: more REST guard calls (1 per PR instead of 1 per repo), but
304 responses cost zero rate limit points.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Forward AO_AGENT_GH_TRACE to session runtimes
* revert: remove PR-scoped ETag guards (Change 3)
Reverts 25ae6013. The per-PR Guard 1 added more REST calls (1 per PR
instead of 1 per repo) without meaningful GraphQL savings at 10-session
scale. Core REST delta went from 16 to 142 while GraphQL rate stayed
flat. The repo-scoped guard is sufficient for current workloads.
Preserves the subsequent 6fc64f4f commit (AO_AGENT_GH_TRACE forwarding).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(core): use real gh binary in execGhObserved, bypass wrapper
execGhObserved() was calling bare "gh" which resolved to ~/.ao/bin/gh
(the wrapper) when that directory was in PATH. This caused:
- AO-side gh calls going through the agent wrapper
- All trace rows with aoSession=null polluting the agent trace
- Cache functions silently failing (no AO_SESSION in AO process)
Now strips ~/.ao/bin from PATH and resolves the real gh binary
(e.g. /opt/homebrew/bin/gh) at startup. Cached after first resolution.
AO process → execGhObserved → real gh → AO_GH_TRACE_FILE
Agent process → ~/.ao/bin/gh wrapper → AO_AGENT_GH_TRACE + cache
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(core): harden gh wrapper caching and agent-side tracing
Cache correctness:
- Include --json fields in cache key (prevents stale partial responses)
- Only cache stdout, not stderr (prevents warning contamination)
- Fix trailing newline inconsistency in PR discovery cache
- Support --key=value arg syntax for all cached flags
- Remove PR create cache pre-population (hardcoded fields, no JSON escaping)
- Log miss-write-failed when ao_cache_write fails (previously silent)
Agent trace improvements:
- Add operation field to invocation rows (gh.pr.list, gh.issue.view, etc.)
- Add durationMs, exitCode, ok to cache outcome rows
- Log passthrough for all non-cached code paths (pr/create, default case)
- Replace exec with child process in default case to enable post-call tracing
Bump wrapper version to 0.6.0.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(runtime-tmux): re-export PATH after shell init to survive macOS path_helper
macOS zsh runs path_helper during shell startup which resets PATH,
wiping entries set via tmux new-session -e. This caused ~/.ao/bin
to be lost, so the gh/git wrappers were never intercepting agent
calls — no caching, no tracing, no metadata auto-updates.
Fix: send `export PATH=...` via send-keys after the shell has
initialized but before the launch command, ensuring PATH sticks.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(runtime-tmux): use launch script for PATH re-export instead of send-keys
The previous send-keys approach sent 1000+ literal keystrokes for the
PATH value, which broke terminal input buffers and caused stuck quote
prompts. Instead, include the PATH export in the launch script file
which is executed directly — no terminal buffer issues.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* docs(experiments): add AO-side gh rate-limit trace report
5-session, 15-minute trace analysis with full call breakdown,
ETag guard effectiveness, anomaly investigation, and ranked
reduction opportunities.
Key findings:
- GraphQL at 41%/hr with 5 sessions (bottleneck at ~12 sessions)
- 47% of calls are individual REST fallbacks that batch should cover
- Review thread GraphQL calls (55/15min) can be folded into batch
- detectPR() and guard failures are working as designed
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* docs(experiments): add AO rate-limit reduction plan with Step 1
Step 1: Remove individual REST fallback from determineStatus().
110 calls (65 pr view + 45 pr checks) eliminated per 15-min window.
Batch enrichment covers all PRs every 30s — fallback is unnecessary
insurance for an event that never occurred in real traces.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* experiments(m2): drop agent-trace gate for claude-code
Claude Code uses native PostToolUse hooks (.claude/settings.json), bypassing
the ~/.ao/bin/gh PATH wrapper, so AO_AGENT_GH_TRACE stays empty even when
Claude makes gh calls. The previous smoke gate required AGENT_ROWS>0 and
aborted every claude-code M2 batch at smoke.
- limit-finder.sh: add REQUIRE_AGENT_TRACE env + --no-require-agent-trace flag,
gate the AGENT_ROWS integrity check behind it.
- m2-ab-run.sh: bump SMOKE_DURATION to 420s; auto-pass --no-require-agent-trace
when AGENT=claude-code; simplify smoke_check to gate only on AO_ROWS>0
(B1 lives in AO-side scm-github, measured by AO trace).
Follow-up tracked as task #39: instrument Claude's hook/tool path or document
that AO_AGENT_GH_TRACE does not cover Claude Code.
* feat(tracker-github): cache issue reads in-process (5 min TTL)
The lifecycle worker polls getIssue/isCompleted repeatedly for the same
issue across a session. Trace data from a 5-session tier-5 bench run
showed the same (repo, issue) pair fetched 64+ times with >97% duplicate
rate — ~744 of 4,059 AO gh calls in 10 minutes were redundant issue views.
Adds an in-process Map<string, CachedIssue> per createGitHubTracker()
instance, keyed by `${repo}#${id}`, TTL 5 min, bounded to 500 entries
(LRU evict-oldest on overflow).
- getIssue: read-through cache, populate on miss
- isCompleted: routes through getIssue (was a separate narrow gh call)
- updateIssue: invalidate the entry before mutating
- createIssue: unchanged, naturally populates via the existing getIssue
- Failures are not cached
Cache lives inside createGitHubTracker so each create() returns an
isolated cache (test isolation comes for free).
Expected reduction: ~744 → ~15 gh issue view calls per tier-5 run.
Tests: 41 existing + 10 new cache tests, all passing.
* feat(scm-github): cache 5 gh pr view callsites with per-method TTLs
The lifecycle worker repeatedly polls each PR for state, summary, reviews,
and review decision. Trace data showed gh pr view was the single largest
AO-side endpoint at 1,280 calls per 5-session tier-5 run with >97% duplicate
rate (e.g. PR #184 polled 86× for --json state alone in 11.5 minutes).
Adds an in-process per-instance cache inside createGitHubSCM(), keyed by
${owner}/${repo}#${prKey}:${method} so different field-sets stay isolated.
Per-method TTLs balance reduction against staleness on decision-influencing
fields:
- resolvePR: 60s (identity metadata only)
- getPRState: 5s
- getPRSummary: 5s (includes state)
- getReviews: 5s
- getReviewDecision: 5s
assignPRToCurrentUser, mergePR, and closePR each invalidate the entire PR
cache for that PR after the mutation, so AO never sees stale state from its
own writes. Failures are not cached.
getCIChecksFromStatusRollup and getMergeability are intentionally NOT cached
here — those need ETag-based revalidation, not blind TTL, and will land
separately.
Expected reduction: ~1,165 of ~1,280 gh pr view calls per tier-5 run.
Tests: 73 existing + 12 new cache tests, all 153 passing.
* feat(scm-github): cache CI checks, mergeability, pending comments, detectPR
Completes the AO-side hot-read caching alongside the prior PR view cache.
All use 5s TTL per the approved policy for decision-influencing fields —
well under one lifecycle poll cycle so state transitions are still seen
next pass.
- getCIChecks (gh pr checks): 5s TTL
- getMergeability (composite pr view + CI + state): 5s TTL on the composite
- getPendingComments (gh api graphql review threads): 5s TTL —
ETag doesn't help on GraphQL per Experiment 2
- detectPR (gh pr list --head BRANCH): 5s TTL, POSITIVE-ONLY.
Empty results are never cached so a freshly created PR is discovered
on the very next poll. The branch-keyed cache entry is invalidated
by mergePR/closePR alongside the number-keyed entries.
Combined with the prior PR view cache, covers the top 6 AO-side gh
operation categories that accounted for ~85% of calls in tier-5 traces.
Tests: 85 existing + 9 new cache tests, all 162 passing.
* experiments(m2): parse REPO from yaml before using it in banner
m2-ab-run.sh referenced $REPO in the header banner before parsing it,
causing 'unbound variable' abort under 'set -u'. Parse it right after
CONFIG_FILE is set.
* test(core): mock full Issue shape in plugin-integration cleanup tests
After tracker-github routed isCompleted() through getIssue() to share
the issue cache, these mocks needed the full Issue shape (number, title,
body, url, state, stateReason, labels, assignees) instead of the narrow
{state} shape that worked when isCompleted made its own --json state call.
* perf(scm-github): tune cache TTLs based on trace replay
Replayed feat run1 + main run2 tier-5 traces (4059 + 1748 rows, 38 min, 5
sessions each) against the shipped cache logic. Three TTLs were materially
under-tuned for the actual lifecycle poll cadence:
- detectPR: 5s → 30s (was 0.5% hit rate; per-branch poll cadence
is ~90s, so 5s caught nothing. 30s catches
intra-cycle bursts when multiple sessions
share a branch. Positive-only stays.)
- getReviewDecision: 5s → 10s (within "10-30s TTL or ETag" policy)
- getPendingComments: 5s → 10s (same policy class)
All three are still well under one poll cycle; freshness contract unchanged
in practice. Other TTLs (5s on state/CI/mergeability, 60s on resolvePR,
5min on issue) hit the targets they were set for and stay as-is.
Replay results before/after:
- feat run1: 53.7% → 57.8% reduction (2179 → 2345 hits of 4059 calls)
- main run2: 47.4% → 52.6% reduction
- Net: ~55% AO-side gh calls eliminated across both traces
Adds experiments/cache-replay.mjs — a counterfactual replay tool that
walks an execGhObserved JSONL trace and simulates per-method cache hits
with the shipped TTLs. Useful as a regression check when tweaking cache
policy.
Tests: 162/162 passing.
* docs(experiments): add cache freshness check runbook
Seven-step manual runbook to validate the cache TTL contract doesn't
cause workflow lag. Covers each cached method with:
- exact gh CLI trigger command
- what to observe in the dashboard / lifecycle log
- pass/fail threshold (TTL + 30s poll cycle)
Companion to experiments/cache-replay.mjs — replay measures how much
we saved, runbook measures whether we lost anything in the process.
* docs(experiments): add Step 2 — consolidate review comment fetching
Single GraphQL call replaces GraphQL + REST for review comments.
Include comment data in agent reaction message to eliminate
agent-side gh read calls. Update future steps.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* docs(experiments): add duplicate API traffic analysis
Three independent sources hit GitHub API for the same PRs:
1. Dashboard serialize.ts — individual REST calls, no batch, no cache
2. CLI lifecycle manager — batch + guards
3. Web lifecycle manager — same batch + guards, 3s offset
~50% of all API traffic is pure duplication. Dashboard and dual
lifecycle managers are the root causes.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* docs(experiments): add full cache architecture to duplicate traffic analysis
Three independent cache layers across two processes with zero shared
state. Web process creates its own plugin registry, SCM plugin, lifecycle
manager, and dashboard cache — all hitting GitHub independently.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* docs(experiments): add shared PR enrichment plan
Persist batch enrichment + review comments to session metadata files.
Dashboard reads from disk instead of making its own GitHub API calls.
Remove web's duplicate lifecycle manager.
Eliminates ~268 calls / 15 min (58% of all traffic). Dashboard data
gets fresher (30s vs 5min). Single writer (CLI lifecycle), web only reads.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* docs(experiments): update Step 1 — remove all three fallback paths
Remove fallback in determineStatus(), maybeDispatchCIFailureDetails(),
and maybeDispatchMergeConflicts(). All three follow the same pattern:
batch cache hit → use it, cache miss → skip (wait 30s for next batch).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* docs(experiments): promote Step 3 (remove dead reviews field) + detail Step 5 (issue caching)
Step 3: Remove reviews(last: 5) from batch query — fetched but never
consumed, reduces GraphQL complexity on every batch call.
Step 5: Persist issue data to session metadata at spawn — eliminates
27 gh issue view calls per 15 min (both processes re-fetch independently).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat(core): remove individual REST fallback from lifecycle polling
Remove fallback paths in determineStatus(), maybeDispatchCIFailureDetails(),
and maybeDispatchMergeConflicts() that made individual REST calls when the
batch enrichment cache missed. The batch runs every 30s — a cache miss
means the data arrives on the next cycle, not that it's lost.
Also add populatePREnrichmentCache() call to check() so single-session
checks also use the batch path.
Eliminates ~110 individual pr view/pr checks calls per 15-min window
(24% of all AO-side traffic).
* feat(core): consolidate review comment fetching into single GraphQL call
Add getReviewThreads() to SCM interface — returns all review threads
(human + bot) with isBot flag from a single GraphQL query. Lifecycle
manager splits locally for separate reaction pipelines.
- Eliminates the REST getAutomatedComments() call (40 calls / 15 min)
- Reaction messages now include inline comment data (file, line, author,
body, URL) so agents don't need to re-fetch via gh api
- Default config messages updated to not tell agents to call gh
- getAutomatedComments kept as optional for backward compatibility
* perf(scm-github): remove unused reviews(last: 5) from batch query
The batch query fetched reviews with author, state, submittedAt but
the data was never consumed — only used in a validation check.
The reviewDecision scalar field provides everything AO needs.
Reduces GraphQL complexity cost on every batch call.
* docs(experiments): add post-optimization trace report (Steps 1-3)
5-session, 17-minute trace after removing REST fallback, consolidating
review comments, and removing dead reviews field.
Results: GraphQL 35%/hr (was 41%), REST <1% (was 3%), automated
comment REST calls eliminated. 54% of remaining traffic is redundant
(duplicate lifecycle manager + dashboard individual calls).
* docs(experiments): add trace file gist link to post-optimization report
* feat(core,web): shared PR enrichment — dashboard reads from metadata
CLI lifecycle manager now persists batch enrichment data and review
comments to session metadata files (prEnrichment + prReviewComments
keys). The web dashboard reads from metadata instead of calling
GitHub API.
Changes:
- lifecycle-manager: add persistPREnrichmentToMetadata() after poll,
write prReviewComments in maybeDispatchReviewBacklog()
- serialize: replace enrichSessionPR (6 API calls) with metadata read
- services: stop web lifecycle polling (keep for webhook checks)
- cache: remove prCache (no longer needed)
- routes: remove timeout wrappers and cacheOnly pattern
Eliminates ~237 calls / 15 min (54% of all AO-side traffic).
Dashboard data freshness improves from 5min to 30s.
* fix(web): remove unused beforeEach import in serialize test
* docs(experiments): add final trace report — 56% GraphQL reduction achieved
5-session, 24-min trace after all optimizations including shared
enrichment. GraphQL 905/hr (was 2,072), REST 5/hr (was 168).
Single lifecycle manager confirmed. Dashboard API calls eliminated.
Max sessions before budget exhaustion: ~27 (was ~12).
* docs(experiments): add REST budget breakdown to final report
* fix(core): use storageKey for getSessionsDir in persistPREnrichmentToMetadata
* fix(test): use OpenCodeSessionManager type in plugin-integration tests
* fix(test): update bugbot-comments and auto-cleanup tests for new review API
* fix(web): fix syntax error and missing import from rebase
* docs(experiments): add complete rate-limiting change log and update final report numbers
* fix(web): fix tmux session resolution for legacy wrapped storageKeys
* fix(web): pass tmuxName directly to terminal server instead of reverse-resolving
* perf(core): gate detectPR behind Guard 1 ETag — skip when PR list unchanged
* perf(core): always run Guard 1 for all repos, dedup issue views, include threadId in review messages
* feat(core): add Guard 3 (review ETag), enrich review data with summaries, dedup issue views, gate detectPR for all repos
* fix(web): reuse cached tmuxSessionId on re-open, add 15-session trace report and comparison docs
* perf(scm-github): reduce contexts to first:10, add -i to review GraphQL for rate limit tracing
* feat(core): merge CI details into transition, enrich merge conflict message, reduce batch contexts, add graphqlCost tracing
* chore(experiments): remove working artifacts, keep final reports and reference docs
* chore: remove experiments directory
* refactor: remove getAutomatedComments from SCM interface and all implementations
* fix: address all PR review comments
- gh-trace: make binary resolution async via fs.access (no event loop
blocking, no shell injection), cache mkdir for trace writes, async
fire-and-forget appendFile, document 10MB maxBuffer rationale
- lifecycle-manager: log detectPR failures via observer instead of
silent catch, add getPRState fallback for terminal states
(merged/closed) when batch enrichment cache misses
- scm-gitlab: implement getReviewThreads with bot+human threads and
isBot flag, fixing silent feature regression after
getAutomatedComments removal
- scm-github: clear reviewThreadsCache in invalidatePRCache, document
first:11 CI checks cost budget
- runtime-tmux: use printf+JSON.stringify for PATH export to prevent
shell injection from single quotes
- agent-workspace-hooks: add cache timestamp sanity check, include
--repo in cache keys to prevent cross-repo collisions
- services: document dashboard dependency on CLI polling
- tests: update gh binary path assertions for resolved paths
* fix: address all 17 PR review comments
- gh-trace: use path.delimiter, cache-only-on-success, last HTTP status
line, await writes with warn-once, redact secrets, gate JSON.parse
- agent-workspace-hooks: validate cache keys, redact wrapper trace args,
sha256 cache keys to prevent collisions, 120s TTL ceiling
- session-manager: skip PATH wrappers for claude-code (native hooks)
- types: add deprecation JSDoc for getReviewThreads
- graphql-batch: clear Guard 3 in clearETagCache, re-read ETag on 304,
switch Guard 2 to check-runs endpoint, drop per_page=1 from Guard 3
- Add gh-trace unit tests for extractOperation, redactArgs, parseHttp
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: adil <adil.business4064@gmail.com>
Co-authored-by: iamasx <adilshaikh4064@gmail.com>
This commit is contained in:
parent
83640c8b56
commit
a8bc746947
|
|
@ -0,0 +1,19 @@
|
|||
---
|
||||
"@aoagents/ao-plugin-scm-github": patch
|
||||
---
|
||||
|
||||
scm-github: cache 4 more hot-path reads (CI, mergeability, pending comments, detectPR)
|
||||
|
||||
Completes the bulk of the AO-side caching work alongside the prior PR view
|
||||
cache. Per-method TTLs match the approved policy: 5s max for
|
||||
decision-influencing fields.
|
||||
|
||||
- `getCIChecks` (`gh pr checks`): 5s TTL
|
||||
- `getMergeability` (composite `pr view` + CI + state): 5s TTL on the composite result
|
||||
- `getPendingComments` (`gh api graphql` review threads): 5s TTL — ETag doesn't help on GraphQL per Experiment 2
|
||||
- `detectPR` (`gh pr list --head BRANCH`): 5s TTL, **positive-only** — `[]` results are never cached so a freshly created PR surfaces on the next poll. Branch-keyed entry is invalidated by `mergePR`/`closePR` alongside the number-keyed entries.
|
||||
|
||||
Combined with the prior PR view cache, this covers the top 6 AO-side gh
|
||||
operation categories that accounted for ~85% of calls in tier-5 bench traces.
|
||||
|
||||
Tests: 85 existing + 9 new cache tests, all 162 passing.
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
---
|
||||
"@aoagents/ao-plugin-scm-github": patch
|
||||
---
|
||||
|
||||
scm-github: cache 5 `gh pr view` callsites with per-method TTLs
|
||||
|
||||
The lifecycle worker repeatedly polls each PR for state, summary, reviews,
|
||||
and review decision. Trace data showed `gh pr view` was the single largest
|
||||
AO-side endpoint at 1,280 calls per 5-session tier-5 run with >97% duplicate
|
||||
rate (e.g. PR #184 polled 86× for `--json state` alone in 11.5 minutes).
|
||||
|
||||
Adds an in-process per-instance cache inside `createGitHubSCM()`, keyed by
|
||||
`${owner}/${repo}#${prKey}:${method}` so different field-sets stay isolated.
|
||||
Per-method TTLs balance reduction against staleness on decision-influencing
|
||||
fields:
|
||||
|
||||
- `resolvePR`: 60s (identity metadata only — number, url, title, branch refs, isDraft)
|
||||
- `getPRState`: 5s
|
||||
- `getPRSummary`: 5s
|
||||
- `getReviews`: 5s
|
||||
- `getReviewDecision`: 5s
|
||||
|
||||
`assignPRToCurrentUser`, `mergePR`, and `closePR` each invalidate the entire
|
||||
PR cache for that PR after the mutation, so AO never sees stale state from
|
||||
its own writes. Failures are not cached.
|
||||
|
||||
`getCIChecksFromStatusRollup` and `getMergeability` are intentionally NOT
|
||||
cached here — those need ETag-based revalidation, not blind TTL, and will
|
||||
land in a follow-up change.
|
||||
|
||||
Expected reduction: ~1,165 of ~1,280 `gh pr view` calls per tier-5 run.
|
||||
|
||||
Tests: 73 existing + 12 new cache tests, all passing.
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
---
|
||||
"@aoagents/ao-plugin-tracker-github": patch
|
||||
---
|
||||
|
||||
tracker-github: cache `gh issue view` responses in-process (5 min TTL, bounded LRU)
|
||||
|
||||
The lifecycle worker polls `getIssue` and `isCompleted` repeatedly for the same
|
||||
issue across a session. In a 5-session tier-5 bench run (10 min), trace data
|
||||
showed the same `(repo, issue)` pair fetched 64+ times with >97% duplicate rate.
|
||||
|
||||
This change caches the full `Issue` object per `(repo, identifier)` for 5
|
||||
minutes inside each `createGitHubTracker()` instance. `isCompleted` now routes
|
||||
through `getIssue` to share the cache. `updateIssue` invalidates the cache
|
||||
entry on any mutation. Failures are not cached.
|
||||
|
||||
Expected reduction: ~744 `gh issue view` calls per tier-5 run → ~15 calls.
|
||||
|
|
@ -1,5 +1,10 @@
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { buildAgentPath, setupPathWrapperWorkspace } from "../agent-workspace-hooks.js";
|
||||
import {
|
||||
buildAgentPath,
|
||||
setupPathWrapperWorkspace,
|
||||
AO_METADATA_HELPER,
|
||||
GH_WRAPPER,
|
||||
} from "../agent-workspace-hooks.js";
|
||||
|
||||
const { mockWriteFile, mockMkdir, mockReadFile, mockRename } = vi.hoisted(() => ({
|
||||
mockWriteFile: vi.fn().mockResolvedValue(undefined),
|
||||
|
|
@ -74,7 +79,7 @@ describe("setupPathWrapperWorkspace", () => {
|
|||
|
||||
it("skips wrapper rewrite when version matches", async () => {
|
||||
mockReadFile
|
||||
.mockResolvedValueOnce("0.3.0") // version marker matches
|
||||
.mockResolvedValueOnce("0.6.0") // version marker matches
|
||||
.mockRejectedValueOnce(new Error("ENOENT")); // AGENTS.md doesn't exist
|
||||
|
||||
await setupPathWrapperWorkspace("/workspace");
|
||||
|
|
@ -95,3 +100,185 @@ describe("setupPathWrapperWorkspace", () => {
|
|||
expect(String(agentsMdWrites[0][1])).toContain("Agent Orchestrator");
|
||||
});
|
||||
});
|
||||
|
||||
describe("AO_METADATA_HELPER", () => {
|
||||
it("contains update_ao_metadata function", () => {
|
||||
expect(AO_METADATA_HELPER).toContain("update_ao_metadata()");
|
||||
});
|
||||
|
||||
it("contains read_ao_metadata function", () => {
|
||||
expect(AO_METADATA_HELPER).toContain("read_ao_metadata()");
|
||||
});
|
||||
|
||||
it("contains cache helper functions", () => {
|
||||
expect(AO_METADATA_HELPER).toContain("ao_cache_dir()");
|
||||
expect(AO_METADATA_HELPER).toContain("ao_cache_fresh()");
|
||||
expect(AO_METADATA_HELPER).toContain("ao_cache_read()");
|
||||
expect(AO_METADATA_HELPER).toContain("ao_cache_write()");
|
||||
});
|
||||
|
||||
it("uses .ghcache subdirectory for cache storage", () => {
|
||||
expect(AO_METADATA_HELPER).toContain(".ghcache");
|
||||
});
|
||||
|
||||
it("validates environment in shared _ao_validate_env", () => {
|
||||
expect(AO_METADATA_HELPER).toContain("_ao_validate_env()");
|
||||
expect(AO_METADATA_HELPER).toContain("AO_DATA_DIR");
|
||||
expect(AO_METADATA_HELPER).toContain("AO_SESSION");
|
||||
});
|
||||
|
||||
it("validates trusted roots for path traversal prevention", () => {
|
||||
expect(AO_METADATA_HELPER).toContain(".agent-orchestrator");
|
||||
expect(AO_METADATA_HELPER).toContain("/tmp/*");
|
||||
});
|
||||
});
|
||||
|
||||
describe("GH_WRAPPER", () => {
|
||||
it("contains PR discovery cache intercept", () => {
|
||||
expect(GH_WRAPPER).toContain('$1" == "pr" && "$2" == "list"');
|
||||
expect(GH_WRAPPER).toContain("pr-disc-");
|
||||
expect(GH_WRAPPER).toContain("ao_cache_fresh");
|
||||
expect(GH_WRAPPER).toContain("ao_cache_read");
|
||||
});
|
||||
|
||||
it("requires --head and --limit 1 for PR discovery cache", () => {
|
||||
expect(GH_WRAPPER).toContain("_ao_head");
|
||||
expect(GH_WRAPPER).toContain("_ao_limit");
|
||||
expect(GH_WRAPPER).toContain('"$_ao_limit" == "1"');
|
||||
});
|
||||
|
||||
it("does not cache empty PR discovery results", () => {
|
||||
expect(GH_WRAPPER).toContain('"$_ao_trimmed" != "[]"');
|
||||
});
|
||||
|
||||
it("passes through on unsupported flags for PR discovery", () => {
|
||||
expect(GH_WRAPPER).toContain("--search");
|
||||
expect(GH_WRAPPER).toContain("--state");
|
||||
expect(GH_WRAPPER).toContain("--assignee");
|
||||
expect(GH_WRAPPER).toContain("--label");
|
||||
expect(GH_WRAPPER).toContain("--jq");
|
||||
expect(GH_WRAPPER).toContain("--template");
|
||||
expect(GH_WRAPPER).toContain("_ao_cacheable=false");
|
||||
});
|
||||
|
||||
it("passes through on unsupported --key=value flags for PR discovery", () => {
|
||||
expect(GH_WRAPPER).toContain("--search=*");
|
||||
expect(GH_WRAPPER).toContain("--state=*");
|
||||
expect(GH_WRAPPER).toContain("--jq=*");
|
||||
expect(GH_WRAPPER).toContain("--template=*");
|
||||
});
|
||||
|
||||
it("contains issue context cache intercept with 300s TTL", () => {
|
||||
expect(GH_WRAPPER).toContain('$1" == "issue" && "$2" == "view"');
|
||||
expect(GH_WRAPPER).toContain("issue-");
|
||||
expect(GH_WRAPPER).toContain("ao_cache_fresh");
|
||||
expect(GH_WRAPPER).toContain("300");
|
||||
});
|
||||
|
||||
it("passes through on --web and --comments for issue view", () => {
|
||||
expect(GH_WRAPPER).toContain("--web");
|
||||
expect(GH_WRAPPER).toContain("--comments");
|
||||
});
|
||||
|
||||
it("includes --json fields in PR discovery cache key", () => {
|
||||
// _ao_json is captured from --json and --json= forms
|
||||
expect(GH_WRAPPER).toContain('_ao_json=""');
|
||||
expect(GH_WRAPPER).toContain('--json) _ao_json=');
|
||||
expect(GH_WRAPPER).toContain('--json=*) _ao_json=');
|
||||
// json fields are included in the raw key fed to sha256
|
||||
expect(GH_WRAPPER).toContain('-j-${_ao_json}');
|
||||
});
|
||||
|
||||
it("includes --json fields in issue context cache key", () => {
|
||||
// Both PR discovery and issue view include --json in cache key via sha256 hash
|
||||
const issueSection = GH_WRAPPER.split('issue" && "$2" == "view"')[1];
|
||||
expect(issueSection).toContain("_ao_json");
|
||||
expect(issueSection).toContain("-j-");
|
||||
});
|
||||
|
||||
it("handles --head=value and --limit=value equals-sign syntax", () => {
|
||||
expect(GH_WRAPPER).toContain('--head=*) _ao_head="${1#--head=}"');
|
||||
expect(GH_WRAPPER).toContain('--limit=*) _ao_limit="${1#--limit=}"');
|
||||
});
|
||||
|
||||
it("does not pre-populate PR discovery cache from gh pr create", () => {
|
||||
// PR create should update metadata but NOT write to the cache,
|
||||
// because we cannot know what --json fields the next pr list will request
|
||||
expect(GH_WRAPPER).toContain("pr/create)");
|
||||
const prCreateSection = GH_WRAPPER.split("pr/create)")[1].split("exit $exit_code")[0];
|
||||
expect(prCreateSection).not.toContain("ao_cache_write");
|
||||
});
|
||||
|
||||
it("only caches stdout, not stderr, in cacheable paths", () => {
|
||||
// The cacheable read paths (pr list, issue view) must redirect only stdout
|
||||
// to the temp file, letting stderr pass through to the agent.
|
||||
// Extract the two cacheable sections and verify no 2>&1 in their gh calls.
|
||||
const prSection = GH_WRAPPER.split('$1" == "pr" && "$2" == "list"')[1].split("fi\nfi")[0];
|
||||
const issueSection = GH_WRAPPER.split('$1" == "issue" && "$2" == "view"')[1].split("fi\nfi")[0];
|
||||
// The real_gh call in cache paths should NOT have 2>&1
|
||||
const prGhCall = prSection.match(/"\$real_gh" "\$@" > "\$_ao_tmpout"(.*)/)?.[1] ?? "";
|
||||
const issueGhCall = issueSection.match(/"\$real_gh" "\$@" > "\$_ao_tmpout"(.*)/)?.[1] ?? "";
|
||||
expect(prGhCall).not.toContain("2>&1");
|
||||
expect(issueGhCall).not.toContain("2>&1");
|
||||
});
|
||||
|
||||
it("still passes through unmatched commands without exec", () => {
|
||||
// Default case runs real gh as child process (not exec) to allow post-call tracing
|
||||
expect(GH_WRAPPER).not.toContain('exec "$real_gh" "$@"');
|
||||
// Real gh is still called in the default case
|
||||
expect(GH_WRAPPER).toContain('"$real_gh" "$@"');
|
||||
});
|
||||
|
||||
it("uses current wrapper version in trace logging", () => {
|
||||
expect(GH_WRAPPER).toContain("0.6.0");
|
||||
});
|
||||
|
||||
it("logs cache outcomes (hit/miss-stored/miss-negative/miss-error) to trace", () => {
|
||||
expect(GH_WRAPPER).toContain("log_ao_cache");
|
||||
expect(GH_WRAPPER).toContain('"hit"');
|
||||
expect(GH_WRAPPER).toContain('"miss-stored"');
|
||||
expect(GH_WRAPPER).toContain('"miss-negative"');
|
||||
expect(GH_WRAPPER).toContain('"miss-error"');
|
||||
expect(GH_WRAPPER).toContain("cacheResult");
|
||||
expect(GH_WRAPPER).toContain("cacheKey");
|
||||
});
|
||||
|
||||
it("logs passthrough for pr/create and default case", () => {
|
||||
// Both pr/create and the default *) case must log passthrough
|
||||
const matches = GH_WRAPPER.match(/"passthrough"/g) ?? [];
|
||||
expect(matches.length).toBeGreaterThanOrEqual(2);
|
||||
// pr/create section logs passthrough
|
||||
const prCreateSection = GH_WRAPPER.split("pr/create)")[1];
|
||||
expect(prCreateSection).toContain('"passthrough"');
|
||||
});
|
||||
|
||||
it("logs miss-write-failed when cache write fails", () => {
|
||||
expect(GH_WRAPPER).toContain('"miss-write-failed"');
|
||||
// miss-stored is conditional on ao_cache_write succeeding
|
||||
const prSection = GH_WRAPPER.split('$1" == "pr" && "$2" == "list"')[1].split("fi\nfi")[0];
|
||||
expect(prSection).toContain("if ao_cache_write");
|
||||
});
|
||||
|
||||
it("includes durationMs, exitCode, ok in cache outcome rows", () => {
|
||||
// log_ao_cache signature includes duration, exit code, ok
|
||||
expect(GH_WRAPPER).toContain("duration_ms");
|
||||
expect(GH_WRAPPER).toContain("exit_code");
|
||||
expect(GH_WRAPPER).toContain('"durationMs"');
|
||||
expect(GH_WRAPPER).toContain('"exitCode"');
|
||||
expect(GH_WRAPPER).toContain('"ok"');
|
||||
});
|
||||
|
||||
it("captures timing around real gh calls", () => {
|
||||
// All paths that call real gh should have start/duration measurement
|
||||
expect(GH_WRAPPER).toContain("_ao_start_s=$(date +%s)");
|
||||
expect(GH_WRAPPER).toContain("_ao_duration_ms=$(");
|
||||
});
|
||||
|
||||
it("includes operation field in invocation trace row", () => {
|
||||
expect(GH_WRAPPER).toContain("_ao_op=");
|
||||
expect(GH_WRAPPER).toContain("operation");
|
||||
// operation format: gh.{arg1}.{arg2}
|
||||
expect(GH_WRAPPER).toContain('"gh.$1"');
|
||||
expect(GH_WRAPPER).toContain('"gh.$1.$2"');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,100 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { _testUtils } from "../gh-trace.js";
|
||||
|
||||
const { extractOperation, redactArgs, parseIncludedHttpResponse } = _testUtils;
|
||||
|
||||
describe("extractOperation", () => {
|
||||
it("returns 'gh' for empty args", () => {
|
||||
expect(extractOperation([])).toBe("gh");
|
||||
});
|
||||
|
||||
it("returns 'gh.<cmd>' for single arg", () => {
|
||||
expect(extractOperation(["api"])).toBe("gh.api");
|
||||
});
|
||||
|
||||
it("returns 'gh.api.graphql' for graphql endpoint", () => {
|
||||
expect(extractOperation(["api", "graphql", "-f", "query=..."])).toBe("gh.api.graphql");
|
||||
});
|
||||
|
||||
it("skips leading flags to find first positional", () => {
|
||||
expect(extractOperation(["api", "--method", "GET", "repos/acme/repo/pulls"])).toBe("gh.api.repos");
|
||||
});
|
||||
|
||||
it("extracts first path segment from REST URL", () => {
|
||||
expect(extractOperation(["api", "repos/acme/repo/pulls/123/comments?per_page=1"])).toBe("gh.api.repos");
|
||||
});
|
||||
|
||||
it("handles -H flag pairs", () => {
|
||||
expect(extractOperation(["api", "-H", "Accept: application/json", "graphql"])).toBe("gh.api.graphql");
|
||||
});
|
||||
});
|
||||
|
||||
describe("redactArgs", () => {
|
||||
it("passes through normal args unchanged", () => {
|
||||
expect(redactArgs(["api", "graphql", "-f", "query={...}"])).toEqual([
|
||||
"api", "graphql", "-f", "query={...}",
|
||||
]);
|
||||
});
|
||||
|
||||
it("redacts Authorization header value after -H", () => {
|
||||
const result = redactArgs(["api", "-H", "Authorization: bearer ghp_secret123"]);
|
||||
expect(result[2]).toBe("Authorization: [REDACTED]");
|
||||
});
|
||||
|
||||
it("redacts Authorization header value after --header", () => {
|
||||
const result = redactArgs(["api", "--header", "Authorization: token abc"]);
|
||||
expect(result[2]).toBe("Authorization: [REDACTED]");
|
||||
});
|
||||
|
||||
it("redacts token= field values", () => {
|
||||
const result = redactArgs(["-f", "token=ghp_secret"]);
|
||||
expect(result[1]).toBe("token=[REDACTED]");
|
||||
});
|
||||
|
||||
it("redacts password= field values", () => {
|
||||
const result = redactArgs(["-F", "password=s3cret"]);
|
||||
expect(result[1]).toBe("password=[REDACTED]");
|
||||
});
|
||||
|
||||
it("does not redact non-sensitive fields", () => {
|
||||
const result = redactArgs(["-f", "owner=acme"]);
|
||||
expect(result[1]).toBe("owner=acme");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseIncludedHttpResponse", () => {
|
||||
it("returns empty headers for non-HTTP output", () => {
|
||||
const result = parseIncludedHttpResponse('{"data":{}}');
|
||||
expect(result.statusLine).toBeUndefined();
|
||||
expect(result.headers).toEqual({});
|
||||
});
|
||||
|
||||
it("parses status line and headers", () => {
|
||||
const output = [
|
||||
"HTTP/2 200",
|
||||
"etag: W/\"abc123\"",
|
||||
"x-ratelimit-remaining: 4999",
|
||||
"",
|
||||
'{"data":{}}',
|
||||
].join("\n");
|
||||
const result = parseIncludedHttpResponse(output);
|
||||
expect(result.statusLine).toBe("HTTP/2 200");
|
||||
expect(result.headers["etag"]).toBe("W/\"abc123\"");
|
||||
expect(result.headers["x-ratelimit-remaining"]).toBe("4999");
|
||||
});
|
||||
|
||||
it("takes the last HTTP/ status line on redirects", () => {
|
||||
const output = [
|
||||
"HTTP/1.1 302 Found",
|
||||
"location: https://example.com",
|
||||
"",
|
||||
"HTTP/1.1 200 OK",
|
||||
"etag: W/\"final\"",
|
||||
"",
|
||||
'{"data":{}}',
|
||||
].join("\n");
|
||||
const result = parseIncludedHttpResponse(output);
|
||||
expect(result.statusLine).toBe("HTTP/1.1 200 OK");
|
||||
expect(result.headers["etag"]).toBe("W/\"final\"");
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -46,7 +46,7 @@ import type {
|
|||
Runtime,
|
||||
Agent,
|
||||
Workspace,
|
||||
SessionManager,
|
||||
OpenCodeSessionManager,
|
||||
Session,
|
||||
} from "../types.js";
|
||||
|
||||
|
|
@ -118,7 +118,7 @@ beforeEach(() => {
|
|||
name: "Test App",
|
||||
repo: "acme/app",
|
||||
path: join(env.tmpDir, "test-app"),
|
||||
storageKey: "111111111111",
|
||||
storageKey: "222222222222",
|
||||
defaultBranch: "main",
|
||||
sessionPrefix: "app",
|
||||
tracker: { plugin: "github" },
|
||||
|
|
@ -305,8 +305,17 @@ describe("plugin integration", () => {
|
|||
runtimeHandle: JSON.stringify(makeHandle("rt-1")),
|
||||
});
|
||||
|
||||
// Mock gh: issue is closed
|
||||
mockGh({ state: "CLOSED" });
|
||||
// Mock gh: issue is closed (full Issue shape so getIssue/isCompleted parse it)
|
||||
mockGh({
|
||||
number: 99,
|
||||
title: "test",
|
||||
body: "",
|
||||
url: "https://github.com/acme/app/issues/99",
|
||||
state: "CLOSED",
|
||||
stateReason: "COMPLETED",
|
||||
labels: [],
|
||||
assignees: [],
|
||||
});
|
||||
|
||||
const result = await sm.cleanup("my-app");
|
||||
|
||||
|
|
@ -330,15 +339,24 @@ describe("plugin integration", () => {
|
|||
runtimeHandle: JSON.stringify(makeHandle("rt-1")),
|
||||
});
|
||||
|
||||
// Mock gh: issue is closed
|
||||
mockGh({ state: "CLOSED" });
|
||||
// Mock gh: issue is closed (full Issue shape so getIssue/isCompleted parse it)
|
||||
mockGh({
|
||||
number: 99,
|
||||
title: "test",
|
||||
body: "",
|
||||
url: "https://github.com/acme/app/issues/99",
|
||||
state: "CLOSED",
|
||||
stateReason: "COMPLETED",
|
||||
labels: [],
|
||||
assignees: [],
|
||||
});
|
||||
|
||||
const result = await sm.cleanup("my-app");
|
||||
|
||||
expect(result.killed).toContain("app-1");
|
||||
// Verify the gh CLI was called with the right args
|
||||
expect(ghMock).toHaveBeenCalledWith(
|
||||
"gh",
|
||||
expect.stringMatching(/(?:^|\/)?gh$/),
|
||||
expect.arrayContaining(["issue", "view", "99", "--repo", "acme/app"]),
|
||||
expect.any(Object),
|
||||
);
|
||||
|
|
@ -357,8 +375,17 @@ describe("plugin integration", () => {
|
|||
runtimeHandle: JSON.stringify(makeHandle("rt-1")),
|
||||
});
|
||||
|
||||
// Mock gh: issue is still open — runtime also alive
|
||||
mockGh({ state: "OPEN" });
|
||||
// Mock gh: issue is still open (full Issue shape so getIssue/isCompleted parse it)
|
||||
mockGh({
|
||||
number: 99,
|
||||
title: "test",
|
||||
body: "",
|
||||
url: "https://github.com/acme/app/issues/99",
|
||||
state: "OPEN",
|
||||
stateReason: null,
|
||||
labels: [],
|
||||
assignees: [],
|
||||
});
|
||||
|
||||
const result = await sm.cleanup("my-app");
|
||||
|
||||
|
|
@ -416,7 +443,7 @@ describe("plugin integration", () => {
|
|||
expect(result.skipped).toContain("app-1");
|
||||
// Verify gh CLI was called for PR state check
|
||||
expect(ghMock).toHaveBeenCalledWith(
|
||||
"gh",
|
||||
expect.stringMatching(/(?:^|\/)?gh$/),
|
||||
expect.arrayContaining(["pr", "view", "42"]),
|
||||
expect.any(Object),
|
||||
);
|
||||
|
|
@ -447,7 +474,7 @@ describe("plugin integration", () => {
|
|||
// -------------------------------------------------------------------------
|
||||
describe("LifecycleManager + SCM", () => {
|
||||
let registry: PluginRegistry;
|
||||
let sm: SessionManager;
|
||||
let sm: OpenCodeSessionManager;
|
||||
|
||||
beforeEach(() => {
|
||||
registry = createTestRegistry();
|
||||
|
|
@ -470,11 +497,20 @@ describe("plugin integration", () => {
|
|||
return session;
|
||||
}
|
||||
|
||||
it("check() detects ci_failed via scm-github getCISummary()", async () => {
|
||||
it("check() detects ci_failed via batch enrichment", async () => {
|
||||
seedSession({ status: "pr_open", pr });
|
||||
|
||||
// Mock the sessionManager.list() to return our session
|
||||
const mockSM: SessionManager = {
|
||||
// Spy on the real SCM plugin's enrichSessionsPRBatch to return batch data
|
||||
const scmPlugin = registry.get("scm", "github") as ReturnType<typeof scmGithub.create>;
|
||||
const originalBatch = scmPlugin.enrichSessionsPRBatch;
|
||||
scmPlugin.enrichSessionsPRBatch = vi.fn().mockResolvedValue(
|
||||
new Map([[`${pr.owner}/${pr.repo}#${pr.number}`, {
|
||||
state: "open", ciStatus: "failing", reviewDecision: "none", mergeable: false,
|
||||
ciChecks: [{ name: "lint", status: "failed", conclusion: "FAILURE" }],
|
||||
}]]),
|
||||
);
|
||||
|
||||
const mockSM: OpenCodeSessionManager = {
|
||||
...sm,
|
||||
list: vi.fn().mockResolvedValue([makeSession({ status: "pr_open", pr })]),
|
||||
get: vi.fn().mockResolvedValue(makeSession({ status: "pr_open", pr })),
|
||||
|
|
@ -490,22 +526,25 @@ describe("plugin integration", () => {
|
|||
sessionManager: mockSM,
|
||||
});
|
||||
|
||||
// gh calls for determineStatus:
|
||||
// 1. getPRState → open
|
||||
mockGh({ state: "OPEN" });
|
||||
// 2. getCISummary → failing (pr checks returns array of checks with correct field names)
|
||||
mockGh([{ name: "lint", state: "FAILURE", link: "", startedAt: "", completedAt: "" }]);
|
||||
|
||||
await lm.check("app-1");
|
||||
scmPlugin.enrichSessionsPRBatch = originalBatch;
|
||||
|
||||
const states = lm.getStates();
|
||||
expect(states.get("app-1")).toBe("ci_failed");
|
||||
});
|
||||
|
||||
it("check() detects merged via scm-github getPRState()", async () => {
|
||||
it("check() detects merged via batch enrichment", async () => {
|
||||
seedSession({ status: "pr_open", pr });
|
||||
|
||||
const mockSM: SessionManager = {
|
||||
const scmPlugin = registry.get("scm", "github") as ReturnType<typeof scmGithub.create>;
|
||||
const originalBatch = scmPlugin.enrichSessionsPRBatch;
|
||||
scmPlugin.enrichSessionsPRBatch = vi.fn().mockResolvedValue(
|
||||
new Map([[`${pr.owner}/${pr.repo}#${pr.number}`, {
|
||||
state: "merged", ciStatus: "none", reviewDecision: "none", mergeable: false,
|
||||
}]]),
|
||||
);
|
||||
|
||||
const mockSM: OpenCodeSessionManager = {
|
||||
...sm,
|
||||
list: vi.fn().mockResolvedValue([makeSession({ status: "pr_open", pr })]),
|
||||
get: vi.fn().mockResolvedValue(makeSession({ status: "pr_open", pr })),
|
||||
|
|
@ -521,19 +560,25 @@ describe("plugin integration", () => {
|
|||
sessionManager: mockSM,
|
||||
});
|
||||
|
||||
// getPRState → merged
|
||||
mockGh({ state: "MERGED" });
|
||||
|
||||
await lm.check("app-1");
|
||||
scmPlugin.enrichSessionsPRBatch = originalBatch;
|
||||
|
||||
const states = lm.getStates();
|
||||
expect(states.get("app-1")).toBe("merged");
|
||||
});
|
||||
|
||||
it("check() detects changes_requested via scm-github getReviewDecision()", async () => {
|
||||
it("check() detects changes_requested via batch enrichment", async () => {
|
||||
seedSession({ status: "pr_open", pr });
|
||||
|
||||
const mockSM: SessionManager = {
|
||||
const scmPlugin = registry.get("scm", "github") as ReturnType<typeof scmGithub.create>;
|
||||
const originalBatch = scmPlugin.enrichSessionsPRBatch;
|
||||
scmPlugin.enrichSessionsPRBatch = vi.fn().mockResolvedValue(
|
||||
new Map([[`${pr.owner}/${pr.repo}#${pr.number}`, {
|
||||
state: "open", ciStatus: "passing", reviewDecision: "changes_requested", mergeable: false,
|
||||
}]]),
|
||||
);
|
||||
|
||||
const mockSM: OpenCodeSessionManager = {
|
||||
...sm,
|
||||
list: vi.fn().mockResolvedValue([makeSession({ status: "pr_open", pr })]),
|
||||
get: vi.fn().mockResolvedValue(makeSession({ status: "pr_open", pr })),
|
||||
|
|
@ -549,14 +594,8 @@ describe("plugin integration", () => {
|
|||
sessionManager: mockSM,
|
||||
});
|
||||
|
||||
// 1. getPRState → open
|
||||
mockGh({ state: "OPEN" });
|
||||
// 2. getCISummary → passing (using correct field names: state and link)
|
||||
mockGh([{ name: "lint", state: "SUCCESS", link: "", startedAt: "", completedAt: "" }]);
|
||||
// 3. getReviewDecision (gh pr view with reviewDecision)
|
||||
mockGh({ reviewDecision: "CHANGES_REQUESTED" });
|
||||
|
||||
await lm.check("app-1");
|
||||
scmPlugin.enrichSessionsPRBatch = originalBatch;
|
||||
|
||||
const states = lm.getStates();
|
||||
expect(states.get("app-1")).toBe("changes_requested");
|
||||
|
|
|
|||
|
|
@ -61,7 +61,6 @@ describe("claimPR", () => {
|
|||
getReviews: vi.fn(),
|
||||
getReviewDecision: vi.fn(),
|
||||
getPendingComments: vi.fn(),
|
||||
getAutomatedComments: vi.fn(),
|
||||
getMergeability: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -243,7 +243,6 @@ describe("cleanup", () => {
|
|||
getReviews: vi.fn(),
|
||||
getReviewDecision: vi.fn(),
|
||||
getPendingComments: vi.fn(),
|
||||
getAutomatedComments: vi.fn(),
|
||||
getMergeability: vi.fn(),
|
||||
};
|
||||
|
||||
|
|
@ -290,7 +289,6 @@ describe("cleanup", () => {
|
|||
getReviews: vi.fn(),
|
||||
getReviewDecision: vi.fn(),
|
||||
getPendingComments: vi.fn(),
|
||||
getAutomatedComments: vi.fn(),
|
||||
getMergeability: vi.fn(),
|
||||
};
|
||||
|
||||
|
|
@ -339,7 +337,6 @@ describe("cleanup", () => {
|
|||
getReviews: vi.fn(),
|
||||
getReviewDecision: vi.fn(),
|
||||
getPendingComments: vi.fn(),
|
||||
getAutomatedComments: vi.fn(),
|
||||
getMergeability: vi.fn(),
|
||||
};
|
||||
|
||||
|
|
@ -596,7 +593,6 @@ describe("cleanup", () => {
|
|||
getReviews: vi.fn(),
|
||||
getReviewDecision: vi.fn(),
|
||||
getPendingComments: vi.fn(),
|
||||
getAutomatedComments: vi.fn(),
|
||||
getMergeability: vi.fn(),
|
||||
};
|
||||
const mockTracker: Tracker = {
|
||||
|
|
|
|||
|
|
@ -86,6 +86,39 @@ describe("restore", () => {
|
|||
expect(meta!["createdAt"]).toBe("2025-01-01T00:00:00.000Z");
|
||||
});
|
||||
|
||||
it("forwards AO_AGENT_GH_TRACE into restored agent runtime env when configured", async () => {
|
||||
const wsPath = join(tmpDir, "ws-app-1-trace");
|
||||
mkdirSync(wsPath, { recursive: true });
|
||||
|
||||
writeMetadata(sessionsDir, "app-1", {
|
||||
worktree: wsPath,
|
||||
branch: "feat/TEST-1",
|
||||
status: "killed",
|
||||
project: "my-app",
|
||||
runtimeHandle: JSON.stringify(makeHandle("rt-old")),
|
||||
});
|
||||
|
||||
const previousTrace = process.env["AO_AGENT_GH_TRACE"];
|
||||
process.env["AO_AGENT_GH_TRACE"] = "/tmp/restored-agent-gh-trace-test.jsonl";
|
||||
|
||||
try {
|
||||
const sm = createSessionManager({ config, registry: mockRegistry });
|
||||
await sm.restore("app-1");
|
||||
|
||||
expect(mockRuntime.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
environment: expect.objectContaining({
|
||||
AO_AGENT_GH_TRACE: "/tmp/restored-agent-gh-trace-test.jsonl",
|
||||
AO_CALLER_TYPE: "agent",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
if (previousTrace === undefined) delete process.env["AO_AGENT_GH_TRACE"];
|
||||
else process.env["AO_AGENT_GH_TRACE"] = previousTrace;
|
||||
}
|
||||
});
|
||||
|
||||
it("continues restore even if old runtime destroy fails", async () => {
|
||||
const wsPath = join(tmpDir, "ws-app-1");
|
||||
mkdirSync(wsPath, { recursive: true });
|
||||
|
|
|
|||
|
|
@ -72,6 +72,27 @@ describe("spawn", () => {
|
|||
expect(mockRuntime.create).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("forwards AO_AGENT_GH_TRACE into spawned agent runtime env when configured", async () => {
|
||||
const previousTrace = process.env["AO_AGENT_GH_TRACE"];
|
||||
process.env["AO_AGENT_GH_TRACE"] = "/tmp/agent-gh-trace-test.jsonl";
|
||||
|
||||
try {
|
||||
const sm = createSessionManager({ config, registry: mockRegistry });
|
||||
await sm.spawn({ projectId: "my-app" });
|
||||
|
||||
expect(mockRuntime.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
environment: expect.objectContaining({
|
||||
AO_AGENT_GH_TRACE: "/tmp/agent-gh-trace-test.jsonl",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
if (previousTrace === undefined) delete process.env["AO_AGENT_GH_TRACE"];
|
||||
else process.env["AO_AGENT_GH_TRACE"] = previousTrace;
|
||||
}
|
||||
});
|
||||
|
||||
it("uses issue ID to derive branch name", async () => {
|
||||
const sm = createSessionManager({ config, registry: mockRegistry });
|
||||
|
||||
|
|
@ -1634,6 +1655,28 @@ describe("spawn", () => {
|
|||
);
|
||||
});
|
||||
|
||||
it("forwards AO_AGENT_GH_TRACE into orchestrator runtime env when configured", async () => {
|
||||
const previousTrace = process.env["AO_AGENT_GH_TRACE"];
|
||||
process.env["AO_AGENT_GH_TRACE"] = "/tmp/orchestrator-gh-trace-test.jsonl";
|
||||
|
||||
try {
|
||||
const sm = createSessionManager({ config, registry: mockRegistry });
|
||||
await sm.spawnOrchestrator({ projectId: "my-app" });
|
||||
|
||||
expect(mockRuntime.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
environment: expect.objectContaining({
|
||||
AO_AGENT_GH_TRACE: "/tmp/orchestrator-gh-trace-test.jsonl",
|
||||
AO_CALLER_TYPE: "orchestrator",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
if (previousTrace === undefined) delete process.env["AO_AGENT_GH_TRACE"];
|
||||
else process.env["AO_AGENT_GH_TRACE"] = previousTrace;
|
||||
}
|
||||
});
|
||||
|
||||
it("does not persist orchestratorSessionReused metadata on newly created sessions", async () => {
|
||||
const sm = createSessionManager({ config, registry: mockRegistry });
|
||||
|
||||
|
|
|
|||
|
|
@ -113,10 +113,10 @@ export function makeSession(overrides: Partial<Session> = {}): Session {
|
|||
export function makePR(overrides: Partial<PRInfo> = {}): PRInfo {
|
||||
return {
|
||||
number: 42,
|
||||
url: "https://github.com/org/repo/pull/42",
|
||||
url: "https://github.com/org/my-app/pull/42",
|
||||
title: "Fix things",
|
||||
owner: "org",
|
||||
repo: "repo",
|
||||
repo: "my-app",
|
||||
branch: "feat/test",
|
||||
baseBranch: "main",
|
||||
isDraft: false,
|
||||
|
|
@ -171,7 +171,7 @@ export function createMockPlugins(): MockPlugins {
|
|||
}
|
||||
|
||||
export function createMockSCM(overrides: Partial<SCM> = {}): SCM {
|
||||
return {
|
||||
const scm: SCM = {
|
||||
name: "github",
|
||||
detectPR: vi.fn().mockResolvedValue(null),
|
||||
getPRState: vi.fn().mockResolvedValue("open"),
|
||||
|
|
@ -182,7 +182,7 @@ export function createMockSCM(overrides: Partial<SCM> = {}): SCM {
|
|||
getReviews: vi.fn().mockResolvedValue([]),
|
||||
getReviewDecision: vi.fn().mockResolvedValue("none"),
|
||||
getPendingComments: vi.fn().mockResolvedValue([]),
|
||||
getAutomatedComments: vi.fn().mockResolvedValue([]),
|
||||
getReviewThreads: vi.fn().mockResolvedValue({ threads: [], reviews: [] }),
|
||||
getMergeability: vi.fn().mockResolvedValue({
|
||||
mergeable: false,
|
||||
ciPassing: true,
|
||||
|
|
@ -190,8 +190,26 @@ export function createMockSCM(overrides: Partial<SCM> = {}): SCM {
|
|||
noConflicts: true,
|
||||
blockers: [],
|
||||
}),
|
||||
// Default batch mock: resolves individual method mocks at call time
|
||||
// so tests that override getPRState/getCISummary/etc. automatically
|
||||
// get matching batch enrichment without explicit enrichSessionsPRBatch.
|
||||
enrichSessionsPRBatch: vi.fn().mockImplementation(async (prs: PRInfo[]) => {
|
||||
const result = new Map();
|
||||
for (const pr of prs) {
|
||||
result.set(`${pr.owner}/${pr.repo}#${pr.number}`, {
|
||||
state: "open" as const,
|
||||
ciStatus: "passing" as const,
|
||||
reviewDecision: "none" as const,
|
||||
mergeable: false,
|
||||
hasConflicts: false,
|
||||
ciChecks: [],
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}),
|
||||
...overrides,
|
||||
};
|
||||
return scm;
|
||||
}
|
||||
|
||||
export function createMockNotifier(): Notifier {
|
||||
|
|
|
|||
|
|
@ -1,11 +1,13 @@
|
|||
/**
|
||||
* Shared PATH-based workspace hooks for agent plugins that don't have
|
||||
* native hook systems (Codex, Aider, OpenCode).
|
||||
* Shared PATH-based workspace hooks for all agent plugins.
|
||||
*
|
||||
* Installs ~/.ao/bin/gh and ~/.ao/bin/git wrappers that intercept
|
||||
* PR creation and branch operations to auto-update session metadata.
|
||||
* Installs ~/.ao/bin/gh and ~/.ao/bin/git wrappers that:
|
||||
* - Intercept PR creation and branch operations to auto-update session metadata
|
||||
* - Cache repeated read-only gh commands (PR discovery, issue context) to reduce
|
||||
* GitHub API traffic — see D4-wrapper-cache-plan.md for design
|
||||
*
|
||||
* Claude Code uses its own PostToolUse hook system instead.
|
||||
* The session manager injects these wrappers into every agent's PATH,
|
||||
* including Claude Code (which also has its own PostToolUse hooks for writes).
|
||||
*/
|
||||
import { writeFile, mkdir, readFile, rename } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
|
@ -32,7 +34,7 @@ function getAoBinDir(): string {
|
|||
}
|
||||
|
||||
/** Current version of wrapper scripts — bump when scripts change */
|
||||
const WRAPPER_VERSION = "0.3.0";
|
||||
const WRAPPER_VERSION = "0.6.0";
|
||||
|
||||
// =============================================================================
|
||||
// PATH Builder
|
||||
|
|
@ -69,11 +71,33 @@ export function buildAgentPath(basePath: string | undefined): string {
|
|||
|
||||
/**
|
||||
* Helper script sourced by both gh and git wrappers.
|
||||
* Provides update_ao_metadata() for writing key=value to the session file.
|
||||
* Provides:
|
||||
* update_ao_metadata <key> <value> — write key=value to session metadata
|
||||
* read_ao_metadata <key> — read a value from session metadata
|
||||
* ao_cache_dir — print the per-session gh cache directory
|
||||
* ao_cache_fresh <key> <max_age> — test if a cache entry is fresh (0 = infinite)
|
||||
* ao_cache_read <key> — print cached stdout
|
||||
* ao_cache_write <key> — write stdin to cache atomically
|
||||
*/
|
||||
export const AO_METADATA_HELPER = `#!/usr/bin/env bash
|
||||
# ao-metadata-helper — shared by gh/git wrappers
|
||||
# Provides: update_ao_metadata <key> <value>
|
||||
# Provides: update_ao_metadata, read_ao_metadata, ao_cache_*
|
||||
|
||||
# ── Shared validation ────────────────────────────────────────────────────────
|
||||
|
||||
_ao_validate_env() {
|
||||
local ao_dir="\${AO_DATA_DIR:-}"
|
||||
local ao_session="\${AO_SESSION:-}"
|
||||
[[ -z "\$ao_dir" || -z "\$ao_session" ]] && return 1
|
||||
case "\$ao_session" in */* | *..*) return 1 ;; esac
|
||||
case "\$ao_dir" in
|
||||
"\$HOME"/.ao/* | "\$HOME"/.agent-orchestrator/* | /tmp/*) ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
return 0
|
||||
}
|
||||
|
||||
# ── Metadata write ───────────────────────────────────────────────────────────
|
||||
|
||||
update_ao_metadata() {
|
||||
local key="\$1" value="\$2"
|
||||
|
|
@ -131,15 +155,76 @@ update_ao_metadata() {
|
|||
|
||||
mv "\$temp_file" "\$metadata_file"
|
||||
}
|
||||
|
||||
# ── Metadata read ────────────────────────────────────────────────────────────
|
||||
|
||||
read_ao_metadata() {
|
||||
local key="\$1"
|
||||
_ao_validate_env || return 1
|
||||
local metadata_file="\${AO_DATA_DIR}/\${AO_SESSION}"
|
||||
[[ -f "\$metadata_file" ]] || return 1
|
||||
[[ "\$key" =~ ^[a-zA-Z0-9_-]+$ ]] || return 1
|
||||
local line
|
||||
line=\$(grep "^\${key}=" "\$metadata_file" 2>/dev/null | head -1) || return 1
|
||||
printf '%s' "\${line#*=}"
|
||||
}
|
||||
|
||||
# ── Cache helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
ao_cache_dir() {
|
||||
_ao_validate_env || return 1
|
||||
local d="\${AO_DATA_DIR}/.ghcache/\${AO_SESSION}"
|
||||
mkdir -p "\$d" 2>/dev/null || return 1
|
||||
printf '%s' "\$d"
|
||||
}
|
||||
|
||||
ao_cache_fresh() {
|
||||
local cache_key="\$1" max_age="\$2"
|
||||
[[ "\$cache_key" =~ ^[a-zA-Z0-9_.-]+$ ]] || return 1
|
||||
local cache_dir
|
||||
cache_dir="\$(ao_cache_dir)" || return 1
|
||||
local ts_file="\$cache_dir/\${cache_key}.ts"
|
||||
local stdout_file="\$cache_dir/\${cache_key}.stdout"
|
||||
[[ -f "\$stdout_file" && -f "\$ts_file" ]] || return 1
|
||||
local cached_ts now
|
||||
cached_ts=\$(cat "\$ts_file" 2>/dev/null) || return 1
|
||||
# Sanity check: cached_ts must be a positive integer (epoch seconds)
|
||||
[[ "\$cached_ts" =~ ^[0-9]+$ && "\$cached_ts" -gt 0 ]] || return 1
|
||||
# max_age=0 means infinite TTL
|
||||
[[ "\$max_age" -eq 0 ]] 2>/dev/null && return 0
|
||||
now=\$(date +%s)
|
||||
(( now - cached_ts < max_age ))
|
||||
}
|
||||
|
||||
ao_cache_read() {
|
||||
local cache_key="\$1"
|
||||
[[ "\$cache_key" =~ ^[a-zA-Z0-9_.-]+$ ]] || return 1
|
||||
local cache_dir
|
||||
cache_dir="\$(ao_cache_dir)" || return 1
|
||||
cat "\$cache_dir/\${cache_key}.stdout"
|
||||
}
|
||||
|
||||
ao_cache_write() {
|
||||
local cache_key="\$1"
|
||||
[[ "\$cache_key" =~ ^[a-zA-Z0-9_.-]+$ ]] || return 1
|
||||
local cache_dir
|
||||
cache_dir="\$(ao_cache_dir)" || return 1
|
||||
local tmp="\$cache_dir/\${cache_key}.stdout.tmp.\$\$"
|
||||
cat > "\$tmp" && mv "\$tmp" "\$cache_dir/\${cache_key}.stdout"
|
||||
date +%s > "\$cache_dir/\${cache_key}.ts"
|
||||
}
|
||||
`;
|
||||
|
||||
/**
|
||||
* gh wrapper — intercepts `gh pr create` to auto-update session metadata.
|
||||
* Merge/close state remains SCM-owned, so `gh pr merge` is not used to set
|
||||
* terminal session state directly.
|
||||
* gh wrapper — intercepts agent-side gh calls for:
|
||||
* 1. Caching repeated read-only commands (PR discovery, issue context)
|
||||
* 2. Auto-updating session metadata on PR creation
|
||||
*
|
||||
* Cache storage: $AO_DATA_DIR/.ghcache/$AO_SESSION/{key}.stdout + {key}.ts
|
||||
* See D4-wrapper-cache-plan.md for full design rationale.
|
||||
*/
|
||||
export const GH_WRAPPER = `#!/usr/bin/env bash
|
||||
# ao gh wrapper — auto-updates session metadata on PR operations
|
||||
# ao gh wrapper — caches reads + auto-updates metadata on writes
|
||||
|
||||
# Find real gh by removing our wrapper directory from PATH
|
||||
ao_bin_dir="\$(cd "\$(dirname "\$0")" && pwd)"
|
||||
|
|
@ -165,18 +250,240 @@ if [[ -z "\$real_gh" ]]; then
|
|||
exit 127
|
||||
fi
|
||||
|
||||
# Source the metadata helper
|
||||
# Source the metadata helper (provides update/read_ao_metadata, ao_cache_*)
|
||||
source "\$ao_bin_dir/ao-metadata-helper.sh" 2>/dev/null || true
|
||||
|
||||
# Only capture output for commands we need to parse (pr/create).
|
||||
# All other commands pass through transparently without stream merging.
|
||||
# Redact sensitive values from args before tracing.
|
||||
# Handles: -H "Authorization: ...", token=..., password=..., secret=...
|
||||
_ao_redact_args() {
|
||||
local prev=""
|
||||
local out=()
|
||||
for arg in "\$@"; do
|
||||
if [[ "\$prev" == "-H" || "\$prev" == "--header" ]] && [[ "\$arg" =~ ^[Aa]uthorization: ]]; then
|
||||
out+=("Authorization: [REDACTED]")
|
||||
elif [[ "\$arg" =~ ^-H[Aa]uthorization: ]]; then
|
||||
out+=("-HAuthorization: [REDACTED]")
|
||||
elif [[ "\$arg" =~ ^[Tt]oken= ]]; then
|
||||
out+=("token=[REDACTED]")
|
||||
elif [[ "\$arg" =~ ^[Pp]assword= ]]; then
|
||||
out+=("password=[REDACTED]")
|
||||
elif [[ "\$arg" =~ ^[Ss]ecret= ]]; then
|
||||
out+=("secret=[REDACTED]")
|
||||
else
|
||||
out+=("\$arg")
|
||||
fi
|
||||
prev="\$arg"
|
||||
done
|
||||
printf '%s\n' "\${out[@]}"
|
||||
}
|
||||
|
||||
# Best-effort JSONL tracing for agent-side gh invocations.
|
||||
log_gh_invocation() {
|
||||
local trace_file="\${AO_AGENT_GH_TRACE:-}"
|
||||
[[ -z "\$trace_file" ]] && return 0
|
||||
command -v jq >/dev/null 2>&1 || return 0
|
||||
|
||||
mkdir -p "\$(dirname "\$trace_file")" 2>/dev/null || return 0
|
||||
|
||||
local args_json
|
||||
args_json="\$(_ao_redact_args "\$@" | jq -Rsc 'split("\n")[:-1]')" || return 0
|
||||
|
||||
# Compute operation: gh.{arg1}.{arg2} (mirrors AO-side extractOperation)
|
||||
local _ao_op="gh"
|
||||
[[ \$# -ge 1 ]] && _ao_op="gh.\$1"
|
||||
[[ \$# -ge 2 && "\$2" != -* ]] && _ao_op="gh.\$1.\$2"
|
||||
|
||||
jq -nc \
|
||||
--arg timestamp "\$(date -u +"%Y-%m-%dT%H:%M:%SZ")" \
|
||||
--arg cwd "\$PWD" \
|
||||
--arg operation "\$_ao_op" \
|
||||
--arg aoSession "\${AO_SESSION:-}" \
|
||||
--arg aoSessionName "\${AO_SESSION_NAME:-}" \
|
||||
--arg aoProjectId "\${AO_PROJECT_ID:-}" \
|
||||
--arg aoIssueId "\${AO_ISSUE_ID:-}" \
|
||||
--arg aoCallerType "\${AO_CALLER_TYPE:-}" \
|
||||
--arg pid "\$\$" \
|
||||
--arg wrapperVersion "${WRAPPER_VERSION}" \
|
||||
--argjson args "\$args_json" \
|
||||
'{
|
||||
timestamp: $timestamp,
|
||||
cwd: $cwd,
|
||||
args: $args,
|
||||
operation: $operation,
|
||||
aoSession: (if $aoSession == "" then null else $aoSession end),
|
||||
aoSessionName: (if $aoSessionName == "" then null else $aoSessionName end),
|
||||
aoProjectId: (if $aoProjectId == "" then null else $aoProjectId end),
|
||||
aoIssueId: (if $aoIssueId == "" then null else $aoIssueId end),
|
||||
aoCallerType: (if $aoCallerType == "" then null else $aoCallerType end),
|
||||
pid: ($pid | tonumber),
|
||||
wrapperVersion: $wrapperVersion
|
||||
}' >> "\$trace_file" 2>/dev/null || true
|
||||
}
|
||||
|
||||
log_gh_invocation "\$@"
|
||||
|
||||
# Best-effort cache-outcome tracing (appends to same JSONL trace file).
|
||||
# result: hit | miss-stored | miss-write-failed | miss-negative | miss-error | passthrough
|
||||
log_ao_cache() {
|
||||
local result="\$1" cache_key="\$2" duration_ms="\${3:-0}" exit_code="\${4:-0}" ok="\${5:-true}"
|
||||
local trace_file="\${AO_AGENT_GH_TRACE:-}"
|
||||
[[ -z "\$trace_file" ]] && return 0
|
||||
printf '{"timestamp":"%s","cacheResult":"%s","cacheKey":"%s","pid":%s,"durationMs":%s,"exitCode":%s,"ok":%s}\\n' \
|
||||
"\$(date -u +"%Y-%m-%dT%H:%M:%SZ")" "\$result" "\$cache_key" "\$\$" \
|
||||
"\$duration_ms" "\$exit_code" "\$ok" \
|
||||
>> "\$trace_file" 2>/dev/null || true
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# Cacheable reads
|
||||
# =============================================================================
|
||||
|
||||
# ── 1. PR discovery: gh pr list --head <B> --limit 1 ────────────────────────
|
||||
# 120s TTL for positive results (non-empty array). Never caches [].
|
||||
if [[ "\$1" == "pr" && "\$2" == "list" ]]; then
|
||||
_ao_head="" _ao_limit="" _ao_json="" _ao_repo="" _ao_cacheable=true
|
||||
_ao_saved_args=("\$@")
|
||||
shift 2
|
||||
while [[ \$# -gt 0 ]]; do
|
||||
case "\$1" in
|
||||
--head) _ao_head="\$2"; shift 2 ;;
|
||||
--head=*) _ao_head="\${1#--head=}"; shift ;;
|
||||
--limit) _ao_limit="\$2"; shift 2 ;;
|
||||
--limit=*) _ao_limit="\${1#--limit=}"; shift ;;
|
||||
--json) _ao_json="\$2"; shift 2 ;;
|
||||
--json=*) _ao_json="\${1#--json=}"; shift ;;
|
||||
--repo) _ao_repo="\$2"; shift 2 ;;
|
||||
--repo=*) _ao_repo="\${1#--repo=}"; shift ;;
|
||||
--search|--state|--assignee|--label|--jq|--template)
|
||||
_ao_cacheable=false; break ;;
|
||||
--search=*|--state=*|--assignee=*|--label=*|--jq=*|--template=*)
|
||||
_ao_cacheable=false; break ;;
|
||||
-*) shift ;; # skip unknown flags
|
||||
*) shift ;; # skip positional
|
||||
esac
|
||||
done
|
||||
set -- "\${_ao_saved_args[@]}"
|
||||
|
||||
if [[ "\$_ao_cacheable" == true && "\$_ao_limit" == "1" && -n "\$_ao_head" ]]; then
|
||||
# Use sha256 hash suffix to avoid collisions from tr-based sanitization
|
||||
# (e.g. feat/foo, feat-foo, feat_foo would otherwise map to the same key)
|
||||
_ao_raw_key="pr-discovery-\${_ao_repo}-\${_ao_head}"
|
||||
if [[ -n "\$_ao_json" ]]; then
|
||||
_ao_raw_key="\${_ao_raw_key}-j-\${_ao_json}"
|
||||
fi
|
||||
_ao_cache_key=\$(printf '%s' "\$_ao_raw_key" | shasum -a 256 | cut -c1-16)
|
||||
_ao_cache_key="pr-disc-\${_ao_cache_key}"
|
||||
|
||||
if ao_cache_fresh "\$_ao_cache_key" 120 2>/dev/null; then
|
||||
log_ao_cache "hit" "\$_ao_cache_key" 0 0 true
|
||||
ao_cache_read "\$_ao_cache_key"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Cache miss — call real gh, cache positive results (stderr passes through)
|
||||
_ao_tmpout="\$(mktemp)"
|
||||
trap 'rm -f "\$_ao_tmpout"' EXIT
|
||||
_ao_start_s=\$(date +%s)
|
||||
"\$real_gh" "\$@" > "\$_ao_tmpout"
|
||||
_ao_exit=\$?
|
||||
_ao_duration_ms=\$(( (\$(date +%s) - _ao_start_s) * 1000 ))
|
||||
_ao_ok=true; [[ \$_ao_exit -ne 0 ]] && _ao_ok=false
|
||||
cat "\$_ao_tmpout"
|
||||
if [[ \$_ao_exit -eq 0 ]]; then
|
||||
_ao_trimmed=\$(tr -d '[:space:]' < "\$_ao_tmpout")
|
||||
# Only cache non-empty positive results
|
||||
if [[ -n "\$_ao_trimmed" && "\$_ao_trimmed" != "[]" ]]; then
|
||||
if ao_cache_write "\$_ao_cache_key" < "\$_ao_tmpout" 2>/dev/null; then
|
||||
log_ao_cache "miss-stored" "\$_ao_cache_key" "\$_ao_duration_ms" "\$_ao_exit" "\$_ao_ok"
|
||||
else
|
||||
log_ao_cache "miss-write-failed" "\$_ao_cache_key" "\$_ao_duration_ms" "\$_ao_exit" "\$_ao_ok"
|
||||
fi
|
||||
else
|
||||
log_ao_cache "miss-negative" "\$_ao_cache_key" "\$_ao_duration_ms" "\$_ao_exit" "\$_ao_ok"
|
||||
fi
|
||||
else
|
||||
log_ao_cache "miss-error" "\$_ao_cache_key" "\$_ao_duration_ms" "\$_ao_exit" "\$_ao_ok"
|
||||
fi
|
||||
exit \$_ao_exit
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── 2. Issue context: gh issue view <N> ─────────────────────────────────────
|
||||
# 300-second TTL. Caches any successful response.
|
||||
if [[ "\$1" == "issue" && "\$2" == "view" ]]; then
|
||||
_ao_issue_id="" _ao_json="" _ao_repo="" _ao_cacheable=true
|
||||
_ao_saved_args=("\$@")
|
||||
shift 2
|
||||
# First non-flag arg is the issue identifier
|
||||
while [[ \$# -gt 0 ]]; do
|
||||
case "\$1" in
|
||||
--web|--comments|--jq|--template)
|
||||
_ao_cacheable=false; break ;;
|
||||
--jq=*|--template=*)
|
||||
_ao_cacheable=false; break ;;
|
||||
--json) _ao_json="\$2"; shift 2 ;;
|
||||
--json=*) _ao_json="\${1#--json=}"; shift ;;
|
||||
--repo) _ao_repo="\$2"; shift 2 ;;
|
||||
--repo=*) _ao_repo="\${1#--repo=}"; shift ;;
|
||||
-*) shift ;;
|
||||
*)
|
||||
if [[ -z "\$_ao_issue_id" && "\$1" =~ ^[0-9]+$ ]]; then
|
||||
_ao_issue_id="\$1"
|
||||
fi
|
||||
shift ;;
|
||||
esac
|
||||
done
|
||||
set -- "\${_ao_saved_args[@]}"
|
||||
|
||||
if [[ "\$_ao_cacheable" == true && -n "\$_ao_issue_id" ]]; then
|
||||
_ao_raw_key="issue-ctx-\${_ao_repo}-\${_ao_issue_id}"
|
||||
if [[ -n "\$_ao_json" ]]; then
|
||||
_ao_raw_key="\${_ao_raw_key}-j-\${_ao_json}"
|
||||
fi
|
||||
_ao_cache_key=\$(printf '%s' "\$_ao_raw_key" | shasum -a 256 | cut -c1-16)
|
||||
_ao_cache_key="issue-\${_ao_cache_key}"
|
||||
|
||||
if ao_cache_fresh "\$_ao_cache_key" 300 2>/dev/null; then
|
||||
log_ao_cache "hit" "\$_ao_cache_key" 0 0 true
|
||||
ao_cache_read "\$_ao_cache_key"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
_ao_tmpout="\$(mktemp)"
|
||||
trap 'rm -f "\$_ao_tmpout"' EXIT
|
||||
_ao_start_s=\$(date +%s)
|
||||
"\$real_gh" "\$@" > "\$_ao_tmpout"
|
||||
_ao_exit=\$?
|
||||
_ao_duration_ms=\$(( (\$(date +%s) - _ao_start_s) * 1000 ))
|
||||
_ao_ok=true; [[ \$_ao_exit -ne 0 ]] && _ao_ok=false
|
||||
cat "\$_ao_tmpout"
|
||||
if [[ \$_ao_exit -eq 0 ]]; then
|
||||
if ao_cache_write "\$_ao_cache_key" < "\$_ao_tmpout" 2>/dev/null; then
|
||||
log_ao_cache "miss-stored" "\$_ao_cache_key" "\$_ao_duration_ms" "\$_ao_exit" "\$_ao_ok"
|
||||
else
|
||||
log_ao_cache "miss-write-failed" "\$_ao_cache_key" "\$_ao_duration_ms" "\$_ao_exit" "\$_ao_ok"
|
||||
fi
|
||||
else
|
||||
log_ao_cache "miss-error" "\$_ao_cache_key" "\$_ao_duration_ms" "\$_ao_exit" "\$_ao_ok"
|
||||
fi
|
||||
exit \$_ao_exit
|
||||
fi
|
||||
fi
|
||||
|
||||
# =============================================================================
|
||||
# Write intercepts
|
||||
# =============================================================================
|
||||
|
||||
case "\$1/\$2" in
|
||||
pr/create)
|
||||
tmpout="\$(mktemp)"
|
||||
trap 'rm -f "\$tmpout"' EXIT
|
||||
|
||||
_ao_start_s=\$(date +%s)
|
||||
"\$real_gh" "\$@" 2>&1 | tee "\$tmpout"
|
||||
exit_code=\${PIPESTATUS[0]}
|
||||
_ao_duration_ms=\$(( (\$(date +%s) - _ao_start_s) * 1000 ))
|
||||
_ao_ok=true; [[ \$exit_code -ne 0 ]] && _ao_ok=false
|
||||
|
||||
if [[ \$exit_code -eq 0 ]]; then
|
||||
output="\$(cat "\$tmpout")"
|
||||
|
|
@ -203,10 +510,17 @@ case "\$1/\$2" in
|
|||
update_ao_metadata agentReportedPrIsDraft "\$report_draft"
|
||||
fi
|
||||
|
||||
log_ao_cache "passthrough" "" "\$_ao_duration_ms" "\$exit_code" "\$_ao_ok"
|
||||
exit \$exit_code
|
||||
;;
|
||||
*)
|
||||
exec "\$real_gh" "\$@"
|
||||
_ao_start_s=\$(date +%s)
|
||||
"\$real_gh" "\$@"
|
||||
_ao_exit=\$?
|
||||
_ao_duration_ms=\$(( (\$(date +%s) - _ao_start_s) * 1000 ))
|
||||
_ao_ok=true; [[ \$_ao_exit -ne 0 ]] && _ao_ok=false
|
||||
log_ao_cache "passthrough" "" "\$_ao_duration_ms" "\$_ao_exit" "\$_ao_ok"
|
||||
exit \$_ao_exit
|
||||
;;
|
||||
esac
|
||||
`;
|
||||
|
|
|
|||
|
|
@ -678,7 +678,7 @@ function applyDefaultReactions(config: OrchestratorConfig): OrchestratorConfig {
|
|||
auto: true,
|
||||
action: "send-to-agent",
|
||||
message:
|
||||
"CI is failing on your PR. Run `gh pr checks` to see the failures, fix them, and push.",
|
||||
"CI is failing on your PR. Investigate the failures, fix the issues, and push again.",
|
||||
retries: 2,
|
||||
escalateAfter: 2,
|
||||
},
|
||||
|
|
@ -686,17 +686,13 @@ function applyDefaultReactions(config: OrchestratorConfig): OrchestratorConfig {
|
|||
auto: true,
|
||||
action: "send-to-agent",
|
||||
message:
|
||||
"There are review comments on your PR. Check with `gh pr view --comments` and `gh api` for inline comments. Address each one, push fixes, and reply.",
|
||||
"There are review comments on your PR. Details will follow shortly.",
|
||||
escalateAfter: "30m",
|
||||
},
|
||||
"bugbot-comments": {
|
||||
auto: true,
|
||||
action: "send-to-agent",
|
||||
// When this exact sentinel is present, the lifecycle dispatcher replaces
|
||||
// it with a detailed listing of the actual comments + correct-API guidance
|
||||
// (see formatAutomatedCommentsMessage, #895). If a project customizes
|
||||
// this message in their YAML, the dispatcher respects it verbatim.
|
||||
message: DEFAULT_BUGBOT_COMMENTS_MESSAGE,
|
||||
message: "Automated review comments found on your PR. Details will follow shortly.",
|
||||
escalateAfter: "30m",
|
||||
},
|
||||
"merge-conflicts": {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,403 @@
|
|||
import { execFile } from "node:child_process";
|
||||
import { access, appendFile, mkdir } from "node:fs/promises";
|
||||
import { constants } from "node:fs";
|
||||
import { delimiter, dirname, join } from "node:path";
|
||||
import { homedir } from "node:os";
|
||||
import { promisify } from "node:util";
|
||||
import type { SessionId } from "./types.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
/**
|
||||
* Resolve the real gh binary path, bypassing ~/.ao/bin wrapper.
|
||||
* AO-owned calls must NOT go through the wrapper (which is for agent sessions).
|
||||
*
|
||||
* Strips ~/.ao/bin from PATH and resolves gh from the clean PATH.
|
||||
* Cached after first resolution.
|
||||
*/
|
||||
let resolvedGhPath: string | null = null;
|
||||
async function getGhBinaryPath(): Promise<string> {
|
||||
if (resolvedGhPath) return resolvedGhPath;
|
||||
const resolved = await resolveGhBinary();
|
||||
// Cache only successful resolutions (non-wrapper paths).
|
||||
// If resolution fails, retry on next call so transient PATH
|
||||
// issues don't permanently route through the wrapper.
|
||||
resolvedGhPath = resolved;
|
||||
return resolved;
|
||||
}
|
||||
|
||||
async function resolveGhBinary(): Promise<string> {
|
||||
// Build a clean PATH without ~/.ao/bin and walk each directory looking
|
||||
// for an executable `gh`. Uses fs.access instead of spawning a shell —
|
||||
// avoids blocking the event loop and shell injection concerns.
|
||||
const aoBinDir = join(homedir(), ".ao", "bin");
|
||||
const dirs = (process.env["PATH"] ?? "")
|
||||
.split(delimiter)
|
||||
.filter((entry) => entry && entry !== aoBinDir);
|
||||
|
||||
for (const dir of dirs) {
|
||||
const candidate = join(dir, "gh");
|
||||
try {
|
||||
await access(candidate, constants.X_OK);
|
||||
return candidate;
|
||||
} catch {
|
||||
// Not found or not executable — try next
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
"gh CLI not found outside ~/.ao/bin. Install gh or set GH_PATH to the real binary.",
|
||||
);
|
||||
}
|
||||
|
||||
const GH_TRACE_FILE_ENV = "AO_GH_TRACE_FILE";
|
||||
|
||||
export interface GhTraceContext {
|
||||
component: string;
|
||||
operation?: string;
|
||||
projectId?: string;
|
||||
sessionId?: SessionId;
|
||||
cwd?: string;
|
||||
}
|
||||
|
||||
export interface GhTraceResult {
|
||||
ok: boolean;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
exitCode?: number;
|
||||
signal?: string;
|
||||
}
|
||||
|
||||
export interface GhTraceEntry {
|
||||
timestamp: string;
|
||||
component: string;
|
||||
operation: string;
|
||||
projectId?: string;
|
||||
sessionId?: SessionId;
|
||||
cwd?: string;
|
||||
args: string[];
|
||||
endpoint?: string;
|
||||
method?: string;
|
||||
ok: boolean;
|
||||
exitCode?: number;
|
||||
signal?: string;
|
||||
durationMs: number;
|
||||
stdoutBytes: number;
|
||||
stderrBytes: number;
|
||||
statusLine?: string;
|
||||
httpStatus?: number;
|
||||
etag?: string;
|
||||
rateLimitLimit?: number;
|
||||
rateLimitRemaining?: number;
|
||||
rateLimitReset?: number;
|
||||
rateLimitResource?: string;
|
||||
/** Exact GraphQL cost from response body `rateLimit { cost }` (only for GraphQL calls). */
|
||||
graphqlCost?: number;
|
||||
/** GraphQL remaining from response body (more accurate than header). */
|
||||
graphqlRemaining?: number;
|
||||
/** GraphQL reset time from response body. */
|
||||
graphqlResetAt?: string;
|
||||
}
|
||||
|
||||
interface HeaderMap {
|
||||
[key: string]: string | undefined;
|
||||
}
|
||||
|
||||
function nowIso(): string {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function parseIntHeader(value: string | undefined): number | undefined {
|
||||
if (!value) return undefined;
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return undefined;
|
||||
const parsed = Number.parseInt(trimmed, 10);
|
||||
return Number.isFinite(parsed) ? parsed : undefined;
|
||||
}
|
||||
|
||||
function extractOperation(args: string[]): string {
|
||||
if (args.length === 0) return "gh";
|
||||
if (args.length === 1) return `gh.${args[0]}`;
|
||||
// Walk past leading flags (e.g. --method, -X, -H) to find the first
|
||||
// positional arg after args[0]. Without this, "api --method GET ..."
|
||||
// gets bucketed as "gh.api.--method" instead of "gh.api.graphql" etc.
|
||||
for (let i = 1; i < args.length; i++) {
|
||||
const arg = args[i];
|
||||
if (!arg || arg.startsWith("-")) {
|
||||
// Skip flags and their values (--method GET, -X POST, -H "...", etc.)
|
||||
if (arg === "--method" || arg === "-X" || arg === "-H" || arg === "--header" ||
|
||||
arg === "-f" || arg === "--raw-field" || arg === "-F" || arg === "--field" ||
|
||||
arg === "--input" || arg === "-t" || arg === "--template") {
|
||||
i++; // skip the flag's value too
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// For REST URL paths like "repos/acme/repo/pulls/123/comments?...",
|
||||
// extract only the first path segment to keep cardinality bounded.
|
||||
// "graphql" stays as-is; "repos/..." becomes "repos"; "user" stays "user".
|
||||
const firstSegment = arg.split("/")[0].split("?")[0];
|
||||
return `gh.${args[0]}.${firstSegment}`;
|
||||
}
|
||||
return `gh.${args[0]}`;
|
||||
}
|
||||
|
||||
function extractMethod(args: string[]): string | undefined {
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i] === "--method" || args[i] === "-X") {
|
||||
return args[i + 1];
|
||||
}
|
||||
}
|
||||
return args[0] === "api" ? "GET" : undefined;
|
||||
}
|
||||
|
||||
function extractEndpoint(args: string[]): string | undefined {
|
||||
if (args[0] !== "api") return undefined;
|
||||
for (let i = 1; i < args.length; i++) {
|
||||
const arg = args[i];
|
||||
if (!arg) continue;
|
||||
if (arg === "--method" || arg === "-X" || arg === "-H" || arg === "--header") {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (arg === "-f" || arg === "--raw-field" || arg === "-F" || arg === "--field") {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--input") {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (!arg.startsWith("-")) {
|
||||
return arg;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function parseIncludedHttpResponse(stdout: string): {
|
||||
statusLine?: string;
|
||||
headers: HeaderMap;
|
||||
} {
|
||||
const headers: HeaderMap = {};
|
||||
const normalized = stdout.replace(/\r/g, "");
|
||||
const lines = normalized.split("\n");
|
||||
// Take the LAST HTTP/ status line — on redirects (3xx → 200), the final
|
||||
// status line corresponds to the actual resource, not the redirect.
|
||||
let startIndex = -1;
|
||||
for (let i = lines.length - 1; i >= 0; i--) {
|
||||
if (lines[i]?.startsWith("HTTP/")) {
|
||||
startIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (startIndex === -1) {
|
||||
return { headers };
|
||||
}
|
||||
const statusLine = lines[startIndex];
|
||||
for (let i = startIndex + 1; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
if (!line) break;
|
||||
const colonIndex = line.indexOf(":");
|
||||
if (colonIndex === -1) continue;
|
||||
const key = line.slice(0, colonIndex).trim().toLowerCase();
|
||||
const value = line.slice(colonIndex + 1).trim();
|
||||
headers[key] = value;
|
||||
}
|
||||
|
||||
return { statusLine, headers };
|
||||
}
|
||||
|
||||
function extractExitCode(err: unknown): number | undefined {
|
||||
const candidate = err as { code?: number | string; exitCode?: number };
|
||||
if (typeof candidate.exitCode === "number") return candidate.exitCode;
|
||||
if (typeof candidate.code === "number") return candidate.code;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function extractSignal(err: unknown): string | undefined {
|
||||
const candidate = err as { signal?: string | null };
|
||||
return typeof candidate.signal === "string" ? candidate.signal : undefined;
|
||||
}
|
||||
|
||||
const ensuredDirs = new Set<string>();
|
||||
const warnedTargets = new Set<string>();
|
||||
|
||||
async function writeTrace(entry: GhTraceEntry): Promise<void> {
|
||||
const target = process.env[GH_TRACE_FILE_ENV];
|
||||
if (!target) return;
|
||||
|
||||
const dir = dirname(target);
|
||||
const line = `${JSON.stringify(entry)}\n`;
|
||||
|
||||
try {
|
||||
if (!ensuredDirs.has(dir)) {
|
||||
await mkdir(dir, { recursive: true });
|
||||
ensuredDirs.add(dir);
|
||||
}
|
||||
await appendFile(target, line, "utf-8");
|
||||
} catch (err) {
|
||||
// Warn once per target to surface disk-full / permission errors
|
||||
if (!warnedTargets.has(target)) {
|
||||
warnedTargets.add(target);
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
// eslint-disable-next-line no-console -- surface trace write failures once
|
||||
console.warn(`[gh-trace] Failed to write trace to ${target}: ${msg}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Redact sensitive values from gh CLI args before persisting to trace JSONL. */
|
||||
function redactArgs(args: string[]): string[] {
|
||||
const sensitiveFlags = new Set(["-H", "--header"]);
|
||||
const sensitiveFieldPrefixes = ["token=", "password=", "secret=", "authorization="];
|
||||
return args.map((arg, i) => {
|
||||
// Redact the value after -H / --header if it contains Authorization
|
||||
const prev = i > 0 ? args[i - 1] : undefined;
|
||||
if (prev && sensitiveFlags.has(prev) && /^authorization:/i.test(arg)) {
|
||||
return "Authorization: [REDACTED]";
|
||||
}
|
||||
// Redact inline -H"Authorization: ..." style
|
||||
if (/^-H/i.test(arg) && /authorization:/i.test(arg)) {
|
||||
return "-HAuthorization: [REDACTED]";
|
||||
}
|
||||
// Redact -f/-F field values like token=..., password=...
|
||||
for (const prefix of sensitiveFieldPrefixes) {
|
||||
if (arg.toLowerCase().startsWith(prefix)) {
|
||||
return `${arg.slice(0, prefix.length)}[REDACTED]`;
|
||||
}
|
||||
}
|
||||
// Redact the value following -f/-F if the next positional matches
|
||||
if (prev && (prev === "-f" || prev === "--raw-field" || prev === "-F" || prev === "--field")) {
|
||||
for (const prefix of sensitiveFieldPrefixes) {
|
||||
if (arg.toLowerCase().startsWith(prefix)) {
|
||||
return `${arg.slice(0, prefix.length)}[REDACTED]`;
|
||||
}
|
||||
}
|
||||
}
|
||||
return arg;
|
||||
});
|
||||
}
|
||||
|
||||
function buildTraceEntry(
|
||||
args: string[],
|
||||
ctx: GhTraceContext,
|
||||
result: GhTraceResult,
|
||||
durationMs: number,
|
||||
): GhTraceEntry {
|
||||
const { statusLine, headers } = parseIncludedHttpResponse(result.stdout ?? "");
|
||||
const httpStatus = statusLine
|
||||
? Number.parseInt(statusLine.replace(/^HTTP\/[0-9.]+\s+/, "").split(" ")[0] ?? "", 10)
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
timestamp: nowIso(),
|
||||
component: ctx.component,
|
||||
operation: ctx.operation ?? extractOperation(args),
|
||||
projectId: ctx.projectId,
|
||||
sessionId: ctx.sessionId,
|
||||
cwd: ctx.cwd,
|
||||
args: redactArgs(args),
|
||||
endpoint: extractEndpoint(args),
|
||||
method: extractMethod(args),
|
||||
ok: result.ok,
|
||||
exitCode: result.exitCode,
|
||||
signal: result.signal,
|
||||
durationMs,
|
||||
stdoutBytes: Buffer.byteLength(result.stdout ?? "", "utf-8"),
|
||||
stderrBytes: Buffer.byteLength(result.stderr ?? "", "utf-8"),
|
||||
statusLine,
|
||||
httpStatus: Number.isFinite(httpStatus) ? httpStatus : undefined,
|
||||
etag: headers["etag"],
|
||||
rateLimitLimit: parseIntHeader(headers["x-ratelimit-limit"]),
|
||||
rateLimitRemaining: parseIntHeader(headers["x-ratelimit-remaining"]),
|
||||
rateLimitReset: parseIntHeader(headers["x-ratelimit-reset"]),
|
||||
rateLimitResource: headers["x-ratelimit-resource"],
|
||||
...parseGraphQLRateLimit(result.stdout ?? "", args),
|
||||
};
|
||||
}
|
||||
|
||||
/** Extract rateLimit { cost, remaining, resetAt } from GraphQL response body. */
|
||||
function parseGraphQLRateLimit(
|
||||
stdout: string,
|
||||
args: string[],
|
||||
): Pick<GhTraceEntry, "graphqlCost" | "graphqlRemaining" | "graphqlResetAt"> {
|
||||
// Only attempt for GraphQL calls
|
||||
if (!args.includes("graphql")) return {};
|
||||
// Quick check — avoid parsing multi-MB response bodies when rateLimit isn't present
|
||||
if (!stdout.includes('"rateLimit"')) return {};
|
||||
// Strip HTTP headers if present (-i flag)
|
||||
const bodyMatch = stdout.match(/\r?\n\r?\n([\s\S]*)$/);
|
||||
const body = bodyMatch ? bodyMatch[1] : stdout;
|
||||
try {
|
||||
const parsed = JSON.parse(body.trim());
|
||||
const rl = parsed?.data?.rateLimit;
|
||||
if (!rl) return {};
|
||||
return {
|
||||
graphqlCost: typeof rl.cost === "number" ? rl.cost : undefined,
|
||||
graphqlRemaining: typeof rl.remaining === "number" ? rl.remaining : undefined,
|
||||
graphqlResetAt: typeof rl.resetAt === "string" ? rl.resetAt : undefined,
|
||||
};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export async function execGhObserved(
|
||||
args: string[],
|
||||
ctx: GhTraceContext,
|
||||
timeout: number = 30_000,
|
||||
): Promise<string> {
|
||||
const startedAt = Date.now();
|
||||
|
||||
try {
|
||||
const ghPath = await getGhBinaryPath();
|
||||
const { stdout, stderr } = await execFileAsync(ghPath, args, {
|
||||
...(ctx.cwd ? { cwd: ctx.cwd } : {}),
|
||||
// 10 MB — matches the previous per-caller maxBuffer in scm-github.
|
||||
// GraphQL batch queries for 25 PRs can produce multi-MB responses.
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
timeout,
|
||||
});
|
||||
const entry = buildTraceEntry(
|
||||
args,
|
||||
ctx,
|
||||
{ ok: true, stdout, stderr },
|
||||
Date.now() - startedAt,
|
||||
);
|
||||
await writeTrace(entry);
|
||||
return stdout.trim();
|
||||
} catch (err) {
|
||||
const stdout = typeof (err as { stdout?: unknown }).stdout === "string"
|
||||
? (err as { stdout: string }).stdout
|
||||
: "";
|
||||
const stderr = typeof (err as { stderr?: unknown }).stderr === "string"
|
||||
? (err as { stderr: string }).stderr
|
||||
: "";
|
||||
const entry = buildTraceEntry(
|
||||
args,
|
||||
ctx,
|
||||
{
|
||||
ok: false,
|
||||
stdout,
|
||||
stderr,
|
||||
exitCode: extractExitCode(err),
|
||||
signal: extractSignal(err),
|
||||
},
|
||||
Date.now() - startedAt,
|
||||
);
|
||||
await writeTrace(entry);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export function getGhTraceFilePath(): string | undefined {
|
||||
return process.env[GH_TRACE_FILE_ENV];
|
||||
}
|
||||
|
||||
// Re-export internal utilities for testing — not part of public API.
|
||||
// These are the functions the reviewer flagged as needing test coverage.
|
||||
export const _testUtils = {
|
||||
extractOperation,
|
||||
redactArgs,
|
||||
parseIncludedHttpResponse,
|
||||
};
|
||||
|
|
@ -193,6 +193,7 @@ export {
|
|||
createProjectObserver,
|
||||
readObservabilitySummary,
|
||||
} from "./observability.js";
|
||||
export { execGhObserved, getGhTraceFilePath } from "./gh-trace.js";
|
||||
export { resolveNotifierTarget } from "./notifier-resolution.js";
|
||||
export type {
|
||||
ObservabilityLevel,
|
||||
|
|
@ -201,6 +202,7 @@ export type {
|
|||
ObservabilitySummary,
|
||||
ProjectObserver,
|
||||
} from "./observability.js";
|
||||
export type { GhTraceContext, GhTraceEntry } from "./gh-trace.js";
|
||||
|
||||
// Feedback tools — contracts, validation, and report storage
|
||||
export {
|
||||
|
|
|
|||
|
|
@ -12,10 +12,8 @@
|
|||
|
||||
import { randomUUID } from "node:crypto";
|
||||
import {
|
||||
SESSION_STATUS,
|
||||
ACTIVITY_STATE,
|
||||
PR_STATE,
|
||||
CI_STATUS,
|
||||
SESSION_STATUS,
|
||||
TERMINAL_STATUSES,
|
||||
type LifecycleManager,
|
||||
type OpenCodeSessionManager,
|
||||
|
|
@ -37,9 +35,9 @@ import {
|
|||
type ProjectConfig as _ProjectConfig,
|
||||
type PREnrichmentData,
|
||||
type CICheck,
|
||||
type ReviewComment,
|
||||
type ReviewSummary,
|
||||
} from "./types.js";
|
||||
import { formatAutomatedCommentsMessage } from "./format-automated-comments.js";
|
||||
import { DEFAULT_BUGBOT_COMMENTS_MESSAGE } from "./config.js";
|
||||
import { buildLifecycleMetadataPatch, cloneLifecycle, deriveLegacyStatus } from "./lifecycle-state.js";
|
||||
import { updateMetadata } from "./metadata.js";
|
||||
import { getSessionsDir } from "./paths.js";
|
||||
|
|
@ -327,9 +325,12 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan
|
|||
*/
|
||||
const prEnrichmentCache = new Map<string, PREnrichmentData>();
|
||||
|
||||
/** Repos where Guard 1 returned 304 in the current poll — safe to skip detectPR. */
|
||||
let prListUnchangedRepos = new Set<string>();
|
||||
|
||||
/**
|
||||
* Per-session timestamp of last review backlog API check.
|
||||
* Used to throttle getPendingComments/getAutomatedComments to at most once per 2 minutes.
|
||||
* Used to throttle review thread checks to at most once per 2 minutes.
|
||||
* In-memory only — resets on restart (acceptable since it's a rate-limit hint, not state).
|
||||
*/
|
||||
const lastReviewBacklogCheckAt = new Map<SessionId, number>();
|
||||
|
|
@ -344,34 +345,45 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan
|
|||
async function populatePREnrichmentCache(sessions: Session[]): Promise<void> {
|
||||
// Clear previous cache
|
||||
prEnrichmentCache.clear();
|
||||
prListUnchangedRepos = new Set();
|
||||
|
||||
// Collect all unique PRs keyed by their owning session's project/plugin.
|
||||
// Collect all unique PRs and repos keyed by their owning session's project/plugin.
|
||||
// Repos are collected from ALL sessions (not just ones with PRs) so Guard 1 runs
|
||||
// for every active repo — enabling detectPR gating even when no PRs exist yet.
|
||||
const prsByPlugin = new Map<string, Array<NonNullable<Session["pr"]>>>();
|
||||
const reposByPlugin = new Map<string, Set<string>>();
|
||||
const seenPRKeys = new Set<string>();
|
||||
for (const session of sessions) {
|
||||
if (!session.pr) continue;
|
||||
const project = config.projects[session.projectId];
|
||||
if (!project?.scm?.plugin) continue;
|
||||
|
||||
const prKey = `${session.pr.owner}/${session.pr.repo}#${session.pr.number}`;
|
||||
if (seenPRKeys.has(prKey)) continue;
|
||||
seenPRKeys.add(prKey);
|
||||
if (!project?.scm?.plugin || !project.repo) continue;
|
||||
|
||||
const pluginKey = project.scm.plugin;
|
||||
if (!prsByPlugin.has(pluginKey)) {
|
||||
prsByPlugin.set(pluginKey, []);
|
||||
}
|
||||
if (!reposByPlugin.has(pluginKey)) {
|
||||
reposByPlugin.set(pluginKey, new Set());
|
||||
}
|
||||
reposByPlugin.get(pluginKey)!.add(project.repo);
|
||||
|
||||
if (!session.pr) continue;
|
||||
|
||||
const prKey = `${session.pr.owner}/${session.pr.repo}#${session.pr.number}`;
|
||||
if (seenPRKeys.has(prKey)) continue;
|
||||
seenPRKeys.add(prKey);
|
||||
|
||||
const pluginPRs = prsByPlugin.get(pluginKey);
|
||||
if (pluginPRs) {
|
||||
pluginPRs.push(session.pr);
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch enrichment data for each plugin's PRs
|
||||
// Fetch enrichment data and run Guard 1 for all active repos
|
||||
for (const [pluginKey, pluginPRs] of prsByPlugin) {
|
||||
const scm = registry.get<SCM>("scm", pluginKey);
|
||||
if (!scm?.enrichSessionsPRBatch) continue;
|
||||
|
||||
const pluginRepos = [...(reposByPlugin.get(pluginKey) ?? [])];
|
||||
const batchStartTime = Date.now();
|
||||
try {
|
||||
const enrichmentData = await scm.enrichSessionsPRBatch(
|
||||
|
|
@ -424,7 +436,13 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan
|
|||
},
|
||||
});
|
||||
},
|
||||
reportPRListUnchangedRepos(repos) {
|
||||
for (const repo of repos) {
|
||||
prListUnchangedRepos.add(repo);
|
||||
}
|
||||
},
|
||||
},
|
||||
pluginRepos,
|
||||
);
|
||||
|
||||
// Merge into cache
|
||||
|
|
@ -446,7 +464,89 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan
|
|||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Discover PRs for sessions that don't have one yet.
|
||||
// Only run detectPR when Guard 1 returned 200 (repo's PR list changed).
|
||||
// When Guard 1 returned 304, the repo is in prListUnchangedRepos — no new PRs exist.
|
||||
for (const session of sessions) {
|
||||
if (session.pr) continue;
|
||||
if (!session.branch) continue;
|
||||
if (session.metadata["prAutoDetect"] === "off") continue;
|
||||
if (session.metadata["role"] === "orchestrator" || session.id.endsWith("-orchestrator")) continue;
|
||||
|
||||
const project = config.projects[session.projectId];
|
||||
if (!project?.repo || !project.scm?.plugin) continue;
|
||||
|
||||
// Skip if Guard 1 confirmed no PR list changes for this repo
|
||||
if (prListUnchangedRepos.has(project.repo)) continue;
|
||||
|
||||
const scm = registry.get<SCM>("scm", project.scm.plugin);
|
||||
if (!scm?.detectPR) continue;
|
||||
|
||||
try {
|
||||
const detectedPR = await scm.detectPR(session, project);
|
||||
if (detectedPR) {
|
||||
session.pr = detectedPR;
|
||||
const sessionsDir = getSessionsDir(project.storageKey);
|
||||
updateMetadata(sessionsDir, session.id, { pr: detectedPR.url });
|
||||
}
|
||||
} catch (error) {
|
||||
observer?.recordOperation?.({
|
||||
metric: "lifecycle_poll",
|
||||
operation: "scm.detect_pr",
|
||||
outcome: "failure",
|
||||
correlationId: createCorrelationId("detect-pr"),
|
||||
projectId: session.projectId,
|
||||
sessionId: session.id,
|
||||
reason: error instanceof Error ? error.message : String(error),
|
||||
level: "warn",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist batch enrichment data to session metadata files.
|
||||
* The web dashboard reads this instead of calling GitHub API.
|
||||
*/
|
||||
function persistPREnrichmentToMetadata(sessions: Session[]): void {
|
||||
for (const session of sessions) {
|
||||
if (!session.pr) continue;
|
||||
const project = config.projects[session.projectId];
|
||||
if (!project) continue;
|
||||
|
||||
const prKey = `${session.pr.owner}/${session.pr.repo}#${session.pr.number}`;
|
||||
const cached = prEnrichmentCache.get(prKey);
|
||||
if (!cached) continue;
|
||||
|
||||
const blob = JSON.stringify({
|
||||
state: cached.state,
|
||||
ciStatus: cached.ciStatus,
|
||||
reviewDecision: cached.reviewDecision,
|
||||
mergeable: cached.mergeable,
|
||||
title: cached.title,
|
||||
additions: cached.additions,
|
||||
deletions: cached.deletions,
|
||||
isDraft: cached.isDraft,
|
||||
hasConflicts: cached.hasConflicts,
|
||||
isBehind: cached.isBehind,
|
||||
blockers: cached.blockers,
|
||||
ciChecks: cached.ciChecks?.map((c) => ({
|
||||
name: c.name,
|
||||
status: c.status,
|
||||
url: c.url,
|
||||
})),
|
||||
enrichedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
if (session.metadata["prEnrichment"] === blob) continue;
|
||||
|
||||
const sessionsDir = getSessionsDir(project.storageKey);
|
||||
updateMetadata(sessionsDir, session.id, { prEnrichment: blob });
|
||||
session.metadata["prEnrichment"] = blob;
|
||||
}
|
||||
}
|
||||
|
||||
/** Check if idle time exceeds the agent-stuck threshold. */
|
||||
function isIdleBeyondThreshold(session: Session, idleTimestamp: Date): boolean {
|
||||
const stuckReaction = getReactionConfigForSession(session, "agent-stuck");
|
||||
|
|
@ -692,39 +792,8 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan
|
|||
return commit(probeDecision);
|
||||
}
|
||||
|
||||
if (
|
||||
!session.pr &&
|
||||
scm &&
|
||||
session.branch &&
|
||||
session.metadata["prAutoDetect"] !== "off" &&
|
||||
session.metadata["role"] !== "orchestrator" &&
|
||||
!session.id.endsWith("-orchestrator")
|
||||
) {
|
||||
try {
|
||||
const detectedPR = await scm.detectPR(session, project);
|
||||
if (detectedPR) {
|
||||
session.pr = detectedPR;
|
||||
lifecycle.pr.state = "open";
|
||||
lifecycle.pr.reason = "in_progress";
|
||||
lifecycle.pr.number = detectedPR.number;
|
||||
lifecycle.pr.url = detectedPR.url;
|
||||
lifecycle.pr.lastObservedAt = nowIso;
|
||||
const sessionsDir = getSessionsDir(project.storageKey);
|
||||
updateMetadata(sessionsDir, session.id, { pr: detectedPR.url });
|
||||
}
|
||||
} catch (error) {
|
||||
observer?.recordOperation?.({
|
||||
metric: "lifecycle_poll",
|
||||
operation: "scm.detect_pr",
|
||||
outcome: "failure",
|
||||
correlationId: createCorrelationId("lifecycle-poll"),
|
||||
projectId: session.projectId,
|
||||
sessionId: session.id,
|
||||
reason: error instanceof Error ? error.message : String(error),
|
||||
level: "warn",
|
||||
});
|
||||
}
|
||||
}
|
||||
// detectPR is handled in populatePREnrichmentCache (gated by Guard 1 ETag).
|
||||
// By this point, session.pr is already set if a PR was discovered.
|
||||
|
||||
if (session.pr && scm) {
|
||||
try {
|
||||
|
|
@ -747,52 +816,29 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan
|
|||
}),
|
||||
);
|
||||
}
|
||||
const prState = await scm.getPRState(session.pr);
|
||||
if (prState === PR_STATE.MERGED || prState === PR_STATE.CLOSED) {
|
||||
return commit(
|
||||
resolvePRLiveDecision({
|
||||
prState,
|
||||
ciStatus: CI_STATUS.NONE,
|
||||
reviewDecision: "none",
|
||||
mergeable: false,
|
||||
shouldEscalateIdleToStuck,
|
||||
idleWasBlocked,
|
||||
activityEvidence,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const ciStatus = await scm.getCISummary(session.pr);
|
||||
if (ciStatus === CI_STATUS.FAILING) {
|
||||
return commit(
|
||||
resolvePRLiveDecision({
|
||||
prState,
|
||||
ciStatus,
|
||||
reviewDecision: "none",
|
||||
mergeable: false,
|
||||
shouldEscalateIdleToStuck,
|
||||
idleWasBlocked,
|
||||
activityEvidence,
|
||||
}),
|
||||
);
|
||||
// Batch enrichment cache miss — fall back to getPRState for terminal
|
||||
// states (merged/closed) only. Detecting these promptly prevents
|
||||
// delayed cleanup. Non-terminal state updates wait for the next batch
|
||||
// cycle (30s) to avoid ~110 individual REST calls per 15-min window.
|
||||
try {
|
||||
const prState = await scm.getPRState(session.pr);
|
||||
if (prState === "merged" || prState === "closed") {
|
||||
return commit(
|
||||
resolvePRLiveDecision({
|
||||
prState,
|
||||
ciStatus: "none",
|
||||
reviewDecision: "none",
|
||||
mergeable: false,
|
||||
shouldEscalateIdleToStuck,
|
||||
idleWasBlocked,
|
||||
activityEvidence,
|
||||
}),
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// Best-effort — batch will retry next cycle
|
||||
}
|
||||
|
||||
const reviewDecision = await scm.getReviewDecision(session.pr);
|
||||
const mergeReady =
|
||||
reviewDecision === "approved" || reviewDecision === "none"
|
||||
? await scm.getMergeability(session.pr)
|
||||
: { mergeable: false };
|
||||
return commit(
|
||||
resolvePRLiveDecision({
|
||||
prState,
|
||||
ciStatus,
|
||||
reviewDecision,
|
||||
mergeable: mergeReady.mergeable,
|
||||
shouldEscalateIdleToStuck,
|
||||
idleWasBlocked,
|
||||
activityEvidence,
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
observer?.recordOperation?.({
|
||||
metric: "lifecycle_poll",
|
||||
|
|
@ -1120,7 +1166,7 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan
|
|||
|
||||
// Throttle review backlog API calls to at most once per 2 minutes.
|
||||
// Comments don't change faster than this in practice, and the SCM calls
|
||||
// (getPendingComments + getAutomatedComments) consume API quota on every poll.
|
||||
// (getReviewThreads) consumes API quota on every poll.
|
||||
//
|
||||
// Exception: bypass throttle when a transition reaction just fired for a
|
||||
// review reaction key. The transitionReaction branch records
|
||||
|
|
@ -1139,21 +1185,52 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan
|
|||
}
|
||||
lastReviewBacklogCheckAt.set(session.id, Date.now());
|
||||
|
||||
const [pendingResult, automatedResult] = await Promise.allSettled([
|
||||
scm.getPendingComments(session.pr),
|
||||
scm.getAutomatedComments(session.pr),
|
||||
]);
|
||||
// Single GraphQL call for all review threads (human + bot) + review summaries.
|
||||
// Split locally by isBot for separate reaction pipelines.
|
||||
let allThreads: ReviewComment[] | null = null;
|
||||
let reviewSummaries: ReviewSummary[] = [];
|
||||
try {
|
||||
if (scm.getReviewThreads) {
|
||||
const result = await scm.getReviewThreads(session.pr);
|
||||
allThreads = result.threads;
|
||||
reviewSummaries = result.reviews;
|
||||
} else {
|
||||
// Fallback for SCM plugins that don't implement getReviewThreads yet
|
||||
allThreads = await scm.getPendingComments(session.pr);
|
||||
}
|
||||
} catch {
|
||||
// Failed to fetch — preserve existing metadata
|
||||
}
|
||||
|
||||
// null means "failed to fetch" — preserve existing metadata.
|
||||
// [] means "confirmed no comments" — safe to clear.
|
||||
const pendingComments =
|
||||
pendingResult.status === "fulfilled" && Array.isArray(pendingResult.value)
|
||||
? pendingResult.value
|
||||
: null;
|
||||
const automatedComments =
|
||||
automatedResult.status === "fulfilled" && Array.isArray(automatedResult.value)
|
||||
? automatedResult.value
|
||||
: null;
|
||||
// Persist review comments + summaries to metadata for dashboard consumption
|
||||
if (allThreads !== null) {
|
||||
const unresolved = allThreads.filter((c) => !c.isBot);
|
||||
const reviewBlob = JSON.stringify({
|
||||
unresolvedThreads: unresolved.length,
|
||||
unresolvedComments: unresolved.map((c) => ({
|
||||
url: c.url,
|
||||
path: c.path ?? "",
|
||||
author: c.author,
|
||||
body: c.body,
|
||||
})),
|
||||
reviews: reviewSummaries.map((r) => ({
|
||||
author: r.author,
|
||||
state: r.state,
|
||||
body: r.body,
|
||||
})),
|
||||
commentsUpdatedAt: new Date().toISOString(),
|
||||
});
|
||||
if (session.metadata["prReviewComments"] !== reviewBlob) {
|
||||
updateSessionMetadata(session, { prReviewComments: reviewBlob });
|
||||
}
|
||||
}
|
||||
|
||||
const pendingComments = allThreads
|
||||
? allThreads.filter((c) => !c.isBot)
|
||||
: null;
|
||||
const automatedComments = allThreads
|
||||
? allThreads.filter((c) => c.isBot)
|
||||
: null;
|
||||
|
||||
// --- Pending (human) review comments ---
|
||||
// null = SCM fetch failed; skip processing to preserve existing metadata.
|
||||
|
|
@ -1201,11 +1278,15 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan
|
|||
reactionConfig.action &&
|
||||
(reactionConfig.auto !== false || reactionConfig.action === "notify")
|
||||
) {
|
||||
const enrichedConfig = {
|
||||
...reactionConfig,
|
||||
message: formatReviewCommentsMessage(pendingComments, "reviewer", reviewSummaries),
|
||||
};
|
||||
const result = await executeReaction(
|
||||
session.id,
|
||||
session.projectId,
|
||||
humanReactionKey,
|
||||
reactionConfig,
|
||||
enrichedConfig,
|
||||
);
|
||||
if (result.success) {
|
||||
updateSessionMetadata(session, {
|
||||
|
|
@ -1247,26 +1328,15 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan
|
|||
reactionConfig.action &&
|
||||
(reactionConfig.auto !== false || reactionConfig.action === "notify")
|
||||
) {
|
||||
// Inject the detailed comment listing + correct-API guidance into the
|
||||
// message so the agent doesn't re-fetch with stale or unpaginated calls
|
||||
// (see #895 — fixes the pagination + stale `gh pr checks` failure modes).
|
||||
// Only override when the message is the built-in sentinel — a user who
|
||||
// customized `reactions.bugbot-comments.message` in their YAML gets
|
||||
// exactly what they wrote, nothing more.
|
||||
const usingDefaultMessage =
|
||||
reactionConfig.message === DEFAULT_BUGBOT_COMMENTS_MESSAGE;
|
||||
const detailedConfig: ReactionConfig =
|
||||
reactionConfig.action === "send-to-agent" && usingDefaultMessage
|
||||
? {
|
||||
...reactionConfig,
|
||||
message: formatAutomatedCommentsMessage(automatedComments, session.pr),
|
||||
}
|
||||
: reactionConfig;
|
||||
const enrichedConfig = {
|
||||
...reactionConfig,
|
||||
message: formatReviewCommentsMessage(automatedComments, "bot"),
|
||||
};
|
||||
const result = await executeReaction(
|
||||
session.id,
|
||||
session.projectId,
|
||||
automatedReactionKey,
|
||||
detailedConfig,
|
||||
enrichedConfig,
|
||||
);
|
||||
if (result.success) {
|
||||
updateSessionMetadata(session, {
|
||||
|
|
@ -1279,6 +1349,43 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format review comments into a message with inline data for the agent.
|
||||
* Includes file, line, author, body, and URL so the agent doesn't need
|
||||
* to re-fetch via gh api.
|
||||
*/
|
||||
function formatReviewCommentsMessage(
|
||||
comments: ReviewComment[],
|
||||
source: "reviewer" | "bot",
|
||||
reviews: ReviewSummary[] = [],
|
||||
): string {
|
||||
const lines: string[] = [];
|
||||
|
||||
// Prepend review summaries (the body submitted with "Changes requested" / "Approve")
|
||||
const nonEmptyReviews = reviews.filter((r) => r.body && r.body.trim().length > 0);
|
||||
if (nonEmptyReviews.length > 0) {
|
||||
for (const r of nonEmptyReviews) {
|
||||
lines.push(`Review by @${r.author} (${r.state}):`);
|
||||
lines.push(`"${r.body.trim()}"`, "");
|
||||
}
|
||||
}
|
||||
|
||||
const header =
|
||||
source === "reviewer"
|
||||
? `The following ${comments.length} unresolved review comment(s) are on your PR (as of just now). You should not need to re-fetch this data unless you need additional context.`
|
||||
: `The following ${comments.length} automated review comment(s) are on your PR (as of just now). You should not need to re-fetch this data unless you need additional context.`;
|
||||
lines.push(header, "");
|
||||
for (let i = 0; i < comments.length; i++) {
|
||||
const c = comments[i];
|
||||
const location = c.path ? `${c.path}${c.line ? `:${c.line}` : ""}` : "(general)";
|
||||
lines.push(`${i + 1}. ${location} (@${c.author}): "${c.body}"`);
|
||||
if (c.url) lines.push(` ${c.url}`);
|
||||
if (c.threadId) lines.push(` Thread ID: ${c.threadId}`);
|
||||
}
|
||||
lines.push("", "Address each comment, push fixes. Use the thread ID to resolve each thread directly after pushing. You should not need to re-fetch review data unless you need additional context beyond what is provided here.");
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Format CI check failures into a human-readable message for the agent.
|
||||
* Includes check names, statuses, and links for debugging.
|
||||
|
|
@ -1300,138 +1407,6 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan
|
|||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch CI failure details to the agent session when new or changed
|
||||
* failures are detected. Follows the same fingerprinting/deduplication
|
||||
* pattern as maybeDispatchReviewBacklog().
|
||||
*/
|
||||
async function maybeDispatchCIFailureDetails(
|
||||
session: Session,
|
||||
_oldStatus: SessionStatus,
|
||||
newStatus: SessionStatus,
|
||||
transitionReaction?: { key: string; result: ReactionResult | null },
|
||||
): Promise<void> {
|
||||
const project = config.projects[session.projectId];
|
||||
if (!project || !session.pr) return;
|
||||
|
||||
const scm = project.scm?.plugin ? registry.get<SCM>("scm", project.scm.plugin) : null;
|
||||
if (!scm) return;
|
||||
|
||||
const ciReactionKey = "ci-failed";
|
||||
|
||||
// Clear tracking when PR is closed/merged
|
||||
if (newStatus === "merged" || newStatus === "killed") {
|
||||
clearReactionTracker(session.id, ciReactionKey);
|
||||
updateSessionMetadata(session, {
|
||||
lastCIFailureFingerprint: "",
|
||||
lastCIFailureDispatchHash: "",
|
||||
lastCIFailureDispatchAt: "",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Only dispatch CI details when in ci_failed state
|
||||
if (newStatus !== "ci_failed") {
|
||||
// CI is no longer failing — clear tracking so next failure is dispatched fresh
|
||||
const lastFingerprint = session.metadata["lastCIFailureFingerprint"] ?? "";
|
||||
if (lastFingerprint) {
|
||||
clearReactionTracker(session.id, ciReactionKey);
|
||||
updateSessionMetadata(session, {
|
||||
lastCIFailureFingerprint: "",
|
||||
lastCIFailureDispatchHash: "",
|
||||
lastCIFailureDispatchAt: "",
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch individual CI checks for failure details.
|
||||
// Use batch enrichment data when available to avoid an extra REST call;
|
||||
// fall back to getCIChecks() when the batch didn't run this cycle.
|
||||
const prKey = `${session.pr.owner}/${session.pr.repo}#${session.pr.number}`;
|
||||
const cachedEnrichment = prEnrichmentCache.get(prKey);
|
||||
|
||||
let checks: CICheck[];
|
||||
if (cachedEnrichment?.ciChecks !== undefined) {
|
||||
checks = cachedEnrichment.ciChecks;
|
||||
} else {
|
||||
try {
|
||||
checks = await scm.getCIChecks(session.pr);
|
||||
} catch {
|
||||
// Failed to fetch checks — skip this cycle
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const failedChecks = checks.filter(
|
||||
(c) => c.status === "failed" || c.conclusion?.toUpperCase() === "FAILURE",
|
||||
);
|
||||
if (failedChecks.length === 0) return;
|
||||
|
||||
const ciFingerprint = makeFingerprint(
|
||||
failedChecks.map((c) => `${c.name}:${c.status}:${c.conclusion ?? ""}`),
|
||||
);
|
||||
const lastCIFingerprint = session.metadata["lastCIFailureFingerprint"] ?? "";
|
||||
const lastCIDispatchHash = session.metadata["lastCIFailureDispatchHash"] ?? "";
|
||||
|
||||
// Reset reaction tracker when failure set changes
|
||||
if (ciFingerprint !== lastCIFingerprint && transitionReaction?.key !== ciReactionKey) {
|
||||
clearReactionTracker(session.id, ciReactionKey);
|
||||
}
|
||||
if (ciFingerprint !== lastCIFingerprint) {
|
||||
updateSessionMetadata(session, {
|
||||
lastCIFailureFingerprint: ciFingerprint,
|
||||
});
|
||||
}
|
||||
|
||||
// If transition already sent a ci-failed reaction with the static message,
|
||||
// skip this cycle but do NOT record dispatch hash — the next poll will send
|
||||
// the detailed CI failure info with check names and URLs.
|
||||
if (
|
||||
transitionReaction?.key === ciReactionKey &&
|
||||
transitionReaction.result?.success
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip if we already dispatched this exact failure set
|
||||
if (ciFingerprint === lastCIDispatchHash) return;
|
||||
|
||||
// Dispatch CI failure details directly via sessionManager.send() rather than
|
||||
// executeReaction() to avoid consuming the ci-failed reaction's retry budget.
|
||||
// The transition reaction owns escalation; this is a follow-up info delivery.
|
||||
const reactionConfig = getReactionConfigForSession(session, ciReactionKey);
|
||||
if (
|
||||
reactionConfig &&
|
||||
reactionConfig.action &&
|
||||
(reactionConfig.auto !== false || reactionConfig.action === "notify")
|
||||
) {
|
||||
const detailedMessage = formatCIFailureMessage(failedChecks);
|
||||
|
||||
try {
|
||||
if (reactionConfig.action === "send-to-agent") {
|
||||
await sessionManager.send(session.id, detailedMessage);
|
||||
} else {
|
||||
// For "notify" action, send to human notifiers instead
|
||||
const event = createEvent("ci.failing", {
|
||||
sessionId: session.id,
|
||||
projectId: session.projectId,
|
||||
message: detailedMessage,
|
||||
data: { failedChecks: failedChecks.map((c) => c.name) },
|
||||
});
|
||||
await notifyHuman(event, reactionConfig.priority ?? "warning");
|
||||
}
|
||||
|
||||
updateSessionMetadata(session, {
|
||||
lastCIFailureDispatchHash: ciFingerprint,
|
||||
lastCIFailureDispatchAt: new Date().toISOString(),
|
||||
});
|
||||
} catch {
|
||||
// Send failed — will retry on next poll cycle
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch merge conflict notifications to the agent session.
|
||||
* Conflicts are detected from the PR enrichment cache or getMergeability()
|
||||
|
|
@ -1478,19 +1453,11 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan
|
|||
const prKey = `${session.pr.owner}/${session.pr.repo}#${session.pr.number}`;
|
||||
const cachedData = prEnrichmentCache.get(prKey);
|
||||
|
||||
let hasConflicts: boolean;
|
||||
if (cachedData) {
|
||||
// Batch ran — trust its data (undefined means CONFLICTING wasn't set → no conflicts)
|
||||
hasConflicts = cachedData.hasConflicts ?? false;
|
||||
} else {
|
||||
// Batch didn't run this cycle — fall back to individual API call
|
||||
try {
|
||||
const mergeReadiness = await scm.getMergeability(session.pr);
|
||||
hasConflicts = !mergeReadiness.noConflicts;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (!cachedData) {
|
||||
// No batch data — skip this cycle, batch will populate on next cycle (30s)
|
||||
return;
|
||||
}
|
||||
const hasConflicts = cachedData.hasConflicts ?? false;
|
||||
|
||||
const lastDispatched = session.metadata["lastMergeConflictDispatched"] ?? "";
|
||||
|
||||
|
|
@ -1506,9 +1473,11 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan
|
|||
) {
|
||||
try {
|
||||
if (reactionConfig.action === "send-to-agent") {
|
||||
const baseBranch = session.pr.baseBranch ?? "the default branch";
|
||||
const behindNote = cachedData.isBehind ? ` is behind ${baseBranch} and` : "";
|
||||
const message =
|
||||
reactionConfig.message ??
|
||||
"Your branch has merge conflicts. Rebase on the default branch and resolve them.";
|
||||
`Your PR branch${behindNote} has merge conflicts with ${baseBranch}. Rebase your branch on ${baseBranch}, resolve the conflicts, and push. You should not need to call gh for merge status unless you need additional context — this information is current.`;
|
||||
await sessionManager.send(session.id, message);
|
||||
} else {
|
||||
const event = createEvent("merge.conflicts", {
|
||||
|
|
@ -1742,7 +1711,19 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan
|
|||
const reactionKey = eventToReactionKey(eventType);
|
||||
|
||||
if (reactionKey) {
|
||||
const reactionConfig = getReactionConfigForSession(session, reactionKey);
|
||||
let reactionConfig = getReactionConfigForSession(session, reactionKey);
|
||||
|
||||
// Enrich CI failure message with actual check details from batch cache
|
||||
if (reactionKey === "ci-failed" && session.pr && reactionConfig) {
|
||||
const prKey = `${session.pr.owner}/${session.pr.repo}#${session.pr.number}`;
|
||||
const cachedData = prEnrichmentCache.get(prKey);
|
||||
if (cachedData?.ciChecks) {
|
||||
const failedChecks = cachedData.ciChecks.filter((c) => c.status === "failed");
|
||||
if (failedChecks.length > 0) {
|
||||
reactionConfig = { ...reactionConfig, message: formatCIFailureMessage(failedChecks) };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (reactionConfig && reactionConfig.action) {
|
||||
// auto: false skips automated agent actions but still allows notifications
|
||||
|
|
@ -1873,7 +1854,6 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan
|
|||
|
||||
await Promise.allSettled([
|
||||
maybeDispatchReviewBacklog(session, oldStatus, newStatus, transitionReaction),
|
||||
maybeDispatchCIFailureDetails(session, oldStatus, newStatus, transitionReaction),
|
||||
maybeDispatchMergeConflicts(session, newStatus),
|
||||
]);
|
||||
|
||||
|
|
@ -1982,6 +1962,10 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan
|
|||
// Poll all sessions concurrently
|
||||
await Promise.allSettled(sessionsToCheck.map((s) => checkSession(s)));
|
||||
|
||||
// Persist batch enrichment data to session metadata files so the
|
||||
// web dashboard can read it without calling GitHub API.
|
||||
persistPREnrichmentToMetadata(sessionsToCheck);
|
||||
|
||||
// Prune stale entries from states, reactionTrackers, and lastReviewBacklogCheckAt
|
||||
// for sessions that no longer appear in the session list (e.g., after kill/cleanup)
|
||||
const currentSessionIds = new Set(sessions.map((s) => s.id));
|
||||
|
|
@ -2088,6 +2072,9 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan
|
|||
async check(sessionId: SessionId): Promise<void> {
|
||||
const session = await sessionManager.get(sessionId);
|
||||
if (!session) throw new Error(`Session ${sessionId} not found`);
|
||||
// Populate batch enrichment cache for this session's PR so
|
||||
// checkSession can read from cache (no individual REST fallback).
|
||||
await populatePREnrichmentCache([session]);
|
||||
await checkSession(session);
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -275,6 +275,10 @@ export function deleteMetadata(dataDir: string, sessionId: SessionId, archive =
|
|||
}
|
||||
|
||||
unlinkSync(path);
|
||||
|
||||
// NOTE: .ghcache/<sessionId>/ is intentionally NOT deleted here.
|
||||
// Cache files are small and useful for post-mortem analysis of wrapper
|
||||
// cache hit/miss behavior. listMetadata() already ignores hidden dirs.
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -80,6 +80,11 @@ import { sessionFromMetadata } from "./utils/session-from-metadata.js";
|
|||
import { safeJsonParse, validateStatus } from "./utils/validation.js";
|
||||
import { isGitBranchNameSafe } from "./utils.js";
|
||||
import { resolveAgentSelection, resolveSessionRole } from "./agent-selection.js";
|
||||
import {
|
||||
buildAgentPath,
|
||||
setupPathWrapperWorkspace,
|
||||
PREFERRED_GH_PATH,
|
||||
} from "./agent-workspace-hooks.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const OPENCODE_DISCOVERY_TIMEOUT_MS = 10_000;
|
||||
|
|
@ -1304,6 +1309,11 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
|
|||
launchCommand,
|
||||
environment: {
|
||||
...environment,
|
||||
PATH: buildAgentPath(environment["PATH"] ?? process.env["PATH"]),
|
||||
GH_PATH: PREFERRED_GH_PATH,
|
||||
...(process.env["AO_AGENT_GH_TRACE"] && {
|
||||
AO_AGENT_GH_TRACE: process.env["AO_AGENT_GH_TRACE"],
|
||||
}),
|
||||
AO_SESSION: sessionId,
|
||||
AO_DATA_DIR: sessionsDir, // Pass sessions directory (not root dataDir)
|
||||
AO_SESSION_NAME: sessionId, // User-facing session name
|
||||
|
|
@ -1581,11 +1591,17 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
|
|||
}
|
||||
};
|
||||
|
||||
// Setup agent hooks for automatic metadata updates
|
||||
// Setup agent hooks for automatic metadata updates.
|
||||
// Claude Code uses native PostToolUse hooks for metadata writes — skip
|
||||
// PATH wrappers to avoid two concurrent writers (wrapper + hook) hitting
|
||||
// the same metadata file with no locking.
|
||||
try {
|
||||
if (plugins.agent.setupWorkspaceHooks) {
|
||||
await plugins.agent.setupWorkspaceHooks(workspacePath, { dataDir: sessionsDir });
|
||||
}
|
||||
if (plugins.agent.name !== "claude-code") {
|
||||
await setupPathWrapperWorkspace(workspacePath);
|
||||
}
|
||||
} catch (err) {
|
||||
await cleanupWorktreeAndMetadata();
|
||||
throw err;
|
||||
|
|
@ -1669,6 +1685,11 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
|
|||
launchCommand,
|
||||
environment: {
|
||||
...environment,
|
||||
PATH: buildAgentPath(environment["PATH"] ?? process.env["PATH"]),
|
||||
GH_PATH: PREFERRED_GH_PATH,
|
||||
...(process.env["AO_AGENT_GH_TRACE"] && {
|
||||
AO_AGENT_GH_TRACE: process.env["AO_AGENT_GH_TRACE"],
|
||||
}),
|
||||
AO_SESSION: sessionId,
|
||||
AO_DATA_DIR: sessionsDir,
|
||||
AO_SESSION_NAME: sessionId,
|
||||
|
|
@ -2831,6 +2852,11 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
|
|||
launchCommand,
|
||||
environment: {
|
||||
...environment,
|
||||
PATH: buildAgentPath(environment["PATH"] ?? process.env["PATH"]),
|
||||
GH_PATH: PREFERRED_GH_PATH,
|
||||
...(process.env["AO_AGENT_GH_TRACE"] && {
|
||||
AO_AGENT_GH_TRACE: process.env["AO_AGENT_GH_TRACE"],
|
||||
}),
|
||||
AO_SESSION: sessionId,
|
||||
AO_DATA_DIR: sessionsDir,
|
||||
AO_SESSION_NAME: sessionId,
|
||||
|
|
|
|||
|
|
@ -784,8 +784,18 @@ export interface SCM {
|
|||
/** Get pending (unresolved) review comments */
|
||||
getPendingComments(pr: PRInfo): Promise<ReviewComment[]>;
|
||||
|
||||
/** Get automated review comments (bots, linters, security scanners) */
|
||||
getAutomatedComments(pr: PRInfo): Promise<AutomatedComment[]>;
|
||||
/**
|
||||
* Get all review threads (human + bot) with isBot flag.
|
||||
* Single GraphQL call for all review threads (human + bot) with review summaries.
|
||||
* Returns unresolved threads only.
|
||||
*
|
||||
* Optional — plugins that do not implement this method will fall back to
|
||||
* `getPendingComments()` (which lacks `isBot` classification and review
|
||||
* summaries). New SCM plugins should prefer implementing this method.
|
||||
*
|
||||
* @since 0.6.0 — replaces the removed `getAutomatedComments` method.
|
||||
*/
|
||||
getReviewThreads?(pr: PRInfo): Promise<ReviewThreadsResult>;
|
||||
|
||||
// --- Merge Readiness ---
|
||||
|
||||
|
|
@ -805,7 +815,7 @@ export interface SCM {
|
|||
* @param observer - Optional observer for batch operation metrics
|
||||
* @returns Map keyed by "${owner}/${repo}#${number}" containing enrichment data
|
||||
*/
|
||||
enrichSessionsPRBatch?(prs: PRInfo[], observer?: BatchObserver): Promise<Map<string, PREnrichmentData>>;
|
||||
enrichSessionsPRBatch?(prs: PRInfo[], observer?: BatchObserver, repos?: string[]): Promise<Map<string, PREnrichmentData>>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -859,6 +869,8 @@ export interface BatchObserver {
|
|||
}): void;
|
||||
/** Log a message at a specific level */
|
||||
log(level: ObservabilityLevel, message: string): void;
|
||||
/** Called after ETag guards with repos where Guard 1 returned 304 (no PR list changes). */
|
||||
reportPRListUnchangedRepos?(repos: Set<string>): void;
|
||||
}
|
||||
|
||||
// --- PR Types ---
|
||||
|
|
@ -955,6 +967,8 @@ export type ReviewDecision = "approved" | "changes_requested" | "pending" | "non
|
|||
|
||||
export interface ReviewComment {
|
||||
id: string;
|
||||
/** GraphQL node ID of the review thread (for resolveReviewThread mutation). */
|
||||
threadId?: string;
|
||||
author: string;
|
||||
body: string;
|
||||
path?: string;
|
||||
|
|
@ -962,6 +976,20 @@ export interface ReviewComment {
|
|||
isResolved: boolean;
|
||||
createdAt: Date;
|
||||
url: string;
|
||||
/** Whether the comment was authored by a known bot */
|
||||
isBot?: boolean;
|
||||
}
|
||||
|
||||
export interface ReviewSummary {
|
||||
author: string;
|
||||
state: string;
|
||||
body: string;
|
||||
submittedAt: Date;
|
||||
}
|
||||
|
||||
export interface ReviewThreadsResult {
|
||||
threads: ReviewComment[];
|
||||
reviews: ReviewSummary[];
|
||||
}
|
||||
|
||||
export interface AutomatedComment {
|
||||
|
|
|
|||
|
|
@ -43,8 +43,8 @@ describe("agent-codex launch/env wiring (integration)", () => {
|
|||
expect(env["CODEX_DISABLE_UPDATE_CHECK"]).toBe("1");
|
||||
});
|
||||
|
||||
it("sets GH_PATH to preferred system gh wrapper location", () => {
|
||||
it("does not set GH_PATH (injected by session-manager for all agents)", () => {
|
||||
const env = agent.getEnvironment(makeLaunchConfig());
|
||||
expect(env["GH_PATH"]).toBe("/usr/local/bin/gh");
|
||||
expect(env["GH_PATH"]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -413,14 +413,14 @@ describe("postLaunchSetup", () => {
|
|||
describe("getEnvironment PATH", () => {
|
||||
const agent = create();
|
||||
|
||||
it("prepends ~/.ao/bin to PATH", () => {
|
||||
it("does not set PATH (injected by session-manager)", () => {
|
||||
const env = agent.getEnvironment(makeLaunchConfig());
|
||||
expect(env["PATH"]).toMatch(/\.ao\/bin/);
|
||||
expect(env["PATH"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("sets GH_PATH", () => {
|
||||
it("does not set GH_PATH (injected by session-manager)", () => {
|
||||
const env = agent.getEnvironment(makeLaunchConfig());
|
||||
expect(env["GH_PATH"]).toBe("/usr/local/bin/gh");
|
||||
expect(env["GH_PATH"]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,13 +1,10 @@
|
|||
import {
|
||||
shellEscape,
|
||||
normalizeAgentPermissionMode,
|
||||
buildAgentPath,
|
||||
setupPathWrapperWorkspace,
|
||||
readLastActivityEntry,
|
||||
checkActivityLogState,
|
||||
getActivityFallbackState,
|
||||
recordTerminalActivity,
|
||||
PREFERRED_GH_PATH,
|
||||
DEFAULT_READY_THRESHOLD_MS,
|
||||
DEFAULT_ACTIVE_WINDOW_MS,
|
||||
type Agent,
|
||||
|
|
@ -145,9 +142,7 @@ function createAiderAgent(): Agent {
|
|||
env["AO_ISSUE_ID"] = config.issueId;
|
||||
}
|
||||
|
||||
// Prepend ~/.ao/bin to PATH so our gh/git wrappers intercept commands.
|
||||
env["PATH"] = buildAgentPath(process.env["PATH"]);
|
||||
env["GH_PATH"] = PREFERRED_GH_PATH;
|
||||
// PATH and GH_PATH are injected by session-manager for all agents.
|
||||
|
||||
return env;
|
||||
},
|
||||
|
|
@ -294,13 +289,12 @@ function createAiderAgent(): Agent {
|
|||
return null;
|
||||
},
|
||||
|
||||
async setupWorkspaceHooks(workspacePath: string, _config: WorkspaceHooksConfig): Promise<void> {
|
||||
await setupPathWrapperWorkspace(workspacePath);
|
||||
async setupWorkspaceHooks(_workspacePath: string, _config: WorkspaceHooksConfig): Promise<void> {
|
||||
// PATH wrappers are installed by session-manager for all agents.
|
||||
},
|
||||
|
||||
async postLaunchSetup(session: Session): Promise<void> {
|
||||
if (!session.workspacePath) return;
|
||||
await setupPathWrapperWorkspace(session.workspacePath);
|
||||
async postLaunchSetup(_session: Session): Promise<void> {
|
||||
// PATH wrappers are re-ensured by session-manager.
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -412,27 +412,9 @@ describe("getEnvironment", () => {
|
|||
expect(env["AO_ISSUE_ID"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("prepends ~/.ao/bin to PATH for shell wrappers", () => {
|
||||
it("does not set PATH (injected by session-manager)", () => {
|
||||
const env = agent.getEnvironment(makeLaunchConfig());
|
||||
expect(env["PATH"]).toMatch(/^.*\/\.ao\/bin:/);
|
||||
});
|
||||
|
||||
it("PATH starts with the ao bin dir specifically", () => {
|
||||
const env = agent.getEnvironment(makeLaunchConfig());
|
||||
expect(env["PATH"]?.startsWith("/mock/home/.ao/bin:")).toBe(true);
|
||||
});
|
||||
|
||||
it("puts /usr/local/bin before linuxbrew paths", () => {
|
||||
const originalPath = process.env["PATH"];
|
||||
process.env["PATH"] = "/home/linuxbrew/.linuxbrew/bin:/usr/local/bin:/usr/bin:/bin";
|
||||
try {
|
||||
const env = agent.getEnvironment(makeLaunchConfig());
|
||||
expect(env["PATH"]).toBe(
|
||||
"/mock/home/.ao/bin:/usr/local/bin:/home/linuxbrew/.linuxbrew/bin:/usr/bin:/bin",
|
||||
);
|
||||
} finally {
|
||||
process.env["PATH"] = originalPath;
|
||||
}
|
||||
expect(env["PATH"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("sets CODEX_DISABLE_UPDATE_CHECK=1 to suppress interactive update prompts", () => {
|
||||
|
|
@ -440,31 +422,9 @@ describe("getEnvironment", () => {
|
|||
expect(env["CODEX_DISABLE_UPDATE_CHECK"]).toBe("1");
|
||||
});
|
||||
|
||||
it("sets GH_PATH to preferred wrapper target", () => {
|
||||
it("does not set GH_PATH (injected by session-manager)", () => {
|
||||
const env = agent.getEnvironment(makeLaunchConfig());
|
||||
expect(env["GH_PATH"]).toBe("/usr/local/bin/gh");
|
||||
});
|
||||
|
||||
it("deduplicates ao and /usr/local/bin entries", () => {
|
||||
const originalPath = process.env["PATH"];
|
||||
process.env["PATH"] = "/mock/home/.ao/bin:/usr/local/bin:/usr/bin:/usr/local/bin";
|
||||
try {
|
||||
const env = agent.getEnvironment(makeLaunchConfig());
|
||||
expect(env["PATH"]).toBe("/mock/home/.ao/bin:/usr/local/bin:/usr/bin");
|
||||
} finally {
|
||||
process.env["PATH"] = originalPath;
|
||||
}
|
||||
});
|
||||
|
||||
it("falls back to /usr/bin:/bin when process.env.PATH is undefined", () => {
|
||||
const originalPath = process.env["PATH"];
|
||||
delete process.env["PATH"];
|
||||
try {
|
||||
const env = agent.getEnvironment(makeLaunchConfig());
|
||||
expect(env["PATH"]).toBe("/mock/home/.ao/bin:/usr/local/bin:/usr/bin:/bin");
|
||||
} finally {
|
||||
process.env["PATH"] = originalPath;
|
||||
}
|
||||
expect(env["GH_PATH"]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -1718,8 +1678,10 @@ describe("postLaunchSetup", () => {
|
|||
mockExecFileAsync.mockRejectedValue(new Error("not found"));
|
||||
mockStat.mockRejectedValue(new Error("ENOENT"));
|
||||
mockReadFile.mockRejectedValue(new Error("ENOENT"));
|
||||
await agent.postLaunchSetup!(makeSession({ workspacePath: "/workspace/test" }));
|
||||
expect(mockMkdir).toHaveBeenCalled();
|
||||
// Should not throw — binary resolution runs even if it falls back to "codex"
|
||||
await expect(
|
||||
agent.postLaunchSetup!(makeSession({ workspacePath: "/workspace/test" })),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns early when session has no workspacePath", async () => {
|
||||
|
|
@ -1760,213 +1722,17 @@ describe("setupWorkspaceHooks", () => {
|
|||
expect(typeof agent.setupWorkspaceHooks).toBe("function");
|
||||
});
|
||||
|
||||
it("creates ~/.ao/bin directory", async () => {
|
||||
// Version marker doesn't exist — triggers full install
|
||||
it("is a no-op (PATH wrappers are installed by session-manager)", async () => {
|
||||
mockReadFile.mockRejectedValue(new Error("ENOENT"));
|
||||
|
||||
await agent.setupWorkspaceHooks!("/workspace/test", {
|
||||
dataDir: "/data",
|
||||
sessionId: "sess-1",
|
||||
});
|
||||
|
||||
expect(mockMkdir).toHaveBeenCalledWith("/mock/home/.ao/bin", { recursive: true });
|
||||
});
|
||||
|
||||
it("writes ao-metadata-helper.sh with executable permissions via atomic write", async () => {
|
||||
mockReadFile.mockRejectedValue(new Error("ENOENT"));
|
||||
|
||||
await agent.setupWorkspaceHooks!("/workspace/test", {
|
||||
dataDir: "/data",
|
||||
sessionId: "sess-1",
|
||||
});
|
||||
|
||||
// Atomic write: writes to .tmp file first, then renames
|
||||
const helperWriteCall = mockWriteFile.mock.calls.find(
|
||||
(call: [string, string, object]) =>
|
||||
typeof call[0] === "string" && call[0].includes("ao-metadata-helper.sh.tmp."),
|
||||
);
|
||||
expect(helperWriteCall).toBeDefined();
|
||||
expect(helperWriteCall![1]).toContain("update_ao_metadata()");
|
||||
expect(helperWriteCall![2]).toEqual({ encoding: "utf-8", mode: 0o755 });
|
||||
|
||||
// Then renamed to final path
|
||||
const helperRenameCall = mockRename.mock.calls.find(
|
||||
(call: string[]) => typeof call[1] === "string" && call[1].endsWith("ao-metadata-helper.sh"),
|
||||
);
|
||||
expect(helperRenameCall).toBeDefined();
|
||||
});
|
||||
|
||||
it("writes gh and git wrappers atomically when version marker is missing", async () => {
|
||||
mockReadFile.mockRejectedValue(new Error("ENOENT"));
|
||||
|
||||
await agent.setupWorkspaceHooks!("/workspace/test", {
|
||||
dataDir: "/data",
|
||||
sessionId: "sess-1",
|
||||
});
|
||||
|
||||
// gh wrapper: written to temp, then renamed
|
||||
const ghWriteCall = mockWriteFile.mock.calls.find(
|
||||
(call: [string, string, object]) =>
|
||||
typeof call[0] === "string" && call[0].includes("/gh.tmp."),
|
||||
);
|
||||
expect(ghWriteCall).toBeDefined();
|
||||
expect(ghWriteCall![1]).toContain("ao gh wrapper");
|
||||
|
||||
const ghRenameCall = mockRename.mock.calls.find(
|
||||
(call: string[]) => typeof call[1] === "string" && call[1].endsWith("/gh"),
|
||||
);
|
||||
expect(ghRenameCall).toBeDefined();
|
||||
|
||||
// git wrapper: written to temp, then renamed
|
||||
const gitWriteCall = mockWriteFile.mock.calls.find(
|
||||
(call: [string, string, object]) =>
|
||||
typeof call[0] === "string" && call[0].includes("/git.tmp."),
|
||||
);
|
||||
expect(gitWriteCall).toBeDefined();
|
||||
expect(gitWriteCall![1]).toContain("ao git wrapper");
|
||||
|
||||
const gitRenameCall = mockRename.mock.calls.find(
|
||||
(call: string[]) => typeof call[1] === "string" && call[1].endsWith("/git"),
|
||||
);
|
||||
expect(gitRenameCall).toBeDefined();
|
||||
});
|
||||
|
||||
it("sets executable permissions on gh and git wrappers via writeFile mode", async () => {
|
||||
mockReadFile.mockRejectedValue(new Error("ENOENT"));
|
||||
|
||||
await agent.setupWorkspaceHooks!("/workspace/test", {
|
||||
dataDir: "/data",
|
||||
sessionId: "sess-1",
|
||||
});
|
||||
|
||||
const ghWriteCall = mockWriteFile.mock.calls.find(
|
||||
(call: [string, string, object]) =>
|
||||
typeof call[0] === "string" && call[0].includes("/gh.tmp."),
|
||||
);
|
||||
expect(ghWriteCall![2]).toEqual({ encoding: "utf-8", mode: 0o755 });
|
||||
|
||||
const gitWriteCall = mockWriteFile.mock.calls.find(
|
||||
(call: [string, string, object]) =>
|
||||
typeof call[0] === "string" && call[0].includes("/git.tmp."),
|
||||
);
|
||||
expect(gitWriteCall![2]).toEqual({ encoding: "utf-8", mode: 0o755 });
|
||||
});
|
||||
|
||||
it("skips wrapper writes when version marker matches", async () => {
|
||||
// First call for version marker — matches current version
|
||||
// Second call for AGENTS.md — file doesn't exist
|
||||
mockReadFile.mockImplementation((path: string) => {
|
||||
if (typeof path === "string" && path.endsWith(".ao-version")) {
|
||||
return Promise.resolve("0.3.0");
|
||||
}
|
||||
// AGENTS.md read attempt
|
||||
return Promise.reject(new Error("ENOENT"));
|
||||
});
|
||||
|
||||
await agent.setupWorkspaceHooks!("/workspace/test", {
|
||||
dataDir: "/data",
|
||||
sessionId: "sess-1",
|
||||
});
|
||||
|
||||
// Should NOT write any wrappers when version matches (helper, gh, git all skipped)
|
||||
const wrapperWrites = mockWriteFile.mock.calls.filter(
|
||||
(call: [string, string, object]) =>
|
||||
typeof call[0] === "string" &&
|
||||
(call[0].includes("ao-metadata-helper.sh.tmp.") ||
|
||||
call[0].includes("/gh.tmp.") ||
|
||||
call[0].includes("/git.tmp.")),
|
||||
);
|
||||
expect(wrapperWrites).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("writes version marker after installing wrappers", async () => {
|
||||
mockReadFile.mockRejectedValue(new Error("ENOENT"));
|
||||
|
||||
await agent.setupWorkspaceHooks!("/workspace/test", {
|
||||
dataDir: "/data",
|
||||
sessionId: "sess-1",
|
||||
});
|
||||
|
||||
// Version marker is also atomically written
|
||||
const versionWriteCall = mockWriteFile.mock.calls.find(
|
||||
(call: [string, string, object]) =>
|
||||
typeof call[0] === "string" && call[0].includes(".ao-version.tmp."),
|
||||
);
|
||||
expect(versionWriteCall).toBeDefined();
|
||||
expect(versionWriteCall![1]).toBe("0.3.0");
|
||||
|
||||
const versionRenameCall = mockRename.mock.calls.find(
|
||||
(call: string[]) => typeof call[1] === "string" && call[1].endsWith(".ao-version"),
|
||||
);
|
||||
expect(versionRenameCall).toBeDefined();
|
||||
});
|
||||
|
||||
it("writes ao session context to .ao/AGENTS.md", async () => {
|
||||
// Version marker matches (skip wrapper install)
|
||||
mockReadFile.mockImplementation((path: string) => {
|
||||
if (typeof path === "string" && path.endsWith(".ao-version")) {
|
||||
return Promise.resolve("0.3.0");
|
||||
}
|
||||
return Promise.reject(new Error("ENOENT"));
|
||||
});
|
||||
|
||||
await agent.setupWorkspaceHooks!("/workspace/test", {
|
||||
dataDir: "/data",
|
||||
sessionId: "sess-1",
|
||||
});
|
||||
|
||||
const agentsMdCall = mockWriteFile.mock.calls.find(
|
||||
(call: string[]) => typeof call[0] === "string" && call[0].includes(".ao/AGENTS.md"),
|
||||
);
|
||||
expect(agentsMdCall).toBeDefined();
|
||||
expect(agentsMdCall![1]).toContain("Agent Orchestrator (ao) Session");
|
||||
});
|
||||
|
||||
it("uses atomic write (temp + rename) to prevent partial reads from concurrent sessions", async () => {
|
||||
mockReadFile.mockRejectedValue(new Error("ENOENT"));
|
||||
|
||||
await agent.setupWorkspaceHooks!("/workspace/test", {
|
||||
dataDir: "/data",
|
||||
sessionId: "sess-1",
|
||||
});
|
||||
|
||||
// Every wrapper file should be written to a .tmp file first, then renamed
|
||||
// This ensures concurrent readers never see a partially written file
|
||||
const tmpWrites = mockWriteFile.mock.calls.filter(
|
||||
(call: [string, string, object]) => typeof call[0] === "string" && call[0].includes(".tmp."),
|
||||
);
|
||||
const renames = mockRename.mock.calls;
|
||||
|
||||
// We expect atomic writes for: helper, gh, git, version marker = 4
|
||||
expect(tmpWrites.length).toBe(4);
|
||||
expect(renames.length).toBe(4);
|
||||
|
||||
// Each rename should move a .tmp file to the final path
|
||||
for (const [src, dst] of renames) {
|
||||
expect(src).toContain(".tmp.");
|
||||
expect(dst).not.toContain(".tmp.");
|
||||
}
|
||||
});
|
||||
|
||||
it("writes .ao/AGENTS.md without modifying repo-tracked AGENTS.md", async () => {
|
||||
mockReadFile.mockImplementation((path: string) => {
|
||||
if (typeof path === "string" && path.endsWith(".ao-version")) {
|
||||
return Promise.resolve("0.3.0");
|
||||
}
|
||||
return Promise.reject(new Error("ENOENT"));
|
||||
});
|
||||
|
||||
await agent.setupWorkspaceHooks!("/workspace/test", {
|
||||
dataDir: "/data",
|
||||
sessionId: "sess-1",
|
||||
});
|
||||
|
||||
// Should write to .ao/AGENTS.md, NOT to workspace root AGENTS.md
|
||||
const allWrites = mockWriteFile.mock.calls.filter(
|
||||
(call: string[]) => typeof call[0] === "string" && call[0].endsWith("AGENTS.md"),
|
||||
);
|
||||
expect(allWrites).toHaveLength(1);
|
||||
expect(allWrites[0]![0]).toContain(".ao/AGENTS.md");
|
||||
// Plugin no longer writes wrappers — session-manager handles it.
|
||||
// mkdir/writeFile/rename should not be called by the plugin.
|
||||
expect(mockMkdir).not.toHaveBeenCalled();
|
||||
expect(mockWriteFile).not.toHaveBeenCalled();
|
||||
expect(mockRename).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -1974,22 +1740,20 @@ describe("setupWorkspaceHooks", () => {
|
|||
// Shell wrapper content verification
|
||||
// =========================================================================
|
||||
describe("shell wrapper content", () => {
|
||||
const agent = create();
|
||||
|
||||
beforeEach(() => {
|
||||
// Force wrapper installation by making version marker miss
|
||||
mockReadFile.mockRejectedValue(new Error("ENOENT"));
|
||||
});
|
||||
|
||||
async function getWrapperContent(name: string): Promise<string> {
|
||||
await agent.setupWorkspaceHooks!("/workspace/test", {
|
||||
dataDir: "/data",
|
||||
sessionId: "sess-1",
|
||||
});
|
||||
// Wrappers are now installed by session-manager via setupPathWrapperWorkspace.
|
||||
// Import and call it directly to test wrapper content.
|
||||
const { setupPathWrapperWorkspace } = await import("@aoagents/ao-core");
|
||||
await setupPathWrapperWorkspace("/workspace/test");
|
||||
|
||||
// With atomic writes, content is written to a .tmp. file
|
||||
const call = mockWriteFile.mock.calls.find(
|
||||
(c: [string, string, object]) => typeof c[0] === "string" && c[0].includes(`/${name}.tmp.`),
|
||||
(c: unknown[]) => typeof c[0] === "string" && (c[0] as string).includes(`/${name}.tmp.`),
|
||||
);
|
||||
return call ? (call[1] as string) : "";
|
||||
}
|
||||
|
|
@ -2055,9 +1819,9 @@ describe("shell wrapper content", () => {
|
|||
expect(content).toContain("pr/create)");
|
||||
});
|
||||
|
||||
it("uses exec for non-PR commands (transparent passthrough)", async () => {
|
||||
it("passes through non-PR commands to real gh", async () => {
|
||||
const content = await getWrapperContent("gh");
|
||||
expect(content).toContain('exec "$real_gh"');
|
||||
expect(content).toContain('"$real_gh" "$@"');
|
||||
});
|
||||
|
||||
it("prefers GH_PATH when provided and executable", async () => {
|
||||
|
|
|
|||
|
|
@ -4,13 +4,10 @@ import {
|
|||
shellEscape,
|
||||
readLastJsonlEntry,
|
||||
normalizeAgentPermissionMode,
|
||||
buildAgentPath,
|
||||
setupPathWrapperWorkspace,
|
||||
readLastActivityEntry,
|
||||
checkActivityLogState,
|
||||
getActivityFallbackState,
|
||||
recordTerminalActivity,
|
||||
PREFERRED_GH_PATH,
|
||||
type Agent,
|
||||
type AgentSessionInfo,
|
||||
type AgentLaunchConfig,
|
||||
|
|
@ -487,11 +484,7 @@ function createCodexAgent(): Agent {
|
|||
env["AO_ISSUE_ID"] = config.issueId;
|
||||
}
|
||||
|
||||
// Prepend ~/.ao/bin to PATH so our gh/git wrappers intercept commands.
|
||||
// The wrappers strip this directory from PATH before calling the real
|
||||
// binary, so there's no infinite recursion.
|
||||
env["PATH"] = buildAgentPath(process.env["PATH"]);
|
||||
env["GH_PATH"] = PREFERRED_GH_PATH;
|
||||
// PATH and GH_PATH are injected by session-manager for all agents.
|
||||
// Disable Codex's version check/update prompt for non-interactive AO sessions.
|
||||
env["CODEX_DISABLE_UPDATE_CHECK"] = "1";
|
||||
|
||||
|
|
@ -743,11 +736,11 @@ function createCodexAgent(): Agent {
|
|||
return parts.join(" ");
|
||||
},
|
||||
|
||||
async setupWorkspaceHooks(workspacePath: string, _config: WorkspaceHooksConfig): Promise<void> {
|
||||
await setupPathWrapperWorkspace(workspacePath);
|
||||
async setupWorkspaceHooks(_workspacePath: string, _config: WorkspaceHooksConfig): Promise<void> {
|
||||
// PATH wrappers are installed by session-manager for all agents.
|
||||
},
|
||||
|
||||
async postLaunchSetup(session: Session): Promise<void> {
|
||||
async postLaunchSetup(_session: Session): Promise<void> {
|
||||
// Resolve binary path on first launch (cached for subsequent calls).
|
||||
// Uses a promise guard to prevent concurrent calls from racing.
|
||||
if (!resolvedBinary) {
|
||||
|
|
@ -760,8 +753,7 @@ function createCodexAgent(): Agent {
|
|||
resolvingBinary = null;
|
||||
}
|
||||
}
|
||||
if (!session.workspacePath) return;
|
||||
await setupPathWrapperWorkspace(session.workspacePath);
|
||||
// PATH wrappers are re-ensured by session-manager.
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -508,14 +508,14 @@ describe("postLaunchSetup", () => {
|
|||
describe("getEnvironment PATH", () => {
|
||||
const agent = create();
|
||||
|
||||
it("prepends ~/.ao/bin to PATH", () => {
|
||||
it("does not set PATH (injected by session-manager)", () => {
|
||||
const env = agent.getEnvironment(makeLaunchConfig());
|
||||
expect(env["PATH"]).toMatch(/\.ao\/bin/);
|
||||
expect(env["PATH"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("sets GH_PATH", () => {
|
||||
it("does not set GH_PATH (injected by session-manager)", () => {
|
||||
const env = agent.getEnvironment(makeLaunchConfig());
|
||||
expect(env["GH_PATH"]).toBe("/usr/local/bin/gh");
|
||||
expect(env["GH_PATH"]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,13 +1,10 @@
|
|||
import {
|
||||
shellEscape,
|
||||
normalizeAgentPermissionMode,
|
||||
buildAgentPath,
|
||||
setupPathWrapperWorkspace,
|
||||
readLastActivityEntry,
|
||||
checkActivityLogState,
|
||||
getActivityFallbackState,
|
||||
recordTerminalActivity,
|
||||
PREFERRED_GH_PATH,
|
||||
DEFAULT_READY_THRESHOLD_MS,
|
||||
DEFAULT_ACTIVE_WINDOW_MS,
|
||||
type Agent,
|
||||
|
|
@ -229,9 +226,7 @@ function createCursorAgent(): Agent {
|
|||
env["AO_ISSUE_ID"] = config.issueId;
|
||||
}
|
||||
|
||||
// Prepend ~/.ao/bin to PATH so our gh/git wrappers intercept commands.
|
||||
env["PATH"] = buildAgentPath(process.env["PATH"]);
|
||||
env["GH_PATH"] = PREFERRED_GH_PATH;
|
||||
// PATH and GH_PATH are injected by session-manager for all agents.
|
||||
|
||||
return env;
|
||||
},
|
||||
|
|
@ -394,13 +389,12 @@ function createCursorAgent(): Agent {
|
|||
return null;
|
||||
},
|
||||
|
||||
async setupWorkspaceHooks(workspacePath: string, _config: WorkspaceHooksConfig): Promise<void> {
|
||||
await setupPathWrapperWorkspace(workspacePath);
|
||||
async setupWorkspaceHooks(_workspacePath: string, _config: WorkspaceHooksConfig): Promise<void> {
|
||||
// PATH wrappers are installed by session-manager for all agents.
|
||||
},
|
||||
|
||||
async postLaunchSetup(session: Session): Promise<void> {
|
||||
if (!session.workspacePath) return;
|
||||
await setupPathWrapperWorkspace(session.workspacePath);
|
||||
async postLaunchSetup(_session: Session): Promise<void> {
|
||||
// PATH wrappers are re-ensured by session-manager.
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -818,14 +818,14 @@ describe("postLaunchSetup", () => {
|
|||
describe("getEnvironment PATH", () => {
|
||||
const agent = create();
|
||||
|
||||
it("prepends ~/.ao/bin to PATH", () => {
|
||||
it("does not set PATH (injected by session-manager)", () => {
|
||||
const env = agent.getEnvironment(makeLaunchConfig());
|
||||
expect(env["PATH"]).toMatch(/\.ao\/bin/);
|
||||
expect(env["PATH"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("sets GH_PATH", () => {
|
||||
it("does not set GH_PATH (injected by session-manager)", () => {
|
||||
const env = agent.getEnvironment(makeLaunchConfig());
|
||||
expect(env["GH_PATH"]).toBe("/usr/local/bin/gh");
|
||||
expect(env["GH_PATH"]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -2,13 +2,10 @@ import {
|
|||
DEFAULT_READY_THRESHOLD_MS,
|
||||
DEFAULT_ACTIVE_WINDOW_MS,
|
||||
shellEscape,
|
||||
buildAgentPath,
|
||||
readLastActivityEntry,
|
||||
checkActivityLogState,
|
||||
getActivityFallbackState,
|
||||
recordTerminalActivity,
|
||||
setupPathWrapperWorkspace,
|
||||
PREFERRED_GH_PATH,
|
||||
asValidOpenCodeSessionId,
|
||||
type Agent,
|
||||
type AgentSessionInfo,
|
||||
|
|
@ -276,9 +273,7 @@ function createOpenCodeAgent(): Agent {
|
|||
env["AO_ISSUE_ID"] = config.issueId;
|
||||
}
|
||||
|
||||
// Prepend ~/.ao/bin to PATH so our gh/git wrappers intercept commands.
|
||||
env["PATH"] = buildAgentPath(process.env["PATH"]);
|
||||
env["GH_PATH"] = PREFERRED_GH_PATH;
|
||||
// PATH and GH_PATH are injected by session-manager for all agents.
|
||||
|
||||
return env;
|
||||
},
|
||||
|
|
@ -437,13 +432,12 @@ function createOpenCodeAgent(): Agent {
|
|||
return parts.join(" ");
|
||||
},
|
||||
|
||||
async setupWorkspaceHooks(workspacePath: string, _config: WorkspaceHooksConfig): Promise<void> {
|
||||
await setupPathWrapperWorkspace(workspacePath);
|
||||
async setupWorkspaceHooks(_workspacePath: string, _config: WorkspaceHooksConfig): Promise<void> {
|
||||
// PATH wrappers are installed by session-manager for all agents.
|
||||
},
|
||||
|
||||
async postLaunchSetup(session: Session): Promise<void> {
|
||||
if (!session.workspacePath) return;
|
||||
await setupPathWrapperWorkspace(session.workspacePath);
|
||||
async postLaunchSetup(_session: Session): Promise<void> {
|
||||
// PATH wrappers are re-ensured by session-manager.
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -66,17 +66,29 @@ export function create(): Runtime {
|
|||
// Create tmux session in detached mode
|
||||
await tmux("new-session", "-d", "-s", sessionName, "-c", config.workspacePath, ...envArgs);
|
||||
|
||||
// Re-export PATH inside the launch script. macOS zsh runs path_helper
|
||||
// during shell startup which resets PATH, wiping entries set via tmux -e.
|
||||
// Including the export in the script file (not send-keys) avoids terminal
|
||||
// buffer issues with long PATH values (1000+ chars).
|
||||
const pathValue = config.environment?.["PATH"];
|
||||
let launchCommand = config.launchCommand;
|
||||
if (pathValue) {
|
||||
// Use printf with JSON-escaped value to avoid shell injection if
|
||||
// PATH contains single quotes or other shell metacharacters.
|
||||
launchCommand = `export PATH=$(printf '%s' ${JSON.stringify(pathValue)})\n${launchCommand}`;
|
||||
}
|
||||
|
||||
// Send the launch command — clean up the session if this fails.
|
||||
// Use a temp script for long commands so the pane shows a short
|
||||
// invocation instead of a pasted wall of shell.
|
||||
try {
|
||||
if (config.launchCommand.length > 200) {
|
||||
const invocation = writeLaunchScript(config.launchCommand);
|
||||
if (launchCommand.length > 200) {
|
||||
const invocation = writeLaunchScript(launchCommand);
|
||||
await tmux("send-keys", "-t", sessionName, "-l", invocation);
|
||||
await sleep(300);
|
||||
await tmux("send-keys", "-t", sessionName, "Enter");
|
||||
} else {
|
||||
await tmux("send-keys", "-t", sessionName, config.launchCommand, "Enter");
|
||||
await tmux("send-keys", "-t", sessionName, launchCommand, "Enter");
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -7,25 +7,49 @@
|
|||
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import type {
|
||||
BatchObserver,
|
||||
CICheck,
|
||||
CIStatus,
|
||||
PREnrichmentData,
|
||||
PRInfo,
|
||||
PRState,
|
||||
ReviewDecision,
|
||||
import {
|
||||
execGhObserved,
|
||||
type BatchObserver,
|
||||
type CICheck,
|
||||
type CIStatus,
|
||||
type PREnrichmentData,
|
||||
type PRInfo,
|
||||
type PRState,
|
||||
type ReviewDecision,
|
||||
} from "@aoagents/ao-core";
|
||||
import { LRUCache } from "./lru-cache.js";
|
||||
|
||||
let execFileAsync = promisify(execFile);
|
||||
let execGhAsync = async (args: string[], timeout: number, operation: string): Promise<string> =>
|
||||
execGhObserved(args, { component: "scm-github-batch", operation }, timeout);
|
||||
|
||||
/**
|
||||
* Set execFileAsync for testing.
|
||||
* Allows mocking the underlying execFile in unit tests.
|
||||
*
|
||||
* NOTE: This bypasses the gh tracer (execGhObserved). Tests that need to
|
||||
* verify tracer behavior should mock execGhObserved directly or use
|
||||
* setExecGhAsync instead.
|
||||
*/
|
||||
export function setExecFileAsync(fn: typeof execFileAsync): void {
|
||||
execFileAsync = fn;
|
||||
execGhAsync = async (args: string[], timeout: number): Promise<string> => {
|
||||
const { stdout } = await execFileAsync("gh", args, {
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
timeout,
|
||||
});
|
||||
return stdout.trim();
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Set execGhAsync for testing — preserves tracer in the call chain.
|
||||
* Use this when testing code that should exercise the traced execution path.
|
||||
*/
|
||||
export function setExecGhAsync(
|
||||
fn: (args: string[], timeout: number, operation: string) => Promise<string>,
|
||||
): void {
|
||||
execGhAsync = fn;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -34,6 +58,7 @@ export function setExecFileAsync(fn: typeof execFileAsync): void {
|
|||
*/
|
||||
const MAX_PR_LIST_ETAGS = 100; // Number of repos to cache
|
||||
const MAX_COMMIT_STATUS_ETAGS = 500; // Number of commits to cache
|
||||
const MAX_REVIEW_COMMENTS_ETAGS = 500; // Number of PRs to cache review ETags
|
||||
const MAX_PR_METADATA = 200; // Number of PRs to cache full data
|
||||
|
||||
/**
|
||||
|
|
@ -47,6 +72,7 @@ const MAX_PR_METADATA = 200; // Number of PRs to cache full data
|
|||
interface ETagCache {
|
||||
prList: LRUCache<string, string>; // Key: "owner/repo", Value: ETag
|
||||
commitStatus: LRUCache<string, string>; // Key: "owner/repo#sha", Value: ETag
|
||||
reviewComments: LRUCache<string, string>; // Key: "owner/repo#number", Value: ETag
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -59,6 +85,7 @@ interface ETagCache {
|
|||
const etagCache: ETagCache = {
|
||||
prList: new LRUCache(MAX_PR_LIST_ETAGS),
|
||||
commitStatus: new LRUCache(MAX_COMMIT_STATUS_ETAGS),
|
||||
reviewComments: new LRUCache(MAX_REVIEW_COMMENTS_ETAGS),
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -67,6 +94,15 @@ const etagCache: ETagCache = {
|
|||
interface ETagGuardResult {
|
||||
shouldRefresh: boolean;
|
||||
details: string[];
|
||||
/** Repos where Guard 1 returned 304 — no PR list changes, detectPR can be skipped. */
|
||||
prListUnchangedRepos: Set<string>;
|
||||
}
|
||||
|
||||
/** Result of enrichSessionsPRBatch including Guard 1 PR-discovery info. */
|
||||
export interface BatchEnrichmentResult {
|
||||
enrichment: Map<string, PREnrichmentData>;
|
||||
/** Repos where Guard 1 returned 304 — safe to skip detectPR. */
|
||||
prListUnchangedRepos: Set<string>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -76,6 +112,7 @@ interface ETagGuardResult {
|
|||
export function clearETagCache(): void {
|
||||
etagCache.prList.clear();
|
||||
etagCache.commitStatus.clear();
|
||||
etagCache.reviewComments.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -176,14 +213,11 @@ function updatePRMetadataCache(
|
|||
*/
|
||||
export async function shouldRefreshPREnrichment(
|
||||
prs: PRInfo[],
|
||||
extraRepos: string[] = [],
|
||||
): Promise<ETagGuardResult> {
|
||||
const details: string[] = [];
|
||||
let shouldRefresh = false;
|
||||
|
||||
if (prs.length === 0) {
|
||||
return { shouldRefresh: false, details: ["No PRs to check"] };
|
||||
}
|
||||
|
||||
// Group PRs by repository for Guard 1 (PR list check)
|
||||
const repos = new Map<string, PRInfo[]>();
|
||||
|
||||
|
|
@ -198,8 +232,20 @@ export async function shouldRefreshPREnrichment(
|
|||
}
|
||||
}
|
||||
|
||||
// Include repos from PR-less sessions so Guard 1 runs for them too
|
||||
for (const repoKey of extraRepos) {
|
||||
if (!repos.has(repoKey)) {
|
||||
repos.set(repoKey, []);
|
||||
}
|
||||
}
|
||||
|
||||
if (repos.size === 0) {
|
||||
return { shouldRefresh: false, details: ["No repos to check"], prListUnchangedRepos: new Set() };
|
||||
}
|
||||
|
||||
// Guard 1: Check PR list ETag for each repository
|
||||
let guard1DetectedChanges = false;
|
||||
const prListUnchangedRepos = new Set<string>();
|
||||
for (const [repoKey] of repos) {
|
||||
const [owner, repo] = repoKey.split("/");
|
||||
const prListChanged = await checkPRListETag(owner, repo);
|
||||
|
|
@ -207,6 +253,8 @@ export async function shouldRefreshPREnrichment(
|
|||
guard1DetectedChanges = true;
|
||||
shouldRefresh = true;
|
||||
details.push(`PR list changed for ${repoKey} (Guard 1)`);
|
||||
} else {
|
||||
prListUnchangedRepos.add(repoKey);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -254,7 +302,7 @@ export async function shouldRefreshPREnrichment(
|
|||
}
|
||||
}
|
||||
|
||||
return { shouldRefresh, details };
|
||||
return { shouldRefresh, details, prListUnchangedRepos };
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -322,6 +370,36 @@ async function verifyGhCLI(): Promise<void> {
|
|||
*/
|
||||
export const MAX_BATCH_SIZE = 25;
|
||||
|
||||
/**
|
||||
* Check if an HTTP response contains a 304 Not Modified status.
|
||||
* Handles HTTP/1.1, HTTP/2, and HTTP/2.0 status lines.
|
||||
*/
|
||||
function is304(output: string): boolean {
|
||||
return /HTTP\/[\d.]+ 304/i.test(output);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract stdout/stderr from an execFile error object.
|
||||
* gh cli puts the HTTP response in stdout even on exit code 1 (e.g. 304).
|
||||
*/
|
||||
function extractErrorOutput(err: unknown): string | null {
|
||||
const e = err as { stdout?: unknown; stderr?: unknown };
|
||||
const stdout = typeof e.stdout === "string" ? e.stdout : "";
|
||||
const stderr = typeof e.stderr === "string" ? e.stderr : "";
|
||||
const combined = stdout + stderr;
|
||||
return combined.length > 0 ? combined : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract ETag from HTTP response output.
|
||||
* Used on both 200 and 304 paths — RFC 7232 allows servers to rotate
|
||||
* the validator on a 304, so we must re-read the ETag even when unchanged.
|
||||
*/
|
||||
function extractETag(output: string): string | undefined {
|
||||
const match = output.match(/etag:\s*(.+)/i);
|
||||
return match ? match[1].trim() : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Guard 1: PR List ETag Check (per repo)
|
||||
*
|
||||
|
|
@ -350,30 +428,34 @@ async function checkPRListETag(
|
|||
}
|
||||
|
||||
try {
|
||||
const { stdout } = await execFileAsync("gh", args, { timeout: 10_000 });
|
||||
const output = stdout.trim();
|
||||
const output = await execGhAsync(args, 10_000, "gh.api.guard-pr-list");
|
||||
|
||||
// Check for HTTP 304 Not Modified response
|
||||
if (output.includes("HTTP/1.1 304") || output.includes("HTTP/2 304")) {
|
||||
// No changes detected - cost: 0 GraphQL points
|
||||
if (is304(output)) {
|
||||
// Re-read ETag on 304 — RFC 7232 allows rotated validators
|
||||
const rotatedETag = extractETag(output);
|
||||
if (rotatedETag) setPRListETag(owner, repo, rotatedETag);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Extract new ETag from response headers
|
||||
// ETag header format: "etag": "W/"abc123..." or "etag": "abc123..."
|
||||
const etagMatch = output.match(/etag:\s*(.+)/i);
|
||||
if (etagMatch) {
|
||||
// Trim to remove trailing whitespace/newlines that could cause comparison issues
|
||||
const newETag = etagMatch[1].trim();
|
||||
const newETag = extractETag(output);
|
||||
if (newETag) {
|
||||
setPRListETag(owner, repo, newETag);
|
||||
}
|
||||
|
||||
// PR list changed - cost: 1 REST point
|
||||
return true;
|
||||
} catch (err) {
|
||||
// On error, assume change to ensure we don't miss anything
|
||||
// gh exits code 1 on 304 Not Modified — check stdout/stderr for the status line
|
||||
const output = extractErrorOutput(err);
|
||||
if (output && is304(output)) {
|
||||
const rotatedETag = extractETag(output);
|
||||
if (rotatedETag) setPRListETag(owner, repo, rotatedETag);
|
||||
return false;
|
||||
}
|
||||
|
||||
const errorMsg = err instanceof Error ? err.message : String(err);
|
||||
// Log but don't throw - allow GraphQL batch to proceed
|
||||
// eslint-disable-next-line no-console -- Observability logging for ETag errors
|
||||
console.warn(`[ETag Guard 1] PR list check failed for ${repoKey}: ${errorMsg}`);
|
||||
return true; // Assume changed to be safe
|
||||
|
|
@ -381,13 +463,15 @@ async function checkPRListETag(
|
|||
}
|
||||
|
||||
/**
|
||||
* Guard 2: Commit Status ETag Check (per PR with pending CI)
|
||||
* Guard 2: Check-Runs ETag Check (per PR with cached head SHA)
|
||||
*
|
||||
* Detects if CI status has changed for a specific commit using REST ETag.
|
||||
*
|
||||
* - Endpoint: GET /repos/{owner}/{repo}/commits/{head_sha}/status
|
||||
* - Endpoint: GET /repos/{owner}/{repo}/commits/{head_sha}/check-runs
|
||||
* Uses the check-runs endpoint (not legacy /status) because the batch
|
||||
* query reads `statusCheckRollup` which aggregates check-runs. Pure-Actions
|
||||
* repos only update check-runs, not the legacy combined-status endpoint.
|
||||
* - Detects: CI check starts, passes, fails, or external status updates
|
||||
* - Only checked for PRs with ciStatus === "pending" to minimize calls
|
||||
*
|
||||
* @returns true if CI status has changed (200 OK), false if unchanged (304 Not Modified)
|
||||
*/
|
||||
|
|
@ -399,8 +483,9 @@ async function checkCommitStatusETag(
|
|||
const commitKey = `${owner}/${repo}#${sha}`;
|
||||
const cachedETag = etagCache.commitStatus.get(commitKey);
|
||||
|
||||
// Build gh CLI args for REST API call
|
||||
const url = `repos/${owner}/${repo}/commits/${sha}/status`;
|
||||
// Use check-runs endpoint (not legacy /status) to match statusCheckRollup
|
||||
// data source. per_page=1 keeps the response small — we only need the ETag.
|
||||
const url = `repos/${owner}/${repo}/commits/${sha}/check-runs?per_page=1`;
|
||||
const args = ["api", "--method", "GET", url, "-i"]; // -i includes headers
|
||||
|
||||
// Add If-None-Match header if we have a cached ETag
|
||||
|
|
@ -409,27 +494,32 @@ async function checkCommitStatusETag(
|
|||
}
|
||||
|
||||
try {
|
||||
const { stdout } = await execFileAsync("gh", args, { timeout: 10_000 });
|
||||
const output = stdout.trim();
|
||||
const output = await execGhAsync(args, 10_000, "gh.api.guard-commit-status");
|
||||
|
||||
// Check for HTTP 304 Not Modified response
|
||||
if (output.includes("HTTP/1.1 304") || output.includes("HTTP/2 304")) {
|
||||
// No CI changes detected - cost: 0 GraphQL points
|
||||
if (is304(output)) {
|
||||
const rotatedETag = extractETag(output);
|
||||
if (rotatedETag) setCommitStatusETag(owner, repo, sha, rotatedETag);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Extract new ETag from response headers
|
||||
const etagMatch = output.match(/etag:\s*(.+)/i);
|
||||
if (etagMatch) {
|
||||
// Trim to remove trailing whitespace/newlines that could cause comparison issues
|
||||
const newETag = etagMatch[1].trim();
|
||||
const newETag = extractETag(output);
|
||||
if (newETag) {
|
||||
setCommitStatusETag(owner, repo, sha, newETag);
|
||||
}
|
||||
|
||||
// CI status changed - cost: 1 REST point
|
||||
return true;
|
||||
} catch (err) {
|
||||
// On error, assume change to ensure we don't miss anything
|
||||
// gh exits code 1 on 304 Not Modified — check stdout/stderr for the status line
|
||||
const output = extractErrorOutput(err);
|
||||
if (output && is304(output)) {
|
||||
const rotatedETag = extractETag(output);
|
||||
if (rotatedETag) setCommitStatusETag(owner, repo, sha, rotatedETag);
|
||||
return false;
|
||||
}
|
||||
|
||||
const errorMsg = err instanceof Error ? err.message : String(err);
|
||||
// eslint-disable-next-line no-console -- Observability logging for ETag errors
|
||||
console.warn(
|
||||
|
|
@ -439,6 +529,66 @@ async function checkCommitStatusETag(
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Guard 3: Review Comments ETag Check (per PR)
|
||||
*
|
||||
* Detects if inline review comments have changed on a PR.
|
||||
* Used to gate the getReviewThreads GraphQL call — if no new comments
|
||||
* exist (304), the cached result is reused without a GraphQL call.
|
||||
*
|
||||
* - Endpoint: GET /repos/{owner}/{repo}/pulls/{number}/comments
|
||||
* No per_page limit — the ETag covers the full resource. With per_page=1,
|
||||
* the ETag only covers the first page, so new comments on page 2+ would
|
||||
* never bust the validator. Typical PR comment counts are small (<100)
|
||||
* so the unbounded list is fine.
|
||||
* - Detects: New review comments, edited comments, deleted comments
|
||||
* - Cost: 0 REST points on 304, 1 REST point on 200
|
||||
*/
|
||||
export async function checkReviewCommentsETag(
|
||||
owner: string,
|
||||
repo: string,
|
||||
prNumber: number,
|
||||
): Promise<boolean> {
|
||||
const cacheKey = `${owner}/${repo}#${prNumber}`;
|
||||
const cachedETag = etagCache.reviewComments.get(cacheKey);
|
||||
|
||||
const url = `repos/${owner}/${repo}/pulls/${prNumber}/comments`;
|
||||
const args = ["api", "--method", "GET", url, "-i"];
|
||||
|
||||
if (cachedETag) {
|
||||
args.push("-H", `If-None-Match: ${cachedETag}`);
|
||||
}
|
||||
|
||||
try {
|
||||
const output = await execGhAsync(args, 10_000, "gh.api.guard-review-comments");
|
||||
|
||||
if (is304(output)) {
|
||||
const rotatedETag = extractETag(output);
|
||||
if (rotatedETag) etagCache.reviewComments.set(cacheKey, rotatedETag);
|
||||
return false;
|
||||
}
|
||||
|
||||
const newETag = extractETag(output);
|
||||
if (newETag) {
|
||||
etagCache.reviewComments.set(cacheKey, newETag);
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (err) {
|
||||
const output = extractErrorOutput(err);
|
||||
if (output && is304(output)) {
|
||||
const rotatedETag = extractETag(output);
|
||||
if (rotatedETag) etagCache.reviewComments.set(cacheKey, rotatedETag);
|
||||
return false;
|
||||
}
|
||||
|
||||
const errorMsg = err instanceof Error ? err.message : String(err);
|
||||
// eslint-disable-next-line no-console -- Observability logging for ETag errors
|
||||
console.warn(`[ETag Guard 3] Review comments check failed for ${cacheKey}: ${errorMsg}`);
|
||||
return true; // Assume changed to be safe
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GraphQL fields to fetch for each PR.
|
||||
* This includes all data needed for orchestrator status detection.
|
||||
|
|
@ -455,19 +605,16 @@ const PR_FIELDS = `
|
|||
reviewDecision
|
||||
headRefName
|
||||
headRefOid
|
||||
reviews(last: 5) {
|
||||
nodes {
|
||||
author { login }
|
||||
state
|
||||
submittedAt
|
||||
}
|
||||
}
|
||||
commits(last: 1) {
|
||||
nodes {
|
||||
commit {
|
||||
statusCheckRollup {
|
||||
state
|
||||
contexts(first: 20) {
|
||||
# 11 keeps per-PR node cost under budget for 25-PR batch queries
|
||||
# (total cost ≤5000). Repos with >11 checks lose individual check
|
||||
# visibility, but the rollup "state" still reflects all checks —
|
||||
# overall pass/fail detection remains correct.
|
||||
contexts(first: 11) {
|
||||
nodes {
|
||||
... on CheckRun {
|
||||
name
|
||||
|
|
@ -534,6 +681,7 @@ export function generateBatchQuery(prs: PRInfo[]): {
|
|||
return {
|
||||
query: `query BatchPRs(${variableDefs}) {
|
||||
${selections.join("\n")}
|
||||
rateLimit { cost remaining resetAt }
|
||||
}`,
|
||||
variables,
|
||||
};
|
||||
|
|
@ -567,22 +715,32 @@ async function executeBatchQuery(
|
|||
}
|
||||
}
|
||||
|
||||
const args = ["api", "graphql", ...varArgs, "-f", `query=${query}`];
|
||||
const args = ["api", "graphql", "-i", ...varArgs, "-f", `query=${query}`];
|
||||
|
||||
// Scale timeout based on batch size to prevent large batches from timing out
|
||||
// Base: 30s, +2s per PR beyond first 10
|
||||
const batchSize = prs.length;
|
||||
const adaptiveTimeout = 30_000 + Math.max(0, (batchSize - 10) * 2000);
|
||||
|
||||
const { stdout } = await execFileAsync("gh", args, {
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
timeout: adaptiveTimeout,
|
||||
});
|
||||
const stdout = await execGhAsync(args, adaptiveTimeout, "gh.api.graphql-batch");
|
||||
|
||||
// With -i, stdout contains HTTP headers + blank line + JSON body.
|
||||
// Split at first blank line to get the JSON body for parsing.
|
||||
// The tracer (execGhObserved) already parses the headers for its trace row.
|
||||
const blankLineIdx = stdout.indexOf("\r\n\r\n");
|
||||
const altBlankLineIdx = stdout.indexOf("\n\n");
|
||||
const splitIdx =
|
||||
blankLineIdx >= 0 && (altBlankLineIdx < 0 || blankLineIdx < altBlankLineIdx)
|
||||
? blankLineIdx + 4
|
||||
: altBlankLineIdx >= 0
|
||||
? altBlankLineIdx + 2
|
||||
: 0;
|
||||
const body = splitIdx > 0 ? stdout.slice(splitIdx) : stdout;
|
||||
|
||||
const result: {
|
||||
data?: Record<string, unknown>;
|
||||
errors?: Array<{ message: string; path?: string[] }>;
|
||||
} = JSON.parse(stdout.trim());
|
||||
} = JSON.parse(body.trim());
|
||||
|
||||
// Check for GraphQL errors and throw to allow individual API fallback
|
||||
if (result.errors && result.errors.length > 0) {
|
||||
|
|
@ -760,7 +918,6 @@ function extractPREnrichment(
|
|||
if (
|
||||
pr["state"] === undefined &&
|
||||
pr["title"] === undefined &&
|
||||
pr["reviews"] === undefined &&
|
||||
pr["commits"] === undefined
|
||||
) {
|
||||
return null;
|
||||
|
|
@ -882,15 +1039,17 @@ function extractPREnrichment(
|
|||
export async function enrichSessionsPRBatch(
|
||||
prs: PRInfo[],
|
||||
observer?: BatchObserver,
|
||||
): Promise<Map<string, PREnrichmentData>> {
|
||||
repos: string[] = [],
|
||||
): Promise<BatchEnrichmentResult> {
|
||||
const result = new Map<string, PREnrichmentData>();
|
||||
|
||||
if (prs.length === 0) {
|
||||
return result;
|
||||
}
|
||||
|
||||
// Step 1: Check if we need to refresh using 2-Guard ETag Strategy
|
||||
const guardResult = await shouldRefreshPREnrichment(prs);
|
||||
// Guard 1 runs for all repos (including those with no PRs yet) so the
|
||||
// lifecycle manager knows whether detectPR can be skipped.
|
||||
const guardResult = await shouldRefreshPREnrichment(prs, repos);
|
||||
|
||||
// Report which repos had no PR list changes so the lifecycle can skip detectPR
|
||||
observer?.reportPRListUnchangedRepos?.(guardResult.prListUnchangedRepos);
|
||||
|
||||
if (!guardResult.shouldRefresh) {
|
||||
// No changes detected - try to return cached data
|
||||
|
|
@ -913,7 +1072,7 @@ export async function enrichSessionsPRBatch(
|
|||
"info",
|
||||
`[ETag Guard] Skipping GraphQL batch - all ${result.size} PRs cached. Reasons: ${guardResult.details.join(", ")}`,
|
||||
);
|
||||
return result;
|
||||
return { enrichment: result, prListUnchangedRepos: guardResult.prListUnchangedRepos };
|
||||
}
|
||||
|
||||
// Some PRs not cached - fetch missing PRs via GraphQL
|
||||
|
|
@ -1006,7 +1165,7 @@ export async function enrichSessionsPRBatch(
|
|||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
return { enrichment: result, prListUnchangedRepos: guardResult.prListUnchangedRepos };
|
||||
}
|
||||
|
||||
// Export internal functions for testing
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { createHmac, timingSafeEqual } from "node:crypto";
|
|||
import { promisify } from "node:util";
|
||||
import {
|
||||
CI_STATUS,
|
||||
execGhObserved,
|
||||
type PluginModule,
|
||||
type SCM,
|
||||
type SCMWebhookEvent,
|
||||
|
|
@ -24,13 +25,15 @@ import {
|
|||
type Review,
|
||||
type ReviewDecision,
|
||||
type ReviewComment,
|
||||
type AutomatedComment,
|
||||
type ReviewSummary,
|
||||
type ReviewThreadsResult,
|
||||
type MergeReadiness,
|
||||
type PREnrichmentData,
|
||||
type BatchObserver,
|
||||
} from "@aoagents/ao-core";
|
||||
import {
|
||||
enrichSessionsPRBatch as enrichSessionsPRBatchImpl,
|
||||
checkReviewCommentsETag,
|
||||
} from "./graphql-batch.js";
|
||||
import {
|
||||
getWebhookHeader,
|
||||
|
|
@ -77,11 +80,11 @@ async function execCli(bin: ExecCommand, args: string[], cwd?: string): Promise<
|
|||
}
|
||||
|
||||
async function gh(args: string[]): Promise<string> {
|
||||
return execCli("gh", args);
|
||||
return execGhObserved(args, { component: "scm-github" }, 30_000);
|
||||
}
|
||||
|
||||
async function ghInDir(args: string[], cwd: string): Promise<string> {
|
||||
return execCli("gh", args, cwd);
|
||||
return execGhObserved(args, { component: "scm-github", cwd }, 30_000);
|
||||
}
|
||||
|
||||
async function git(args: string[], cwd: string): Promise<string> {
|
||||
|
|
@ -450,7 +453,89 @@ function parseDate(val: string | undefined | null): Date {
|
|||
// SCM implementation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// In-process PR cache. Per-method TTLs balance call reduction against
|
||||
// staleness. Tightest TTLs (5s) on the fastest-changing decision-critical
|
||||
// fields (state, CI, mergeability) — well under one poll cycle. Slightly
|
||||
// looser (10s) on review-state and review-comments which tolerate up to
|
||||
// 10-30s staleness per the agreed policy and benefit measurably from a
|
||||
// looser window in trace replay. detectPR uses 30s because once a PR is
|
||||
// discovered for a branch, that fact is stable for the session — and 5s was
|
||||
// far below the per-branch poll cadence (~30s), making the cache near-useless.
|
||||
// detectPR caches positive results only (never []) so a freshly created PR
|
||||
// is discovered on the very next poll.
|
||||
const PR_CACHE_TTL_MS = {
|
||||
resolvePR: 60_000, // identity metadata (number, url, title, branch refs, isDraft)
|
||||
getPRState: 5_000, // open / merged / closed
|
||||
getPRSummary: 5_000, // state + title + additions/deletions
|
||||
getReviews: 10_000, // review array (state, body, author)
|
||||
getReviewDecision: 10_000, // approved / changes_requested / pending
|
||||
getCIChecks: 5_000, // CI check list (name, state, link, timestamps)
|
||||
getMergeability: 5_000, // composite merge readiness
|
||||
getPendingComments: 10_000, // unresolved review threads (GraphQL)
|
||||
detectPR: 30_000, // positive hits only — branch-PR mapping is stable once known
|
||||
} as const;
|
||||
|
||||
const PR_CACHE_MAX_ENTRIES = 1000;
|
||||
|
||||
type PRCacheMethod = keyof typeof PR_CACHE_TTL_MS;
|
||||
|
||||
function createGitHubSCM(): SCM {
|
||||
// Per-instance cache so each createGitHubSCM() returns an isolated cache —
|
||||
// tests get clean state on each create() call.
|
||||
const prCache = new Map<string, { value: unknown; expiresAt: number }>();
|
||||
// ETag-controlled cache for review threads + reviews. Freshness is managed by
|
||||
// Guard 3 (checkReviewCommentsETag) — not a TTL timer.
|
||||
const reviewThreadsCache = new Map<string, ReviewThreadsResult>();
|
||||
|
||||
function prCacheKey(owner: string, repo: string, prKey: string, method: PRCacheMethod): string {
|
||||
return `${owner}/${repo}#${prKey}:${method}`;
|
||||
}
|
||||
|
||||
function readPRCache<T>(key: string): T | null {
|
||||
const entry = prCache.get(key);
|
||||
if (!entry) return null;
|
||||
if (Date.now() > entry.expiresAt) {
|
||||
prCache.delete(key);
|
||||
return null;
|
||||
}
|
||||
return entry.value as T;
|
||||
}
|
||||
|
||||
function writePRCache<T>(key: string, value: T, ttlMs: number): void {
|
||||
if (prCache.size >= PR_CACHE_MAX_ENTRIES) {
|
||||
const oldest = prCache.keys().next().value;
|
||||
if (oldest !== undefined) prCache.delete(oldest);
|
||||
}
|
||||
prCache.set(key, { value, expiresAt: Date.now() + ttlMs });
|
||||
}
|
||||
|
||||
// Wipe every method's cache entry for a specific PR. Called on writes
|
||||
// (pr edit/merge/close) to avoid serving stale state after our own mutation.
|
||||
// Also wipes the branch-keyed detectPR entry since mergePR deletes the branch.
|
||||
function invalidatePRCache(pr: PRInfo): void {
|
||||
const prefix = `${pr.owner}/${pr.repo}#${pr.number}:`;
|
||||
for (const key of prCache.keys()) {
|
||||
if (key.startsWith(prefix)) prCache.delete(key);
|
||||
}
|
||||
prCache.delete(prCacheKey(pr.owner, pr.repo, pr.branch, "detectPR"));
|
||||
reviewThreadsCache.delete(`${pr.owner}/${pr.repo}#${pr.number}`);
|
||||
}
|
||||
|
||||
async function withPRCache<T>(
|
||||
owner: string,
|
||||
repo: string,
|
||||
prKey: string,
|
||||
method: PRCacheMethod,
|
||||
fetcher: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
const key = prCacheKey(owner, repo, prKey, method);
|
||||
const cached = readPRCache<T>(key);
|
||||
if (cached !== null) return cached;
|
||||
const value = await fetcher();
|
||||
writePRCache(key, value, PR_CACHE_TTL_MS[method]);
|
||||
return value;
|
||||
}
|
||||
|
||||
return {
|
||||
name: "github",
|
||||
|
||||
|
|
@ -517,6 +602,13 @@ function createGitHubSCM(): SCM {
|
|||
async detectPR(session: Session, project: ProjectConfig): Promise<PRInfo | null> {
|
||||
if (!session.branch || !project.repo) return null;
|
||||
parseProjectRepo(project.repo);
|
||||
const [owner, repoName] = project.repo.split("/");
|
||||
// Positive-only cache: never cache [] (null). A just-created PR must
|
||||
// surface on the next poll, so we pay the gh call for misses but save
|
||||
// every call after the PR is discovered.
|
||||
const cacheK = prCacheKey(owner ?? "", repoName ?? "", session.branch, "detectPR");
|
||||
const cached = readPRCache<PRInfo>(cacheK);
|
||||
if (cached !== null) return cached;
|
||||
try {
|
||||
const raw = await gh([
|
||||
"pr",
|
||||
|
|
@ -542,7 +634,9 @@ function createGitHubSCM(): SCM {
|
|||
|
||||
if (prs.length === 0) return null;
|
||||
|
||||
return prInfoFromView(prs[0], project.repo);
|
||||
const info = prInfoFromView(prs[0], project.repo);
|
||||
writePRCache(cacheK, info, PR_CACHE_TTL_MS.detectPR);
|
||||
return info;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
|
@ -552,30 +646,38 @@ function createGitHubSCM(): SCM {
|
|||
if (!project.repo) {
|
||||
throw new Error("Cannot resolve PR: project has no repo configured");
|
||||
}
|
||||
const raw = await gh([
|
||||
"pr",
|
||||
"view",
|
||||
reference,
|
||||
"--repo",
|
||||
project.repo,
|
||||
"--json",
|
||||
"number,url,title,headRefName,baseRefName,isDraft",
|
||||
]);
|
||||
const repo = project.repo;
|
||||
const [owner, repoName] = repo.split("/");
|
||||
// Cache by reference (number, branch, or URL — caller-provided).
|
||||
// Identity metadata (number, url, title, branch refs, isDraft) is stable
|
||||
// for the life of a PR; 60s TTL is safely under any user-noticeable window.
|
||||
return withPRCache(owner ?? "", repoName ?? "", `ref=${reference}`, "resolvePR", async () => {
|
||||
const raw = await gh([
|
||||
"pr",
|
||||
"view",
|
||||
reference,
|
||||
"--repo",
|
||||
repo,
|
||||
"--json",
|
||||
"number,url,title,headRefName,baseRefName,isDraft",
|
||||
]);
|
||||
|
||||
const data: {
|
||||
number: number;
|
||||
url: string;
|
||||
title: string;
|
||||
headRefName: string;
|
||||
baseRefName: string;
|
||||
isDraft: boolean;
|
||||
} = JSON.parse(raw);
|
||||
const data: {
|
||||
number: number;
|
||||
url: string;
|
||||
title: string;
|
||||
headRefName: string;
|
||||
baseRefName: string;
|
||||
isDraft: boolean;
|
||||
} = JSON.parse(raw);
|
||||
|
||||
return prInfoFromView(data, project.repo);
|
||||
return prInfoFromView(data, repo);
|
||||
});
|
||||
},
|
||||
|
||||
async assignPRToCurrentUser(pr: PRInfo): Promise<void> {
|
||||
await gh(["pr", "edit", String(pr.number), "--repo", repoFlag(pr), "--add-assignee", "@me"]);
|
||||
invalidatePRCache(pr);
|
||||
},
|
||||
|
||||
async checkoutPR(pr: PRInfo, workspacePath: string): Promise<boolean> {
|
||||
|
|
@ -594,96 +696,113 @@ function createGitHubSCM(): SCM {
|
|||
},
|
||||
|
||||
async getPRState(pr: PRInfo): Promise<PRState> {
|
||||
const raw = await gh([
|
||||
"pr",
|
||||
"view",
|
||||
String(pr.number),
|
||||
"--repo",
|
||||
repoFlag(pr),
|
||||
"--json",
|
||||
"state",
|
||||
]);
|
||||
const data: { state: string } = JSON.parse(raw);
|
||||
const s = data.state.toUpperCase();
|
||||
if (s === "MERGED") return "merged";
|
||||
if (s === "CLOSED") return "closed";
|
||||
return "open";
|
||||
// 5s TTL — state is decision-influencing (lifecycle uses it for cleanup),
|
||||
// but 5s is well under one poll cycle so the lifecycle worker still sees
|
||||
// freshly observed transitions on its next pass.
|
||||
return withPRCache(pr.owner, pr.repo, String(pr.number), "getPRState", async () => {
|
||||
const raw = await gh([
|
||||
"pr",
|
||||
"view",
|
||||
String(pr.number),
|
||||
"--repo",
|
||||
repoFlag(pr),
|
||||
"--json",
|
||||
"state",
|
||||
]);
|
||||
const data: { state: string } = JSON.parse(raw);
|
||||
const s = data.state.toUpperCase();
|
||||
if (s === "MERGED") return "merged";
|
||||
if (s === "CLOSED") return "closed";
|
||||
return "open";
|
||||
});
|
||||
},
|
||||
|
||||
async getPRSummary(pr: PRInfo) {
|
||||
const raw = await gh([
|
||||
"pr",
|
||||
"view",
|
||||
String(pr.number),
|
||||
"--repo",
|
||||
repoFlag(pr),
|
||||
"--json",
|
||||
"state,title,additions,deletions",
|
||||
]);
|
||||
const data: {
|
||||
state: string;
|
||||
title: string;
|
||||
additions: number;
|
||||
deletions: number;
|
||||
} = JSON.parse(raw);
|
||||
const s = data.state.toUpperCase();
|
||||
const state: PRState = s === "MERGED" ? "merged" : s === "CLOSED" ? "closed" : "open";
|
||||
return {
|
||||
state,
|
||||
title: data.title ?? "",
|
||||
additions: data.additions ?? 0,
|
||||
deletions: data.deletions ?? 0,
|
||||
};
|
||||
// 5s TTL — includes state, so same freshness contract as getPRState.
|
||||
// Title and additions/deletions change rarely; they ride along.
|
||||
return withPRCache(pr.owner, pr.repo, String(pr.number), "getPRSummary", async () => {
|
||||
const raw = await gh([
|
||||
"pr",
|
||||
"view",
|
||||
String(pr.number),
|
||||
"--repo",
|
||||
repoFlag(pr),
|
||||
"--json",
|
||||
"state,title,additions,deletions",
|
||||
]);
|
||||
const data: {
|
||||
state: string;
|
||||
title: string;
|
||||
additions: number;
|
||||
deletions: number;
|
||||
} = JSON.parse(raw);
|
||||
const s = data.state.toUpperCase();
|
||||
const state: PRState = s === "MERGED" ? "merged" : s === "CLOSED" ? "closed" : "open";
|
||||
return {
|
||||
state,
|
||||
title: data.title ?? "",
|
||||
additions: data.additions ?? 0,
|
||||
deletions: data.deletions ?? 0,
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
async mergePR(pr: PRInfo, method: MergeMethod = "squash"): Promise<void> {
|
||||
const flag = method === "rebase" ? "--rebase" : method === "merge" ? "--merge" : "--squash";
|
||||
|
||||
await gh(["pr", "merge", String(pr.number), "--repo", repoFlag(pr), flag, "--delete-branch"]);
|
||||
invalidatePRCache(pr);
|
||||
},
|
||||
|
||||
async closePR(pr: PRInfo): Promise<void> {
|
||||
await gh(["pr", "close", String(pr.number), "--repo", repoFlag(pr)]);
|
||||
invalidatePRCache(pr);
|
||||
},
|
||||
|
||||
async getCIChecks(pr: PRInfo): Promise<CICheck[]> {
|
||||
try {
|
||||
const raw = await gh([
|
||||
"pr",
|
||||
"checks",
|
||||
String(pr.number),
|
||||
"--repo",
|
||||
repoFlag(pr),
|
||||
"--json",
|
||||
"name,state,link,startedAt,completedAt",
|
||||
]);
|
||||
// 5s TTL — CI state can flip quickly; within one poll cycle is acceptable
|
||||
// per the agreed fast-changing-fields policy. Fallback to statusCheckRollup
|
||||
// for older gh CLI versions happens inside the fetcher and rides on the
|
||||
// same cache entry.
|
||||
return withPRCache(pr.owner, pr.repo, String(pr.number), "getCIChecks", async () => {
|
||||
try {
|
||||
const raw = await gh([
|
||||
"pr",
|
||||
"checks",
|
||||
String(pr.number),
|
||||
"--repo",
|
||||
repoFlag(pr),
|
||||
"--json",
|
||||
"name,state,link,startedAt,completedAt",
|
||||
]);
|
||||
|
||||
const checks: Array<{
|
||||
name: string;
|
||||
state: string;
|
||||
link: string;
|
||||
startedAt: string;
|
||||
completedAt: string;
|
||||
}> = JSON.parse(raw);
|
||||
const checks: Array<{
|
||||
name: string;
|
||||
state: string;
|
||||
link: string;
|
||||
startedAt: string;
|
||||
completedAt: string;
|
||||
}> = JSON.parse(raw);
|
||||
|
||||
return checks.map((c) => {
|
||||
const state = c.state?.toUpperCase();
|
||||
return checks.map((c) => {
|
||||
const state = c.state?.toUpperCase();
|
||||
|
||||
return {
|
||||
name: c.name,
|
||||
status: mapRawCheckStateToStatus(state),
|
||||
url: c.link || undefined,
|
||||
conclusion: state || undefined,
|
||||
startedAt: c.startedAt ? new Date(c.startedAt) : undefined,
|
||||
completedAt: c.completedAt ? new Date(c.completedAt) : undefined,
|
||||
};
|
||||
});
|
||||
} catch (err) {
|
||||
if (isUnsupportedPrChecksJsonError(err)) {
|
||||
return getCIChecksFromStatusRollup(pr);
|
||||
return {
|
||||
name: c.name,
|
||||
status: mapRawCheckStateToStatus(state),
|
||||
url: c.link || undefined,
|
||||
conclusion: state || undefined,
|
||||
startedAt: c.startedAt ? new Date(c.startedAt) : undefined,
|
||||
completedAt: c.completedAt ? new Date(c.completedAt) : undefined,
|
||||
};
|
||||
});
|
||||
} catch (err) {
|
||||
if (isUnsupportedPrChecksJsonError(err)) {
|
||||
return getCIChecksFromStatusRollup(pr);
|
||||
}
|
||||
throw new Error("Failed to fetch CI checks", { cause: err });
|
||||
}
|
||||
throw new Error("Failed to fetch CI checks", { cause: err });
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
async getCISummary(pr: PRInfo): Promise<CIStatus> {
|
||||
|
|
@ -721,65 +840,78 @@ function createGitHubSCM(): SCM {
|
|||
},
|
||||
|
||||
async getReviews(pr: PRInfo): Promise<Review[]> {
|
||||
const raw = await gh([
|
||||
"pr",
|
||||
"view",
|
||||
String(pr.number),
|
||||
"--repo",
|
||||
repoFlag(pr),
|
||||
"--json",
|
||||
"reviews",
|
||||
]);
|
||||
const data: {
|
||||
reviews: Array<{
|
||||
author: { login: string };
|
||||
state: string;
|
||||
body: string;
|
||||
submittedAt: string;
|
||||
}>;
|
||||
} = JSON.parse(raw);
|
||||
// 5s TTL — review array. Reviewers are async, so the lifecycle worker
|
||||
// sees a new review on its next poll cycle within 5s of the cache expiring.
|
||||
return withPRCache(pr.owner, pr.repo, String(pr.number), "getReviews", async () => {
|
||||
const raw = await gh([
|
||||
"pr",
|
||||
"view",
|
||||
String(pr.number),
|
||||
"--repo",
|
||||
repoFlag(pr),
|
||||
"--json",
|
||||
"reviews",
|
||||
]);
|
||||
const data: {
|
||||
reviews: Array<{
|
||||
author: { login: string };
|
||||
state: string;
|
||||
body: string;
|
||||
submittedAt: string;
|
||||
}>;
|
||||
} = JSON.parse(raw);
|
||||
|
||||
return data.reviews.map((r) => {
|
||||
let state: Review["state"];
|
||||
const s = r.state?.toUpperCase();
|
||||
if (s === "APPROVED") state = "approved";
|
||||
else if (s === "CHANGES_REQUESTED") state = "changes_requested";
|
||||
else if (s === "DISMISSED") state = "dismissed";
|
||||
else if (s === "PENDING") state = "pending";
|
||||
else state = "commented";
|
||||
return data.reviews.map((r) => {
|
||||
let state: Review["state"];
|
||||
const s = r.state?.toUpperCase();
|
||||
if (s === "APPROVED") state = "approved";
|
||||
else if (s === "CHANGES_REQUESTED") state = "changes_requested";
|
||||
else if (s === "DISMISSED") state = "dismissed";
|
||||
else if (s === "PENDING") state = "pending";
|
||||
else state = "commented";
|
||||
|
||||
return {
|
||||
author: r.author?.login ?? "unknown",
|
||||
state,
|
||||
body: r.body || undefined,
|
||||
submittedAt: parseDate(r.submittedAt),
|
||||
};
|
||||
return {
|
||||
author: r.author?.login ?? "unknown",
|
||||
state,
|
||||
body: r.body || undefined,
|
||||
submittedAt: parseDate(r.submittedAt),
|
||||
};
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
async getReviewDecision(pr: PRInfo): Promise<ReviewDecision> {
|
||||
const raw = await gh([
|
||||
"pr",
|
||||
"view",
|
||||
String(pr.number),
|
||||
"--repo",
|
||||
repoFlag(pr),
|
||||
"--json",
|
||||
"reviewDecision",
|
||||
]);
|
||||
const data: { reviewDecision: string } = JSON.parse(raw);
|
||||
// 5s TTL — review decision is decision-influencing (gates merge), kept
|
||||
// tight so a fresh "approved" surfaces within one poll cycle.
|
||||
return withPRCache(pr.owner, pr.repo, String(pr.number), "getReviewDecision", async () => {
|
||||
const raw = await gh([
|
||||
"pr",
|
||||
"view",
|
||||
String(pr.number),
|
||||
"--repo",
|
||||
repoFlag(pr),
|
||||
"--json",
|
||||
"reviewDecision",
|
||||
]);
|
||||
const data: { reviewDecision: string } = JSON.parse(raw);
|
||||
|
||||
const d = (data.reviewDecision ?? "").toUpperCase();
|
||||
if (d === "APPROVED") return "approved";
|
||||
if (d === "CHANGES_REQUESTED") return "changes_requested";
|
||||
if (d === "REVIEW_REQUIRED") return "pending";
|
||||
return "none";
|
||||
const d = (data.reviewDecision ?? "").toUpperCase();
|
||||
if (d === "APPROVED") return "approved";
|
||||
if (d === "CHANGES_REQUESTED") return "changes_requested";
|
||||
if (d === "REVIEW_REQUIRED") return "pending";
|
||||
return "none";
|
||||
});
|
||||
},
|
||||
|
||||
async getPendingComments(pr: PRInfo): Promise<ReviewComment[]> {
|
||||
try {
|
||||
// Use GraphQL with variables to get review threads with actual isResolved status
|
||||
const raw = await gh([
|
||||
// 5s TTL — review threads are decision-influencing (gates whether AO
|
||||
// reacts to new comments). Within one poll cycle is acceptable. Note:
|
||||
// ETag does not work on /graphql per Experiment 2 (G2), so TTL is the
|
||||
// only practical lever here.
|
||||
return withPRCache(pr.owner, pr.repo, String(pr.number), "getPendingComments", async () => {
|
||||
try {
|
||||
// Use GraphQL with variables to get review threads with actual isResolved status
|
||||
const raw = await gh([
|
||||
"api",
|
||||
"graphql",
|
||||
"-f",
|
||||
|
|
@ -794,6 +926,7 @@ function createGitHubSCM(): SCM {
|
|||
pullRequest(number: $number) {
|
||||
reviewThreads(first: 100) {
|
||||
nodes {
|
||||
id
|
||||
isResolved
|
||||
comments(first: 1) {
|
||||
nodes {
|
||||
|
|
@ -819,6 +952,7 @@ function createGitHubSCM(): SCM {
|
|||
pullRequest: {
|
||||
reviewThreads: {
|
||||
nodes: Array<{
|
||||
id: string;
|
||||
isResolved: boolean;
|
||||
comments: {
|
||||
nodes: Array<{
|
||||
|
|
@ -852,6 +986,7 @@ function createGitHubSCM(): SCM {
|
|||
const c = t.comments.nodes[0];
|
||||
return {
|
||||
id: c.id,
|
||||
threadId: t.id,
|
||||
author: c.author?.login ?? "unknown",
|
||||
body: c.body,
|
||||
path: c.path || undefined,
|
||||
|
|
@ -861,171 +996,234 @@ function createGitHubSCM(): SCM {
|
|||
url: c.url,
|
||||
};
|
||||
});
|
||||
} catch (err) {
|
||||
throw new Error("Failed to fetch pending comments", { cause: err });
|
||||
}
|
||||
} catch (err) {
|
||||
throw new Error("Failed to fetch pending comments", { cause: err });
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
async getAutomatedComments(pr: PRInfo): Promise<AutomatedComment[]> {
|
||||
async getReviewThreads(pr: PRInfo): Promise<ReviewThreadsResult> {
|
||||
const cacheKey = `${pr.owner}/${pr.repo}#${pr.number}`;
|
||||
|
||||
// Guard 3: check if review comments changed via REST ETag
|
||||
const reviewsChanged = await checkReviewCommentsETag(pr.owner, pr.repo, pr.number);
|
||||
if (!reviewsChanged) {
|
||||
const cached = reviewThreadsCache.get(cacheKey);
|
||||
if (cached) return cached;
|
||||
}
|
||||
|
||||
try {
|
||||
const perPage = 100;
|
||||
const comments: Array<{
|
||||
id: number;
|
||||
user: { login: string };
|
||||
body: string;
|
||||
path: string;
|
||||
line: number | null;
|
||||
original_line: number | null;
|
||||
created_at: string;
|
||||
html_url: string;
|
||||
}> = [];
|
||||
|
||||
for (let page = 1; ; page++) {
|
||||
const raw = await gh([
|
||||
"api",
|
||||
"--method",
|
||||
"GET",
|
||||
`repos/${repoFlag(pr)}/pulls/${pr.number}/comments?per_page=${perPage}&page=${page}`,
|
||||
]);
|
||||
const pageComments: Array<{
|
||||
id: number;
|
||||
user: { login: string };
|
||||
body: string;
|
||||
path: string;
|
||||
line: number | null;
|
||||
original_line: number | null;
|
||||
created_at: string;
|
||||
html_url: string;
|
||||
}> = JSON.parse(raw);
|
||||
|
||||
if (pageComments.length === 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
comments.push(...pageComments);
|
||||
if (pageComments.length < perPage) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return comments
|
||||
.filter((c) => BOT_AUTHORS.has(c.user?.login ?? ""))
|
||||
.map((c) => {
|
||||
// Determine severity from body content
|
||||
let severity: AutomatedComment["severity"] = "info";
|
||||
const bodyLower = c.body.toLowerCase();
|
||||
if (
|
||||
bodyLower.includes("error") ||
|
||||
bodyLower.includes("bug") ||
|
||||
bodyLower.includes("critical") ||
|
||||
bodyLower.includes("potential issue")
|
||||
) {
|
||||
severity = "error";
|
||||
} else if (
|
||||
bodyLower.includes("warning") ||
|
||||
bodyLower.includes("suggest") ||
|
||||
bodyLower.includes("consider")
|
||||
) {
|
||||
severity = "warning";
|
||||
const rawWithHeaders = await gh([
|
||||
"api",
|
||||
"graphql",
|
||||
"-i",
|
||||
"-f",
|
||||
`owner=${pr.owner}`,
|
||||
"-f",
|
||||
`name=${pr.repo}`,
|
||||
"-F",
|
||||
`number=${pr.number}`,
|
||||
"-f",
|
||||
`query=query($owner: String!, $name: String!, $number: Int!) {
|
||||
repository(owner: $owner, name: $name) {
|
||||
pullRequest(number: $number) {
|
||||
reviewThreads(last: 100) {
|
||||
nodes {
|
||||
id
|
||||
isResolved
|
||||
comments(first: 1) {
|
||||
nodes {
|
||||
id
|
||||
author { login }
|
||||
body
|
||||
path
|
||||
line
|
||||
url
|
||||
createdAt
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
reviews(last: 5) {
|
||||
nodes {
|
||||
author { login }
|
||||
state
|
||||
body
|
||||
submittedAt
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
rateLimit { cost remaining resetAt }
|
||||
}`,
|
||||
]);
|
||||
// Strip HTTP headers from -i response to get JSON body
|
||||
const raw = rawWithHeaders.replace(/^[\s\S]*?\r?\n\r?\n/, "");
|
||||
|
||||
const data: {
|
||||
data: {
|
||||
repository: {
|
||||
pullRequest: {
|
||||
reviewThreads: {
|
||||
nodes: Array<{
|
||||
id: string;
|
||||
isResolved: boolean;
|
||||
comments: {
|
||||
nodes: Array<{
|
||||
id: string;
|
||||
author: { login: string } | null;
|
||||
body: string;
|
||||
path: string | null;
|
||||
line: number | null;
|
||||
url: string;
|
||||
createdAt: string;
|
||||
}>;
|
||||
};
|
||||
}>;
|
||||
};
|
||||
reviews: {
|
||||
nodes: Array<{
|
||||
author: { login: string } | null;
|
||||
state: string;
|
||||
body: string;
|
||||
submittedAt: string;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
} = JSON.parse(raw);
|
||||
|
||||
const threadNodes = data.data.repository.pullRequest.reviewThreads.nodes;
|
||||
const reviewNodes = data.data.repository.pullRequest.reviews.nodes;
|
||||
|
||||
const threads: ReviewComment[] = threadNodes
|
||||
.filter((t) => {
|
||||
if (t.isResolved) return false;
|
||||
const c = t.comments.nodes[0];
|
||||
return !!c;
|
||||
})
|
||||
.map((t) => {
|
||||
const c = t.comments.nodes[0];
|
||||
const author = c.author?.login ?? "unknown";
|
||||
return {
|
||||
id: String(c.id),
|
||||
botName: c.user?.login ?? "unknown",
|
||||
id: c.id,
|
||||
threadId: t.id,
|
||||
author,
|
||||
body: c.body,
|
||||
path: c.path || undefined,
|
||||
line: c.line ?? c.original_line ?? undefined,
|
||||
severity,
|
||||
createdAt: parseDate(c.created_at),
|
||||
url: c.html_url,
|
||||
line: c.line ?? undefined,
|
||||
isResolved: t.isResolved,
|
||||
createdAt: parseDate(c.createdAt),
|
||||
url: c.url,
|
||||
isBot: BOT_AUTHORS.has(author),
|
||||
};
|
||||
});
|
||||
|
||||
const reviews: ReviewSummary[] = reviewNodes
|
||||
.filter((r) => r.body && r.body.trim().length > 0)
|
||||
.map((r) => ({
|
||||
author: r.author?.login ?? "unknown",
|
||||
state: r.state,
|
||||
body: r.body,
|
||||
submittedAt: parseDate(r.submittedAt),
|
||||
}));
|
||||
|
||||
const result: ReviewThreadsResult = { threads, reviews };
|
||||
reviewThreadsCache.set(cacheKey, result);
|
||||
return result;
|
||||
} catch (err) {
|
||||
throw new Error("Failed to fetch automated comments", { cause: err });
|
||||
throw new Error("Failed to fetch review threads", { cause: err });
|
||||
}
|
||||
},
|
||||
|
||||
async getMergeability(pr: PRInfo): Promise<MergeReadiness> {
|
||||
const blockers: string[] = [];
|
||||
// 5s TTL — composite merge readiness. Internal getPRState/getCISummary
|
||||
// calls are also cached (5s each) so even on cache miss this is cheap.
|
||||
// Cached entry covers the full computed result so duplicate poll-cycle
|
||||
// calls don't re-derive blockers.
|
||||
return withPRCache(pr.owner, pr.repo, String(pr.number), "getMergeability", async () => {
|
||||
const blockers: string[] = [];
|
||||
|
||||
// First, check if the PR is merged
|
||||
// GitHub returns mergeable=null for merged PRs, which is not useful
|
||||
// Note: We only skip checks for merged PRs. Closed PRs still need accurate status.
|
||||
const state = await this.getPRState(pr);
|
||||
if (state === "merged") {
|
||||
// For merged PRs, return a clean result without querying mergeable status
|
||||
return {
|
||||
mergeable: true,
|
||||
ciPassing: true,
|
||||
approved: true,
|
||||
noConflicts: true,
|
||||
blockers: [],
|
||||
};
|
||||
}
|
||||
|
||||
// Fetch PR details with merge state
|
||||
const raw = await gh([
|
||||
"pr",
|
||||
"view",
|
||||
String(pr.number),
|
||||
"--repo",
|
||||
repoFlag(pr),
|
||||
"--json",
|
||||
"mergeable,reviewDecision,mergeStateStatus,isDraft",
|
||||
]);
|
||||
|
||||
const data: {
|
||||
mergeable: string;
|
||||
reviewDecision: string;
|
||||
mergeStateStatus: string;
|
||||
isDraft: boolean;
|
||||
} = JSON.parse(raw);
|
||||
|
||||
// CI
|
||||
const ciStatus = await this.getCISummary(pr);
|
||||
const ciPassing = ciStatus === CI_STATUS.PASSING || ciStatus === CI_STATUS.NONE;
|
||||
if (!ciPassing) {
|
||||
blockers.push(`CI is ${ciStatus}`);
|
||||
}
|
||||
|
||||
// Reviews
|
||||
const reviewDecision = (data.reviewDecision ?? "").toUpperCase();
|
||||
const approved = reviewDecision === "APPROVED";
|
||||
if (reviewDecision === "CHANGES_REQUESTED") {
|
||||
blockers.push("Changes requested in review");
|
||||
} else if (reviewDecision === "REVIEW_REQUIRED") {
|
||||
blockers.push("Review required");
|
||||
}
|
||||
|
||||
// Conflicts / merge state
|
||||
const mergeable = (data.mergeable ?? "").toUpperCase();
|
||||
const mergeState = (data.mergeStateStatus ?? "").toUpperCase();
|
||||
const noConflicts = mergeable === "MERGEABLE";
|
||||
if (mergeable === "CONFLICTING") {
|
||||
blockers.push("Merge conflicts");
|
||||
} else if (mergeable === "UNKNOWN" || mergeable === "") {
|
||||
blockers.push("Merge status unknown (GitHub is computing)");
|
||||
}
|
||||
if (mergeState === "BEHIND") {
|
||||
blockers.push("Branch is behind base branch");
|
||||
} else if (mergeState === "BLOCKED") {
|
||||
blockers.push("Merge is blocked by branch protection");
|
||||
} else if (mergeState === "UNSTABLE") {
|
||||
blockers.push("Required checks are failing");
|
||||
}
|
||||
|
||||
// Draft
|
||||
if (data.isDraft) {
|
||||
blockers.push("PR is still a draft");
|
||||
}
|
||||
|
||||
// First, check if the PR is merged
|
||||
// GitHub returns mergeable=null for merged PRs, which is not useful
|
||||
// Note: We only skip checks for merged PRs. Closed PRs still need accurate status.
|
||||
const state = await this.getPRState(pr);
|
||||
if (state === "merged") {
|
||||
// For merged PRs, return a clean result without querying mergeable status
|
||||
return {
|
||||
mergeable: true,
|
||||
ciPassing: true,
|
||||
approved: true,
|
||||
noConflicts: true,
|
||||
blockers: [],
|
||||
mergeable: blockers.length === 0,
|
||||
ciPassing,
|
||||
approved,
|
||||
noConflicts,
|
||||
blockers,
|
||||
};
|
||||
}
|
||||
|
||||
// Fetch PR details with merge state
|
||||
const raw = await gh([
|
||||
"pr",
|
||||
"view",
|
||||
String(pr.number),
|
||||
"--repo",
|
||||
repoFlag(pr),
|
||||
"--json",
|
||||
"mergeable,reviewDecision,mergeStateStatus,isDraft",
|
||||
]);
|
||||
|
||||
const data: {
|
||||
mergeable: string;
|
||||
reviewDecision: string;
|
||||
mergeStateStatus: string;
|
||||
isDraft: boolean;
|
||||
} = JSON.parse(raw);
|
||||
|
||||
// CI
|
||||
const ciStatus = await this.getCISummary(pr);
|
||||
const ciPassing = ciStatus === CI_STATUS.PASSING || ciStatus === CI_STATUS.NONE;
|
||||
if (!ciPassing) {
|
||||
blockers.push(`CI is ${ciStatus}`);
|
||||
}
|
||||
|
||||
// Reviews
|
||||
const reviewDecision = (data.reviewDecision ?? "").toUpperCase();
|
||||
const approved = reviewDecision === "APPROVED";
|
||||
if (reviewDecision === "CHANGES_REQUESTED") {
|
||||
blockers.push("Changes requested in review");
|
||||
} else if (reviewDecision === "REVIEW_REQUIRED") {
|
||||
blockers.push("Review required");
|
||||
}
|
||||
|
||||
// Conflicts / merge state
|
||||
const mergeable = (data.mergeable ?? "").toUpperCase();
|
||||
const mergeState = (data.mergeStateStatus ?? "").toUpperCase();
|
||||
const noConflicts = mergeable === "MERGEABLE";
|
||||
if (mergeable === "CONFLICTING") {
|
||||
blockers.push("Merge conflicts");
|
||||
} else if (mergeable === "UNKNOWN" || mergeable === "") {
|
||||
blockers.push("Merge status unknown (GitHub is computing)");
|
||||
}
|
||||
if (mergeState === "BEHIND") {
|
||||
blockers.push("Branch is behind base branch");
|
||||
} else if (mergeState === "BLOCKED") {
|
||||
blockers.push("Merge is blocked by branch protection");
|
||||
} else if (mergeState === "UNSTABLE") {
|
||||
blockers.push("Required checks are failing");
|
||||
}
|
||||
|
||||
// Draft
|
||||
if (data.isDraft) {
|
||||
blockers.push("PR is still a draft");
|
||||
}
|
||||
|
||||
return {
|
||||
mergeable: blockers.length === 0,
|
||||
ciPassing,
|
||||
approved,
|
||||
noConflicts,
|
||||
blockers,
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
|
|
@ -1041,8 +1239,10 @@ function createGitHubSCM(): SCM {
|
|||
async enrichSessionsPRBatch(
|
||||
prs: PRInfo[],
|
||||
observer?: BatchObserver,
|
||||
repos?: string[],
|
||||
): Promise<Map<string, PREnrichmentData>> {
|
||||
return enrichSessionsPRBatchImpl(prs, observer);
|
||||
const batchResult = await enrichSessionsPRBatchImpl(prs, observer, repos);
|
||||
return batchResult.enrichment;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -221,7 +221,6 @@ describe("GraphQL Query Generation", () => {
|
|||
"mergeable",
|
||||
"mergeStateStatus",
|
||||
"reviewDecision",
|
||||
"reviews",
|
||||
"commits",
|
||||
"statusCheckRollup",
|
||||
];
|
||||
|
|
|
|||
|
|
@ -161,7 +161,6 @@ describe("GraphQL Batch Query Generation", () => {
|
|||
expect(query).toContain("mergeable");
|
||||
expect(query).toContain("mergeStateStatus");
|
||||
expect(query).toContain("reviewDecision");
|
||||
expect(query).toContain("reviews");
|
||||
expect(query).toContain("commits");
|
||||
expect(query).toContain("statusCheckRollup");
|
||||
});
|
||||
|
|
@ -823,7 +822,7 @@ describe("shouldRefreshPREnrichment - ETag Guard Strategy", () => {
|
|||
const result = await shouldRefreshPREnrichment([]);
|
||||
|
||||
expect(result.shouldRefresh).toBe(false);
|
||||
expect(result.details).toContain("No PRs to check");
|
||||
expect(result.details).toContain("No repos to check");
|
||||
// Should not make any API calls
|
||||
expect(mockExecFileImpl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -483,7 +483,7 @@ describe("scm-github plugin", () => {
|
|||
ghMock.mockResolvedValueOnce({ stdout: "" });
|
||||
await scm.assignPRToCurrentUser?.(pr);
|
||||
expect(ghMock).toHaveBeenCalledWith(
|
||||
"gh",
|
||||
expect.stringMatching(/(?:^|\/)?gh$/),
|
||||
["pr", "edit", "42", "--repo", "acme/repo", "--add-assignee", "@me"],
|
||||
expect.any(Object),
|
||||
);
|
||||
|
|
@ -530,7 +530,7 @@ describe("scm-github plugin", () => {
|
|||
ghMock.mockResolvedValueOnce({ stdout: "" });
|
||||
await scm.mergePR(pr);
|
||||
expect(ghMock).toHaveBeenCalledWith(
|
||||
"gh",
|
||||
expect.stringMatching(/(?:^|\/)?gh$/),
|
||||
["pr", "merge", "42", "--repo", "acme/repo", "--squash", "--delete-branch"],
|
||||
expect.any(Object),
|
||||
);
|
||||
|
|
@ -540,7 +540,7 @@ describe("scm-github plugin", () => {
|
|||
ghMock.mockResolvedValueOnce({ stdout: "" });
|
||||
await scm.mergePR(pr, "merge");
|
||||
expect(ghMock).toHaveBeenCalledWith(
|
||||
"gh",
|
||||
expect.stringMatching(/(?:^|\/)?gh$/),
|
||||
expect.arrayContaining(["--merge"]),
|
||||
expect.any(Object),
|
||||
);
|
||||
|
|
@ -550,7 +550,7 @@ describe("scm-github plugin", () => {
|
|||
ghMock.mockResolvedValueOnce({ stdout: "" });
|
||||
await scm.mergePR(pr, "rebase");
|
||||
expect(ghMock).toHaveBeenCalledWith(
|
||||
"gh",
|
||||
expect.stringMatching(/(?:^|\/)?gh$/),
|
||||
expect.arrayContaining(["--rebase"]),
|
||||
expect.any(Object),
|
||||
);
|
||||
|
|
@ -564,7 +564,7 @@ describe("scm-github plugin", () => {
|
|||
ghMock.mockResolvedValueOnce({ stdout: "" });
|
||||
await scm.closePR(pr);
|
||||
expect(ghMock).toHaveBeenCalledWith(
|
||||
"gh",
|
||||
expect.stringMatching(/(?:^|\/)?gh$/),
|
||||
["pr", "close", "42", "--repo", "acme/repo"],
|
||||
expect.any(Object),
|
||||
);
|
||||
|
|
@ -916,169 +916,6 @@ describe("scm-github plugin", () => {
|
|||
});
|
||||
});
|
||||
|
||||
// ---- getAutomatedComments ----------------------------------------------
|
||||
|
||||
describe("getAutomatedComments", () => {
|
||||
it("uses explicit GET query for pulls comments and paginates", async () => {
|
||||
const page1 = Array.from({ length: 100 }, (_, i) => ({
|
||||
id: i + 1,
|
||||
user: { login: "cursor[bot]" },
|
||||
body: "Potential issue detected",
|
||||
path: "a.ts",
|
||||
line: i + 1,
|
||||
original_line: null,
|
||||
created_at: "2025-01-01T00:00:00Z",
|
||||
html_url: `u${i + 1}`,
|
||||
}));
|
||||
|
||||
const page2 = [
|
||||
{
|
||||
id: 101,
|
||||
user: { login: "cursor[bot]" },
|
||||
body: "Warning: check this",
|
||||
path: "b.ts",
|
||||
line: 7,
|
||||
original_line: null,
|
||||
created_at: "2025-01-01T00:00:00Z",
|
||||
html_url: "u101",
|
||||
},
|
||||
];
|
||||
|
||||
mockGh(page1);
|
||||
mockGh(page2);
|
||||
|
||||
const comments = await scm.getAutomatedComments(pr);
|
||||
|
||||
expect(comments).toHaveLength(101);
|
||||
expect(ghMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"gh",
|
||||
["api", "--method", "GET", "repos/acme/repo/pulls/42/comments?per_page=100&page=1"],
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(ghMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"gh",
|
||||
["api", "--method", "GET", "repos/acme/repo/pulls/42/comments?per_page=100&page=2"],
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns bot comments filtered from all PR comments", async () => {
|
||||
mockGh([
|
||||
{
|
||||
id: 1,
|
||||
user: { login: "cursor[bot]" },
|
||||
body: "Found a potential issue",
|
||||
path: "a.ts",
|
||||
line: 5,
|
||||
original_line: null,
|
||||
created_at: "2025-01-01T00:00:00Z",
|
||||
html_url: "u1",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
user: { login: "alice" },
|
||||
body: "Human comment",
|
||||
path: "a.ts",
|
||||
line: 1,
|
||||
original_line: null,
|
||||
created_at: "2025-01-01T00:00:00Z",
|
||||
html_url: "u2",
|
||||
},
|
||||
]);
|
||||
|
||||
const comments = await scm.getAutomatedComments(pr);
|
||||
expect(comments).toHaveLength(1);
|
||||
expect(comments[0].botName).toBe("cursor[bot]");
|
||||
expect(comments[0].severity).toBe("error"); // "potential issue" → error
|
||||
});
|
||||
|
||||
it("classifies severity from body content", async () => {
|
||||
mockGh([
|
||||
{
|
||||
id: 1,
|
||||
user: { login: "github-actions[bot]" },
|
||||
body: "Error: build failed",
|
||||
path: "a.ts",
|
||||
line: 1,
|
||||
original_line: null,
|
||||
created_at: "2025-01-01T00:00:00Z",
|
||||
html_url: "u",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
user: { login: "github-actions[bot]" },
|
||||
body: "Warning: deprecated API",
|
||||
path: "a.ts",
|
||||
line: 2,
|
||||
original_line: null,
|
||||
created_at: "2025-01-01T00:00:00Z",
|
||||
html_url: "u",
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
user: { login: "github-actions[bot]" },
|
||||
body: "Deployed to staging",
|
||||
path: "a.ts",
|
||||
line: 3,
|
||||
original_line: null,
|
||||
created_at: "2025-01-01T00:00:00Z",
|
||||
html_url: "u",
|
||||
},
|
||||
]);
|
||||
|
||||
const comments = await scm.getAutomatedComments(pr);
|
||||
expect(comments).toHaveLength(3);
|
||||
expect(comments[0].severity).toBe("error");
|
||||
expect(comments[1].severity).toBe("warning");
|
||||
expect(comments[2].severity).toBe("info");
|
||||
});
|
||||
|
||||
it("returns empty when no bot comments", async () => {
|
||||
mockGh([
|
||||
{
|
||||
id: 1,
|
||||
user: { login: "alice" },
|
||||
body: "Human comment",
|
||||
path: "a.ts",
|
||||
line: 1,
|
||||
original_line: null,
|
||||
created_at: "2025-01-01T00:00:00Z",
|
||||
html_url: "u",
|
||||
},
|
||||
]);
|
||||
|
||||
const comments = await scm.getAutomatedComments(pr);
|
||||
expect(comments).toEqual([]);
|
||||
});
|
||||
|
||||
it("throws on error", async () => {
|
||||
mockGhError("network failure");
|
||||
await expect(scm.getAutomatedComments(pr)).rejects.toThrow(
|
||||
"Failed to fetch automated comments",
|
||||
);
|
||||
});
|
||||
|
||||
it("uses original_line as fallback", async () => {
|
||||
mockGh([
|
||||
{
|
||||
id: 1,
|
||||
user: { login: "dependabot[bot]" },
|
||||
body: "Suggest update",
|
||||
path: "a.ts",
|
||||
line: null,
|
||||
original_line: 15,
|
||||
created_at: "2025-01-01T00:00:00Z",
|
||||
html_url: "u",
|
||||
},
|
||||
]);
|
||||
|
||||
const comments = await scm.getAutomatedComments(pr);
|
||||
expect(comments[0].line).toBe(15);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- getMergeability ---------------------------------------------------
|
||||
|
||||
describe("getMergeability", () => {
|
||||
|
|
@ -1264,4 +1101,334 @@ describe("scm-github plugin", () => {
|
|||
expect(result.mergeable).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- PR cache (per-method TTLs, write invalidation) -------------------
|
||||
|
||||
describe("PR cache", () => {
|
||||
it("getPRState second call within 5s hits cache", async () => {
|
||||
mockGh({ state: "OPEN" });
|
||||
const first = await scm.getPRState(pr);
|
||||
const second = await scm.getPRState(pr);
|
||||
expect(first).toBe("open");
|
||||
expect(second).toBe("open");
|
||||
expect(ghMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("getPRState re-fetches after TTL expires (5s)", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
mockGh({ state: "OPEN" });
|
||||
await scm.getPRState(pr);
|
||||
expect(ghMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
vi.advanceTimersByTime(5_001);
|
||||
|
||||
mockGh({ state: "MERGED" });
|
||||
const fresh = await scm.getPRState(pr);
|
||||
expect(fresh).toBe("merged");
|
||||
expect(ghMock).toHaveBeenCalledTimes(2);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("getPRState and getPRSummary use separate cache slots", async () => {
|
||||
mockGh({ state: "OPEN" });
|
||||
mockGh({ state: "OPEN", title: "T", additions: 10, deletions: 5 });
|
||||
|
||||
await scm.getPRState(pr);
|
||||
await scm.getPRSummary(pr);
|
||||
expect(ghMock).toHaveBeenCalledTimes(2);
|
||||
|
||||
// Both now cached — second round hits cache, no new gh calls
|
||||
await scm.getPRState(pr);
|
||||
await scm.getPRSummary(pr);
|
||||
expect(ghMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("getReviews caches independently of getReviewDecision", async () => {
|
||||
mockGh({ reviews: [] });
|
||||
mockGh({ reviewDecision: "APPROVED" });
|
||||
await scm.getReviews(pr);
|
||||
await scm.getReviewDecision(pr);
|
||||
expect(ghMock).toHaveBeenCalledTimes(2);
|
||||
|
||||
// Cache hits on second round
|
||||
await scm.getReviews(pr);
|
||||
await scm.getReviewDecision(pr);
|
||||
expect(ghMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("different PRs cache independently", async () => {
|
||||
const otherPR = { ...pr, number: 99 };
|
||||
mockGh({ state: "OPEN" });
|
||||
mockGh({ state: "MERGED" });
|
||||
const a = await scm.getPRState(pr);
|
||||
const b = await scm.getPRState(otherPR);
|
||||
expect(a).toBe("open");
|
||||
expect(b).toBe("merged");
|
||||
expect(ghMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("mergePR invalidates the PR's cache", async () => {
|
||||
mockGh({ state: "OPEN" });
|
||||
await scm.getPRState(pr);
|
||||
expect(ghMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
ghMock.mockResolvedValueOnce({ stdout: "" }); // gh pr merge
|
||||
await scm.mergePR(pr);
|
||||
|
||||
mockGh({ state: "MERGED" });
|
||||
const fresh = await scm.getPRState(pr);
|
||||
expect(fresh).toBe("merged");
|
||||
expect(ghMock).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("closePR invalidates the PR's cache", async () => {
|
||||
mockGh({ state: "OPEN" });
|
||||
await scm.getPRState(pr);
|
||||
|
||||
ghMock.mockResolvedValueOnce({ stdout: "" }); // gh pr close
|
||||
await scm.closePR(pr);
|
||||
|
||||
mockGh({ state: "CLOSED" });
|
||||
const fresh = await scm.getPRState(pr);
|
||||
expect(fresh).toBe("closed");
|
||||
expect(ghMock).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("assignPRToCurrentUser invalidates the PR's cache", async () => {
|
||||
mockGh({ reviewDecision: "REVIEW_REQUIRED" });
|
||||
await scm.getReviewDecision(pr);
|
||||
|
||||
ghMock.mockResolvedValueOnce({ stdout: "" }); // gh pr edit
|
||||
await scm.assignPRToCurrentUser(pr);
|
||||
|
||||
mockGh({ reviewDecision: "REVIEW_REQUIRED" });
|
||||
await scm.getReviewDecision(pr);
|
||||
expect(ghMock).toHaveBeenCalledTimes(3); // view + edit + view again
|
||||
});
|
||||
|
||||
it("invalidating one PR does not affect a different PR's cache", async () => {
|
||||
const otherPR = { ...pr, number: 99 };
|
||||
mockGh({ state: "OPEN" });
|
||||
mockGh({ state: "OPEN" });
|
||||
await scm.getPRState(pr);
|
||||
await scm.getPRState(otherPR);
|
||||
expect(ghMock).toHaveBeenCalledTimes(2);
|
||||
|
||||
ghMock.mockResolvedValueOnce({ stdout: "" });
|
||||
await scm.closePR(pr); // wipes pr #42 only
|
||||
|
||||
mockGh({ state: "CLOSED" });
|
||||
await scm.getPRState(pr); // re-fetches
|
||||
await scm.getPRState(otherPR); // still cached
|
||||
expect(ghMock).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
|
||||
it("resolvePR caches by reference for 60s", async () => {
|
||||
mockGh({
|
||||
number: 7,
|
||||
url: "https://github.com/acme/repo/pull/7",
|
||||
title: "Fix",
|
||||
headRefName: "feat/x",
|
||||
baseRefName: "main",
|
||||
isDraft: false,
|
||||
});
|
||||
const first = await scm.resolvePR("feat/x", project);
|
||||
const second = await scm.resolvePR("feat/x", project);
|
||||
expect(first).toEqual(second);
|
||||
expect(ghMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("failures are not cached", async () => {
|
||||
ghMock.mockRejectedValueOnce(new Error("boom"));
|
||||
await expect(scm.getPRState(pr)).rejects.toThrow();
|
||||
|
||||
mockGh({ state: "OPEN" });
|
||||
const fresh = await scm.getPRState(pr);
|
||||
expect(fresh).toBe("open");
|
||||
expect(ghMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("each create() returns an isolated cache", async () => {
|
||||
const scmA = create();
|
||||
const scmB = create();
|
||||
mockGh({ state: "OPEN" });
|
||||
await scmA.getPRState(pr);
|
||||
mockGh({ state: "MERGED" });
|
||||
const fromB = await scmB.getPRState(pr);
|
||||
expect(fromB).toBe("merged");
|
||||
expect(ghMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
// ---- detectPR (positive-only cache) ----
|
||||
|
||||
it("detectPR caches positive results (PR found)", async () => {
|
||||
mockGh([
|
||||
{
|
||||
number: 42,
|
||||
url: "https://github.com/acme/repo/pull/42",
|
||||
title: "feat: add feature",
|
||||
headRefName: "feat/my-feature",
|
||||
baseRefName: "main",
|
||||
isDraft: false,
|
||||
},
|
||||
]);
|
||||
const a = await scm.detectPR(makeSession(), project);
|
||||
const b = await scm.detectPR(makeSession(), project);
|
||||
expect(a?.number).toBe(42);
|
||||
expect(b?.number).toBe(42);
|
||||
expect(ghMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("detectPR does NOT cache negative results (no PR yet)", async () => {
|
||||
mockGh([]);
|
||||
mockGh([]);
|
||||
const a = await scm.detectPR(makeSession(), project);
|
||||
const b = await scm.detectPR(makeSession(), project);
|
||||
expect(a).toBeNull();
|
||||
expect(b).toBeNull();
|
||||
expect(ghMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("detectPR transitions null → PR on next call without cache poisoning", async () => {
|
||||
mockGh([]); // first call: no PR yet
|
||||
const before = await scm.detectPR(makeSession(), project);
|
||||
expect(before).toBeNull();
|
||||
|
||||
mockGh([
|
||||
{
|
||||
number: 7,
|
||||
url: "https://github.com/acme/repo/pull/7",
|
||||
title: "Just created",
|
||||
headRefName: "feat/my-feature",
|
||||
baseRefName: "main",
|
||||
isDraft: false,
|
||||
},
|
||||
]);
|
||||
const after = await scm.detectPR(makeSession(), project);
|
||||
expect(after?.number).toBe(7);
|
||||
expect(ghMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("detectPR caches different branches independently", async () => {
|
||||
mockGh([
|
||||
{
|
||||
number: 1,
|
||||
url: "u1",
|
||||
title: "t1",
|
||||
headRefName: "feat/a",
|
||||
baseRefName: "main",
|
||||
isDraft: false,
|
||||
},
|
||||
]);
|
||||
mockGh([
|
||||
{
|
||||
number: 2,
|
||||
url: "u2",
|
||||
title: "t2",
|
||||
headRefName: "feat/b",
|
||||
baseRefName: "main",
|
||||
isDraft: false,
|
||||
},
|
||||
]);
|
||||
const a = await scm.detectPR(makeSession({ branch: "feat/a" }), project);
|
||||
const b = await scm.detectPR(makeSession({ branch: "feat/b" }), project);
|
||||
expect(a?.number).toBe(1);
|
||||
expect(b?.number).toBe(2);
|
||||
expect(ghMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("mergePR invalidates the branch's detectPR entry", async () => {
|
||||
mockGh([
|
||||
{
|
||||
number: 42,
|
||||
url: "https://github.com/acme/repo/pull/42",
|
||||
title: "feat",
|
||||
headRefName: pr.branch,
|
||||
baseRefName: "main",
|
||||
isDraft: false,
|
||||
},
|
||||
]);
|
||||
await scm.detectPR(makeSession({ branch: pr.branch }), project);
|
||||
expect(ghMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
ghMock.mockResolvedValueOnce({ stdout: "" }); // gh pr merge
|
||||
await scm.mergePR(pr);
|
||||
|
||||
// detectPR re-fetches because the merge invalidated the branch entry
|
||||
mockGh([]);
|
||||
const after = await scm.detectPR(makeSession({ branch: pr.branch }), project);
|
||||
expect(after).toBeNull();
|
||||
expect(ghMock).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
// ---- getCIChecks / getMergeability / getPendingComments ----
|
||||
|
||||
it("getCIChecks caches result (5s TTL)", async () => {
|
||||
mockGh([{ name: "build", state: "SUCCESS", link: "u", startedAt: "", completedAt: "" }]);
|
||||
await scm.getCIChecks(pr);
|
||||
await scm.getCIChecks(pr);
|
||||
expect(ghMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("getMergeability caches the composite result", async () => {
|
||||
// First call: getPRState (1) + pr view mergeable (2) + getCISummary→getCIChecks (3)
|
||||
mockGh({ state: "OPEN" }); // getPRState
|
||||
mockGh({
|
||||
mergeable: "MERGEABLE",
|
||||
reviewDecision: "APPROVED",
|
||||
mergeStateStatus: "CLEAN",
|
||||
isDraft: false,
|
||||
});
|
||||
mockGh([{ name: "build", state: "SUCCESS" }]); // getCIChecks
|
||||
const first = await scm.getMergeability(pr);
|
||||
expect(first.mergeable).toBe(true);
|
||||
|
||||
// Second call within TTL: top-level getMergeability cache hits.
|
||||
// No new gh calls because the composite result short-circuits.
|
||||
const second = await scm.getMergeability(pr);
|
||||
expect(second).toEqual(first);
|
||||
expect(ghMock).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("mergePR invalidates getMergeability cache", async () => {
|
||||
mockGh({ state: "OPEN" });
|
||||
mockGh({
|
||||
mergeable: "MERGEABLE",
|
||||
reviewDecision: "APPROVED",
|
||||
mergeStateStatus: "CLEAN",
|
||||
isDraft: false,
|
||||
});
|
||||
mockGh([{ name: "build", state: "SUCCESS" }]);
|
||||
await scm.getMergeability(pr);
|
||||
expect(ghMock).toHaveBeenCalledTimes(3);
|
||||
|
||||
ghMock.mockResolvedValueOnce({ stdout: "" }); // gh pr merge
|
||||
await scm.mergePR(pr);
|
||||
|
||||
// After merge: state cached as merged; getMergeability re-derives
|
||||
// the merged shortcut result without making more gh calls.
|
||||
mockGh({ state: "MERGED" }); // getPRState refetch
|
||||
const after = await scm.getMergeability(pr);
|
||||
expect(after.mergeable).toBe(true);
|
||||
expect(after.blockers).toEqual([]);
|
||||
});
|
||||
|
||||
it("getPendingComments caches result", async () => {
|
||||
mockGhRaw(
|
||||
JSON.stringify({
|
||||
data: { repository: { pullRequest: { reviewThreads: { nodes: [] } } } },
|
||||
}),
|
||||
);
|
||||
await scm.getPendingComments(pr);
|
||||
await scm.getPendingComments(pr);
|
||||
expect(ghMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function mockGhRaw(stdout: string) {
|
||||
ghMock.mockResolvedValueOnce({ stdout });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,7 +22,8 @@ import {
|
|||
type Review,
|
||||
type ReviewDecision,
|
||||
type ReviewComment,
|
||||
type AutomatedComment,
|
||||
type ReviewSummary,
|
||||
type ReviewThreadsResult,
|
||||
type MergeReadiness,
|
||||
} from "@aoagents/ao-core";
|
||||
import {
|
||||
|
|
@ -105,21 +106,6 @@ function mapPRState(state: string): PRState {
|
|||
return "open";
|
||||
}
|
||||
|
||||
function inferSeverity(body: string): AutomatedComment["severity"] {
|
||||
const lower = body.toLowerCase();
|
||||
if (
|
||||
lower.includes("error") ||
|
||||
lower.includes("bug") ||
|
||||
lower.includes("critical") ||
|
||||
lower.includes("potential issue")
|
||||
) {
|
||||
return "error";
|
||||
}
|
||||
if (lower.includes("warning") || lower.includes("suggest") || lower.includes("consider")) {
|
||||
return "warning";
|
||||
}
|
||||
return "info";
|
||||
}
|
||||
|
||||
function getGitLabWebhookConfig(project: ProjectConfig) {
|
||||
const webhook = project.scm?.webhook;
|
||||
|
|
@ -701,32 +687,57 @@ function createGitLabSCM(config?: Record<string, unknown>): SCM {
|
|||
return comments;
|
||||
},
|
||||
|
||||
async getAutomatedComments(pr: PRInfo): Promise<AutomatedComment[]> {
|
||||
async getReviewThreads(pr: PRInfo): Promise<ReviewThreadsResult> {
|
||||
const hostname = resolveHostname(pr);
|
||||
const discussions = await fetchDiscussions(
|
||||
pr,
|
||||
resolveHostname(pr),
|
||||
`getAutomatedComments for MR !${pr.number}`,
|
||||
hostname,
|
||||
`getReviewThreads for MR !${pr.number}`,
|
||||
);
|
||||
|
||||
const comments: AutomatedComment[] = [];
|
||||
// Unresolved threads — includes both human and bot comments (with isBot flag)
|
||||
const threads: ReviewComment[] = [];
|
||||
for (const d of discussions) {
|
||||
const note = d.notes[0];
|
||||
if (!note) continue;
|
||||
const author = note.author?.username ?? "";
|
||||
if (!isBot(author)) continue;
|
||||
if (!note.resolvable || note.resolved) continue;
|
||||
|
||||
comments.push({
|
||||
const author = note.author?.username ?? "unknown";
|
||||
threads.push({
|
||||
id: String(note.id),
|
||||
botName: author,
|
||||
threadId: d.id,
|
||||
author,
|
||||
body: note.body,
|
||||
path: note.position?.new_path || undefined,
|
||||
line: note.position?.new_line ?? undefined,
|
||||
severity: inferSeverity(note.body),
|
||||
isResolved: false,
|
||||
createdAt: parseDate(note.created_at),
|
||||
url: "",
|
||||
isBot: isBot(author),
|
||||
});
|
||||
}
|
||||
return comments;
|
||||
|
||||
// Review summaries from approvals
|
||||
const reviews: ReviewSummary[] = [];
|
||||
try {
|
||||
const approvalsRaw = await glab(["api", `${mrApiPath(pr)}/approvals`], hostname);
|
||||
const approvals = parseJSON<{
|
||||
approved_by: Array<{ user: { username: string } }>;
|
||||
}>(approvalsRaw, `getReviewThreads approvals for MR !${pr.number}`);
|
||||
|
||||
for (const a of approvals.approved_by ?? []) {
|
||||
reviews.push({
|
||||
author: a.user?.username ?? "unknown",
|
||||
state: "APPROVED",
|
||||
body: "",
|
||||
submittedAt: new Date(0),
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Best-effort — threads are the critical data
|
||||
}
|
||||
|
||||
return { threads, reviews };
|
||||
},
|
||||
|
||||
async getMergeability(pr: PRInfo): Promise<MergeReadiness> {
|
||||
|
|
|
|||
|
|
@ -1022,87 +1022,6 @@ describe("scm-gitlab plugin", () => {
|
|||
});
|
||||
});
|
||||
|
||||
// ---- getAutomatedComments ----------------------------------------------
|
||||
|
||||
describe("getAutomatedComments", () => {
|
||||
it("returns bot discussion notes with severity", async () => {
|
||||
mockGlab([
|
||||
{
|
||||
notes: [
|
||||
{
|
||||
id: 101,
|
||||
author: { username: "gitlab-bot" },
|
||||
body: "Found a critical error",
|
||||
position: { new_path: "a.ts", new_line: 5 },
|
||||
created_at: "2025-01-01T00:00:00Z",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
notes: [
|
||||
{
|
||||
id: 102,
|
||||
author: { username: "alice" },
|
||||
body: "Human comment",
|
||||
created_at: "2025-01-01T00:00:00Z",
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
const comments = await scm.getAutomatedComments(pr);
|
||||
expect(comments).toHaveLength(1);
|
||||
expect(comments[0].botName).toBe("gitlab-bot");
|
||||
expect(comments[0].severity).toBe("error");
|
||||
});
|
||||
|
||||
it("classifies severity from body content", async () => {
|
||||
mockGlab([
|
||||
{
|
||||
notes: [
|
||||
{
|
||||
id: 1,
|
||||
author: { username: "sast-bot" },
|
||||
body: "Error: build failed",
|
||||
created_at: "2025-01-01T00:00:00Z",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
notes: [
|
||||
{
|
||||
id: 2,
|
||||
author: { username: "sast-bot" },
|
||||
body: "Warning: deprecated API",
|
||||
created_at: "2025-01-01T00:00:00Z",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
notes: [
|
||||
{
|
||||
id: 3,
|
||||
author: { username: "sast-bot" },
|
||||
body: "Deployed to staging",
|
||||
created_at: "2025-01-01T00:00:00Z",
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
const comments = await scm.getAutomatedComments(pr);
|
||||
expect(comments).toHaveLength(3);
|
||||
expect(comments[0].severity).toBe("error");
|
||||
expect(comments[1].severity).toBe("warning");
|
||||
expect(comments[2].severity).toBe("info");
|
||||
});
|
||||
|
||||
it("throws on error (fail-closed)", async () => {
|
||||
mockGlabError("network failure");
|
||||
await expect(scm.getAutomatedComments(pr)).rejects.toThrow("network failure");
|
||||
});
|
||||
});
|
||||
|
||||
// ---- getMergeability ---------------------------------------------------
|
||||
|
||||
describe("getMergeability", () => {
|
||||
|
|
|
|||
|
|
@ -4,31 +4,24 @@
|
|||
* Uses the `gh` CLI for all GitHub API interactions.
|
||||
*/
|
||||
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import type {
|
||||
PluginModule,
|
||||
Tracker,
|
||||
Issue,
|
||||
IssueFilters,
|
||||
IssueUpdate,
|
||||
CreateIssueInput,
|
||||
ProjectConfig,
|
||||
import {
|
||||
execGhObserved,
|
||||
type PluginModule,
|
||||
type Tracker,
|
||||
type Issue,
|
||||
type IssueFilters,
|
||||
type IssueUpdate,
|
||||
type CreateIssueInput,
|
||||
type ProjectConfig,
|
||||
} from "@aoagents/ao-core";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function gh(args: string[]): Promise<string> {
|
||||
try {
|
||||
const { stdout } = await execFileAsync("gh", args, {
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
timeout: 30_000,
|
||||
});
|
||||
return stdout.trim();
|
||||
return await execGhObserved(args, { component: "tracker-github" }, 30_000);
|
||||
} catch (err) {
|
||||
throw new Error(`gh ${args.slice(0, 3).join(" ")} failed: ${(err as Error).message}`, {
|
||||
cause: err,
|
||||
|
|
@ -121,47 +114,106 @@ function requireRepo(project: ProjectConfig): string {
|
|||
return project.repo;
|
||||
}
|
||||
|
||||
// Issue cache: 5 min TTL, bounded to 500 entries. Issue metadata (title, body,
|
||||
// labels, state) rarely changes during a session, and the lifecycle worker
|
||||
// polls `getIssue` / `isCompleted` repeatedly — same (repo, id) seen 64+ times
|
||||
// per 5-session tier-5 run in our traces.
|
||||
const ISSUE_CACHE_TTL_MS = 5 * 60_000;
|
||||
const ISSUE_CACHE_MAX = 500;
|
||||
|
||||
interface CachedIssue {
|
||||
issue: Issue;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
function issueCacheKey(repo: string, identifier: string): string {
|
||||
return `${repo}#${identifier.replace(/^#/, "")}`;
|
||||
}
|
||||
|
||||
function createGitHubTracker(): Tracker {
|
||||
return {
|
||||
const issueCache = new Map<string, CachedIssue>();
|
||||
const inflight = new Map<string, Promise<Issue>>();
|
||||
|
||||
function readCachedIssue(repo: string, identifier: string): Issue | null {
|
||||
const key = issueCacheKey(repo, identifier);
|
||||
const entry = issueCache.get(key);
|
||||
if (!entry) return null;
|
||||
if (Date.now() > entry.expiresAt) {
|
||||
issueCache.delete(key);
|
||||
return null;
|
||||
}
|
||||
return entry.issue;
|
||||
}
|
||||
|
||||
function writeCachedIssue(repo: string, identifier: string, issue: Issue): void {
|
||||
if (issueCache.size >= ISSUE_CACHE_MAX) {
|
||||
const oldest = issueCache.keys().next().value;
|
||||
if (oldest !== undefined) issueCache.delete(oldest);
|
||||
}
|
||||
issueCache.set(issueCacheKey(repo, identifier), {
|
||||
issue,
|
||||
expiresAt: Date.now() + ISSUE_CACHE_TTL_MS,
|
||||
});
|
||||
}
|
||||
|
||||
function invalidateCachedIssue(repo: string, identifier: string): void {
|
||||
issueCache.delete(issueCacheKey(repo, identifier));
|
||||
}
|
||||
|
||||
const tracker: Tracker = {
|
||||
name: "github",
|
||||
|
||||
async getIssue(identifier: string, project: ProjectConfig): Promise<Issue> {
|
||||
const raw = await ghIssueViewJson(identifier, project);
|
||||
const repo = requireRepo(project);
|
||||
const cached = readCachedIssue(repo, identifier);
|
||||
if (cached) return cached;
|
||||
|
||||
const data: {
|
||||
number: number;
|
||||
title: string;
|
||||
body: string;
|
||||
url: string;
|
||||
state: string;
|
||||
stateReason?: string | null;
|
||||
labels: Array<{ name: string }>;
|
||||
assignees: Array<{ login: string }>;
|
||||
} = JSON.parse(raw);
|
||||
// Deduplicate concurrent requests for the same issue
|
||||
const key = issueCacheKey(repo, identifier);
|
||||
const pending = inflight.get(key);
|
||||
if (pending) return pending;
|
||||
|
||||
return {
|
||||
id: String(data.number),
|
||||
title: data.title,
|
||||
description: data.body ?? "",
|
||||
url: data.url,
|
||||
state: mapState(data.state, data.stateReason),
|
||||
labels: data.labels.map((l) => l.name),
|
||||
assignee: data.assignees[0]?.login,
|
||||
};
|
||||
const promise = (async () => {
|
||||
const raw = await ghIssueViewJson(identifier, project);
|
||||
|
||||
const data: {
|
||||
number: number;
|
||||
title: string;
|
||||
body: string;
|
||||
url: string;
|
||||
state: string;
|
||||
stateReason?: string | null;
|
||||
labels: Array<{ name: string }>;
|
||||
assignees: Array<{ login: string }>;
|
||||
} = JSON.parse(raw);
|
||||
|
||||
const issue: Issue = {
|
||||
id: String(data.number),
|
||||
title: data.title,
|
||||
description: data.body ?? "",
|
||||
url: data.url,
|
||||
state: mapState(data.state, data.stateReason),
|
||||
labels: data.labels.map((l) => l.name),
|
||||
assignee: data.assignees[0]?.login,
|
||||
};
|
||||
|
||||
writeCachedIssue(repo, identifier, issue);
|
||||
return issue;
|
||||
})();
|
||||
|
||||
inflight.set(key, promise);
|
||||
try {
|
||||
return await promise;
|
||||
} finally {
|
||||
inflight.delete(key);
|
||||
}
|
||||
},
|
||||
|
||||
async isCompleted(identifier: string, project: ProjectConfig): Promise<boolean> {
|
||||
const raw = await gh([
|
||||
"issue",
|
||||
"view",
|
||||
identifier,
|
||||
"--repo",
|
||||
requireRepo(project),
|
||||
"--json",
|
||||
"state",
|
||||
]);
|
||||
const data: { state: string } = JSON.parse(raw);
|
||||
return data.state.toUpperCase() === "CLOSED";
|
||||
// Route through getIssue so the cache covers the hot isCompleted poll path too.
|
||||
// "closed" and "cancelled" (CLOSED + NOT_PLANNED stateReason) both count as completed.
|
||||
const issue = await tracker.getIssue(identifier, project);
|
||||
return issue.state === "closed" || issue.state === "cancelled";
|
||||
},
|
||||
|
||||
issueUrl(identifier: string, project: ProjectConfig): string {
|
||||
|
|
@ -204,6 +256,8 @@ function createGitHubTracker(): Tracker {
|
|||
}
|
||||
|
||||
lines.push(
|
||||
"",
|
||||
"The issue context above is complete and current. You should not need to call gh issue view unless you need additional context beyond what is provided here.",
|
||||
"",
|
||||
"Please implement the changes described in this issue. When done, commit and push your changes.",
|
||||
);
|
||||
|
|
@ -266,6 +320,8 @@ function createGitHubTracker(): Tracker {
|
|||
project: ProjectConfig,
|
||||
): Promise<void> {
|
||||
const repo = requireRepo(project);
|
||||
// Any mutation invalidates the cached Issue for this (repo, identifier).
|
||||
invalidateCachedIssue(repo, identifier);
|
||||
// Handle state change — GitHub Issues only supports open/closed.
|
||||
// "in_progress" is not a GitHub state, so it is intentionally a no-op.
|
||||
if (update.state === "closed") {
|
||||
|
|
@ -357,9 +413,11 @@ function createGitHubTracker(): Tracker {
|
|||
}
|
||||
const number = match[1];
|
||||
|
||||
return this.getIssue(number, project);
|
||||
return tracker.getIssue(number, project);
|
||||
},
|
||||
};
|
||||
|
||||
return tracker;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -169,21 +169,122 @@ describe("tracker-github plugin", () => {
|
|||
|
||||
describe("isCompleted", () => {
|
||||
it("returns true for CLOSED issues", async () => {
|
||||
mockGh({ state: "CLOSED" });
|
||||
mockGh({ ...sampleIssue, state: "CLOSED", stateReason: "COMPLETED" });
|
||||
expect(await tracker.isCompleted("123", project)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for OPEN issues", async () => {
|
||||
mockGh({ state: "OPEN" });
|
||||
mockGh({ ...sampleIssue, state: "OPEN" });
|
||||
expect(await tracker.isCompleted("123", project)).toBe(false);
|
||||
});
|
||||
|
||||
it("handles lowercase state", async () => {
|
||||
mockGh({ state: "closed" });
|
||||
it("returns true for cancelled (CLOSED + NOT_PLANNED) issues", async () => {
|
||||
mockGh({ ...sampleIssue, state: "CLOSED", stateReason: "NOT_PLANNED" });
|
||||
expect(await tracker.isCompleted("123", project)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- issue cache -------------------------------------------------------
|
||||
|
||||
describe("issue cache", () => {
|
||||
it("second getIssue for same (repo, id) hits cache — no gh call", async () => {
|
||||
mockGh(sampleIssue);
|
||||
const first = await tracker.getIssue("123", project);
|
||||
const second = await tracker.getIssue("123", project);
|
||||
expect(first).toEqual(second);
|
||||
expect(ghMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("isCompleted shares the same cache as getIssue", async () => {
|
||||
mockGh(sampleIssue);
|
||||
await tracker.getIssue("123", project);
|
||||
const done = await tracker.isCompleted("123", project);
|
||||
expect(done).toBe(false);
|
||||
expect(ghMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("different identifiers cache independently", async () => {
|
||||
mockGh(sampleIssue);
|
||||
mockGh({ ...sampleIssue, number: 456 });
|
||||
await tracker.getIssue("123", project);
|
||||
await tracker.getIssue("456", project);
|
||||
expect(ghMock).toHaveBeenCalledTimes(2);
|
||||
// Both now cached — no new gh calls
|
||||
await tracker.getIssue("123", project);
|
||||
await tracker.getIssue("456", project);
|
||||
expect(ghMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("different repos cache independently", async () => {
|
||||
const otherProject: ProjectConfig = { ...project, repo: "acme/other" };
|
||||
mockGh(sampleIssue);
|
||||
mockGh(sampleIssue);
|
||||
await tracker.getIssue("123", project);
|
||||
await tracker.getIssue("123", otherProject);
|
||||
expect(ghMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("strips '#' prefix — '#123' and '123' share a cache entry", async () => {
|
||||
mockGh(sampleIssue);
|
||||
await tracker.getIssue("#123", project);
|
||||
await tracker.getIssue("123", project);
|
||||
expect(ghMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("updateIssue invalidates the cache entry", async () => {
|
||||
mockGh(sampleIssue);
|
||||
await tracker.getIssue("123", project);
|
||||
expect(ghMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
ghMock.mockResolvedValueOnce({ stdout: "" }); // gh issue close
|
||||
await tracker.updateIssue!("123", { state: "closed" }, project);
|
||||
|
||||
// Next getIssue must re-fetch from gh
|
||||
mockGh({ ...sampleIssue, state: "CLOSED", stateReason: "COMPLETED" });
|
||||
const fresh = await tracker.getIssue("123", project);
|
||||
expect(fresh.state).toBe("closed");
|
||||
expect(ghMock).toHaveBeenCalledTimes(3); // view + close + view again
|
||||
});
|
||||
|
||||
it("entries expire after TTL", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
mockGh(sampleIssue);
|
||||
await tracker.getIssue("123", project);
|
||||
expect(ghMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Advance time past TTL (5 min)
|
||||
vi.advanceTimersByTime(5 * 60_000 + 1);
|
||||
|
||||
mockGh(sampleIssue);
|
||||
await tracker.getIssue("123", project);
|
||||
expect(ghMock).toHaveBeenCalledTimes(2);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("each create() returns a tracker with an isolated cache", async () => {
|
||||
const trackerA = create();
|
||||
const trackerB = create();
|
||||
mockGh(sampleIssue);
|
||||
await trackerA.getIssue("123", project);
|
||||
mockGh(sampleIssue);
|
||||
await trackerB.getIssue("123", project);
|
||||
expect(ghMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("failures are not cached", async () => {
|
||||
mockGhError("issue not found");
|
||||
await expect(tracker.getIssue("999", project)).rejects.toThrow();
|
||||
// Next call must re-try, not serve a stale error
|
||||
mockGh({ ...sampleIssue, number: 999 });
|
||||
const issue = await tracker.getIssue("999", project);
|
||||
expect(issue.id).toBe("999");
|
||||
expect(ghMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- issueUrl ----------------------------------------------------------
|
||||
|
||||
describe("issueUrl", () => {
|
||||
|
|
@ -259,7 +360,7 @@ describe("tracker-github plugin", () => {
|
|||
mockGh([]);
|
||||
await tracker.listIssues!({ state: "closed" }, project);
|
||||
expect(ghMock).toHaveBeenCalledWith(
|
||||
"gh",
|
||||
expect.stringMatching(/(?:^|\/)?gh$/),
|
||||
expect.arrayContaining(["--state", "closed"]),
|
||||
expect.any(Object),
|
||||
);
|
||||
|
|
@ -269,7 +370,7 @@ describe("tracker-github plugin", () => {
|
|||
mockGh([]);
|
||||
await tracker.listIssues!({ state: "all" }, project);
|
||||
expect(ghMock).toHaveBeenCalledWith(
|
||||
"gh",
|
||||
expect.stringMatching(/(?:^|\/)?gh$/),
|
||||
expect.arrayContaining(["--state", "all"]),
|
||||
expect.any(Object),
|
||||
);
|
||||
|
|
@ -279,7 +380,7 @@ describe("tracker-github plugin", () => {
|
|||
mockGh([]);
|
||||
await tracker.listIssues!({}, project);
|
||||
expect(ghMock).toHaveBeenCalledWith(
|
||||
"gh",
|
||||
expect.stringMatching(/(?:^|\/)?gh$/),
|
||||
expect.arrayContaining(["--state", "open"]),
|
||||
expect.any(Object),
|
||||
);
|
||||
|
|
@ -289,7 +390,7 @@ describe("tracker-github plugin", () => {
|
|||
mockGh([]);
|
||||
await tracker.listIssues!({ labels: ["bug", "urgent"] }, project);
|
||||
expect(ghMock).toHaveBeenCalledWith(
|
||||
"gh",
|
||||
expect.stringMatching(/(?:^|\/)?gh$/),
|
||||
expect.arrayContaining(["--label", "bug,urgent"]),
|
||||
expect.any(Object),
|
||||
);
|
||||
|
|
@ -299,7 +400,7 @@ describe("tracker-github plugin", () => {
|
|||
mockGh([]);
|
||||
await tracker.listIssues!({ assignee: "alice" }, project);
|
||||
expect(ghMock).toHaveBeenCalledWith(
|
||||
"gh",
|
||||
expect.stringMatching(/(?:^|\/)?gh$/),
|
||||
expect.arrayContaining(["--assignee", "alice"]),
|
||||
expect.any(Object),
|
||||
);
|
||||
|
|
@ -309,7 +410,7 @@ describe("tracker-github plugin", () => {
|
|||
mockGh([]);
|
||||
await tracker.listIssues!({ limit: 5 }, project);
|
||||
expect(ghMock).toHaveBeenCalledWith(
|
||||
"gh",
|
||||
expect.stringMatching(/(?:^|\/)?gh$/),
|
||||
expect.arrayContaining(["--limit", "5"]),
|
||||
expect.any(Object),
|
||||
);
|
||||
|
|
@ -350,7 +451,7 @@ describe("tracker-github plugin", () => {
|
|||
ghMock.mockResolvedValueOnce({ stdout: "" });
|
||||
await tracker.updateIssue!("123", { state: "closed" }, project);
|
||||
expect(ghMock).toHaveBeenCalledWith(
|
||||
"gh",
|
||||
expect.stringMatching(/(?:^|\/)?gh$/),
|
||||
["issue", "close", "123", "--repo", "acme/repo"],
|
||||
expect.any(Object),
|
||||
);
|
||||
|
|
@ -360,7 +461,7 @@ describe("tracker-github plugin", () => {
|
|||
ghMock.mockResolvedValueOnce({ stdout: "" });
|
||||
await tracker.updateIssue!("123", { state: "open" }, project);
|
||||
expect(ghMock).toHaveBeenCalledWith(
|
||||
"gh",
|
||||
expect.stringMatching(/(?:^|\/)?gh$/),
|
||||
["issue", "reopen", "123", "--repo", "acme/repo"],
|
||||
expect.any(Object),
|
||||
);
|
||||
|
|
@ -370,7 +471,7 @@ describe("tracker-github plugin", () => {
|
|||
ghMock.mockResolvedValueOnce({ stdout: "" });
|
||||
await tracker.updateIssue!("123", { labels: ["bug", "urgent"] }, project);
|
||||
expect(ghMock).toHaveBeenCalledWith(
|
||||
"gh",
|
||||
expect.stringMatching(/(?:^|\/)?gh$/),
|
||||
["issue", "edit", "123", "--repo", "acme/repo", "--add-label", "bug,urgent"],
|
||||
expect.any(Object),
|
||||
);
|
||||
|
|
@ -380,7 +481,7 @@ describe("tracker-github plugin", () => {
|
|||
ghMock.mockResolvedValueOnce({ stdout: "" });
|
||||
await tracker.updateIssue!("123", { assignee: "bob" }, project);
|
||||
expect(ghMock).toHaveBeenCalledWith(
|
||||
"gh",
|
||||
expect.stringMatching(/(?:^|\/)?gh$/),
|
||||
["issue", "edit", "123", "--repo", "acme/repo", "--add-assignee", "bob"],
|
||||
expect.any(Object),
|
||||
);
|
||||
|
|
@ -390,7 +491,7 @@ describe("tracker-github plugin", () => {
|
|||
ghMock.mockResolvedValueOnce({ stdout: "" });
|
||||
await tracker.updateIssue!("123", { comment: "Working on this" }, project);
|
||||
expect(ghMock).toHaveBeenCalledWith(
|
||||
"gh",
|
||||
expect.stringMatching(/(?:^|\/)?gh$/),
|
||||
["issue", "comment", "123", "--repo", "acme/repo", "--body", "Working on this"],
|
||||
expect.any(Object),
|
||||
);
|
||||
|
|
@ -451,7 +552,7 @@ describe("tracker-github plugin", () => {
|
|||
project,
|
||||
);
|
||||
expect(ghMock).toHaveBeenCalledWith(
|
||||
"gh",
|
||||
expect.stringMatching(/(?:^|\/)?gh$/),
|
||||
expect.arrayContaining(["issue", "create", "--label", "bug", "--assignee", "alice"]),
|
||||
expect.any(Object),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ import { findTmux, resolveTmuxSession, validateSessionId } from "./tmux-utils.js
|
|||
type ClientMessage =
|
||||
| { ch: "terminal"; id: string; type: "data"; data: string }
|
||||
| { ch: "terminal"; id: string; type: "resize"; cols: number; rows: number }
|
||||
| { ch: "terminal"; id: string; type: "open" }
|
||||
| { ch: "terminal"; id: string; type: "open"; tmuxName?: string }
|
||||
| { ch: "terminal"; id: string; type: "close" }
|
||||
| { ch: "system"; type: "ping" }
|
||||
| { ch: "subscribe"; topics: ("sessions")[] };
|
||||
|
|
@ -246,13 +246,15 @@ class TerminalManager {
|
|||
* Open/attach to a terminal. If already open, just return.
|
||||
* If has subscribers but PTY crashed, re-attach.
|
||||
*/
|
||||
open(id: string): string {
|
||||
open(id: string, tmuxName?: string): string {
|
||||
// Validate and resolve
|
||||
if (!validateSessionId(id)) {
|
||||
throw new Error(`Invalid session ID: ${id}`);
|
||||
}
|
||||
|
||||
const tmuxSessionId = resolveTmuxSession(id, this.TMUX);
|
||||
// Use provided tmuxName, or reuse from existing terminal entry, or resolve
|
||||
const existing = this.terminals.get(id);
|
||||
const tmuxSessionId = tmuxName ?? existing?.tmuxSessionId ?? resolveTmuxSession(id, this.TMUX);
|
||||
if (!tmuxSessionId) {
|
||||
throw new Error(`Session not found: ${id}`);
|
||||
}
|
||||
|
|
@ -499,7 +501,7 @@ export function createMuxWebSocket(tmuxPath?: string): WebSocketServer | null {
|
|||
try {
|
||||
if (type === "open") {
|
||||
// Validate session exists
|
||||
terminalManager.open(id);
|
||||
terminalManager.open(id, "tmuxName" in msg ? msg.tmuxName : undefined);
|
||||
|
||||
// Send opened confirmation (idempotent — safe to send on re-open)
|
||||
const openedMsg: ServerMessage = { ch: "terminal", id, type: "opened" };
|
||||
|
|
|
|||
|
|
@ -161,14 +161,6 @@ export function resolveTmuxSession(
|
|||
// even when the storageKey is wrapped (`{hash}-{projectName}`). Walk
|
||||
// every candidate so a stale metadata dir in one project can't shadow
|
||||
// the live session of another project with the same sessionId.
|
||||
//
|
||||
// Pre-existing limitation (not introduced by this fix): when two
|
||||
// different projects have both (a) the same user-facing sessionId and
|
||||
// (b) live tmux sessions, we return the first live match. The terminal
|
||||
// open protocol carries only the session ID — no project context — so
|
||||
// there's nothing to disambiguate with here. The previous bare-hash
|
||||
// code had the same behavior via `Array.find(...)`. A proper fix would
|
||||
// thread projectId through the mux protocol from the browser URL.
|
||||
for (const storageKey of findStorageKeysForSession(sessionId, fs)) {
|
||||
const tmuxName = `${storageKey}-${sessionId}`;
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { type NextRequest } from "next/server";
|
||||
import { getSessionsDir, readAgentReportAuditTrailAsync } from "@aoagents/ao-core";
|
||||
import { getServices, getSCM } from "@/lib/services";
|
||||
import { getServices } from "@/lib/services";
|
||||
import {
|
||||
sessionToDashboard,
|
||||
resolveProject,
|
||||
|
|
@ -12,8 +12,6 @@ import { getCorrelationId, jsonWithCorrelation, recordApiObservation } from "@/l
|
|||
|
||||
const AGENT_REPORT_AUDIT_TIMEOUT_MS = 1000;
|
||||
const METADATA_ENRICH_TIMEOUT_MS = 3000;
|
||||
const PR_CACHE_ENRICH_TIMEOUT_MS = 1000;
|
||||
const PR_LIVE_ENRICH_TIMEOUT_MS = 2000;
|
||||
|
||||
export async function GET(_request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const correlationId = getCorrelationId(_request);
|
||||
|
|
@ -43,27 +41,9 @@ export async function GET(_request: NextRequest, { params }: { params: Promise<{
|
|||
METADATA_ENRICH_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
// Enrich PR — serve cache immediately, refresh in background if stale
|
||||
// Enrich PR from session metadata (written by CLI lifecycle)
|
||||
if (coreSession.pr) {
|
||||
const scm = getSCM(registry, project);
|
||||
if (scm) {
|
||||
let cached = false;
|
||||
const cachedSettled = await settlesWithin(
|
||||
enrichSessionPR(dashboardSession, scm, coreSession.pr, {
|
||||
cacheOnly: true,
|
||||
}).then((result) => {
|
||||
cached = result;
|
||||
}),
|
||||
PR_CACHE_ENRICH_TIMEOUT_MS,
|
||||
);
|
||||
if (!cached) {
|
||||
// Nothing cached yet — block once to populate, then future calls use cache
|
||||
await settlesWithin(
|
||||
enrichSessionPR(dashboardSession, scm, coreSession.pr),
|
||||
cachedSettled ? PR_LIVE_ENRICH_TIMEOUT_MS : PR_CACHE_ENRICH_TIMEOUT_MS + PR_LIVE_ENRICH_TIMEOUT_MS,
|
||||
);
|
||||
}
|
||||
}
|
||||
enrichSessionPR(dashboardSession);
|
||||
}
|
||||
|
||||
recordApiObservation({
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
import { ACTIVITY_STATE, isOrchestratorSession, isTerminalSession } from "@aoagents/ao-core";
|
||||
import { getServices, getSCM } from "@/lib/services";
|
||||
import { getServices } from "@/lib/services";
|
||||
import {
|
||||
sessionToDashboard,
|
||||
resolveProject,
|
||||
enrichSessionPR,
|
||||
enrichSessionsMetadata,
|
||||
computeStats,
|
||||
|
|
@ -14,13 +13,6 @@ import { settlesWithin } from "@/lib/async-utils";
|
|||
import type { DashboardOrchestratorLink } from "@/lib/types";
|
||||
|
||||
const METADATA_ENRICH_TIMEOUT_MS = 3_000;
|
||||
const PR_ENRICH_TIMEOUT_MS = 4_000;
|
||||
const PER_PR_ENRICH_TIMEOUT_MS = 1_500;
|
||||
|
||||
function hasTerminalPRState(session: Parameters<typeof isTerminalSession>[0]): boolean {
|
||||
const prState = session.lifecycle?.pr.state;
|
||||
return prState === "merged" || prState === "closed";
|
||||
}
|
||||
|
||||
function compareOrchestratorRecency(a: { lastActivityAt?: Date | null; createdAt?: Date | null; id: string }, b: { lastActivityAt?: Date | null; createdAt?: Date | null; id: string }): number {
|
||||
return (
|
||||
|
|
@ -152,34 +144,11 @@ export async function GET(request: Request) {
|
|||
);
|
||||
|
||||
if (metadataSettled) {
|
||||
const prEnrichPromises: Promise<boolean>[] = [];
|
||||
|
||||
// PR enrichment: read from session metadata (written by CLI lifecycle).
|
||||
// No GitHub API calls — synchronous metadata read.
|
||||
for (let i = 0; i < workerSessions.length; i++) {
|
||||
const core = workerSessions[i];
|
||||
const pr = core?.pr;
|
||||
if (!pr) continue;
|
||||
|
||||
const project = resolveProject(core, config.projects);
|
||||
const scm = getSCM(registry, project);
|
||||
if (!scm) continue;
|
||||
|
||||
prEnrichPromises.push(
|
||||
settlesWithin(
|
||||
hasTerminalPRState(core)
|
||||
? enrichSessionPR(dashboardSessions[i], scm, pr, { cacheOnly: true }).then(
|
||||
(cached) =>
|
||||
cached
|
||||
? true
|
||||
: enrichSessionPR(dashboardSessions[i], scm, pr),
|
||||
)
|
||||
: enrichSessionPR(dashboardSessions[i], scm, pr),
|
||||
PER_PR_ENRICH_TIMEOUT_MS,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (prEnrichPromises.length > 0) {
|
||||
await settlesWithin(Promise.allSettled(prEnrichPromises), PR_ENRICH_TIMEOUT_MS);
|
||||
if (!workerSessions[i]?.pr) continue;
|
||||
enrichSessionPR(dashboardSessions[i]);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -72,6 +72,8 @@ function getStoredFontSize(): number {
|
|||
|
||||
interface DirectTerminalProps {
|
||||
sessionId: string;
|
||||
/** Actual tmux session name. When provided, the terminal server uses it directly instead of resolving from sessionId. */
|
||||
tmuxName?: string;
|
||||
startFullscreen?: boolean;
|
||||
/** Visual variant. Orchestrator keeps the same design-system blue accent as the rest of the app. */
|
||||
variant?: "agent" | "orchestrator";
|
||||
|
|
@ -164,6 +166,7 @@ export function buildTerminalThemes(variant: TerminalVariant): { dark: ITheme; l
|
|||
*/
|
||||
export function DirectTerminal({
|
||||
sessionId,
|
||||
tmuxName,
|
||||
startFullscreen = false,
|
||||
variant = "agent",
|
||||
appearance = "theme",
|
||||
|
|
@ -474,7 +477,7 @@ export function DirectTerminal({
|
|||
});
|
||||
|
||||
// Open terminal via mux
|
||||
openTerminal(sessionId);
|
||||
openTerminal(sessionId, tmuxName);
|
||||
|
||||
// Subscribe to terminal data via mux
|
||||
unsubscribe = subscribeTerminal(sessionId, (data) => {
|
||||
|
|
|
|||
|
|
@ -651,6 +651,7 @@ export function SessionDetail({
|
|||
) : (
|
||||
<DirectTerminal
|
||||
sessionId={session.id}
|
||||
tmuxName={session.metadata?.tmuxName}
|
||||
startFullscreen={startFullscreen}
|
||||
variant={terminalVariant}
|
||||
appearance="dark"
|
||||
|
|
|
|||
|
|
@ -60,10 +60,10 @@ describe("Dashboard done bar", () => {
|
|||
expect(screen.queryByText(/No active sessions/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not render a restore action for merged sessions", () => {
|
||||
it("renders a restore action for merged sessions", () => {
|
||||
render(<Dashboard initialSessions={[DONE_SESSION]} />);
|
||||
const toggle = screen.getByText("Done / Terminated").closest("button")!;
|
||||
fireEvent.click(toggle);
|
||||
expect(screen.queryByRole("button", { name: /restore/i })).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: /restore/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -94,15 +94,9 @@ describe("getDashboardPageData fast path", () => {
|
|||
{ projects: { docs: { id: "docs" } } },
|
||||
{ scm: "registry" },
|
||||
);
|
||||
expect(hoisted.enrichSessionPRMock).toHaveBeenCalledTimes(1);
|
||||
expect(hoisted.enrichSessionPRMock).toHaveBeenCalledWith(
|
||||
dashboardMerged,
|
||||
{ provider: "github" },
|
||||
mergedCore.pr,
|
||||
{ cacheOnly: true },
|
||||
);
|
||||
expect(dashboardClosed.pr.state).toBe("closed");
|
||||
expect(dashboardMerged.pr.state).toBe("merged");
|
||||
expect(hoisted.enrichSessionPRMock).toHaveBeenCalledTimes(2);
|
||||
expect(hoisted.enrichSessionPRMock).toHaveBeenCalledWith(dashboardClosed);
|
||||
expect(hoisted.enrichSessionPRMock).toHaveBeenCalledWith(dashboardMerged);
|
||||
expect(pageData.sessions).toEqual([dashboardNoPr, dashboardClosed, dashboardMerged]);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -2,12 +2,11 @@
|
|||
* Tests for session serialization and PR enrichment
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import {
|
||||
createInitialCanonicalLifecycle,
|
||||
type Session,
|
||||
type PRInfo,
|
||||
type SCM,
|
||||
type Agent,
|
||||
type Tracker,
|
||||
type ProjectConfig,
|
||||
|
|
@ -18,7 +17,7 @@ import {
|
|||
sessionToDashboard,
|
||||
resolveProject,
|
||||
enrichSessionPR,
|
||||
enrichSessionIssue,
|
||||
readPREnrichmentFromMetadata,
|
||||
enrichSessionAgentSummary,
|
||||
enrichSessionIssueTitle,
|
||||
enrichSessionsMetadata,
|
||||
|
|
@ -26,7 +25,6 @@ import {
|
|||
computeStats,
|
||||
listDashboardOrchestrators,
|
||||
} from "../serialize";
|
||||
import { prCache, prCacheKey } from "../cache";
|
||||
import type { DashboardSession } from "../types";
|
||||
|
||||
// Helper to create a minimal Session for testing
|
||||
|
|
@ -77,56 +75,32 @@ function createPRInfo(overrides?: Partial<PRInfo>): PRInfo {
|
|||
};
|
||||
}
|
||||
|
||||
// Mock SCM that succeeds
|
||||
function createMockSCM(): SCM {
|
||||
return {
|
||||
name: "mock",
|
||||
detectPR: vi.fn(),
|
||||
getPRState: vi.fn().mockResolvedValue("open"),
|
||||
getPRSummary: vi.fn().mockResolvedValue({
|
||||
state: "open",
|
||||
title: "Test PR",
|
||||
additions: 100,
|
||||
deletions: 50,
|
||||
}),
|
||||
getCIChecks: vi
|
||||
.fn()
|
||||
.mockResolvedValue([{ name: "test", status: "passed", url: "https://example.com" }]),
|
||||
getCISummary: vi.fn().mockResolvedValue("passing"),
|
||||
getReviewDecision: vi.fn().mockResolvedValue("approved"),
|
||||
getMergeability: vi.fn().mockResolvedValue({
|
||||
mergeable: true,
|
||||
ciPassing: true,
|
||||
approved: true,
|
||||
noConflicts: true,
|
||||
blockers: [],
|
||||
}),
|
||||
getPendingComments: vi.fn().mockResolvedValue([]),
|
||||
getReviews: vi.fn(),
|
||||
getAutomatedComments: vi.fn(),
|
||||
mergePR: vi.fn(),
|
||||
closePR: vi.fn(),
|
||||
};
|
||||
// Helper to create prEnrichment metadata JSON
|
||||
function createEnrichmentMetadata(overrides?: Record<string, unknown>): string {
|
||||
return JSON.stringify({
|
||||
state: "open",
|
||||
title: "Test PR",
|
||||
additions: 100,
|
||||
deletions: 50,
|
||||
ciStatus: "passing",
|
||||
reviewDecision: "approved",
|
||||
mergeable: true,
|
||||
isDraft: false,
|
||||
hasConflicts: false,
|
||||
ciChecks: [{ name: "test", status: "passed", url: "https://example.com" }],
|
||||
enrichedAt: new Date().toISOString(),
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
// Mock SCM that fails all requests
|
||||
function createFailingSCM(): SCM {
|
||||
const error = new Error("API rate limited");
|
||||
return {
|
||||
name: "mock-failing",
|
||||
detectPR: vi.fn(),
|
||||
getPRState: vi.fn().mockRejectedValue(error),
|
||||
getPRSummary: vi.fn().mockRejectedValue(error),
|
||||
getCIChecks: vi.fn().mockRejectedValue(error),
|
||||
getCISummary: vi.fn().mockRejectedValue(error),
|
||||
getReviewDecision: vi.fn().mockRejectedValue(error),
|
||||
getMergeability: vi.fn().mockRejectedValue(error),
|
||||
getPendingComments: vi.fn().mockRejectedValue(error),
|
||||
getReviews: vi.fn(),
|
||||
getAutomatedComments: vi.fn(),
|
||||
mergePR: vi.fn(),
|
||||
closePR: vi.fn(),
|
||||
};
|
||||
// Helper to create prReviewComments metadata JSON
|
||||
function createReviewCommentsMetadata(overrides?: Record<string, unknown>): string {
|
||||
return JSON.stringify({
|
||||
unresolvedThreads: 0,
|
||||
unresolvedComments: [],
|
||||
commentsUpdatedAt: new Date().toISOString(),
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
describe("sessionToDashboard", () => {
|
||||
|
|
@ -400,18 +374,20 @@ describe("resolveProject", () => {
|
|||
});
|
||||
|
||||
describe("enrichSessionPR", () => {
|
||||
beforeEach(() => {
|
||||
prCache.clear();
|
||||
});
|
||||
|
||||
it("should enrich PR with live SCM data", async () => {
|
||||
it("should enrich PR from metadata", () => {
|
||||
const pr = createPRInfo();
|
||||
const coreSession = createCoreSession({ pr });
|
||||
const coreSession = createCoreSession({
|
||||
pr,
|
||||
metadata: {
|
||||
prEnrichment: createEnrichmentMetadata(),
|
||||
prReviewComments: createReviewCommentsMetadata(),
|
||||
},
|
||||
});
|
||||
const dashboard = sessionToDashboard(coreSession);
|
||||
const scm = createMockSCM();
|
||||
|
||||
await enrichSessionPR(dashboard, scm, pr);
|
||||
const result = enrichSessionPR(dashboard);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(dashboard.pr?.state).toBe("open");
|
||||
expect(dashboard.pr?.additions).toBe(100);
|
||||
expect(dashboard.pr?.deletions).toBe(50);
|
||||
|
|
@ -420,106 +396,75 @@ describe("enrichSessionPR", () => {
|
|||
expect(dashboard.pr?.mergeability.mergeable).toBe(true);
|
||||
expect(dashboard.pr?.ciChecks).toHaveLength(1);
|
||||
expect(dashboard.pr?.ciChecks[0]?.name).toBe("test");
|
||||
expect(dashboard.pr?.enriched).toBe(true);
|
||||
});
|
||||
|
||||
it("should cache successful enrichment results", async () => {
|
||||
it("should return false when prEnrichment metadata is missing", () => {
|
||||
const pr = createPRInfo();
|
||||
const coreSession = createCoreSession({ pr });
|
||||
const dashboard = sessionToDashboard(coreSession);
|
||||
const scm = createMockSCM();
|
||||
|
||||
await enrichSessionPR(dashboard, scm, pr);
|
||||
|
||||
const cacheKey = prCacheKey(pr.owner, pr.repo, pr.number);
|
||||
const cached = prCache.get(cacheKey);
|
||||
expect(cached).not.toBeNull();
|
||||
expect(cached?.additions).toBe(100);
|
||||
expect(cached?.deletions).toBe(50);
|
||||
});
|
||||
|
||||
it("should use cached data on subsequent calls", async () => {
|
||||
const pr = createPRInfo();
|
||||
const coreSession = createCoreSession({ pr });
|
||||
const dashboard1 = sessionToDashboard(coreSession);
|
||||
const dashboard2 = sessionToDashboard(coreSession);
|
||||
const scm = createMockSCM();
|
||||
|
||||
// First call: fetch from SCM
|
||||
await enrichSessionPR(dashboard1, scm, pr);
|
||||
expect(scm.getPRSummary).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Second call: use cache
|
||||
await enrichSessionPR(dashboard2, scm, pr);
|
||||
expect(scm.getPRSummary).toHaveBeenCalledTimes(1); // Still 1, not 2
|
||||
expect(dashboard2.pr?.additions).toBe(100);
|
||||
});
|
||||
|
||||
it("should handle rate limit errors gracefully", async () => {
|
||||
const pr = createPRInfo();
|
||||
const coreSession = createCoreSession({ pr });
|
||||
const dashboard = sessionToDashboard(coreSession);
|
||||
const scm = createFailingSCM();
|
||||
|
||||
// Spy on console.warn (enrichSessionPR uses warn for rate-limit, not error)
|
||||
const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
|
||||
await enrichSessionPR(dashboard, scm, pr);
|
||||
|
||||
// Should keep default values but update blocker message
|
||||
expect(dashboard.pr?.additions).toBe(0);
|
||||
expect(dashboard.pr?.deletions).toBe(0);
|
||||
expect(dashboard.pr?.mergeability.blockers).toContain("API rate limited or unavailable");
|
||||
|
||||
// Should log warning
|
||||
expect(consoleWarnSpy).toHaveBeenCalled();
|
||||
|
||||
consoleWarnSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("should cache even when most requests fail (to reduce API pressure)", async () => {
|
||||
const pr = createPRInfo();
|
||||
const coreSession = createCoreSession({ pr });
|
||||
const dashboard = sessionToDashboard(coreSession);
|
||||
const scm = createFailingSCM();
|
||||
|
||||
await enrichSessionPR(dashboard, scm, pr);
|
||||
|
||||
// Even with all failures, we cache the default/partial data to prevent repeated API hits
|
||||
const cacheKey = prCacheKey(pr.owner, pr.repo, pr.number);
|
||||
const cached = prCache.get(cacheKey);
|
||||
expect(cached).not.toBeNull();
|
||||
expect(cached?.mergeability.blockers).toContain("API rate limited or unavailable");
|
||||
});
|
||||
|
||||
it("should handle partial failures gracefully", async () => {
|
||||
const pr = createPRInfo();
|
||||
const coreSession = createCoreSession({ pr });
|
||||
const coreSession = createCoreSession({ pr, metadata: {} });
|
||||
const dashboard = sessionToDashboard(coreSession);
|
||||
|
||||
// Mock SCM with partial failures
|
||||
const scm: SCM = {
|
||||
...createMockSCM(),
|
||||
getCISummary: vi.fn().mockRejectedValue(new Error("CI API failed")),
|
||||
getMergeability: vi.fn().mockRejectedValue(new Error("Merge API failed")),
|
||||
};
|
||||
const result = enrichSessionPR(dashboard);
|
||||
|
||||
await enrichSessionPR(dashboard, scm, pr);
|
||||
|
||||
// Successful fields should be populated
|
||||
expect(dashboard.pr?.additions).toBe(100);
|
||||
expect(dashboard.pr?.deletions).toBe(50);
|
||||
expect(dashboard.pr?.reviewDecision).toBe("approved");
|
||||
|
||||
// Failed fields should have graceful defaults
|
||||
expect(dashboard.pr?.mergeability.blockers).toContain("Merge status unavailable");
|
||||
|
||||
// Should still cache partial results
|
||||
const cacheKey = prCacheKey(pr.owner, pr.repo, pr.number);
|
||||
const cached = prCache.get(cacheKey);
|
||||
expect(cached).not.toBeNull();
|
||||
expect(result).toBe(false);
|
||||
expect(dashboard.pr?.enriched).toBe(false);
|
||||
});
|
||||
|
||||
it("should do nothing if dashboard.pr is null", async () => {
|
||||
it("should enrich review comments from metadata", () => {
|
||||
const pr = createPRInfo();
|
||||
const coreSession = createCoreSession({
|
||||
pr,
|
||||
metadata: {
|
||||
prEnrichment: createEnrichmentMetadata(),
|
||||
prReviewComments: createReviewCommentsMetadata({
|
||||
unresolvedThreads: 2,
|
||||
unresolvedComments: [
|
||||
{ url: "https://example.com", path: "src/app.ts", author: "reviewer", body: "Fix this" },
|
||||
],
|
||||
}),
|
||||
},
|
||||
});
|
||||
const dashboard = sessionToDashboard(coreSession);
|
||||
|
||||
enrichSessionPR(dashboard);
|
||||
|
||||
expect(dashboard.pr?.unresolvedThreads).toBe(2);
|
||||
expect(dashboard.pr?.unresolvedComments).toHaveLength(1);
|
||||
expect(dashboard.pr?.unresolvedComments[0]?.author).toBe("reviewer");
|
||||
});
|
||||
|
||||
it("should handle missing review comments metadata gracefully", () => {
|
||||
const pr = createPRInfo();
|
||||
const coreSession = createCoreSession({
|
||||
pr,
|
||||
metadata: {
|
||||
prEnrichment: createEnrichmentMetadata(),
|
||||
// No prReviewComments
|
||||
},
|
||||
});
|
||||
const dashboard = sessionToDashboard(coreSession);
|
||||
|
||||
enrichSessionPR(dashboard);
|
||||
|
||||
expect(dashboard.pr?.unresolvedThreads).toBe(0);
|
||||
expect(dashboard.pr?.unresolvedComments).toEqual([]);
|
||||
});
|
||||
|
||||
it("should handle malformed prEnrichment JSON gracefully", () => {
|
||||
const pr = createPRInfo();
|
||||
const coreSession = createCoreSession({
|
||||
pr,
|
||||
metadata: { prEnrichment: "not valid json{" },
|
||||
});
|
||||
const dashboard = sessionToDashboard(coreSession);
|
||||
|
||||
const result = enrichSessionPR(dashboard);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(dashboard.pr?.enriched).toBe(false);
|
||||
});
|
||||
|
||||
it("should do nothing if dashboard.pr is null", () => {
|
||||
const dashboard: DashboardSession = {
|
||||
id: "test-1",
|
||||
projectId: "test",
|
||||
|
|
@ -541,32 +486,87 @@ describe("enrichSessionPR", () => {
|
|||
createdAt: new Date().toISOString(),
|
||||
lastActivityAt: new Date().toISOString(),
|
||||
pr: null,
|
||||
metadata: {},
|
||||
metadata: { prEnrichment: createEnrichmentMetadata() },
|
||||
};
|
||||
const pr = createPRInfo();
|
||||
const scm = createMockSCM();
|
||||
|
||||
await enrichSessionPR(dashboard, scm, pr);
|
||||
const result = enrichSessionPR(dashboard);
|
||||
|
||||
expect(scm.getPRSummary).not.toHaveBeenCalled();
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("should handle missing optional SCM methods", async () => {
|
||||
it("should derive mergeability fields from enrichment data", () => {
|
||||
const pr = createPRInfo();
|
||||
const coreSession = createCoreSession({ pr });
|
||||
const coreSession = createCoreSession({
|
||||
pr,
|
||||
metadata: {
|
||||
prEnrichment: createEnrichmentMetadata({
|
||||
ciStatus: "failing",
|
||||
reviewDecision: "changes_requested",
|
||||
mergeable: false,
|
||||
hasConflicts: true,
|
||||
}),
|
||||
},
|
||||
});
|
||||
const dashboard = sessionToDashboard(coreSession);
|
||||
|
||||
// SCM without getPRSummary
|
||||
const scm: SCM = {
|
||||
...createMockSCM(),
|
||||
getPRSummary: undefined,
|
||||
};
|
||||
enrichSessionPR(dashboard);
|
||||
|
||||
await enrichSessionPR(dashboard, scm, pr);
|
||||
expect(dashboard.pr?.mergeability.mergeable).toBe(false);
|
||||
expect(dashboard.pr?.mergeability.ciPassing).toBe(false);
|
||||
expect(dashboard.pr?.mergeability.approved).toBe(false);
|
||||
expect(dashboard.pr?.mergeability.noConflicts).toBe(false);
|
||||
});
|
||||
|
||||
// Should fall back to getPRState
|
||||
expect(scm.getPRState).toHaveBeenCalled();
|
||||
expect(dashboard.pr?.state).toBe("open");
|
||||
it("should set enriched flag on successful enrichment", () => {
|
||||
const pr = createPRInfo();
|
||||
const coreSession = createCoreSession({
|
||||
pr,
|
||||
metadata: { prEnrichment: createEnrichmentMetadata() },
|
||||
});
|
||||
const dashboard = sessionToDashboard(coreSession);
|
||||
expect(dashboard.pr?.enriched).toBe(false);
|
||||
|
||||
enrichSessionPR(dashboard);
|
||||
|
||||
expect(dashboard.pr?.enriched).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("readPREnrichmentFromMetadata", () => {
|
||||
it("should parse valid enrichment metadata", () => {
|
||||
const data = readPREnrichmentFromMetadata({
|
||||
prEnrichment: createEnrichmentMetadata(),
|
||||
});
|
||||
|
||||
expect(data).not.toBeNull();
|
||||
expect(data?.state).toBe("open");
|
||||
expect(data?.additions).toBe(100);
|
||||
expect(data?.deletions).toBe(50);
|
||||
});
|
||||
|
||||
it("should return null when prEnrichment is missing", () => {
|
||||
const data = readPREnrichmentFromMetadata({});
|
||||
expect(data).toBeNull();
|
||||
});
|
||||
|
||||
it("should return null for invalid JSON", () => {
|
||||
const data = readPREnrichmentFromMetadata({ prEnrichment: "bad json{" });
|
||||
expect(data).toBeNull();
|
||||
});
|
||||
|
||||
it("should include review comments when present", () => {
|
||||
const data = readPREnrichmentFromMetadata({
|
||||
prEnrichment: createEnrichmentMetadata(),
|
||||
prReviewComments: createReviewCommentsMetadata({
|
||||
unresolvedThreads: 3,
|
||||
unresolvedComments: [
|
||||
{ url: "https://example.com", path: "src/app.ts", author: "alice", body: "Please fix" },
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
expect(data?.unresolvedThreads).toBe(3);
|
||||
expect(data?.unresolvedComments).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -105,9 +105,6 @@ export interface PREnrichmentData {
|
|||
}>;
|
||||
}
|
||||
|
||||
/** Global PR enrichment cache (60s TTL) */
|
||||
export const prCache = new TTLCache<PREnrichmentData>();
|
||||
|
||||
/** Generate cache key for a PR: `owner/repo#123` */
|
||||
export function prCacheKey(owner: string, repo: string, number: number): string {
|
||||
return `${owner}/${repo}#${number}`;
|
||||
|
|
|
|||
|
|
@ -6,10 +6,9 @@ import {
|
|||
type DashboardOrchestratorLink,
|
||||
type DashboardAttentionZoneMode,
|
||||
} from "@/lib/types";
|
||||
import { getServices, getSCM } from "@/lib/services";
|
||||
import { getServices } from "@/lib/services";
|
||||
import {
|
||||
sessionToDashboard,
|
||||
resolveProject,
|
||||
enrichSessionPR,
|
||||
enrichSessionsMetadataFast,
|
||||
listDashboardOrchestrators,
|
||||
|
|
@ -126,23 +125,10 @@ export const getDashboardPageData = cache(async function getDashboardPageData(pr
|
|||
console.warn("[dashboard-page-data] metadata fast enrichment failed:", err);
|
||||
}
|
||||
|
||||
// PR cache hits only (in-memory lookup, no SCM API calls).
|
||||
// PR enrichment from session metadata (no API calls).
|
||||
for (let i = 0; i < coreSessions.length; i++) {
|
||||
const core = coreSessions[i];
|
||||
if (!core.pr) continue;
|
||||
try {
|
||||
const projectConfig = resolveProject(core, config.projects);
|
||||
const scm = getSCM(registry, projectConfig);
|
||||
if (scm) {
|
||||
try {
|
||||
await enrichSessionPR(pageData.sessions[i], scm, core.pr, { cacheOnly: true });
|
||||
} catch {
|
||||
// Preserve the base session payload if PR enrichment fails.
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(`[dashboard-page-data] PR enrichment failed for session ${core.id}:`, err);
|
||||
}
|
||||
if (!coreSessions[i].pr) continue;
|
||||
enrichSessionPR(pageData.sessions[i]);
|
||||
}
|
||||
|
||||
return pageData;
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import type { AttentionLevel } from "./types";
|
|||
export type ClientMessage =
|
||||
| { ch: "terminal"; id: string; type: "data"; data: string }
|
||||
| { ch: "terminal"; id: string; type: "resize"; cols: number; rows: number }
|
||||
| { ch: "terminal"; id: string; type: "open" }
|
||||
| { ch: "terminal"; id: string; type: "open"; tmuxName?: string }
|
||||
| { ch: "terminal"; id: string; type: "close" }
|
||||
| { ch: "system"; type: "ping" }
|
||||
| { ch: "subscribe"; topics: ("sessions")[] };
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@ import {
|
|||
isTerminalSession,
|
||||
type Session,
|
||||
type Agent,
|
||||
type SCM,
|
||||
type PRInfo,
|
||||
type Tracker,
|
||||
type ProjectConfig,
|
||||
|
|
@ -26,7 +25,7 @@ import {
|
|||
type DashboardOrchestratorLink,
|
||||
getAttentionLevel,
|
||||
} from "./types";
|
||||
import { TTLCache, prCache, prCacheKey, type PREnrichmentData } from "./cache";
|
||||
import { TTLCache, type PREnrichmentData } from "./cache";
|
||||
|
||||
/** Cache for issue titles (5 min TTL — issue titles rarely change) */
|
||||
const issueTitleCache = new TTLCache<string>(300_000);
|
||||
|
|
@ -302,159 +301,86 @@ function normalizeDashboardPRState(
|
|||
* Enrich a DashboardSession's PR with live data from the SCM plugin.
|
||||
* Uses cache to reduce API calls and handles rate limit errors gracefully.
|
||||
*/
|
||||
export async function enrichSessionPR(
|
||||
/**
|
||||
* Read PR enrichment data from session metadata.
|
||||
* The CLI lifecycle manager writes this data every 30s (batch enrichment)
|
||||
* and every 2min (review comments). Returns null if not available.
|
||||
*/
|
||||
export function readPREnrichmentFromMetadata(
|
||||
metadata: Record<string, string>,
|
||||
): PREnrichmentData | null {
|
||||
const enrichmentRaw = metadata["prEnrichment"];
|
||||
if (!enrichmentRaw) return null;
|
||||
|
||||
try {
|
||||
const e = JSON.parse(enrichmentRaw);
|
||||
|
||||
let reviewData = {
|
||||
unresolvedThreads: 0,
|
||||
unresolvedComments: [] as Array<{ url: string; path: string; author: string; body: string }>,
|
||||
};
|
||||
const reviewRaw = metadata["prReviewComments"];
|
||||
if (reviewRaw) {
|
||||
try {
|
||||
reviewData = JSON.parse(reviewRaw);
|
||||
} catch {
|
||||
/* use defaults */
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
state: e.state ?? "open",
|
||||
title: e.title ?? "",
|
||||
additions: e.additions ?? 0,
|
||||
deletions: e.deletions ?? 0,
|
||||
ciStatus: e.ciStatus ?? "none",
|
||||
ciChecks: (e.ciChecks ?? []).map(
|
||||
(c: { name: string; status: string; url?: string }) => ({
|
||||
name: c.name,
|
||||
status: c.status,
|
||||
url: c.url,
|
||||
}),
|
||||
),
|
||||
reviewDecision: e.reviewDecision ?? "none",
|
||||
mergeability: {
|
||||
mergeable: e.mergeable ?? false,
|
||||
ciPassing: e.ciStatus === "passing" || e.ciStatus === "none",
|
||||
approved: e.reviewDecision === "approved",
|
||||
noConflicts: !(e.hasConflicts ?? false),
|
||||
blockers: e.blockers ?? [],
|
||||
},
|
||||
unresolvedThreads: reviewData.unresolvedThreads ?? 0,
|
||||
unresolvedComments: reviewData.unresolvedComments ?? [],
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enrich a DashboardSession's PR data from session metadata.
|
||||
* The CLI lifecycle manager persists batch enrichment data to metadata files.
|
||||
* No GitHub API calls — reads from disk via the metadata already loaded.
|
||||
*/
|
||||
export function enrichSessionPR(
|
||||
dashboard: DashboardSession,
|
||||
scm: SCM,
|
||||
pr: PRInfo,
|
||||
opts?: { cacheOnly?: boolean },
|
||||
): Promise<boolean> {
|
||||
): boolean {
|
||||
if (!dashboard.pr) return false;
|
||||
|
||||
const cacheKey = prCacheKey(pr.owner, pr.repo, pr.number);
|
||||
const data = readPREnrichmentFromMetadata(dashboard.metadata);
|
||||
if (!data) return false;
|
||||
|
||||
// Check cache first
|
||||
const cached = prCache.get(cacheKey);
|
||||
if (cached && dashboard.pr) {
|
||||
dashboard.pr.state = cached.state;
|
||||
dashboard.pr.title = cached.title;
|
||||
dashboard.pr.additions = cached.additions;
|
||||
dashboard.pr.deletions = cached.deletions;
|
||||
dashboard.pr.ciStatus = cached.ciStatus;
|
||||
dashboard.pr.ciChecks = cached.ciChecks;
|
||||
dashboard.pr.reviewDecision = cached.reviewDecision;
|
||||
dashboard.pr.mergeability = cached.mergeability;
|
||||
dashboard.pr.unresolvedThreads = cached.unresolvedThreads;
|
||||
dashboard.pr.unresolvedComments = cached.unresolvedComments;
|
||||
dashboard.pr.enriched = true;
|
||||
refreshDashboardSessionDerivedFields(dashboard);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Cache miss — if cacheOnly, signal caller to refresh in background
|
||||
if (opts?.cacheOnly) return false;
|
||||
|
||||
// Fetch from SCM
|
||||
const results = await Promise.allSettled([
|
||||
scm.getPRSummary
|
||||
? scm.getPRSummary(pr)
|
||||
: scm.getPRState(pr).then((state) => ({ state, title: "", additions: 0, deletions: 0 })),
|
||||
scm.getCIChecks(pr),
|
||||
scm.getCISummary(pr),
|
||||
scm.getReviewDecision(pr),
|
||||
scm.getMergeability(pr),
|
||||
scm.getPendingComments(pr),
|
||||
]);
|
||||
|
||||
const [summaryR, checksR, ciR, reviewR, mergeR, commentsR] = results;
|
||||
|
||||
// Check if most critical requests failed (likely rate limit)
|
||||
// Note: Some methods (like getCISummary) return fallback values instead of rejecting,
|
||||
// so we can't rely on "all rejected" — check if majority failed instead
|
||||
const failedCount = results.filter((r) => r.status === "rejected").length;
|
||||
const mostFailed = failedCount >= results.length / 2;
|
||||
|
||||
if (mostFailed) {
|
||||
const rejectedResults = results.filter(
|
||||
(r) => r.status === "rejected",
|
||||
) as PromiseRejectedResult[];
|
||||
const firstError = rejectedResults[0]?.reason;
|
||||
console.warn(
|
||||
`[enrichSessionPR] ${failedCount}/${results.length} API calls failed for PR #${pr.number} (rate limited or unavailable):`,
|
||||
String(firstError),
|
||||
);
|
||||
// Don't return early — apply any successful results below
|
||||
}
|
||||
|
||||
// Apply successful results
|
||||
if (summaryR.status === "fulfilled") {
|
||||
dashboard.pr.state = summaryR.value.state;
|
||||
dashboard.pr.additions = summaryR.value.additions;
|
||||
dashboard.pr.deletions = summaryR.value.deletions;
|
||||
if (summaryR.value.title) {
|
||||
dashboard.pr.title = summaryR.value.title;
|
||||
}
|
||||
}
|
||||
|
||||
if (checksR.status === "fulfilled") {
|
||||
dashboard.pr.ciChecks = checksR.value.map((c) => ({
|
||||
name: c.name,
|
||||
status: c.status,
|
||||
url: c.url,
|
||||
}));
|
||||
}
|
||||
|
||||
if (ciR.status === "fulfilled") {
|
||||
dashboard.pr.ciStatus = ciR.value;
|
||||
}
|
||||
|
||||
if (reviewR.status === "fulfilled") {
|
||||
dashboard.pr.reviewDecision = reviewR.value;
|
||||
}
|
||||
|
||||
if (mergeR.status === "fulfilled") {
|
||||
dashboard.pr.mergeability = mergeR.value;
|
||||
} else {
|
||||
// Mergeability failed — mark as unavailable
|
||||
dashboard.pr.mergeability.blockers = ["Merge status unavailable"];
|
||||
}
|
||||
|
||||
if (commentsR.status === "fulfilled") {
|
||||
const comments = commentsR.value;
|
||||
dashboard.pr.unresolvedThreads = comments.length;
|
||||
dashboard.pr.unresolvedComments = comments.map((c) => ({
|
||||
url: c.url,
|
||||
path: c.path ?? "",
|
||||
author: c.author,
|
||||
body: c.body,
|
||||
}));
|
||||
}
|
||||
|
||||
// Mark as enriched — we attempted SCM API calls and applied whatever succeeded
|
||||
dashboard.pr.state = data.state;
|
||||
if (data.title) dashboard.pr.title = data.title;
|
||||
dashboard.pr.additions = data.additions;
|
||||
dashboard.pr.deletions = data.deletions;
|
||||
dashboard.pr.ciStatus = data.ciStatus;
|
||||
dashboard.pr.ciChecks = data.ciChecks;
|
||||
dashboard.pr.reviewDecision = data.reviewDecision;
|
||||
dashboard.pr.mergeability = data.mergeability;
|
||||
dashboard.pr.unresolvedThreads = data.unresolvedThreads;
|
||||
dashboard.pr.unresolvedComments = data.unresolvedComments;
|
||||
dashboard.pr.enriched = true;
|
||||
|
||||
// Add rate-limit warning blocker if most requests failed
|
||||
// (but we still applied any successful results above)
|
||||
if (
|
||||
mostFailed &&
|
||||
!dashboard.pr.mergeability.blockers.includes("API rate limited or unavailable")
|
||||
) {
|
||||
dashboard.pr.mergeability.blockers.push("API rate limited or unavailable");
|
||||
}
|
||||
|
||||
// If rate limited, cache the partial data with a long TTL (5 min) so we stop
|
||||
// hammering the API on every page load. The rate-limit blocker flag tells the
|
||||
// UI to show stale-data warnings instead of making decisions on bad data.
|
||||
if (mostFailed) {
|
||||
const rateLimitedData: PREnrichmentData = {
|
||||
state: dashboard.pr.state,
|
||||
title: dashboard.pr.title,
|
||||
additions: dashboard.pr.additions,
|
||||
deletions: dashboard.pr.deletions,
|
||||
ciStatus: dashboard.pr.ciStatus,
|
||||
ciChecks: dashboard.pr.ciChecks,
|
||||
reviewDecision: dashboard.pr.reviewDecision,
|
||||
mergeability: dashboard.pr.mergeability,
|
||||
unresolvedThreads: dashboard.pr.unresolvedThreads,
|
||||
unresolvedComments: dashboard.pr.unresolvedComments,
|
||||
};
|
||||
prCache.set(cacheKey, rateLimitedData, 60 * 60_000); // 60 min — GitHub rate limit resets hourly
|
||||
refreshDashboardSessionDerivedFields(dashboard);
|
||||
return true;
|
||||
}
|
||||
|
||||
const cacheData: PREnrichmentData = {
|
||||
state: dashboard.pr.state,
|
||||
title: dashboard.pr.title,
|
||||
additions: dashboard.pr.additions,
|
||||
deletions: dashboard.pr.deletions,
|
||||
ciStatus: dashboard.pr.ciStatus,
|
||||
ciChecks: dashboard.pr.ciChecks,
|
||||
reviewDecision: dashboard.pr.reviewDecision,
|
||||
mergeability: dashboard.pr.mergeability,
|
||||
unresolvedThreads: dashboard.pr.unresolvedThreads,
|
||||
unresolvedComments: dashboard.pr.unresolvedComments,
|
||||
};
|
||||
prCache.set(cacheKey, cacheData);
|
||||
refreshDashboardSessionDerivedFields(dashboard);
|
||||
return true;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -115,10 +115,14 @@ async function initServices(): Promise<Services> {
|
|||
|
||||
const sessionManager = createSessionManager({ config, registry });
|
||||
|
||||
// Start the lifecycle manager — polls sessions every 30s, triggers reactions
|
||||
// (CI failure → send fix message, review comments → forward to agent, etc.)
|
||||
// Lifecycle manager for webhook-triggered checks only — no independent polling.
|
||||
// The CLI process (`ao`) runs the 30s polling loop and writes PR enrichment
|
||||
// data to session metadata files. The dashboard reads from metadata instead
|
||||
// of calling GitHub API directly. This means the dashboard is NOT self-sufficient:
|
||||
// if the CLI process isn't running, sessions will have no PR enrichment data,
|
||||
// no state transitions, and no reactions. The SSE endpoint surfaces whatever
|
||||
// metadata the CLI has written — stale data is expected when CLI is down.
|
||||
const lifecycleManager = createLifecycleManager({ config, registry, sessionManager });
|
||||
lifecycleManager.start(30_000);
|
||||
|
||||
return { config, registry, sessionManager, lifecycleManager };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import type { ClientMessage, ServerMessage, SessionPatch } from "@/lib/mux-proto
|
|||
interface MuxContextValue {
|
||||
subscribeTerminal: (id: string, callback: (data: string) => void) => () => void;
|
||||
writeTerminal: (id: string, data: string) => void;
|
||||
openTerminal: (id: string) => void;
|
||||
openTerminal: (id: string, tmuxName?: string) => void;
|
||||
closeTerminal: (id: string) => void;
|
||||
resizeTerminal: (id: string, cols: number, rows: number) => void;
|
||||
status: "connecting" | "connected" | "reconnecting" | "disconnected";
|
||||
|
|
@ -71,7 +71,7 @@ function buildMuxWsUrl(runtimeConfig: { directTerminalPort?: string; proxyWsPath
|
|||
export function MuxProvider({ children }: { children: ReactNode }) {
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const subscribersRef = useRef(new Map<string, Set<(data: string) => void>>());
|
||||
const openedTerminalsRef = useRef(new Set<string>());
|
||||
const openedTerminalsRef = useRef(new Map<string, string | undefined>());
|
||||
const [status, setStatus] = useState<"connecting" | "connected" | "reconnecting" | "disconnected">(
|
||||
"connecting",
|
||||
);
|
||||
|
|
@ -105,11 +105,12 @@ export function MuxProvider({ children }: { children: ReactNode }) {
|
|||
reconnectAttempt.current = 0;
|
||||
|
||||
// Re-open previously opened terminals
|
||||
for (const terminalId of openedTerminalsRef.current) {
|
||||
for (const [terminalId, tmuxName] of openedTerminalsRef.current) {
|
||||
const openMsg: ClientMessage = {
|
||||
ch: "terminal",
|
||||
id: terminalId,
|
||||
type: "open",
|
||||
...(tmuxName && { tmuxName }),
|
||||
};
|
||||
ws.send(JSON.stringify(openMsg));
|
||||
}
|
||||
|
|
@ -136,8 +137,10 @@ export function MuxProvider({ children }: { children: ReactNode }) {
|
|||
}
|
||||
}
|
||||
} else if (msg.type === "opened") {
|
||||
// Terminal opened successfully
|
||||
openedTerminalsRef.current.add(msg.id);
|
||||
// Terminal opened successfully — preserve existing tmuxName
|
||||
if (!openedTerminalsRef.current.has(msg.id)) {
|
||||
openedTerminalsRef.current.set(msg.id, undefined);
|
||||
}
|
||||
} else if (msg.type === "exited") {
|
||||
// PTY exited and could not be re-attached — remove so it isn't
|
||||
// re-opened on reconnect, and surface a terminal-level error chunk
|
||||
|
|
@ -269,13 +272,14 @@ export function MuxProvider({ children }: { children: ReactNode }) {
|
|||
}
|
||||
}, []);
|
||||
|
||||
const openTerminal = useCallback((id: string) => {
|
||||
openedTerminalsRef.current.add(id);
|
||||
const openTerminal = useCallback((id: string, tmuxName?: string) => {
|
||||
openedTerminalsRef.current.set(id, tmuxName);
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
const msg: ClientMessage = {
|
||||
ch: "terminal",
|
||||
id,
|
||||
type: "open",
|
||||
...(tmuxName && { tmuxName }),
|
||||
};
|
||||
wsRef.current.send(JSON.stringify(msg));
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue