diff --git a/apps/decodex/src/agent/tracker_tool_bridge.rs b/apps/decodex/src/agent/tracker_tool_bridge.rs index 8096501..fd6696a 100644 --- a/apps/decodex/src/agent/tracker_tool_bridge.rs +++ b/apps/decodex/src/agent/tracker_tool_bridge.rs @@ -82,6 +82,7 @@ pub(crate) trait PullRequestInspector { cwd: &Path, pr_url: &str, github_token: &str, + gh_command_path: Option<&Path>, ) -> std::result::Result; } @@ -482,6 +483,7 @@ pub(crate) struct ReviewHandoffContext { pub(crate) worktree_path: String, pub(crate) cwd: PathBuf, pub(crate) github_token_env_var: Option, + pub(crate) github_command_path: Option, pub(crate) internal_review_mode: InternalReviewMode, pub(crate) mode: ReviewExecutionMode, pub(crate) recorded_pr_url: Option, @@ -603,8 +605,9 @@ impl PullRequestInspector for GhPullRequestInspector { cwd: &Path, pr_url: &str, github_token: &str, + gh_command_path: Option<&Path>, ) -> std::result::Result { - let mut command = github::gh_command(); + let mut command = github::gh_command_with_config(gh_command_path); command.args([ "pr", diff --git a/apps/decodex/src/agent/tracker_tool_bridge/review.rs b/apps/decodex/src/agent/tracker_tool_bridge/review.rs index b36803d..e8fc7ec 100644 --- a/apps/decodex/src/agent/tracker_tool_bridge/review.rs +++ b/apps/decodex/src/agent/tracker_tool_bridge/review.rs @@ -164,6 +164,7 @@ impl<'a> TrackerToolBridge<'a> { &review_context.cwd, pr_url, github_token.as_str(), + review_context.github_command_path.as_deref(), )?; let local_repo = self.local_repo_inspector.inspect_local_repo(&review_context.cwd)?; @@ -233,6 +234,7 @@ impl<'a> TrackerToolBridge<'a> { &review_context.cwd, pr_url, github_token.as_str(), + review_context.github_command_path.as_deref(), )?; let local_repo = self.local_repo_inspector.inspect_local_repo(&review_context.cwd)?; diff --git a/apps/decodex/src/agent/tracker_tool_bridge/tests.rs b/apps/decodex/src/agent/tracker_tool_bridge/tests.rs index 9a30b99..a57cdfa 100644 --- a/apps/decodex/src/agent/tracker_tool_bridge/tests.rs +++ b/apps/decodex/src/agent/tracker_tool_bridge/tests.rs @@ -235,6 +235,7 @@ impl PullRequestInspector for FakePullRequestInspector { _cwd: &Path, _pr_url: &str, _github_token: &str, + _gh_command_path: Option<&Path>, ) -> std::result::Result { self.responses.borrow_mut().remove(0) } @@ -250,6 +251,7 @@ impl PullRequestInspector for GitHubTokenAssertingPullRequestInspector { _cwd: &Path, _pr_url: &str, github_token: &str, + _gh_command_path: Option<&Path>, ) -> std::result::Result { assert_eq!(github_token, self.expected_token.as_str()); @@ -442,6 +444,7 @@ fn sample_review_context() -> ReviewHandoffContext { worktree_path: String::from(".worktrees/PUB-618"), cwd: PathBuf::from("/tmp/PUB-618"), github_token_env_var: Some(String::from("HOME")), + github_command_path: None, internal_review_mode: InternalReviewMode::Loop, mode: ReviewExecutionMode::Handoff, recorded_pr_url: None, @@ -469,6 +472,7 @@ fn sample_review_context_in(cwd: &Path) -> ReviewHandoffContext { worktree_path: String::from(".worktrees/PUB-618"), cwd: cwd.to_path_buf(), github_token_env_var: Some(String::from("HOME")), + github_command_path: None, internal_review_mode: InternalReviewMode::Loop, mode: ReviewExecutionMode::Handoff, recorded_pr_url: None, @@ -484,6 +488,7 @@ fn sample_review_repair_context_in(cwd: &Path, pr_url: &str) -> ReviewHandoffCon worktree_path: String::from(".worktrees/PUB-618"), cwd: cwd.to_path_buf(), github_token_env_var: Some(String::from("HOME")), + github_command_path: None, internal_review_mode: InternalReviewMode::Loop, mode: ReviewExecutionMode::Repair, recorded_pr_url: Some(pr_url.to_owned()), @@ -499,6 +504,7 @@ fn sample_closeout_context_in(cwd: &Path, pr_url: &str) -> ReviewHandoffContext worktree_path: String::from(".worktrees/PUB-618"), cwd: cwd.to_path_buf(), github_token_env_var: Some(String::from("HOME")), + github_command_path: None, internal_review_mode: InternalReviewMode::Loop, mode: ReviewExecutionMode::Closeout, recorded_pr_url: Some(pr_url.to_owned()), diff --git a/apps/decodex/src/config.rs b/apps/decodex/src/config.rs index 8f19bb2..80e529c 100644 --- a/apps/decodex/src/config.rs +++ b/apps/decodex/src/config.rs @@ -107,7 +107,7 @@ impl ServiceConfig { worktree_root: document.paths.resolve_worktree_root(&repo_root)?, workflow_path: config_dir.join(WORKFLOW_FILE_NAME), tracker: document.tracker, - github: document.github, + github: document.github.resolve_paths(config_dir)?, codex: document.codex.resolve_paths(config_dir)?, privacy_classifier: document.privacy_classifier, }) @@ -143,6 +143,7 @@ impl ProjectTrackerConfig { #[serde(deny_unknown_fields)] pub struct ProjectGitHubConfig { token_env_var: String, + command_path: Option, } impl ProjectGitHubConfig { /// Name of the environment variable that stores the GitHub token. @@ -150,14 +151,33 @@ impl ProjectGitHubConfig { &self.token_env_var } + /// Optional configured GitHub CLI command path. + pub fn command_path(&self) -> Option<&Path> { + self.command_path.as_deref() + } + /// Resolve the configured GitHub token env-var name into a concrete token string. pub fn resolve_token(&self) -> Result { resolve_secret_env_var("github.token_env_var", self.token_env_var()) } + fn resolve_paths(mut self, config_dir: &Path) -> Result { + if let Some(command_path) = self.command_path.take() { + validate_nonempty_path("github.command_path", &command_path)?; + + self.command_path = Some(resolve_relative_path(config_dir, &command_path)); + } + + Ok(self) + } + fn validate(&self) -> Result<()> { validate_env_var_name("github.token_env_var", self.token_env_var())?; + if let Some(command_path) = self.command_path.as_deref() { + validate_nonempty_path("github.command_path", command_path)?; + } + Ok(()) } } @@ -951,6 +971,7 @@ mod tests { [github] token_env_var = "HOME" + command_path = "bin/gh" "#, ); let config = @@ -963,6 +984,7 @@ mod tests { assert_eq!(config.worktree_root(), canonical_root.join(".worktrees")); assert_eq!(config.workflow_path(), canonical_root.join("WORKFLOW.md")); assert_eq!(config.github().token_env_var(), "HOME"); + assert_eq!(config.github().command_path(), Some(canonical_root.join("bin/gh").as_path())); assert_eq!(config.codex().internal_review_mode(), InternalReviewMode::Loop); assert!(config.codex().external_review_enabled()); } diff --git a/apps/decodex/src/github.rs b/apps/decodex/src/github.rs index 753cd11..8cd25f1 100644 --- a/apps/decodex/src/github.rs +++ b/apps/decodex/src/github.rs @@ -60,6 +60,55 @@ const GH_BINARY: &str = "gh"; const GH_FALLBACK_PATHS: &[&str] = &["/run/current-system/sw/bin/gh", "/opt/homebrew/bin/gh", "/usr/local/bin/gh", "/usr/bin/gh"]; +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum GhCommandDiscoveryTier { + Configured, + Path, + UserBin, + KnownFallback, + Missing, +} +impl GhCommandDiscoveryTier { + pub(crate) const fn as_str(self) -> &'static str { + match self { + Self::Configured => "configured", + Self::Path => "path", + Self::UserBin => "user-bin", + Self::KnownFallback => "known-fallback", + Self::Missing => "missing", + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct GhCommandResolution { + command_path: PathBuf, + resolved_path: Option, + configured_path: Option, + discovery_tier: GhCommandDiscoveryTier, +} +impl GhCommandResolution { + pub(crate) fn command_path(&self) -> &Path { + &self.command_path + } + + pub(crate) fn resolved_path(&self) -> Option<&Path> { + self.resolved_path.as_deref() + } + + pub(crate) fn configured_path(&self) -> Option<&Path> { + self.configured_path.as_deref() + } + + pub(crate) const fn discovery_tier(&self) -> GhCommandDiscoveryTier { + self.discovery_tier + } + + pub(crate) const fn available(&self) -> bool { + self.resolved_path.is_some() + } +} + #[derive(Debug)] pub(crate) struct PullRequestLocator { pub(crate) owner: String, @@ -123,6 +172,17 @@ struct PullRequestLandingStateNode { commits: PullRequestCommitConnection, } +struct PullRequestLandingStatePageQuery<'a> { + cwd: &'a Path, + owner: &'a str, + repo: &'a str, + number: u64, + review_threads_after: Option<&'a str>, + pr_url: &'a str, + github_token: &'a str, + gh_command_path: Option<&'a Path>, +} + #[derive(Debug, Deserialize)] struct PullRequestReviewRequestConnection { #[serde(rename = "totalCount")] @@ -228,12 +288,12 @@ pub(crate) fn configure_gh_command(command: &mut Command, github_token: &str) { .env("GCM_INTERACTIVE", "never"); } -pub(crate) fn gh_command() -> Command { - Command::new(gh_command_program()) +pub(crate) fn gh_command_with_config(configured_path: Option<&Path>) -> Command { + Command::new(gh_command_resolution(configured_path).command_path()) } -pub(crate) fn gh_command_program() -> PathBuf { - gh_command_program_from_env(env::var_os("PATH"), env::var_os("HOME")) +pub(crate) fn gh_command_resolution(configured_path: Option<&Path>) -> GhCommandResolution { + gh_command_resolution_from_env(configured_path, env::var_os("PATH"), env::var_os("HOME")) } pub(crate) fn parse_pull_request_url(pr_url: &str) -> Result { @@ -278,11 +338,12 @@ pub(crate) fn post_pull_request_issue_comment( pr_url: &str, body: &str, github_token: &str, + gh_command_path: Option<&Path>, ) -> Result<(i64, i64)> { let locator = parse_pull_request_url(pr_url)?; let endpoint = format!("repos/{}/{}/issues/{}/comments", locator.owner, locator.repo, locator.number); - let mut command = gh_command(); + let mut command = gh_command_with_config(gh_command_path); command.args(["api", endpoint.as_str(), "-f", &format!("body={body}")]); command.current_dir(cwd); @@ -314,21 +375,24 @@ pub(crate) fn inspect_pull_request_landing_state( cwd: &Path, pr_url: &str, github_token: &str, + gh_command_path: Option<&Path>, ) -> Result { let locator = parse_pull_request_url(pr_url)?; let mut review_threads_after: Option = None; let mut landing_state: Option = None; loop { - let pull_request = query_pull_request_landing_state_page( - cwd, - &locator.owner, - &locator.repo, - locator.number, - review_threads_after.as_deref(), - pr_url, - github_token, - )?; + let pull_request = + query_pull_request_landing_state_page(PullRequestLandingStatePageQuery { + cwd, + owner: &locator.owner, + repo: &locator.repo, + number: locator.number, + review_threads_after: review_threads_after.as_deref(), + pr_url, + github_token, + gh_command_path, + })?; let next_cursor = match &mut landing_state { Some(landing_state) => merge_pull_request_landing_state_page(landing_state, &pull_request)?, @@ -355,8 +419,9 @@ pub(crate) fn inspect_pull_request_landing_state( pub(crate) fn inspect_repository_context( cwd: &Path, github_token: &str, + gh_command_path: Option<&Path>, ) -> Result { - let mut command = gh_command(); + let mut command = gh_command_with_config(gh_command_path); command.args(["repo", "view", "--json", "name,owner,defaultBranchRef,mergeCommitAllowed"]); command.current_dir(cwd); @@ -396,6 +461,7 @@ pub(crate) fn delete_pull_request_head_branch_if_present( pr_url: &str, branch_name: &str, github_token: &str, + gh_command_path: Option<&Path>, ) -> Result<()> { let locator = parse_pull_request_url(pr_url)?; @@ -405,6 +471,7 @@ pub(crate) fn delete_pull_request_head_branch_if_present( &locator.repo, branch_name, github_token, + gh_command_path, ) } @@ -414,8 +481,9 @@ pub(crate) fn admin_merge_pull_request( reviewed_head_sha: &str, merge_subject: Option<&str>, github_token: &str, + gh_command_path: Option<&Path>, ) -> Result<()> { - let mut command = gh_command(); + let mut command = gh_command_with_config(gh_command_path); configure_admin_merge_command(&mut command, pr_url, reviewed_head_sha, merge_subject); @@ -444,8 +512,9 @@ pub(crate) fn inspect_pull_request_merge_commit( cwd: &Path, pr_url: &str, github_token: &str, + gh_command_path: Option<&Path>, ) -> Result { - let response = inspect_pull_request_merge_response(cwd, pr_url, github_token)?; + let response = inspect_pull_request_merge_response(cwd, pr_url, github_token, gh_command_path)?; if response.state != "MERGED" { eyre::bail!("Pull request `{pr_url}` did not reach `MERGED` state after landing."); @@ -463,11 +532,12 @@ pub(crate) fn wait_for_pull_request_merge_commit( pr_url: &str, github_token: &str, timeout: Duration, + gh_command_path: Option<&Path>, ) -> Result { let deadline = Instant::now() + timeout; loop { - match inspect_pull_request_merge_commit(cwd, pr_url, github_token) { + match inspect_pull_request_merge_commit(cwd, pr_url, github_token, gh_command_path) { Ok(merge_commit) => return Ok(merge_commit), Err(error) if Instant::now() >= deadline => return Err(error), Err(error) if merge_commit_wait_error_is_retryable(&error) => {}, @@ -483,9 +553,10 @@ pub(crate) fn inspect_commit_subject( pr_url: &str, commit_oid: &str, github_token: &str, + gh_command_path: Option<&Path>, ) -> Result { let locator = parse_pull_request_url(pr_url)?; - let mut command = gh_command(); + let mut command = gh_command_with_config(gh_command_path); command .args(["api", &format!("repos/{}/{}/commits/{}", locator.owner, locator.repo, commit_oid)]); @@ -526,11 +597,12 @@ pub(crate) fn wait_for_commit_subject( commit_oid: &str, github_token: &str, timeout: Duration, + gh_command_path: Option<&Path>, ) -> Result { let deadline = Instant::now() + timeout; loop { - match inspect_commit_subject(cwd, pr_url, commit_oid, github_token) { + match inspect_commit_subject(cwd, pr_url, commit_oid, github_token, gh_command_path) { Ok(subject) => return Ok(subject), Err(error) if Instant::now() >= deadline => return Err(error), Err(error) if commit_subject_wait_error_is_retryable(&error) => {}, @@ -546,19 +618,40 @@ pub(crate) fn pull_request_is_merged_at_head( pr_url: &str, expected_head_sha: &str, github_token: &str, + gh_command_path: Option<&Path>, ) -> Result { - let response = inspect_pull_request_merge_response(cwd, pr_url, github_token)?; + let response = inspect_pull_request_merge_response(cwd, pr_url, github_token, gh_command_path)?; Ok(response.state == "MERGED" && response.head_ref_oid.as_deref() == Some(expected_head_sha)) } -fn gh_command_program_from_env(path_env: Option, home: Option) -> PathBuf { +fn gh_command_resolution_from_env( + configured_path: Option<&Path>, + path_env: Option, + home: Option, +) -> GhCommandResolution { + if let Some(configured_path) = configured_path { + let command_path = configured_path.to_path_buf(); + let resolved_path = command_path.is_file().then_some(command_path.clone()); + + return GhCommandResolution { + command_path, + resolved_path, + configured_path: Some(configured_path.to_path_buf()), + discovery_tier: GhCommandDiscoveryTier::Configured, + }; + } if let Some(path_env) = path_env { for path_entry in env::split_paths(&path_env) { let candidate = path_entry.join(GH_BINARY); if candidate.is_file() { - return candidate; + return GhCommandResolution { + command_path: candidate.clone(), + resolved_path: Some(candidate), + configured_path: None, + discovery_tier: GhCommandDiscoveryTier::Path, + }; } } } @@ -571,7 +664,12 @@ fn gh_command_program_from_env(path_env: Option, home: Option, home: Option, ) -> Result<()> { if branch_name.trim().is_empty() { eyre::bail!("Refusing to delete an empty GitHub branch name."); @@ -600,7 +709,7 @@ fn delete_repository_branch_if_present( let endpoint = format!("repos/{owner}/{repo}/git/refs/heads/{}", github_api_ref_path(branch_name)); - let mut command = gh_command(); + let mut command = gh_command_with_config(gh_command_path); command.args(["api", "--method", "DELETE", "--silent", endpoint.as_str()]); command.current_dir(cwd); @@ -626,8 +735,9 @@ fn inspect_pull_request_merge_response( cwd: &Path, pr_url: &str, github_token: &str, + gh_command_path: Option<&Path>, ) -> Result { - let mut command = gh_command(); + let mut command = gh_command_with_config(gh_command_path); command.args(["pr", "view", pr_url, "--json", "state,headRefOid,mergeCommit"]); command.current_dir(cwd); @@ -690,43 +800,44 @@ fn github_api_path_component(component: &str) -> String { } fn query_pull_request_landing_state_page( - cwd: &Path, - owner: &str, - repo: &str, - number: u64, - review_threads_after: Option<&str>, - pr_url: &str, - github_token: &str, + query: PullRequestLandingStatePageQuery<'_>, ) -> Result { - let mut command = gh_command(); + let mut command = gh_command_with_config(query.gh_command_path); command.args(["api", "graphql", "-f", &format!("query={PULL_REQUEST_LANDING_STATE_QUERY}")]); - command.args(["-F", &format!("owner={owner}")]); - command.args(["-F", &format!("name={repo}")]); - command.args(["-F", &format!("number={number}")]); + command.args(["-F", &format!("owner={}", query.owner)]); + command.args(["-F", &format!("name={}", query.repo)]); + command.args(["-F", &format!("number={}", query.number)]); - if let Some(review_threads_after) = review_threads_after { + if let Some(review_threads_after) = query.review_threads_after { command.args(["-F", &format!("reviewThreadsAfter={review_threads_after}")]); } - command.current_dir(cwd); + command.current_dir(query.cwd); - configure_gh_command(&mut command, github_token); + configure_gh_command(&mut command, query.github_token); let output = command.output()?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); - eyre::bail!("Failed to inspect pull request landing state `{pr_url}`: {}", stderr.trim()); + eyre::bail!( + "Failed to inspect pull request landing state `{}`: {}", + query.pr_url, + stderr.trim() + ); } let response = serde_json::from_slice::(&output.stdout)?; let Some(repository) = response.data.repository else { - eyre::bail!("GitHub GraphQL response for `{pr_url}` did not include a repository."); + eyre::bail!("GitHub GraphQL response for `{}` did not include a repository.", query.pr_url); }; let Some(pull_request) = repository.pull_request else { - eyre::bail!("GitHub GraphQL response for `{pr_url}` did not include a pull request."); + eyre::bail!( + "GitHub GraphQL response for `{}` did not include a pull request.", + query.pr_url + ); }; Ok(pull_request) @@ -907,22 +1018,25 @@ mod tests { } #[test] - fn gh_command_program_prefers_path_candidate() { + fn gh_command_resolution_prefers_path_candidate() { let temp_dir = tempfile::TempDir::new().expect("temp dir should exist"); let gh_path = temp_dir.path().join("gh"); fs::write(&gh_path, "").expect("fake gh should write"); - let resolved = super::gh_command_program_from_env( + let resolution = super::gh_command_resolution_from_env( + None, Some(OsString::from(temp_dir.path().as_os_str())), None, ); - assert_eq!(resolved, gh_path); + assert_eq!(resolution.command_path(), gh_path.as_path()); + assert_eq!(resolution.resolved_path(), Some(gh_path.as_path())); + assert_eq!(resolution.discovery_tier(), super::GhCommandDiscoveryTier::Path); } #[test] - fn gh_command_program_falls_back_to_home_local_bin() { + fn gh_command_resolution_falls_back_to_home_local_bin() { let temp_dir = tempfile::TempDir::new().expect("temp dir should exist"); let bin_dir = temp_dir.path().join(".local/bin"); let gh_path = bin_dir.join("gh"); @@ -930,16 +1044,35 @@ mod tests { fs::create_dir_all(&bin_dir).expect("fake home bin should exist"); fs::write(&gh_path, "").expect("fake gh should write"); - let resolved = super::gh_command_program_from_env( + let resolution = super::gh_command_resolution_from_env( + None, Some(OsString::new()), Some(OsString::from(temp_dir.path().as_os_str())), ); - assert_eq!(resolved, gh_path); + assert_eq!(resolution.command_path(), gh_path.as_path()); + assert_eq!(resolution.resolved_path(), Some(gh_path.as_path())); + assert_eq!(resolution.discovery_tier(), super::GhCommandDiscoveryTier::UserBin); + } + + #[test] + fn gh_command_resolution_uses_configured_path_as_authority() { + let temp_dir = tempfile::TempDir::new().expect("temp dir should exist"); + let gh_path = temp_dir.path().join("configured-gh"); + + fs::write(&gh_path, "").expect("fake configured gh should write"); + + let resolution = + super::gh_command_resolution_from_env(Some(&gh_path), Some(OsString::new()), None); + + assert_eq!(resolution.command_path(), gh_path.as_path()); + assert_eq!(resolution.configured_path(), Some(gh_path.as_path())); + assert_eq!(resolution.resolved_path(), Some(gh_path.as_path())); + assert_eq!(resolution.discovery_tier(), super::GhCommandDiscoveryTier::Configured); } #[test] - fn gh_command_program_knows_nix_profile_fallback() { + fn gh_command_resolution_knows_nix_profile_fallback() { assert!(super::GH_FALLBACK_PATHS.contains(&"/run/current-system/sw/bin/gh")); } diff --git a/apps/decodex/src/manual.rs b/apps/decodex/src/manual.rs index ad1fc1f..aaae5e9 100644 --- a/apps/decodex/src/manual.rs +++ b/apps/decodex/src/manual.rs @@ -76,6 +76,7 @@ struct ManualLandContext { workflow: WorkflowDocument, github_token_env_var: String, github_token: String, + github_command_path: Option, repository: RepositoryContext, prepared_closeout: Option, review_handoff: Option, @@ -218,6 +219,7 @@ pub(crate) fn run_land(config_path: Option<&Path>, request: &ManualLandRequest) &context.canonical_repo_root, &context.pr_url, &context.github_token, + context.github_command_path.as_deref(), )?; let current_head = current_head_oid(&context.cwd)?; let execution_mode = validate_landing_state( @@ -263,11 +265,13 @@ fn inspect_pull_request_landing_state_for_manual_land( cwd: &Path, pr_url: &str, github_token: &str, + gh_command_path: Option<&Path>, ) -> Result { let mut last_landing_state = None; for attempt in 1..=MANUAL_LAND_MERGEABILITY_RETRY_ATTEMPTS { - let landing_state = github::inspect_pull_request_landing_state(cwd, pr_url, github_token)?; + let landing_state = + github::inspect_pull_request_landing_state(cwd, pr_url, github_token, gh_command_path)?; if landing_state.state == "MERGED" || !pull_request::mergeability_unknown(landing_state.gate_view()) @@ -315,7 +319,12 @@ fn prepare_manual_land_context( &worktree_root, )?; let github_token = config.github().resolve_token()?; - let repository = github::inspect_repository_context(&canonical_repo_root, &github_token)?; + let github_command_path = config.github().command_path().map(Path::to_path_buf); + let repository = github::inspect_repository_context( + &canonical_repo_root, + &github_token, + github_command_path.as_deref(), + )?; let workflow = WorkflowDocument::from_path(config.workflow_path())?; let public_projection_privacy_classifier = ConfiguredPublicProjectionPrivacyClassifier::from_config(config.privacy_classifier())?; @@ -352,6 +361,7 @@ fn prepare_manual_land_context( workflow, github_token_env_var: config.github().token_env_var().to_owned(), github_token, + github_command_path, repository, prepared_closeout, review_handoff: handoff, @@ -398,6 +408,7 @@ fn execute_land_merge( current_head, Some(landed_change_record), &context.github_token, + context.github_command_path.as_deref(), ) { if matches!( github::pull_request_is_merged_at_head( @@ -405,6 +416,7 @@ fn execute_land_merge( &context.pr_url, current_head, &context.github_token, + context.github_command_path.as_deref(), ), Ok(true) ) { @@ -413,6 +425,7 @@ fn execute_land_merge( &context.pr_url, &context.github_token, MANUAL_LAND_MERGE_VISIBILITY_TIMEOUT, + context.github_command_path.as_deref(), ); } @@ -427,6 +440,7 @@ fn execute_land_merge( &context.pr_url, &context.github_token, MANUAL_LAND_MERGE_VISIBILITY_TIMEOUT, + context.github_command_path.as_deref(), ) } @@ -440,6 +454,7 @@ fn load_authoritative_landed_change_record( merge_commit, &context.github_token, MANUAL_LAND_MERGE_VISIBILITY_TIMEOUT, + context.github_command_path.as_deref(), ) } @@ -580,6 +595,7 @@ fn cleanup_manual_land_lane_checkout(context: &ManualLandContext) -> Result<()> &context.pr_url, &context.current_branch, &context.github_token, + context.github_command_path.as_deref(), )?; orchestrator::detach_worktree_head_from_branch_if_checked_out( &context.worktree_root, @@ -962,6 +978,7 @@ fn finalize_already_merged_manual_land_recovery( &context.canonical_repo_root, &context.pr_url, &context.github_token, + context.github_command_path.as_deref(), )?; if landing_state.state != "MERGED" { @@ -977,6 +994,7 @@ fn finalize_already_merged_manual_land_recovery( &context.pr_url, &context.github_token, MANUAL_LAND_MERGE_VISIBILITY_TIMEOUT, + context.github_command_path.as_deref(), )?; ensure_already_merged_manual_land_recovery_ready(context, &landing_state, &merge_commit)?; @@ -1978,6 +1996,7 @@ mod tests { workflow: sample_workflow(), github_token_env_var: String::from("GITHUB_TOKEN"), github_token: String::from("test-token"), + github_command_path: None, repository: crate::github::RepositoryContext { owner: String::from("hack-ink"), name: String::from("decodex"), @@ -2424,6 +2443,7 @@ exit 1\n", workflow: sample_workflow(), github_token_env_var: String::from("GITHUB_TOKEN"), github_token: String::from("test-token"), + github_command_path: None, repository: crate::github::RepositoryContext { owner: String::from("hack-ink"), name: String::from("decodex"), @@ -2480,6 +2500,7 @@ exit 1\n", workflow: sample_workflow(), github_token_env_var: String::from("GITHUB_TOKEN"), github_token: String::from("test-token"), + github_command_path: None, repository: crate::github::RepositoryContext { owner: String::from("hack-ink"), name: String::from("decodex"), @@ -2537,6 +2558,7 @@ exit 1\n", workflow: sample_workflow(), github_token_env_var: String::from("GITHUB_TOKEN"), github_token: String::from("test-token"), + github_command_path: None, repository: crate::github::RepositoryContext { owner: String::from("hack-ink"), name: String::from("decodex"), @@ -2839,6 +2861,7 @@ exit 1\n", workflow: sample_workflow(), github_token_env_var: String::from("GITHUB_TOKEN"), github_token: String::from("test-token"), + github_command_path: None, repository: crate::github::RepositoryContext { owner: String::from("hack-ink"), name: String::from("decodex"), @@ -2900,6 +2923,7 @@ exit 1\n", workflow: sample_workflow(), github_token_env_var: String::from("GITHUB_TOKEN"), github_token: String::from("test-token"), + github_command_path: None, repository: crate::github::RepositoryContext { owner: String::from("hack-ink"), name: String::from("decodex"), diff --git a/apps/decodex/src/orchestrator/agent_evidence.rs b/apps/decodex/src/orchestrator/agent_evidence.rs index e653651..d1554da 100644 --- a/apps/decodex/src/orchestrator/agent_evidence.rs +++ b/apps/decodex/src/orchestrator/agent_evidence.rs @@ -46,6 +46,7 @@ struct AgentHandoffIndex { runs_dir: String, events_path: String, summary: AgentEvidenceSummary, + github_cli_authority: Option, warnings: Vec, connector_backoffs: Vec, blockers: Vec, @@ -438,6 +439,10 @@ fn write_agent_evidence_snapshot( connector_backoff_count: connector_backoffs.len(), warning_count: project_view.warnings.len(), }; + let github_cli_authority = project_view + .projects + .first() + .map(|project| project.github_cli_authority.clone()); let index = AgentHandoffIndex { schema: AGENT_HANDOFF_INDEX_SCHEMA, project_id: project_id.clone(), @@ -449,6 +454,7 @@ fn write_agent_evidence_snapshot( runs_dir: runs_dir.display().to_string(), events_path: events_path.display().to_string(), summary, + github_cli_authority, warnings: project_view.warnings.clone(), connector_backoffs, blockers, diff --git a/apps/decodex/src/orchestrator/daemon.rs b/apps/decodex/src/orchestrator/daemon.rs index d4ae350..834cfa4 100644 --- a/apps/decodex/src/orchestrator/daemon.rs +++ b/apps/decodex/src/orchestrator/daemon.rs @@ -81,6 +81,7 @@ fn run_daemon_tick( ) -> Result<()> { let review_state_inspector = GhPullRequestReviewStateInspector { github_token_env_var: Some(context.config.github().token_env_var().to_owned()), + github_command_path: context.config.github().command_path().map(Path::to_path_buf), }; run_daemon_tick_with_review_state_inspector( @@ -209,6 +210,7 @@ where if warnings.contains(&TRACKER_RATE_LIMIT_WARNING) { let review_state_inspector = GhPullRequestReviewStateInspector { github_token_env_var: Some(project.github().token_env_var().to_owned()), + github_command_path: project.github().command_path().map(Path::to_path_buf), }; snapshot.post_review_lanes = @@ -927,7 +929,8 @@ where state_store, &GhPullRequestReviewStateInspector { github_token_env_var: Some(project.github().token_env_var().to_owned()), - }, + github_command_path: project.github().command_path().map(Path::to_path_buf), + }, )? { CloseoutDispatchEligibility::Eligible => RetryEntryRetentionDecision::Retain, CloseoutDispatchEligibility::Ineligible => RetryEntryRetentionDecision::Drop, diff --git a/apps/decodex/src/orchestrator/dispatch_policy.rs b/apps/decodex/src/orchestrator/dispatch_policy.rs index 2c4904b..09568b3 100644 --- a/apps/decodex/src/orchestrator/dispatch_policy.rs +++ b/apps/decodex/src/orchestrator/dispatch_policy.rs @@ -184,6 +184,7 @@ where { let review_state_inspector = GhPullRequestReviewStateInspector { github_token_env_var: Some(project.github().token_env_var().to_owned()), + github_command_path: project.github().command_path().map(Path::to_path_buf), }; issue_passes_closeout_dispatch_policy_with_inspector( @@ -233,6 +234,7 @@ where { let review_state_inspector = GhPullRequestReviewStateInspector { github_token_env_var: Some(project.github().token_env_var().to_owned()), + github_command_path: project.github().command_path().map(Path::to_path_buf), }; closeout_dispatch_block_reason_with_inspector( @@ -688,6 +690,7 @@ fn cleanup_completed_post_review_lane( &issue_run.worktree.path, review_handoff.pr_url(), &github_token, + project.github().command_path(), )?; if landing_state.state != "MERGED" { @@ -724,6 +727,7 @@ fn cleanup_completed_post_review_lane( review_handoff.pr_url(), &issue_run.worktree.branch_name, &github_token, + project.github().command_path(), )?; detach_worktree_head_from_branch_if_checked_out( diff --git a/apps/decodex/src/orchestrator/entrypoints.rs b/apps/decodex/src/orchestrator/entrypoints.rs index 798432f..9b51a9a 100644 --- a/apps/decodex/src/orchestrator/entrypoints.rs +++ b/apps/decodex/src/orchestrator/entrypoints.rs @@ -625,6 +625,7 @@ fn build_operator_status_snapshot_for_tracker_backoff( ) -> Result { let review_state_inspector = GhPullRequestReviewStateInspector { github_token_env_var: Some(project.github().token_env_var().to_owned()), + github_command_path: project.github().command_path().map(Path::to_path_buf), }; let mut snapshot = build_operator_status_snapshot_with_account_mode( project, @@ -1440,6 +1441,7 @@ fn operator_project_status_from_registration( config_path: project.config_path().display().to_string(), repo_root: project.repo_root().display().to_string(), enabled: project.enabled(), + github_cli_authority: operator_github_cli_authority_from_registration(project), active_run_count: 0, queued_candidate_count: 0, post_review_lane_count: 0, @@ -1470,6 +1472,7 @@ fn operator_project_status_from_dev_registration( config_path: project.config_path().display().to_string(), repo_root: project.repo_root().display().to_string(), enabled: project.enabled(), + github_cli_authority: operator_github_cli_authority_from_registration(project), active_run_count: 0, queued_candidate_count: 0, post_review_lane_count: 0, diff --git a/apps/decodex/src/orchestrator/execution.rs b/apps/decodex/src/orchestrator/execution.rs index ce599b4..5b21f22 100644 --- a/apps/decodex/src/orchestrator/execution.rs +++ b/apps/decodex/src/orchestrator/execution.rs @@ -504,6 +504,15 @@ where ) } +fn build_closeout_review_state_inspector( + project: &ServiceConfig, +) -> GhPullRequestReviewStateInspector { + GhPullRequestReviewStateInspector { + github_token_env_var: Some(project.github().token_env_var().to_owned()), + github_command_path: project.github().command_path().map(Path::to_path_buf), + } +} + fn execute_issue_run_inner( tracker: &T, project: &ServiceConfig, @@ -545,9 +554,7 @@ where prepare_agent_git_credentials(project, &issue_run.run_id, &issue_run.worktree.path)?; let codex_account_pool = project.codex().accounts().map(CodexAccountPool::from_config).transpose()?; - let closeout_review_state_inspector = GhPullRequestReviewStateInspector { - github_token_env_var: Some(project.github().token_env_var().to_owned()), - }; + let closeout_review_state_inspector = build_closeout_review_state_inspector(project); let continuation_guard = build_issue_turn_continuation_guard( tracker, &tracker_tool_bridge, diff --git a/apps/decodex/src/orchestrator/prompting.rs b/apps/decodex/src/orchestrator/prompting.rs index fba85ec..a1f22b2 100644 --- a/apps/decodex/src/orchestrator/prompting.rs +++ b/apps/decodex/src/orchestrator/prompting.rs @@ -495,6 +495,7 @@ fn build_review_run_context( worktree_path: relative_worktree_path(project, &issue_run.worktree), cwd: issue_run.worktree.path.clone(), github_token_env_var: Some(project.github().token_env_var().to_owned()), + github_command_path: project.github().command_path().map(Path::to_path_buf), internal_review_mode: project.codex().internal_review_mode(), mode: ReviewExecutionMode::Repair, recorded_pr_url: Some(review_handoff.pr_url().to_owned()), @@ -520,6 +521,7 @@ fn build_review_run_context( worktree_path: relative_worktree_path(project, &issue_run.worktree), cwd: issue_run.worktree.path.clone(), github_token_env_var: Some(project.github().token_env_var().to_owned()), + github_command_path: project.github().command_path().map(Path::to_path_buf), internal_review_mode: project.codex().internal_review_mode(), mode: ReviewExecutionMode::Closeout, recorded_pr_url: Some(review_handoff.pr_url().to_owned()), @@ -533,6 +535,7 @@ fn build_review_run_context( worktree_path: relative_worktree_path(project, &issue_run.worktree), cwd: issue_run.worktree.path.clone(), github_token_env_var: Some(project.github().token_env_var().to_owned()), + github_command_path: project.github().command_path().map(Path::to_path_buf), internal_review_mode: project.codex().internal_review_mode(), mode: ReviewExecutionMode::Handoff, recorded_pr_url: None, diff --git a/apps/decodex/src/orchestrator/pull_request_review.rs b/apps/decodex/src/orchestrator/pull_request_review.rs index a3eb4ca..203dc84 100644 --- a/apps/decodex/src/orchestrator/pull_request_review.rs +++ b/apps/decodex/src/orchestrator/pull_request_review.rs @@ -1,66 +1,83 @@ -fn query_pull_request_review_state_page( - cwd: &Path, - owner: &str, - repo: &str, +struct PullRequestReviewStatePageQuery<'a> { + cwd: &'a Path, + owner: &'a str, + repo: &'a str, number: u64, - review_threads_after: Option<&str>, - pr_url: &str, - github_token: &str, + review_threads_after: Option<&'a str>, + pr_url: &'a str, + github_token: &'a str, + gh_command_path: Option<&'a Path>, +} + +struct PullRequestIssueCommentsPageQuery<'a> { + cwd: &'a Path, + owner: &'a str, + repo: &'a str, + number: u64, + comments_after: &'a str, + pr_url: &'a str, + github_token: &'a str, + gh_command_path: Option<&'a Path>, +} + +fn query_pull_request_review_state_page( + query: PullRequestReviewStatePageQuery<'_>, ) -> Result { - let mut command = github::gh_command(); + let mut command = github::gh_command_with_config(query.gh_command_path); command.args(["api", "graphql", "-f", &format!("query={PULL_REQUEST_REVIEW_STATE_QUERY}")]); - command.args(["-F", &format!("owner={owner}")]); - command.args(["-F", &format!("name={repo}")]); - command.args(["-F", &format!("number={number}")]); + command.args(["-F", &format!("owner={}", query.owner)]); + command.args(["-F", &format!("name={}", query.repo)]); + command.args(["-F", &format!("number={}", query.number)]); - if let Some(review_threads_after) = review_threads_after { + if let Some(review_threads_after) = query.review_threads_after { command.args(["-F", &format!("reviewThreadsAfter={review_threads_after}")]); } - command.current_dir(cwd); + command.current_dir(query.cwd); - github::configure_gh_command(&mut command, github_token); + github::configure_gh_command(&mut command, query.github_token); let output = command.output()?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); - eyre::bail!("Failed to inspect pull request review state `{pr_url}`: {}", stderr.trim()); + eyre::bail!( + "Failed to inspect pull request review state `{}`: {}", + query.pr_url, + stderr.trim() + ); } let response = serde_json::from_slice::(&output.stdout)?; let Some(repository) = response.data.repository else { - eyre::bail!("GitHub GraphQL response for `{pr_url}` did not include a repository."); + eyre::bail!("GitHub GraphQL response for `{}` did not include a repository.", query.pr_url); }; if repository.pull_request.is_none() { - eyre::bail!("GitHub GraphQL response for `{pr_url}` did not include a pull request."); + eyre::bail!( + "GitHub GraphQL response for `{}` did not include a pull request.", + query.pr_url + ); } Ok(repository) } fn query_pull_request_issue_comments_page( - cwd: &Path, - owner: &str, - repo: &str, - number: u64, - comments_after: &str, - pr_url: &str, - github_token: &str, + query: PullRequestIssueCommentsPageQuery<'_>, ) -> Result { - let mut command = github::gh_command(); + let mut command = github::gh_command_with_config(query.gh_command_path); command.args(["api", "graphql", "-f", &format!("query={PULL_REQUEST_ISSUE_COMMENTS_QUERY}")]); - command.args(["-F", &format!("owner={owner}")]); - command.args(["-F", &format!("name={repo}")]); - command.args(["-F", &format!("number={number}")]); - command.args(["-F", &format!("commentsAfter={comments_after}")]); - command.current_dir(cwd); + command.args(["-F", &format!("owner={}", query.owner)]); + command.args(["-F", &format!("name={}", query.repo)]); + command.args(["-F", &format!("number={}", query.number)]); + command.args(["-F", &format!("commentsAfter={}", query.comments_after)]); + command.current_dir(query.cwd); - github::configure_gh_command(&mut command, github_token); + github::configure_gh_command(&mut command, query.github_token); let output = command.output()?; @@ -68,17 +85,21 @@ fn query_pull_request_issue_comments_page( let stderr = String::from_utf8_lossy(&output.stderr); eyre::bail!( - "Failed to inspect pull request issue comments for `{pr_url}`: {}", + "Failed to inspect pull request issue comments for `{}`: {}", + query.pr_url, stderr.trim() ); } let response = serde_json::from_slice::(&output.stdout)?; let Some(repository) = response.data.repository else { - eyre::bail!("GitHub GraphQL response for `{pr_url}` did not include a repository."); + eyre::bail!("GitHub GraphQL response for `{}` did not include a repository.", query.pr_url); }; let Some(pull_request) = repository.pull_request else { - eyre::bail!("GitHub GraphQL response for `{pr_url}` did not include a pull request."); + eyre::bail!( + "GitHub GraphQL response for `{}` did not include a pull request.", + query.pr_url + ); }; Ok(pull_request) diff --git a/apps/decodex/src/orchestrator/run_cycle.rs b/apps/decodex/src/orchestrator/run_cycle.rs index 2ac9e8b..ee9069b 100644 --- a/apps/decodex/src/orchestrator/run_cycle.rs +++ b/apps/decodex/src/orchestrator/run_cycle.rs @@ -197,6 +197,7 @@ where { let review_state_inspector = GhPullRequestReviewStateInspector { github_token_env_var: Some(project.github().token_env_var().to_owned()), + github_command_path: project.github().command_path().map(Path::to_path_buf), }; reconcile_post_review_orchestration_with_inspector( @@ -668,6 +669,7 @@ where lane.review_state.url.as_str(), EXTERNAL_REVIEW_REQUEST_BODY, github_token, + project.github().command_path(), )?; write_retained_review_orchestration_marker( @@ -722,12 +724,13 @@ where lane.review_state.url.as_str(), EXTERNAL_REVIEW_REQUEST_BODY, github_token, + project.github().command_path(), )?; - return write_retained_review_orchestration_marker( - state_store, - lane, - ReviewOrchestrationPhase::WaitingForAck, + return write_retained_review_orchestration_marker( + state_store, + lane, + ReviewOrchestrationPhase::WaitingForAck, RetainedReviewOrchestrationMarkerFields { request_comment_database_id: Some(comment_id), request_created_at_unix_epoch: Some(created_at_unix_epoch), @@ -967,6 +970,7 @@ where lane.orchestration_marker.head_sha(), Some(merge_subject.as_str()), github_token, + runtime.project.github().command_path(), ) { Ok(()) => true, Err(_error) => @@ -976,6 +980,7 @@ where lane.review_state.url.as_str(), lane.orchestration_marker.head_sha(), github_token, + runtime.project.github().command_path(), ), Ok(true) ), @@ -1433,6 +1438,7 @@ where { let review_state_inspector = GhPullRequestReviewStateInspector { github_token_env_var: Some(project.github().token_env_var().to_owned()), + github_command_path: project.github().command_path().map(Path::to_path_buf), }; select_post_review_issue_candidate_with_inspector( @@ -1915,6 +1921,7 @@ where let target_issue_id = resolve_target_issue_id(context.tracker, context.issue_id)?; let review_state_inspector = GhPullRequestReviewStateInspector { github_token_env_var: Some(context.project.github().token_env_var().to_owned()), + github_command_path: context.project.github().command_path().map(Path::to_path_buf), }; let Some(candidate) = select_target_post_review_closeout_issue_candidate_with_inspector( context.tracker, @@ -2348,6 +2355,7 @@ where )?; let review_state_inspector = GhPullRequestReviewStateInspector { github_token_env_var: Some(project.github().token_env_var().to_owned()), + github_command_path: project.github().command_path().map(Path::to_path_buf), }; if let Some(retained_summary) = drain_internal_review_only_retained_tail_with_inspector( diff --git a/apps/decodex/src/orchestrator/runtime_validation.rs b/apps/decodex/src/orchestrator/runtime_validation.rs index e562158..2404668 100644 --- a/apps/decodex/src/orchestrator/runtime_validation.rs +++ b/apps/decodex/src/orchestrator/runtime_validation.rs @@ -6,7 +6,11 @@ fn validate_review_handoff_runtime( return Ok(()); } - validate_command_available("gh", "PR-backed review handoff")?; + validate_command_available( + "gh", + project.github().command_path(), + "PR-backed review handoff", + )?; resolve_configured_env_var("github.token_env_var", Some(project.github().token_env_var()))?; Ok(()) @@ -20,7 +24,11 @@ fn validate_review_repair_runtime( return Ok(()); } - validate_command_available("gh", "retained review-repair re-entry")?; + validate_command_available( + "gh", + project.github().command_path(), + "retained review-repair re-entry", + )?; resolve_configured_env_var("github.token_env_var", Some(project.github().token_env_var()))?; Ok(()) @@ -34,7 +42,11 @@ fn validate_closeout_runtime( return Ok(()); } - validate_command_available("gh", "retained closeout re-entry")?; + validate_command_available( + "gh", + project.github().command_path(), + "retained closeout re-entry", + )?; resolve_configured_env_var("github.token_env_var", Some(project.github().token_env_var()))?; Ok(()) @@ -44,14 +56,19 @@ fn validate_daemon_runtime() -> Result<()> { Ok(()) } -fn validate_command_available(command: &str, purpose: &str) -> Result<()> { +fn validate_command_available( + command: &str, + configured_path: Option<&Path>, + purpose: &str, +) -> Result<()> { let mut command_runner = if command == "gh" { - github::gh_command() + github::gh_command_with_config(configured_path) } else { Command::new(command) }; + let command_label = command_runner.get_program().to_string_lossy().into_owned(); let output = command_runner.arg("--version").output().map_err(|error| { - eyre::eyre!("Required command `{command}` is unavailable for {purpose}: {error}") + eyre::eyre!("Required command `{command_label}` is unavailable for {purpose}: {error}") })?; if output.status.success() { @@ -64,11 +81,11 @@ fn validate_command_available(command: &str, purpose: &str) -> Result<()> { if detail.is_empty() { eyre::bail!( - "Required command `{command}` is unavailable for {purpose}: `{command} --version` exited unsuccessfully." + "Required command `{command_label}` is unavailable for {purpose}: `{command_label} --version` exited unsuccessfully." ); } eyre::bail!( - "Required command `{command}` is unavailable for {purpose}: `{command} --version` failed with `{detail}`." + "Required command `{command_label}` is unavailable for {purpose}: `{command_label} --version` failed with `{detail}`." ); } diff --git a/apps/decodex/src/orchestrator/status.rs b/apps/decodex/src/orchestrator/status.rs index 8257064..d528f0a 100644 --- a/apps/decodex/src/orchestrator/status.rs +++ b/apps/decodex/src/orchestrator/status.rs @@ -12,6 +12,7 @@ use libc::SZOMB; use libc::c_void; #[cfg(target_os = "macos")] use libc::proc_bsdinfo; +use github::GhCommandResolution; use crate::pull_request::{self, PullRequestLandingGateView}; use crate::worktree; @@ -249,6 +250,7 @@ fn build_operator_status_snapshot_with_account_mode( config_path: String::new(), repo_root: project.repo_root().display().to_string(), enabled: true, + github_cli_authority: operator_github_cli_authority(project), active_run_count: active_runs.len(), queued_candidate_count: 0, post_review_lane_count: 0, @@ -289,6 +291,63 @@ fn global_codex_account_control_status() -> OperatorCodexAccountControlStatus { } } +fn operator_github_cli_authority(project: &ServiceConfig) -> OperatorGitHubCliAuthority { + operator_github_cli_authority_from_resolution(&github::gh_command_resolution( + project.github().command_path(), + )) +} + +fn operator_github_cli_authority_from_registration( + project: &ProjectRegistration, +) -> OperatorGitHubCliAuthority { + let configured_path = ServiceConfig::from_path(project.config_path()) + .ok() + .and_then(|config| config.github().command_path().map(Path::to_path_buf)); + + operator_github_cli_authority_from_resolution(&github::gh_command_resolution( + configured_path.as_deref(), + )) +} + +fn operator_github_cli_authority_from_resolution( + resolution: &GhCommandResolution, +) -> OperatorGitHubCliAuthority { + let discovery_tier = resolution.discovery_tier().as_str().to_owned(); + let configured_path = resolution.configured_path().map(display_path); + let available = resolution.available(); + + OperatorGitHubCliAuthority { + command_path: display_path(resolution.command_path()), + resolved_path: resolution.resolved_path().map(display_path), + configured_path, + discovery_tier: discovery_tier.clone(), + available, + next_action: github_cli_authority_next_action(discovery_tier.as_str(), available), + } +} + +fn github_cli_authority_next_action(discovery_tier: &str, available: bool) -> String { + match (discovery_tier, available) { + ("configured", true) => + String::from("No action needed; Decodex will use the configured GitHub CLI path."), + ("configured", false) => String::from( + "Fix `github.command_path` in project.toml so it points to an installed `gh` binary.", + ), + ("path", true) => + String::from("No action needed; Decodex resolved `gh` from the process PATH."), + ("user-bin" | "known-fallback", true) => String::from( + "Set `github.command_path` in project.toml if this fallback path is unexpected.", + ), + _ => String::from( + "Install GitHub CLI or set `github.command_path` in project.toml to the expected `gh` binary.", + ), + } +} + +fn display_path(path: &Path) -> String { + path.display().to_string() +} + fn build_live_operator_status_snapshot( tracker: &T, project: &ServiceConfig, @@ -356,6 +415,7 @@ where let review_state_inspector = GhPullRequestReviewStateInspector { github_token_env_var: Some(project.github().token_env_var().to_owned()), + github_command_path: project.github().command_path().map(Path::to_path_buf), }; let mut snapshot = build_operator_status_snapshot_with_account_mode( project, @@ -4862,6 +4922,8 @@ fn render_operator_status(snapshot: &OperatorStatusSnapshot) -> String { output.push_str(&format!("Warning details: {}\n", render_warning_details(snapshot))); } + append_rendered_github_cli_authority(&mut output, snapshot); + output.push_str(&format!("Running lanes: {}\n", snapshot.active_runs.len())); output.push_str(&format!( "Run ledger shown: {} issue lanes from {} history attempts{}\n", @@ -4926,35 +4988,66 @@ fn render_operator_status(snapshot: &OperatorStatusSnapshot) -> String { output.push_str("\nPost-Review Lanes\n"); + append_rendered_post_review_lanes(&mut output, snapshot); + + output +} + +fn append_rendered_github_cli_authority(output: &mut String, snapshot: &OperatorStatusSnapshot) { + if let Some(authority) = rendered_project_github_cli_authority(snapshot) { + output.push_str(&format!( + "GitHub CLI: tier={} available={} command_path={} resolved_path={} configured_path={} next_action={}\n", + authority.discovery_tier, + authority.available, + authority.command_path, + authority.resolved_path.as_deref().unwrap_or("none"), + authority.configured_path.as_deref().unwrap_or("none"), + authority.next_action + )); + } +} + +fn append_rendered_post_review_lanes(output: &mut String, snapshot: &OperatorStatusSnapshot) { if snapshot.post_review_lanes.is_empty() { output.push_str("- none\n"); - } else { - for lane in &snapshot.post_review_lanes { - output.push_str(&format!( - "- issue_id: {}\n issue: {}\n state: {}\n classification: {}\n reason: {}\n branch: {}\n worktree_path: {}\n pr_url: {}\n pr_head_sha: {}\n pr_state: {}\n review_decision: {}\n mergeable: {}\n check_state: {}\n unresolved_review_threads: {}\n readback_warning: {}\n readback_root_cause: {}\n", - lane.issue_id, - lane.issue_identifier, - lane.issue_state, - lane.classification, - lane.reason, - lane.branch_name, - lane.worktree_path, - lane.pr_url.as_deref().unwrap_or("none"), - lane.pr_head_sha.as_deref().unwrap_or("none"), - lane.pr_state.as_deref().unwrap_or("none"), - lane.review_decision.as_deref().unwrap_or("none"), - lane.mergeable.as_deref().unwrap_or("none"), - lane.check_state.as_deref().unwrap_or("none"), - lane - .unresolved_review_threads - .map_or_else(|| String::from("none"), |value| value.to_string()), - lane.readback_warning.as_deref().unwrap_or("none"), - lane.readback_root_cause.as_deref().unwrap_or("none") - )); - } + + return; } - output + for lane in &snapshot.post_review_lanes { + output.push_str(&format!( + "- issue_id: {}\n issue: {}\n state: {}\n classification: {}\n reason: {}\n branch: {}\n worktree_path: {}\n pr_url: {}\n pr_head_sha: {}\n pr_state: {}\n review_decision: {}\n mergeable: {}\n check_state: {}\n unresolved_review_threads: {}\n readback_warning: {}\n readback_root_cause: {}\n", + lane.issue_id, + lane.issue_identifier, + lane.issue_state, + lane.classification, + lane.reason, + lane.branch_name, + lane.worktree_path, + lane.pr_url.as_deref().unwrap_or("none"), + lane.pr_head_sha.as_deref().unwrap_or("none"), + lane.pr_state.as_deref().unwrap_or("none"), + lane.review_decision.as_deref().unwrap_or("none"), + lane.mergeable.as_deref().unwrap_or("none"), + lane.check_state.as_deref().unwrap_or("none"), + lane + .unresolved_review_threads + .map_or_else(|| String::from("none"), |value| value.to_string()), + lane.readback_warning.as_deref().unwrap_or("none"), + lane.readback_root_cause.as_deref().unwrap_or("none") + )); + } +} + +fn rendered_project_github_cli_authority( + snapshot: &OperatorStatusSnapshot, +) -> Option<&OperatorGitHubCliAuthority> { + snapshot + .projects + .iter() + .find(|project| project.project_id == snapshot.project_id) + .or_else(|| snapshot.projects.first()) + .map(|project| &project.github_cli_authority) } fn render_warning_details(snapshot: &OperatorStatusSnapshot) -> String { diff --git a/apps/decodex/src/orchestrator/tests.rs b/apps/decodex/src/orchestrator/tests.rs index 4bd02dd..f659d32 100644 --- a/apps/decodex/src/orchestrator/tests.rs +++ b/apps/decodex/src/orchestrator/tests.rs @@ -33,7 +33,7 @@ use crate::config::{InternalReviewMode, ServiceConfig}; #[rustfmt::skip] use crate::github; #[rustfmt::skip] -use crate::orchestrator::{self, ActiveChildRunContext, ActiveRunDisposition, ActiveRunReconciliation, ActiveWorkflowOverride, AgentEvidenceSource, ChildExitRetryContext, ChildRunRef, ControlPlaneProjectTick, CONTINUATION_PENDING_RUN_STATUS, DaemonRunChild, DaemonTickRuntimeContext, DashboardEventHub, EvidenceRequest, GhPullRequestReviewStateInspector, ISSUE_DELIVERY_CLOSEOUT_COMPLETE_TOOL_NAME, ISSUE_LABEL_ADD_TOOL_NAME, ISSUE_PROGRESS_CHECKPOINT_TOOL_NAME, ISSUE_REVIEW_CHECKPOINT_TOOL_NAME, ISSUE_REVIEW_HANDOFF_TOOL_NAME, ISSUE_REVIEW_REPAIR_COMPLETE_TOOL_NAME, ISSUE_TERMINAL_FINALIZE_TOOL_NAME, ISSUE_TRANSITION_TOOL_NAME, IssueDispatchMode, IssueRunPlan, IssueTurnContinuationGuard, ManualAttentionRequested, OPERATOR_DASHBOARD_ALIAS_ENDPOINT_PATH, OPERATOR_DASHBOARD_ENDPOINT_PATH, OperatorCodexAccountControlStatus, OperatorStatusSnapshot, PostReviewLaneClassification, PostReviewLaneDecision, PostReviewLaneSnapshot, PreferredRunIdentity, PrepareIssueRunContext, PublishedOperatorSnapshot, PullRequestCommitConnection, PullRequestCommitNode, PullRequestCommitPayload, PullRequestIssueCommentConnection, PullRequestIssueCommentState, PullRequestIssueCommentsNode, PullRequestPageInfo, PullRequestReactionGroup, PullRequestReactionUsersConnection, PullRequestActor, PullRequestRepository, PullRequestRepositoryOwner, PullRequestReviewConnection, PullRequestIssueCommentNode, PullRequestReviewNode, PullRequestReviewRequestConnection, PullRequestReviewState, PullRequestReviewStateInspector, PullRequestReviewStateNode, PullRequestReviewStateRepository, PullRequestReviewSummaryState, PullRequestReviewThreadConnection, PullRequestReviewThreadNode, PullRequestStatusCheckRollup, RecoveredRuntimeState, RetainedPartialProgress, RetainedReviewRunIdentity, RetryComment, RetryDispatchDecision, RetryEntry, RetryKind, RetryQueue, RunCompletionDisposition, RunSummary, RepoGateFailure, TERMINAL_GUARD_MARKER_FILE, TERMINAL_GUARDED_RUN_STATUS, TRACKER_RATE_LIMIT_WARNING, TargetIssueRunContext, EXTERNAL_REVIEW_ACTOR_LOGIN, EXTERNAL_REVIEW_PASS_PHRASE, EXTERNAL_REVIEW_REQUEST_BODY}; +use crate::orchestrator::{self, ActiveChildRunContext, ActiveRunDisposition, ActiveRunReconciliation, ActiveWorkflowOverride, AgentEvidenceSource, ChildExitRetryContext, ChildRunRef, ControlPlaneProjectTick, CONTINUATION_PENDING_RUN_STATUS, DaemonRunChild, DaemonTickRuntimeContext, DashboardEventHub, EvidenceRequest, GhPullRequestReviewStateInspector, ISSUE_DELIVERY_CLOSEOUT_COMPLETE_TOOL_NAME, ISSUE_LABEL_ADD_TOOL_NAME, ISSUE_PROGRESS_CHECKPOINT_TOOL_NAME, ISSUE_REVIEW_CHECKPOINT_TOOL_NAME, ISSUE_REVIEW_HANDOFF_TOOL_NAME, ISSUE_REVIEW_REPAIR_COMPLETE_TOOL_NAME, ISSUE_TERMINAL_FINALIZE_TOOL_NAME, ISSUE_TRANSITION_TOOL_NAME, IssueDispatchMode, IssueRunPlan, IssueTurnContinuationGuard, ManualAttentionRequested, OPERATOR_DASHBOARD_ALIAS_ENDPOINT_PATH, OPERATOR_DASHBOARD_ENDPOINT_PATH, OperatorCodexAccountControlStatus, OperatorGitHubCliAuthority, OperatorProjectStatus, OperatorStatusSnapshot, PostReviewLaneClassification, PostReviewLaneDecision, PostReviewLaneSnapshot, PreferredRunIdentity, PrepareIssueRunContext, PublishedOperatorSnapshot, PullRequestCommitConnection, PullRequestCommitNode, PullRequestCommitPayload, PullRequestIssueCommentConnection, PullRequestIssueCommentState, PullRequestIssueCommentsNode, PullRequestPageInfo, PullRequestReactionGroup, PullRequestReactionUsersConnection, PullRequestActor, PullRequestRepository, PullRequestRepositoryOwner, PullRequestReviewConnection, PullRequestIssueCommentNode, PullRequestReviewNode, PullRequestReviewRequestConnection, PullRequestReviewState, PullRequestReviewStateInspector, PullRequestReviewStateNode, PullRequestReviewStateRepository, PullRequestReviewSummaryState, PullRequestReviewThreadConnection, PullRequestReviewThreadNode, PullRequestStatusCheckRollup, RecoveredRuntimeState, RetainedPartialProgress, RetainedReviewRunIdentity, RetryComment, RetryDispatchDecision, RetryEntry, RetryKind, RetryQueue, RunCompletionDisposition, RunSummary, RepoGateFailure, TERMINAL_GUARD_MARKER_FILE, TERMINAL_GUARDED_RUN_STATUS, TRACKER_RATE_LIMIT_WARNING, TargetIssueRunContext, EXTERNAL_REVIEW_ACTOR_LOGIN, EXTERNAL_REVIEW_PASS_PHRASE, EXTERNAL_REVIEW_REQUEST_BODY}; #[rustfmt::skip] use crate::prelude::Result; #[rustfmt::skip] diff --git a/apps/decodex/src/orchestrator/tests/intake/run_and_prompting.rs b/apps/decodex/src/orchestrator/tests/intake/run_and_prompting.rs index 1e2dea2..a5f48d8 100644 --- a/apps/decodex/src/orchestrator/tests/intake/run_and_prompting.rs +++ b/apps/decodex/src/orchestrator/tests/intake/run_and_prompting.rs @@ -1700,6 +1700,7 @@ fn continuation_guard_allows_closeout_continuation_after_issue_reaches_completed worktree_path: worktree.path.display().to_string(), cwd: worktree.path.clone(), github_token_env_var: None, + github_command_path: None, internal_review_mode: InternalReviewMode::Loop, mode: ReviewExecutionMode::Closeout, recorded_pr_url: Some(String::from(pr_url)), @@ -1783,6 +1784,7 @@ fn continuation_guard_blocks_closeout_continuation_when_completed_issue_pr_is_op worktree_path: worktree.path.display().to_string(), cwd: worktree.path.clone(), github_token_env_var: None, + github_command_path: None, internal_review_mode: InternalReviewMode::Loop, mode: ReviewExecutionMode::Closeout, recorded_pr_url: Some(String::from(pr_url)), @@ -1865,6 +1867,7 @@ fn continuation_guard_errors_when_completed_issue_pr_state_cannot_be_read() { worktree_path: worktree.path.display().to_string(), cwd: worktree.path.clone(), github_token_env_var: None, + github_command_path: None, internal_review_mode: InternalReviewMode::Loop, mode: ReviewExecutionMode::Closeout, recorded_pr_url: Some(String::from(pr_url)), diff --git a/apps/decodex/src/orchestrator/tests/operator/status/agent_evidence.rs b/apps/decodex/src/orchestrator/tests/operator/status/agent_evidence.rs index 9676c00..3cc8a4f 100644 --- a/apps/decodex/src/orchestrator/tests/operator/status/agent_evidence.rs +++ b/apps/decodex/src/orchestrator/tests/operator/status/agent_evidence.rs @@ -7,29 +7,13 @@ fn agent_evidence_snapshot_writes_index_blockers_capsules_and_event_stream() { active_run.suspected_stall = true; active_run.phase = String::from("stalled"); - let mut blocked_candidate = operator_status_text_queued_candidates() - .into_iter() - .find(|candidate| candidate.issue_identifier == "PUB-102") - .expect("fixture should include queued issue"); - - blocked_candidate.classification = String::from("blocked"); - blocked_candidate.reason = String::from("missing_dispatch_briefing"); - - let mut missing_handoff_lane = operator_status_text_post_review_lanes() - .into_iter() - .next() - .expect("fixture should include retained review lane"); - - missing_handoff_lane.classification = String::from("blocked"); - missing_handoff_lane.reason = String::from("missing_review_handoff_record"); - let snapshot = OperatorStatusSnapshot { project_id: String::from(TEST_SERVICE_ID), run_limit: 10, warnings: Vec::new(), warning_details: Vec::new(), connector_backoffs: Vec::new(), - projects: Vec::new(), + projects: vec![agent_evidence_project_status_with_configured_gh()], account_control: OperatorCodexAccountControlStatus { mode: String::from("balanced"), account_selector: None, @@ -38,9 +22,9 @@ fn agent_evidence_snapshot_writes_index_blockers_capsules_and_event_stream() { active_runs: vec![active_run.clone()], recent_runs: vec![active_run], history_lanes: Vec::new(), - queued_candidates: vec![blocked_candidate], + queued_candidates: vec![agent_evidence_blocked_candidate()], worktrees: operator_status_text_worktrees(), - post_review_lanes: vec![missing_handoff_lane], + post_review_lanes: vec![agent_evidence_missing_handoff_lane()], }; let results = orchestrator::write_agent_evidence_snapshot( &snapshot, @@ -58,6 +42,9 @@ fn agent_evidence_snapshot_writes_index_blockers_capsules_and_event_stream() { assert_eq!(index_json["schema"], "decodex.agent_handoff_index/1"); assert_eq!(index_json["project_id"], TEST_SERVICE_ID); assert_eq!(index_json["source"], "diagnose_command"); + + assert_agent_evidence_github_cli_authority(&index_json); + assert_eq!(index_json["summary"]["blocker_count"], 3); assert_eq!(index_json["summary"]["run_capsule_count"], 1); assert_eq!( @@ -119,6 +106,69 @@ fn agent_evidence_snapshot_writes_index_blockers_capsules_and_event_stream() { assert_eq!(event_json["blocker_count"], 3); } +fn assert_agent_evidence_github_cli_authority(index_json: &Value) { + assert_eq!(index_json["github_cli_authority"]["discovery_tier"], "configured"); + assert_eq!(index_json["github_cli_authority"]["command_path"], "/opt/homebrew/bin/gh"); + assert_eq!( + index_json["github_cli_authority"]["next_action"], + "No action needed; Decodex will use the configured GitHub CLI path." + ); +} + +fn agent_evidence_blocked_candidate() -> OperatorQueuedIssueStatus { + let mut blocked_candidate = operator_status_text_queued_candidates() + .into_iter() + .find(|candidate| candidate.issue_identifier == "PUB-102") + .expect("fixture should include queued issue"); + + blocked_candidate.classification = String::from("blocked"); + blocked_candidate.reason = String::from("missing_dispatch_briefing"); + + blocked_candidate +} + +fn agent_evidence_missing_handoff_lane() -> OperatorPostReviewLaneStatus { + let mut missing_handoff_lane = operator_status_text_post_review_lanes() + .into_iter() + .next() + .expect("fixture should include retained review lane"); + + missing_handoff_lane.classification = String::from("blocked"); + missing_handoff_lane.reason = String::from("missing_review_handoff_record"); + + missing_handoff_lane +} + +fn agent_evidence_project_status_with_configured_gh() -> OperatorProjectStatus { + OperatorProjectStatus { + project_id: String::from(TEST_SERVICE_ID), + config_path: String::from("project.toml"), + repo_root: String::from("/repo/pubfi"), + enabled: true, + github_cli_authority: OperatorGitHubCliAuthority { + command_path: String::from("/opt/homebrew/bin/gh"), + resolved_path: Some(String::from("/opt/homebrew/bin/gh")), + configured_path: Some(String::from("/opt/homebrew/bin/gh")), + discovery_tier: String::from("configured"), + available: true, + next_action: String::from( + "No action needed; Decodex will use the configured GitHub CLI path.", + ), + }, + active_run_count: 0, + queued_candidate_count: 0, + post_review_lane_count: 0, + retained_worktree_count: 0, + waiting_lane_count: 0, + attention_count: 0, + cleanup_blocked_count: 0, + cleanup_pending_count: 0, + connector_state: String::from("ok"), + last_activity_at: None, + warning_count: 0, + } +} + #[test] fn private_evidence_readback_summarizes_payloads_without_connector() { let (_temp_dir, config, _workflow) = temp_project_layout(); diff --git a/apps/decodex/src/orchestrator/tests/operator/status/text.rs b/apps/decodex/src/orchestrator/tests/operator/status/text.rs index 5b5f981..f0dc08c 100644 --- a/apps/decodex/src/orchestrator/tests/operator/status/text.rs +++ b/apps/decodex/src/orchestrator/tests/operator/status/text.rs @@ -1,3 +1,57 @@ +#[test] +fn operator_status_text_surfaces_github_cli_authority() { + let snapshot = OperatorStatusSnapshot { + project_id: String::from("pubfi"), + run_limit: 10, + warnings: Vec::new(), + warning_details: Vec::new(), + connector_backoffs: Vec::new(), + projects: vec![OperatorProjectStatus { + project_id: String::from("pubfi"), + config_path: String::from("project.toml"), + repo_root: String::from("/repo/pubfi"), + enabled: true, + github_cli_authority: OperatorGitHubCliAuthority { + command_path: String::from("/opt/homebrew/bin/gh"), + resolved_path: Some(String::from("/opt/homebrew/bin/gh")), + configured_path: Some(String::from("/opt/homebrew/bin/gh")), + discovery_tier: String::from("configured"), + available: true, + next_action: String::from( + "No action needed; Decodex will use the configured GitHub CLI path.", + ), + }, + active_run_count: 0, + queued_candidate_count: 0, + post_review_lane_count: 0, + retained_worktree_count: 0, + waiting_lane_count: 0, + attention_count: 0, + cleanup_blocked_count: 0, + cleanup_pending_count: 0, + connector_state: String::from("ok"), + last_activity_at: None, + warning_count: 0, + }], + account_control: OperatorCodexAccountControlStatus { + mode: String::from("balanced"), + account_selector: None, + }, + accounts: Vec::new(), + active_runs: Vec::new(), + queued_candidates: Vec::new(), + recent_runs: Vec::new(), + history_lanes: Vec::new(), + worktrees: Vec::new(), + post_review_lanes: Vec::new(), + }; + let rendered = orchestrator::render_operator_status(&snapshot); + + assert!(rendered.contains( + "GitHub CLI: tier=configured available=true command_path=/opt/homebrew/bin/gh resolved_path=/opt/homebrew/bin/gh configured_path=/opt/homebrew/bin/gh next_action=No action needed; Decodex will use the configured GitHub CLI path." + )); +} + #[test] fn operator_status_text_renders_human_readable_sections() { let active_run = operator_status_text_active_run(); diff --git a/apps/decodex/src/orchestrator/tests/review_landing/classification_checks.rs b/apps/decodex/src/orchestrator/tests/review_landing/classification_checks.rs index e5a9a51..11ea190 100644 --- a/apps/decodex/src/orchestrator/tests/review_landing/classification_checks.rs +++ b/apps/decodex/src/orchestrator/tests/review_landing/classification_checks.rs @@ -549,7 +549,7 @@ fn classify_post_review_lane_with_github_token_env_var( &snapshot, &state_store, &sample_workflow(), - &GhPullRequestReviewStateInspector { github_token_env_var }, + &GhPullRequestReviewStateInspector { github_token_env_var, github_command_path: None }, ) .expect("classification should degrade to blocked") } diff --git a/apps/decodex/src/orchestrator/tests/runtime/failure.rs b/apps/decodex/src/orchestrator/tests/runtime/failure.rs index 4054d14..01ea269 100644 --- a/apps/decodex/src/orchestrator/tests/runtime/failure.rs +++ b/apps/decodex/src/orchestrator/tests/runtime/failure.rs @@ -708,7 +708,7 @@ fn validate_review_handoff_runtime_requires_gh_and_github_token_authority() { assert!(orchestrator::validate_review_handoff_runtime(&config, true).is_ok()); assert!(orchestrator::validate_review_handoff_runtime(&config, false).is_ok()); assert!(orchestrator::validate_daemon_runtime().is_ok()); - assert!(orchestrator::validate_command_available("git", "test preflight").is_ok()); + assert!(orchestrator::validate_command_available("git", None, "test preflight").is_ok()); let error = orchestrator::validate_review_handoff_runtime(&config_missing_github, false) .expect_err("missing github token env-var should fail live preflight"); @@ -726,6 +726,7 @@ fn validate_review_handoff_runtime_requires_gh_and_github_token_authority() { let error = orchestrator::validate_command_available( "__decodex_missing_command__", + None, "PR-backed review handoff", ) .expect_err("missing command should fail preflight"); diff --git a/apps/decodex/src/orchestrator/types.rs b/apps/decodex/src/orchestrator/types.rs index 1e6ad52..ff8ae5c 100644 --- a/apps/decodex/src/orchestrator/types.rs +++ b/apps/decodex/src/orchestrator/types.rs @@ -1037,6 +1037,7 @@ struct OperatorProjectStatus { config_path: String, repo_root: String, enabled: bool, + github_cli_authority: OperatorGitHubCliAuthority, active_run_count: usize, queued_candidate_count: usize, post_review_lane_count: usize, @@ -1050,6 +1051,16 @@ struct OperatorProjectStatus { warning_count: usize, } +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +struct OperatorGitHubCliAuthority { + command_path: String, + resolved_path: Option, + configured_path: Option, + discovery_tier: String, + available: bool, + next_action: String, +} + #[derive(Clone, Debug, Eq, PartialEq, Serialize)] struct OperatorCodexAccountControlStatus { mode: String, @@ -1307,6 +1318,7 @@ impl SelectedIssueRunCandidate { struct GhPullRequestReviewStateInspector { github_token_env_var: Option, + github_command_path: Option, } impl PullRequestReviewStateInspector for GhPullRequestReviewStateInspector { fn inspect_review_state( @@ -1333,15 +1345,16 @@ impl PullRequestReviewStateInspector for GhPullRequestReviewStateInspector { let mut comments_after: Option = None; loop { - let repository = query_pull_request_review_state_page( + let repository = query_pull_request_review_state_page(PullRequestReviewStatePageQuery { cwd, - &locator.owner, - &locator.repo, - locator.number, - review_threads_after.as_deref(), + owner: &locator.owner, + repo: &locator.repo, + number: locator.number, + review_threads_after: review_threads_after.as_deref(), pr_url, - github_token.as_str(), - )?; + github_token: github_token.as_str(), + gh_command_path: self.github_command_path.as_deref(), + })?; let pull_request = repository.pull_request.as_ref().ok_or_else(|| { eyre::eyre!("GitHub GraphQL response for `{pr_url}` did not include a pull request.") })?; @@ -1370,15 +1383,16 @@ impl PullRequestReviewStateInspector for GhPullRequestReviewStateInspector { })?; while let Some(cursor) = comments_after.take() { - let pull_request = query_pull_request_issue_comments_page( + let pull_request = query_pull_request_issue_comments_page(PullRequestIssueCommentsPageQuery { cwd, - &locator.owner, - &locator.repo, - locator.number, - &cursor, + owner: &locator.owner, + repo: &locator.repo, + number: locator.number, + comments_after: &cursor, pr_url, - github_token.as_str(), - )?; + github_token: github_token.as_str(), + gh_command_path: self.github_command_path.as_deref(), + })?; comments_after = merge_pull_request_issue_comment_page(&mut review_state, &pull_request)?; } diff --git a/apps/decodex/src/recovery.rs b/apps/decodex/src/recovery.rs index 06ddc7c..056f423 100644 --- a/apps/decodex/src/recovery.rs +++ b/apps/decodex/src/recovery.rs @@ -1000,7 +1000,11 @@ fn inspect_project_pull_request( pr_url: &str, ) -> Result<(PullRequestLandingState, String)> { let github_token = context.config.github().resolve_token()?; - let repository = github::inspect_repository_context(context.config.repo_root(), &github_token)?; + let repository = github::inspect_repository_context( + context.config.repo_root(), + &github_token, + context.config.github().command_path(), + )?; if !github::pull_request_matches_repository(pr_url, &repository)? { eyre::bail!( @@ -1015,6 +1019,7 @@ fn inspect_project_pull_request( context.config.repo_root(), pr_url, &github_token, + context.config.github().command_path(), )?; Ok((landing_state, repository.default_branch)) diff --git a/decodex.example.toml b/decodex.example.toml index 0b36a56..f096d4c 100644 --- a/decodex.example.toml +++ b/decodex.example.toml @@ -5,6 +5,9 @@ api_key_env_var = "LINEAR_API_KEY" [github] token_env_var = "GITHUB_TOKEN" +# Optional explicit GitHub CLI authority. Use an absolute path when GUI-launched +# Decodex processes do not inherit the shell PATH that contains `gh`. +# command_path = "/opt/homebrew/bin/gh" # Optional Codex-specific runtime policy. # Omit this block to use `internal_review_mode = "loop"` and `external_review_enabled = true`. diff --git a/docs/reference/github-operations.md b/docs/reference/github-operations.md index f7d2ba3..a2de053 100644 --- a/docs/reference/github-operations.md +++ b/docs/reference/github-operations.md @@ -28,11 +28,30 @@ criteria for future simplification. | Default branch sync and local branch/worktree cleanup | Git commands in `apps/decodex/src/default_branch_sync.rs` and `apps/decodex/src/orchestrator/git_ops.rs` | Keep local Git | These steps mutate or inspect the local repository/worktree state. `gh` does not replace the required local checkout synchronization and linked-worktree cleanup. | Decodex resolves the `gh` executable through the runtime helper before these -operations. The helper checks `PATH`, then common local install locations such as -`$HOME/.local/bin`, `$HOME/.cargo/bin`, `/run/current-system/sw/bin`, -`/opt/homebrew/bin`, and `/usr/local/bin` so a long-running GUI-started control plane -uses the same GitHub CLI binary an operator can run from a shell when validating PR -handoff state. +operations. A project may set `[github].command_path` in `project.toml` to make one +GitHub CLI binary authoritative for GUI-launched control-plane runs. When that field is +absent, the helper checks `PATH`, then common user install locations such as +`$HOME/.local/bin` and `$HOME/.cargo/bin`, then known host fallbacks including +`/run/current-system/sw/bin`, `/opt/homebrew/bin`, `/usr/local/bin`, and `/usr/bin`. +The known fallback paths remain compatibility behavior; a project-level +`github.command_path` is the diagnosable authority when an operator expects a specific +binary. + +`decodex status` and `decodex diagnose --json` expose the GitHub CLI authority without +secrets. The diagnostic tier is one of: + +- `configured`: Decodex will invoke `github.command_path`. +- `path`: Decodex found `gh` on the process `PATH`. +- `user-bin`: Decodex found `gh` in a common user bin directory. +- `known-fallback`: Decodex found `gh` in a built-in compatibility fallback path. +- `missing`: Decodex did not find an installed `gh` path and will fail closed at the + GitHub-dependent review, repair, landing, or cleanup boundary. + +If status shows `missing`, install GitHub CLI or set `github.command_path` to the +expected binary. If status shows `user-bin` or `known-fallback` but that path is not +the operator-intended authority, set `github.command_path` in the registered project +config and rerun `decodex status` or `decodex diagnose --json` to confirm the tier is +`configured`. ## Replacement Criteria diff --git a/docs/spec/runtime.md b/docs/spec/runtime.md index 0cf18e4..958eb59 100644 --- a/docs/spec/runtime.md +++ b/docs/spec/runtime.md @@ -187,16 +187,17 @@ After each `app-server` turn completes, `decodex` must resolve one continuation - Before starting a live run, the service must reconcile stale local leases and any terminal worktree mappings against current tracker state. - Generic live dispatch must not require GitHub CLI authority before the lane actually attempts PR-backed review handoff. - Generic live dispatch must resolve `github.token_env_var` before launching the agent app-server so lane-owned `git push` and `gh pr create` commands inherit noninteractive GitHub credentials. Missing or blank GitHub credentials must fail the run through the human-required path instead of retrying or leaving a promptable lane running. +- Project configs may set `[github].command_path` to make one expected GitHub CLI binary authoritative for project-scoped GitHub operations. When it is configured, review handoff validation, retained review readback, landing inspection, GitHub comments, admin merge, merge readback, and remote branch cleanup must invoke that path instead of silently rediscovering another `gh` binary. - The service must fail fast on missing `gh` CLI authority only at the GitHub-dependent review boundary: - when a normal lane is about to validate and persist PR-backed review handoff - when a retained post-review lane is about to re-enter review repair - when a retained closeout lane is about to validate merged PR state or delete the retained remote branch ref - GitHub CLI discovery for those boundaries must use the same resolved `gh` command - path as PR inspection, including normal `PATH` lookup and the runtime's known local - install fallbacks. A valid PR that `gh pr view` can inspect with the routed project - token must not fail review handoff solely because the long-running Decodex process - started with a narrower shell path. + path as PR inspection, including configured project path, normal `PATH` lookup, user + bin lookup, and the runtime's known local install fallbacks. A valid PR that + `gh pr view` can inspect with the routed project token must not fail review handoff + solely because the long-running Decodex process started with a narrower shell path. ## Linear writeback model @@ -367,7 +368,7 @@ mutations, or duplicate comment for that logical event. The local runtime store is the global Decodex SQLite database for one local installation. It lives at `~/.codex/decodex/runtime.sqlite3`, not inside any registered project checkout or worktree. Every row that belongs to a repo is scoped by `project_id`. Decodex logs live beside that database under `~/.codex/decodex/logs/`, the optional shared Codex account pool lives at `~/.codex/decodex/accounts.jsonl`, global operator config lives at `~/.codex/decodex/config.toml`, bounded local account usage estimates live at `~/.codex/decodex/account-usage-history.jsonl`, and agent-readable derived evidence lives under `~/.codex/decodex/agent-evidence//`; vendor-qualified app-data directories and per-project runtime databases are not part of the runtime contract. Global operator config owns account-pool routing and shared account display-name offsets. Account usage history owns local seven-day display estimates and non-secret account capacity weights only; it does not contain token material and does not decide scheduling. UI-only preferences such as theme, table sorting, and local privacy visibility are not runtime state. -Project contracts live outside registered repositories under `~/.codex/decodex/projects//`. Each project directory must contain `project.toml` and `WORKFLOW.md`; arbitrary project file names such as `.toml` are not part of the contract. `project.toml` must set `[paths].repo_root` so the project contract is explicit. Project registration stores the centralized `config_path`, target `repo_root`, `worktree_root`, and workflow path in the global runtime database. Commands that start inside a registered checkout or lane worktree resolve the project through that registry; they do not discover or trust worktree-local config files. Project config refreshes preserve an existing enabled or disabled registry toggle; only explicit operator commands such as `decodex project add `, `decodex project enable `, and `decodex project disable ` may change that toggle. `decodex serve` loads enabled registered projects from the global runtime database. It must not scan `.codex` history, repo-local config files, or currently open worktrees to infer additional projects. +Project contracts live outside registered repositories under `~/.codex/decodex/projects//`. Each project directory must contain `project.toml` and `WORKFLOW.md`; arbitrary project file names such as `.toml` are not part of the contract. `project.toml` must set `[paths].repo_root` so the project contract is explicit. The `[github]` table owns the routed token environment variable and may also set `command_path` when the expected `gh` binary should be explicit for GUI-launched runs. Project registration stores the centralized `config_path`, target `repo_root`, `worktree_root`, and workflow path in the global runtime database. Commands that start inside a registered checkout or lane worktree resolve the project through that registry; they do not discover or trust worktree-local config files. Project config refreshes preserve an existing enabled or disabled registry toggle; only explicit operator commands such as `decodex project add `, `decodex project enable `, and `decodex project disable ` may change that toggle. `decodex serve` loads enabled registered projects from the global runtime database. It must not scan `.codex` history, repo-local config files, or currently open worktrees to infer additional projects. `project.toml` may also configure `[privacy_classifier]` with a loopback HTTP `endpoint` and bounded `timeout_ms` for an operator-managed local classifier runtime. @@ -411,8 +412,8 @@ Restart recovery must use the runtime database plus retained worktrees and exter The minimum supported surface is: - structured runtime logs with stable identifiers such as `project_id`, `issue_id`, `issue`, `run_id`, `attempt`, `branch`, and repository-relative `worktree_path` -- a local status command that renders the current service snapshot in both human-readable and JSON forms -- an agent evidence command, `decodex diagnose`, that writes a compact derived handoff index, blocker snapshots, run capsules, and an append-only evidence event stream under `~/.codex/decodex/agent-evidence//` +- a local status command that renders the current service snapshot in both human-readable and JSON forms, including non-secret GitHub CLI authority diagnostics for the resolved command path, discovery tier, configured path when present, availability, and operator next action +- an agent evidence command, `decodex diagnose`, that writes a compact derived handoff index, blocker snapshots, run capsules, and an append-only evidence event stream under `~/.codex/decodex/agent-evidence//`; the handoff index includes the same non-secret GitHub CLI authority readback so repair agents can diagnose missing or fallback-only `gh` authority Structured logs remain diagnostic. They may help explain a live failure, but they are not the structured private evidence ledger. Private execution events belong in the