Keep the PR monitor live: gh token fallback, honest errors, poll gating

The monitor went stale-empty overnight because every failure path
compounded: the app usually launches without GITHUB_TOKEN in its env,
so all GitHub calls ran anonymous (60 req/h) while the panel polls
~180 req/h — rate-limited within minutes — and non-success responses
returned Ok(None), which the UI rendered as a confident "No checks /
0 open comments" instead of an error.

Three fixes: ambient_github_token falls back to `gh auth token`
(resolved once per process) so the desktop uses the CLI credential
that's actually on the machine; GitHub commands now return Err on
non-404 failures so React Query keeps the last good snapshot and
retries instead of overwriting it with nothing; and polling pauses
entirely while the drawer is closed — unless automation is armed,
which keeps watching in the background.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
klopez4212
2026-07-05 07:10:32 +01:00
parent d2d26bc07c
commit 527330a093
2 changed files with 64 additions and 14 deletions

View File

@@ -103,9 +103,14 @@ pub async fn fetch_github_pull_request(
.send()
.await
.map_err(|error| format!("github request failed: {error}"))?;
if !response.status().is_success() {
if response.status() == reqwest::StatusCode::NOT_FOUND {
return Ok(None);
}
if !response.status().is_success() {
// Rate limits and transient failures must NOT read as "no data" —
// the UI keeps its last good snapshot on error.
return Err(format!("github request failed: {}", response.status()));
}
let body: serde_json::Value = response
.json()
@@ -167,9 +172,14 @@ pub async fn fetch_github_check_summary(
.send()
.await
.map_err(|error| format!("github request failed: {error}"))?;
if !response.status().is_success() {
if response.status() == reqwest::StatusCode::NOT_FOUND {
return Ok(None);
}
if !response.status().is_success() {
// Rate limits and transient failures must NOT read as "no data" —
// the UI keeps its last good snapshot on error.
return Err(format!("github request failed: {}", response.status()));
}
let body: serde_json::Value = response
.json()
@@ -249,9 +259,12 @@ pub async fn fetch_github_pr_comment_state(
.send()
.await
.map_err(|error| format!("github request failed: {error}"))?;
if !pr_response.status().is_success() {
if pr_response.status() == reqwest::StatusCode::NOT_FOUND {
return Ok(None);
}
if !pr_response.status().is_success() {
return Err(format!("github request failed: {}", pr_response.status()));
}
let pr: serde_json::Value = pr_response
.json()
.await
@@ -264,9 +277,15 @@ pub async fn fetch_github_pr_comment_state(
.send()
.await
.map_err(|error| format!("github request failed: {error}"))?;
if !comments_response.status().is_success() {
if comments_response.status() == reqwest::StatusCode::NOT_FOUND {
return Ok(None);
}
if !comments_response.status().is_success() {
return Err(format!(
"github request failed: {}",
comments_response.status()
));
}
let comments: serde_json::Value = comments_response
.json()
.await
@@ -417,12 +436,34 @@ fn parse_github_remote(remote: &str) -> Option<(String, String)> {
}
fn ambient_github_token() -> Option<String> {
["GITHUB_TOKEN", "GH_TOKEN"].iter().find_map(|name| {
std::env::var(name)
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
})
static TOKEN: std::sync::OnceLock<Option<String>> = std::sync::OnceLock::new();
TOKEN
.get_or_init(|| {
let env_token = ["GITHUB_TOKEN", "GH_TOKEN"].iter().find_map(|name| {
std::env::var(name)
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
});
if env_token.is_some() {
return env_token;
}
// Desktop apps rarely inherit a token env (launched from Finder or
// a clean shell), but the gh CLI credential is usually present —
// without it the anonymous 60 req/h limit starves the PR monitor
// within minutes. Resolved once per process.
let output = std::process::Command::new("gh")
.args(["auth", "token"])
.stdin(std::process::Stdio::null())
.output()
.ok()?;
if !output.status.success() {
return None;
}
let token = String::from_utf8_lossy(&output.stdout).trim().to_string();
(!token.is_empty()).then_some(token)
})
.clone()
}
fn is_valid_github_name(value: &str) -> bool {

View File

@@ -55,23 +55,32 @@ export function ChatWorkPanel({
projectPath?: string | null;
}) {
// No PR link in the chat yet? Discover it from the branch via the
// project's git remote (agents don't always paste the URL).
// project's git remote (agents don't always paste the URL). All GitHub
// polling pauses while the drawer is closed — a hidden panel burning the
// API rate limit is how the monitor went stale-empty.
const automation = useChatWorkAutomation(chatId);
// Armed automation keeps watching with the drawer closed; otherwise a
// hidden panel stops polling entirely.
const monitorActive =
open || automation.autoFixCi || automation.addressComments;
const discoveredPrQuery = useGithubPrForBranchQuery(
prHref ? null : projectPath,
monitorActive && !prHref ? projectPath : null,
branch,
);
const effectiveHref = prHref ?? discoveredPrQuery.data ?? null;
const preview = effectiveHref
? parseSupportedLinkPreview(effectiveHref)
: null;
const ref = effectiveHref ? parseGithubPullRequestRef(effectiveHref) : null;
const parsedRef = effectiveHref
? parseGithubPullRequestRef(effectiveHref)
: null;
const ref = monitorActive ? parsedRef : null;
const prQuery = useGithubPullRequestQuery(ref);
const pr = prQuery.data ?? null;
const checksQuery = useGithubCheckSummaryQuery(ref, pr?.headSha);
const checks = checksQuery.data ?? null;
const commentStateQuery = useGithubCommentStateQuery(ref);
const openThreads = commentStateQuery.data?.openThreads ?? 0;
const automation = useChatWorkAutomation(chatId);
// Live activity wins over the PR's head ref: the agent may have moved to a
// new worktree since opening the PR, and activity updates immediately.
const currentBranch = branch?.trim() || pr?.headRef?.trim() || null;