diff --git a/.repository-projection.json b/.repository-projection.json
index 40d8d357d..6041c0554 100644
--- a/.repository-projection.json
+++ b/.repository-projection.json
@@ -3,11 +3,11 @@
"projection": "deixic-code",
"projectionSchemaVersion": 1,
"sourceRepository": "dx-corp/mono",
- "sourceSha": "60419c45bebb8cd7edc555c1384b249aa3d27df4",
+ "sourceSha": "aad70dca9153ee14f58ad83743851edf3756e515",
"destinationRepository": "dx-corp/code",
- "priorProjectedBase": "ead62c78a1e6094c02f4ae61ebff1714d4965e2f",
+ "priorProjectedBase": "fd774e71a9294482c95af94611c6dd458df46640",
"definitionDigest": "82936441c776e3e8edb5d215a75007ec9714a233f489d460075d79d5ef5ba32f",
"toolDigest": "ab19140af8e449c04288cb7c52e992982833e3882ddce59d22314d971feaf946",
- "contentDigest": "bf5843be1ca2cd2fa79d378c1b3ae7f7d9879de8d559bb90545a0beaaccc9ddf",
+ "contentDigest": "cb9bcb8eeb4b7107c0dbf55cedd0d8ad81db6ad3edc8ff3c2c53587d62015449",
"publicationEligible": true
}
diff --git a/docs/TOOLS_REFERENCE.md b/docs/TOOLS_REFERENCE.md
index d14f12542..659137065 100644
--- a/docs/TOOLS_REFERENCE.md
+++ b/docs/TOOLS_REFERENCE.md
@@ -72,10 +72,16 @@ error distinctions.
| Tool | Description | Actions / Options |
| ---- | ----------- | ----------------- |
-| `gh_pr` | Pull request operations | **Actions:** `create`, `checkout`, `view`, `list`, `comment`
**Options:** `number`, `title`, `body`, `branch`, `base`, `draft`, `state`, `author`, `limit`, `json` |
+| `gh_pr` | Pull request operations | **Actions:** `create`, `checkout`, `view`, `list`, `comment`, `checks`, `diff`, `review_threads`, `reply_review_thread`, `resolve_review_thread`, `checks_watch`
**Options:** `number`, `title`, `body`, `branch`, `base`, `draft`, `state`, `author`, `limit`, `json`, `nameOnly`, `threadId`, `includeResolved`, `timeoutSecs` |
| `gh_issue` | Issue operations | **Actions:** `create`, `view`, `list`, `comment`, `close`
**Options:** `number`, `title`, `body`, `labels`, `state`, `author`, `limit`, `json` |
| `gh_repo` | Repository operations | **Actions:** `view`, `fork`, `clone`
**Options:** `repository`, `directory`, `json` |
+**Review and CI loop (`gh_pr`):**
+- `review_threads` (`number`, optional `includeResolved`) -- lists unresolved review threads via GraphQL (`gh api graphql`), each with `path`, `line`, `isResolved`, `isOutdated`, the thread's GraphQL `id`, and its comments (author, body, `createdAt`), plus top-level review summaries (state + body). Output is bounded: comment/review bodies and the thread/review counts are capped and marked `truncated` when a PR has more than fits.
+- `reply_review_thread` (`threadId`, `body`) -- replies inline to a thread returned by `review_threads` (GraphQL `addPullRequestReviewThreadReply`).
+- `resolve_review_thread` (`threadId`) -- marks a thread resolved (GraphQL `resolveReviewThread`) once it's actually been addressed.
+- `checks_watch` (`number`, optional `timeoutSecs`, default 900s / max 3600s) -- polls CI every 30s until every check is `completed` or the timeout elapses, instead of a single `checks` snapshot. Returns a per-check summary and, for failed checks, up to 3 jobs' worth of failure-log tail (`gh run view --log-failed`, capped to 200 lines / 16KiB each).
+
**Prerequisites:**
- GitHub CLI (`gh`) must be installed: `brew install gh` (macOS) or see [cli.github.com](https://cli.github.com)
- Must be authenticated: `gh auth login`
diff --git a/packages/local-host-rs/src/tools/gh.rs b/packages/local-host-rs/src/tools/gh.rs
index c1e9fc9a8..3035ceedf 100644
--- a/packages/local-host-rs/src/tools/gh.rs
+++ b/packages/local-host-rs/src/tools/gh.rs
@@ -22,10 +22,114 @@ use serde_json::Value;
use std::process::{Output, Stdio};
use std::time::Duration;
use tokio::process::Command;
+use tokio::time::Instant;
use tokio_util::sync::CancellationToken;
use crate::agent::ToolResult;
+/// Output-bounding limits for `gh_pr` actions that fan out over
+/// model-authored or third-party text (review threads, reviews, CI logs).
+/// Mirrors the cap-and-mark-truncated pattern used by `tools/exa.rs`
+/// (`MAX_OUTPUT_CHARS`) so an unattended agent can never be handed an
+/// unbounded response from a PR with hundreds of comments or a noisy CI log.
+mod bounds {
+ /// Review threads returned by `review_threads` (GraphQL `first:`).
+ pub(super) const MAX_REVIEW_THREADS: usize = 50;
+ /// Comments returned per thread (GraphQL `first:`).
+ pub(super) const MAX_THREAD_COMMENTS: usize = 20;
+ /// Top-level review summaries returned (GraphQL `first:`).
+ pub(super) const MAX_REVIEWS: usize = 20;
+ /// Characters kept per review-thread comment body.
+ pub(super) const MAX_COMMENT_BODY_CHARS: usize = 2000;
+ /// Characters kept per top-level review body.
+ pub(super) const MAX_REVIEW_BODY_CHARS: usize = 4000;
+ /// Final safety net on the serialized `review_threads` output.
+ pub(super) const MAX_OUTPUT_CHARS: usize = 20_000;
+ /// Poll interval for `checks_watch`.
+ pub(super) const CHECKS_WATCH_INTERVAL_SECS: u64 = 30;
+ /// Default `checks_watch` timeout when the caller doesn't specify one.
+ pub(super) const CHECKS_WATCH_DEFAULT_TIMEOUT_SECS: u64 = 900;
+ /// Hard ceiling on `checks_watch` timeout regardless of caller input.
+ pub(super) const CHECKS_WATCH_MAX_TIMEOUT_SECS: u64 = 3600;
+ /// Failed jobs whose logs are fetched by `checks_watch`.
+ pub(super) const MAX_FAILED_JOB_LOGS: usize = 3;
+ /// Lines kept per failed-job log tail.
+ pub(super) const MAX_LOG_TAIL_LINES: usize = 200;
+ /// Bytes kept per failed-job log tail.
+ pub(super) const MAX_LOG_TAIL_BYTES: usize = 16 * 1024;
+}
+
+/// Truncate `text` to at most `max_chars` UTF-8 scalar values, appending a
+/// `(truncated)` marker when truncation actually happened.
+fn truncate_text(text: &str, max_chars: usize) -> (String, bool) {
+ if text.chars().count() <= max_chars {
+ return (text.to_string(), false);
+ }
+ let mut truncated: String = text.chars().take(max_chars).collect();
+ truncated.push_str("\n\n(truncated)");
+ (truncated, true)
+}
+
+/// Keep the last `max_lines` lines of `text`, further bounded to
+/// `max_bytes`. Used to cap CI failure logs, which can otherwise run to
+/// megabytes for a single job.
+fn tail_text(text: &str, max_lines: usize, max_bytes: usize) -> (String, bool) {
+ let lines: Vec<&str> = text.lines().collect();
+ let mut truncated_lines = lines.len() > max_lines;
+ let start = lines.len().saturating_sub(max_lines);
+ let mut tail = lines[start..].join("\n");
+ if tail.len() > max_bytes {
+ truncated_lines = true;
+ // Byte-safe: walk back to a char boundary before slicing.
+ let mut cut = tail.len() - max_bytes;
+ while cut < tail.len() && !tail.is_char_boundary(cut) {
+ cut += 1;
+ }
+ tail = tail[cut..].to_string();
+ }
+ (tail, truncated_lines)
+}
+
+/// Validate a GitHub GraphQL node id used to address a pull request review
+/// thread. Real ids look like `PRRT_kwDOA...`: an opaque prefix identifying
+/// the node type, an underscore, then a base64url-ish payload. Reject
+/// anything else up front so a malformed or hallucinated id fails with a
+/// clear tool error instead of reaching `gh api graphql` as an untrusted
+/// argument.
+fn is_valid_review_thread_id(id: &str) -> bool {
+ match id.strip_prefix("PRRT_") {
+ Some(rest) => {
+ !rest.is_empty()
+ && rest.len() <= 128
+ && rest
+ .chars()
+ .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '=')
+ }
+ None => false,
+ }
+}
+
+/// Extract `(run_id, job_id)` from a check run's `details_url`, e.g.
+/// `https://github.com/{owner}/{repo}/actions/runs/{run_id}/job/{job_id}`.
+/// Returns `None` for check runs that aren't backed by a GitHub Actions job
+/// (third-party checks apps use their own `details_url` shape).
+fn parse_run_and_job_ids(details_url: &str) -> Option<(String, String)> {
+ let runs_at = details_url.find("/actions/runs/")?;
+ let rest = &details_url[runs_at + "/actions/runs/".len()..];
+ let (run_id, rest) = rest.split_once('/')?;
+ let rest = rest.strip_prefix("job/")?;
+ let job_id = rest.split(['/', '?', '#']).next().unwrap_or(rest);
+ if run_id.chars().all(|c| c.is_ascii_digit())
+ && !run_id.is_empty()
+ && job_id.chars().all(|c| c.is_ascii_digit())
+ && !job_id.is_empty()
+ {
+ Some((run_id.to_string(), job_id.to_string()))
+ } else {
+ None
+ }
+}
+
#[cfg(test)]
static TEST_GH_BINARY: std::sync::Mutex