Skip to main content

aft/github_read/
fetch.rs

1use std::fmt;
2use std::path::PathBuf;
3use std::process::Command;
4use std::sync::LazyLock;
5
6use serde_json::Value;
7
8use super::model::GithubDocument;
9use super::normalize::normalize_structured_document;
10use super::resource::{GithubResource, GithubResourceKind};
11
12const ISSUE_JSON_FIELDS: &str = "number,title,state,author,createdAt,updatedAt,labels,assignees,milestone,body,reactionGroups,comments,url";
13const PR_JSON_FIELDS: &str = "number,title,state,author,createdAt,updatedAt,labels,assignees,milestone,body,reactionGroups,comments,files,reviews,url";
14const PR_REVIEW_COMMENTS_QUERY: &str = "query AftReadPullRequestReviewComments($owner: String!, $name: String!, $number: Int!) { repository(owner: $owner, name: $name) { nameWithOwner pullRequest(number: $number) { number reviews(first: 100) { nodes { author { login } body state submittedAt comments(first: 100) { totalCount nodes { author { login } body createdAt updatedAt isMinimized } } } } } } }";
15
16/// Request context passed to a structured GitHub fetcher.
17#[derive(Clone, Debug, Eq, PartialEq)]
18pub struct GithubFetchRequest {
19    pub resource: GithubResource,
20    pub working_directory: PathBuf,
21}
22
23/// Fetches structured GitHub data. Implementations never return CLI display
24/// text; the engine only accepts a normalized document from this interface.
25pub trait GithubFetcher: Send + Sync {
26    fn fetch(&self, request: &GithubFetchRequest) -> Result<GithubDocument, GithubReadError>;
27}
28
29/// The typed failures returned by the GitHub read engine.
30#[derive(Clone, Debug, Eq, PartialEq)]
31pub enum GithubReadError {
32    GithubReadDisabled,
33    InvalidResource(String),
34    InvalidCommentSelector(String),
35    GithubCliMissing,
36    FetchFailed(String),
37    InvalidStructuredResponse(String),
38}
39
40impl GithubReadError {
41    pub fn code(&self) -> &'static str {
42        match self {
43            Self::GithubReadDisabled => "gh_read_disabled",
44            Self::InvalidResource(_) => "invalid_resource",
45            Self::InvalidCommentSelector(_) => "invalid_comment_selector",
46            Self::GithubCliMissing => "github_cli_missing",
47            Self::FetchFailed(_) | Self::InvalidStructuredResponse(_) => "github_fetch_failed",
48        }
49    }
50
51    pub fn invalid_resource(message: impl Into<String>) -> Self {
52        Self::InvalidResource(message.into())
53    }
54}
55
56impl fmt::Display for GithubReadError {
57    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
58        match self {
59            Self::GithubReadDisabled => formatter
60                .write_str("GitHub reads are disabled; set gh_read.enabled: true in aft.jsonc"),
61            Self::InvalidResource(message)
62            | Self::InvalidCommentSelector(message)
63            | Self::FetchFailed(message)
64            | Self::InvalidStructuredResponse(message) => formatter.write_str(message),
65            Self::GithubCliMissing => formatter.write_str(
66                "GitHub reads require the `gh` CLI. Install GitHub CLI and authenticate it with `gh auth login`.",
67            ),
68        }
69    }
70}
71
72impl std::error::Error for GithubReadError {}
73
74/// A subprocess result for the `gh` command seam.
75#[derive(Clone, Debug, Eq, PartialEq)]
76pub struct GhCommandOutput {
77    pub success: bool,
78    pub stdout: Vec<u8>,
79    pub stderr: Vec<u8>,
80}
81
82/// Error running `gh` before it produced a process result.
83#[derive(Clone, Debug, Eq, PartialEq)]
84pub enum GhCommandError {
85    NotFound,
86    Other(String),
87}
88
89/// Injectable `gh` runner. Fixture runners can assert the exact command and
90/// request working directory without requiring a real GitHub installation.
91pub trait GhCommandRunner: Send + Sync {
92    fn run(
93        &self,
94        working_directory: &std::path::Path,
95        args: &[String],
96    ) -> Result<GhCommandOutput, GhCommandError>;
97}
98
99/// Production runner that executes the bare `gh` command in the caller's
100/// working directory so the CLI owns short-form repository resolution.
101#[derive(Default)]
102pub struct SystemGhCommandRunner;
103
104impl GhCommandRunner for SystemGhCommandRunner {
105    fn run(
106        &self,
107        working_directory: &std::path::Path,
108        args: &[String],
109    ) -> Result<GhCommandOutput, GhCommandError> {
110        let output = Command::new("gh")
111            .args(args)
112            .current_dir(working_directory)
113            .output()
114            .map_err(|error| match error.kind() {
115                std::io::ErrorKind::NotFound => GhCommandError::NotFound,
116                _ => GhCommandError::Other(error.to_string()),
117            })?;
118        Ok(GhCommandOutput {
119            success: output.status.success(),
120            stdout: output.stdout,
121            stderr: output.stderr,
122        })
123    }
124}
125
126/// Structured `gh issue view` / `gh pr view` fetcher.
127///
128/// The only parser after the subprocess boundary is JSON normalization. In
129/// particular, stderr is used only to construct an actionable redacted error,
130/// never to infer fields or repository selection.
131pub struct GhCliFetcher<R = SystemGhCommandRunner> {
132    runner: R,
133}
134
135impl GhCliFetcher<SystemGhCommandRunner> {
136    pub fn system() -> Self {
137        Self {
138            runner: SystemGhCommandRunner,
139        }
140    }
141}
142
143impl<R> GhCliFetcher<R> {
144    pub fn new(runner: R) -> Self {
145        Self { runner }
146    }
147}
148
149impl<R: GhCommandRunner> GithubFetcher for GhCliFetcher<R> {
150    fn fetch(&self, request: &GithubFetchRequest) -> Result<GithubDocument, GithubReadError> {
151        let json =
152            self.structured_json(&request.working_directory, &gh_view_args(&request.resource))?;
153        let mut document = normalize_document(&request.resource, &json)?;
154        if request.resource.kind == GithubResourceKind::PullRequest {
155            let review_json = self.structured_json(
156                &request.working_directory,
157                &gh_pr_review_comments_args(&request.resource, &document.repository)?,
158            )?;
159            let review_document = normalize_document(&request.resource, &review_json)?;
160            document.review_comment_sections = review_document.review_comment_sections;
161        }
162        Ok(document)
163    }
164}
165
166impl<R: GhCommandRunner> GhCliFetcher<R> {
167    fn structured_json(
168        &self,
169        working_directory: &std::path::Path,
170        args: &[String],
171    ) -> Result<Value, GithubReadError> {
172        let result = self
173            .runner
174            .run(working_directory, args)
175            .map_err(|error| match error {
176                GhCommandError::NotFound => GithubReadError::GithubCliMissing,
177                GhCommandError::Other(message) => GithubReadError::FetchFailed(format!(
178                    "could not start GitHub CLI: {}",
179                    redact_gh_error(&message)
180                )),
181            })?;
182        if !result.success {
183            return Err(GithubReadError::FetchFailed(redact_gh_error(
184                &best_gh_error(&result.stderr, &result.stdout),
185            )));
186        }
187        let json: Value = serde_json::from_slice(&result.stdout).map_err(|error| {
188            GithubReadError::InvalidStructuredResponse(format!(
189                "GitHub CLI returned invalid structured JSON: {error}"
190            ))
191        })?;
192        if json.get("success").and_then(Value::as_bool) == Some(false) {
193            let underlying = json
194                .get("error")
195                .or_else(|| json.get("message"))
196                .and_then(Value::as_str)
197                .unwrap_or("GitHub declined the resource request");
198            return Err(GithubReadError::FetchFailed(redact_gh_error(underlying)));
199        }
200        if let Some(errors) = json.get("errors") {
201            return Err(GithubReadError::FetchFailed(redact_gh_error(
202                &errors.to_string(),
203            )));
204        }
205        Ok(json)
206    }
207}
208
209fn normalize_document(
210    resource: &GithubResource,
211    json: &Value,
212) -> Result<GithubDocument, GithubReadError> {
213    normalize_structured_document(resource, json).map_err(|error| {
214        GithubReadError::InvalidStructuredResponse(format!(
215            "GitHub returned an incomplete structured response: {}",
216            redact_gh_error(&error.to_string())
217        ))
218    })
219}
220
221/// Build the exact structured-view invocation. Short forms intentionally omit
222/// `-R`; explicit forms include it and are otherwise identical.
223pub fn gh_view_args(resource: &GithubResource) -> Vec<String> {
224    let fields = match resource.kind {
225        GithubResourceKind::Issue => ISSUE_JSON_FIELDS,
226        GithubResourceKind::PullRequest => PR_JSON_FIELDS,
227    };
228    let mut args = vec![
229        resource.kind.command().to_string(),
230        "view".to_string(),
231        resource.number.to_string(),
232    ];
233    if let Some(repository) = &resource.repository {
234        args.push("-R".to_string());
235        args.push(repository.clone());
236    }
237    args.push("--json".to_string());
238    args.push(fields.to_string());
239    args
240}
241
242/// Build the structured GraphQL fetch for inline PR review comments. The first
243/// `pr view --json` call resolves the repository; this second JSON call fills
244/// comment sections that `gh pr view` does not expose as a display field.
245pub fn gh_pr_review_comments_args(
246    resource: &GithubResource,
247    resolved_repository: &str,
248) -> Result<Vec<String>, GithubReadError> {
249    if resource.kind != GithubResourceKind::PullRequest {
250        return Err(GithubReadError::InvalidStructuredResponse(
251            "review-comment query requested for a non-pull-request resource".to_string(),
252        ));
253    }
254    let (owner, repository) = resolved_repository.split_once('/').ok_or_else(|| {
255        GithubReadError::InvalidStructuredResponse(
256            "GitHub structured response returned an invalid resolved repository".to_string(),
257        )
258    })?;
259    if owner.is_empty() || repository.is_empty() || repository.contains('/') {
260        return Err(GithubReadError::InvalidStructuredResponse(
261            "GitHub structured response returned an invalid resolved repository".to_string(),
262        ));
263    }
264    let number = i32::try_from(resource.number).map_err(|_| {
265        GithubReadError::InvalidStructuredResponse(
266            "GitHub resource number exceeds the GraphQL integer range".to_string(),
267        )
268    })?;
269    let mut args = vec![
270        "api".to_string(),
271        "graphql".to_string(),
272        "-f".to_string(),
273        format!("query={PR_REVIEW_COMMENTS_QUERY}"),
274        "-F".to_string(),
275        format!("owner={owner}"),
276        "-F".to_string(),
277        format!("name={repository}"),
278        "-F".to_string(),
279        format!("number={number}"),
280    ];
281    if let Some(repository) = &resource.repository {
282        args.push("-R".to_string());
283        args.push(repository.clone());
284    }
285    Ok(args)
286}
287
288fn best_gh_error(stderr: &[u8], stdout: &[u8]) -> String {
289    let stderr = String::from_utf8_lossy(stderr).trim().to_string();
290    if !stderr.is_empty() {
291        return stderr;
292    }
293    let stdout = String::from_utf8_lossy(stdout).trim().to_string();
294    if !stdout.is_empty() {
295        return stdout;
296    }
297    "GitHub CLI failed without an error message".to_string()
298}
299
300static GITHUB_TOKEN: LazyLock<regex::Regex> = LazyLock::new(|| {
301    regex::Regex::new(r"(?:gh[pousr]_[A-Za-z0-9_]+|github_pat_[A-Za-z0-9_]+)")
302        .expect("GitHub token redaction expression is valid")
303});
304static AUTHORIZATION_VALUE: LazyLock<regex::Regex> = LazyLock::new(|| {
305    regex::Regex::new(r"(?i)((?:authorization|token)\s*[:=]\s*)[^\s,;]+")
306        .expect("authorization redaction expression is valid")
307});
308
309/// Redact ambient credentials while keeping GitHub's actionable authorization,
310/// private-resource, and not-found diagnostics visible to the caller.
311pub fn redact_gh_error(message: &str) -> String {
312    let with_tokens = GITHUB_TOKEN.replace_all(message, "[redacted]");
313    AUTHORIZATION_VALUE
314        .replace_all(&with_tokens, "${1}[redacted]")
315        .into_owned()
316}
317
318#[cfg(test)]
319mod tests {
320    use std::sync::Mutex;
321
322    use serde_json::json;
323
324    use super::*;
325    use crate::github_read::resource::{GithubResource, GithubResourceKind};
326
327    #[derive(Default)]
328    struct FixtureRunner {
329        calls: Mutex<Vec<(PathBuf, Vec<String>)>>,
330        output: Mutex<Option<Result<GhCommandOutput, GhCommandError>>>,
331    }
332
333    impl GhCommandRunner for FixtureRunner {
334        fn run(
335            &self,
336            working_directory: &std::path::Path,
337            args: &[String],
338        ) -> Result<GhCommandOutput, GhCommandError> {
339            self.calls
340                .lock()
341                .unwrap()
342                .push((working_directory.to_path_buf(), args.to_vec()));
343            self.output.lock().unwrap().take().unwrap()
344        }
345    }
346
347    #[test]
348    fn explicit_and_short_forms_differ_only_by_repo_flag() {
349        let short = GithubResource {
350            kind: GithubResourceKind::Issue,
351            number: 1,
352            repository: None,
353            comment_selector: None,
354        };
355        let explicit = GithubResource {
356            repository: Some("owner/repo".to_string()),
357            ..short.clone()
358        };
359        let short_args = gh_view_args(&short);
360        let explicit_args = gh_view_args(&explicit);
361        assert!(!short_args.iter().any(|argument| argument == "-R"));
362        assert!(explicit_args
363            .windows(2)
364            .any(|pair| pair == ["-R", "owner/repo"]));
365
366        let short_pr = GithubResource {
367            kind: GithubResourceKind::PullRequest,
368            ..short.clone()
369        };
370        let explicit_pr = GithubResource {
371            repository: Some("owner/repo".to_string()),
372            ..short_pr.clone()
373        };
374        let short_review_args = gh_pr_review_comments_args(&short_pr, "owner/repo").unwrap();
375        assert!(!short_review_args.iter().any(|argument| argument == "-R"));
376        let explicit_review_args = gh_pr_review_comments_args(&explicit_pr, "owner/repo").unwrap();
377        assert!(explicit_review_args
378            .windows(2)
379            .any(|pair| pair == ["-R", "owner/repo"]));
380        assert!(short_review_args
381            .iter()
382            .any(|argument| argument.starts_with("query=query AftReadPullRequestReviewComments")));
383    }
384
385    #[test]
386    fn fetcher_uses_structured_json_and_redacts_failures() {
387        let runner = FixtureRunner::default();
388        *runner.output.lock().unwrap() = Some(Ok(GhCommandOutput {
389            success: true,
390            stdout: serde_json::to_vec(&json!({
391                "number": 1,
392                "title": "fixture",
393                "url": "https://github.com/owner/repo/issues/1"
394            }))
395            .unwrap(),
396            stderr: Vec::new(),
397        }));
398        let fetcher = GhCliFetcher::new(runner);
399        let request = GithubFetchRequest {
400            resource: GithubResource {
401                kind: GithubResourceKind::Issue,
402                number: 1,
403                repository: None,
404                comment_selector: None,
405            },
406            working_directory: PathBuf::from("/fixture"),
407        };
408        let document = fetcher.fetch(&request).unwrap();
409        assert_eq!(document.repository, "owner/repo");
410
411        let redacted = redact_gh_error("HTTP 401 token=ghp_secret github_pat_secret");
412        assert!(!redacted.contains("secret"));
413        assert!(redacted.contains("HTTP 401"));
414    }
415}