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, normalize_timeline_events};
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,baseRefName,headRefName,reviewDecision,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 path line originalLine } } } } } } }";
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        let timeline_json = self.structured_json(
163            &request.working_directory,
164            &gh_timeline_args(&request.resource, &document.repository)?,
165        )?;
166        document.timeline = normalize_timeline_events(&timeline_json);
167        Ok(document)
168    }
169}
170
171impl<R: GhCommandRunner> GhCliFetcher<R> {
172    fn structured_json(
173        &self,
174        working_directory: &std::path::Path,
175        args: &[String],
176    ) -> Result<Value, GithubReadError> {
177        let result = self
178            .runner
179            .run(working_directory, args)
180            .map_err(|error| match error {
181                GhCommandError::NotFound => GithubReadError::GithubCliMissing,
182                GhCommandError::Other(message) => GithubReadError::FetchFailed(format!(
183                    "could not start GitHub CLI: {}",
184                    redact_gh_error(&message)
185                )),
186            })?;
187        if !result.success {
188            return Err(GithubReadError::FetchFailed(redact_gh_error(
189                &best_gh_error(&result.stderr, &result.stdout),
190            )));
191        }
192        let json: Value = serde_json::from_slice(&result.stdout).map_err(|error| {
193            GithubReadError::InvalidStructuredResponse(format!(
194                "GitHub CLI returned invalid structured JSON: {error}"
195            ))
196        })?;
197        if json.get("success").and_then(Value::as_bool) == Some(false) {
198            let underlying = json
199                .get("error")
200                .or_else(|| json.get("message"))
201                .and_then(Value::as_str)
202                .unwrap_or("GitHub declined the resource request");
203            return Err(GithubReadError::FetchFailed(redact_gh_error(underlying)));
204        }
205        if let Some(errors) = json.get("errors") {
206            return Err(GithubReadError::FetchFailed(redact_gh_error(
207                &errors.to_string(),
208            )));
209        }
210        Ok(json)
211    }
212}
213
214fn normalize_document(
215    resource: &GithubResource,
216    json: &Value,
217) -> Result<GithubDocument, GithubReadError> {
218    normalize_structured_document(resource, json).map_err(|error| {
219        GithubReadError::InvalidStructuredResponse(format!(
220            "GitHub returned an incomplete structured response: {}",
221            redact_gh_error(&error.to_string())
222        ))
223    })
224}
225
226/// Build the exact structured-view invocation. Short forms intentionally omit
227/// `-R`; explicit forms include it and are otherwise identical.
228pub fn gh_view_args(resource: &GithubResource) -> Vec<String> {
229    let fields = match resource.kind {
230        GithubResourceKind::Issue => ISSUE_JSON_FIELDS,
231        GithubResourceKind::PullRequest => PR_JSON_FIELDS,
232    };
233    let mut args = vec![
234        resource.kind.command().to_string(),
235        "view".to_string(),
236        resource.number.to_string(),
237    ];
238    if let Some(repository) = &resource.repository {
239        args.push("-R".to_string());
240        args.push(repository.clone());
241    }
242    args.push("--json".to_string());
243    args.push(fields.to_string());
244    args
245}
246
247/// Build the paginated timeline request with the owner/repository resolved by
248/// the initial resource lookup, expanding shorthand resource names first.
249pub fn gh_timeline_args(
250    resource: &GithubResource,
251    resolved_repository: &str,
252) -> Result<Vec<String>, GithubReadError> {
253    let (owner, repository) = resolved_repository.split_once('/').ok_or_else(|| {
254        GithubReadError::InvalidStructuredResponse(
255            "GitHub structured response returned an invalid resolved repository".to_string(),
256        )
257    })?;
258    if owner.is_empty() || repository.is_empty() || repository.contains('/') {
259        return Err(GithubReadError::InvalidStructuredResponse(
260            "GitHub structured response returned an invalid resolved repository".to_string(),
261        ));
262    }
263    Ok(vec![
264        "api".to_string(),
265        format!(
266            "repos/{owner}/{repository}/issues/{}/timeline?per_page=100",
267            resource.number
268        ),
269        "--paginate".to_string(),
270        "--slurp".to_string(),
271    ])
272}
273
274pub fn gh_pr_review_comments_args(
275    resource: &GithubResource,
276    resolved_repository: &str,
277) -> Result<Vec<String>, GithubReadError> {
278    if resource.kind != GithubResourceKind::PullRequest {
279        return Err(GithubReadError::InvalidStructuredResponse(
280            "review-comment query requested for a non-pull-request resource".to_string(),
281        ));
282    }
283    let (owner, repository) = resolved_repository.split_once('/').ok_or_else(|| {
284        GithubReadError::InvalidStructuredResponse(
285            "GitHub structured response returned an invalid resolved repository".to_string(),
286        )
287    })?;
288    if owner.is_empty() || repository.is_empty() || repository.contains('/') {
289        return Err(GithubReadError::InvalidStructuredResponse(
290            "GitHub structured response returned an invalid resolved repository".to_string(),
291        ));
292    }
293    let number = i32::try_from(resource.number).map_err(|_| {
294        GithubReadError::InvalidStructuredResponse(
295            "GitHub resource number exceeds the GraphQL integer range".to_string(),
296        )
297    })?;
298    let args = vec![
299        "api".to_string(),
300        "graphql".to_string(),
301        "-f".to_string(),
302        format!("query={PR_REVIEW_COMMENTS_QUERY}"),
303        "-F".to_string(),
304        format!("owner={owner}"),
305        "-F".to_string(),
306        format!("name={repository}"),
307        "-F".to_string(),
308        format!("number={number}"),
309    ];
310    Ok(args)
311}
312
313fn best_gh_error(stderr: &[u8], stdout: &[u8]) -> String {
314    let stderr = String::from_utf8_lossy(stderr).trim().to_string();
315    if !stderr.is_empty() {
316        return stderr;
317    }
318    let stdout = String::from_utf8_lossy(stdout).trim().to_string();
319    if !stdout.is_empty() {
320        return stdout;
321    }
322    "GitHub CLI failed without an error message".to_string()
323}
324
325static GITHUB_TOKEN: LazyLock<regex::Regex> = LazyLock::new(|| {
326    regex::Regex::new(r"(?:gh[pousr]_[A-Za-z0-9_]+|github_pat_[A-Za-z0-9_]+)")
327        .expect("GitHub token redaction expression is valid")
328});
329static AUTHORIZATION_VALUE: LazyLock<regex::Regex> = LazyLock::new(|| {
330    regex::Regex::new(r"(?i)((?:authorization|token)\s*[:=]\s*)[^\s,;]+")
331        .expect("authorization redaction expression is valid")
332});
333
334/// Redact ambient credentials while keeping GitHub's actionable authorization,
335/// private-resource, and not-found diagnostics visible to the caller.
336pub fn redact_gh_error(message: &str) -> String {
337    let with_tokens = GITHUB_TOKEN.replace_all(message, "[redacted]");
338    AUTHORIZATION_VALUE
339        .replace_all(&with_tokens, "${1}[redacted]")
340        .into_owned()
341}
342
343#[cfg(test)]
344mod tests {
345    use std::sync::Mutex;
346
347    use serde_json::json;
348
349    use super::*;
350    use crate::github_read::resource::{GithubResource, GithubResourceKind};
351
352    #[derive(Default)]
353    struct FixtureRunner {
354        calls: Mutex<Vec<(PathBuf, Vec<String>)>>,
355        output: Mutex<Vec<Result<GhCommandOutput, GhCommandError>>>,
356    }
357
358    impl GhCommandRunner for FixtureRunner {
359        fn run(
360            &self,
361            working_directory: &std::path::Path,
362            args: &[String],
363        ) -> Result<GhCommandOutput, GhCommandError> {
364            self.calls
365                .lock()
366                .unwrap()
367                .push((working_directory.to_path_buf(), args.to_vec()));
368            self.output.lock().unwrap().remove(0)
369        }
370    }
371
372    #[test]
373    fn explicit_and_short_forms_differ_only_by_repo_flag() {
374        let short = GithubResource {
375            kind: GithubResourceKind::Issue,
376            number: 1,
377            repository: None,
378            comment_selector: None,
379        };
380        let explicit = GithubResource {
381            repository: Some("owner/repo".to_string()),
382            ..short.clone()
383        };
384        let short_args = gh_view_args(&short);
385        let explicit_args = gh_view_args(&explicit);
386        assert!(!short_args.iter().any(|argument| argument == "-R"));
387        assert!(explicit_args
388            .windows(2)
389            .any(|pair| pair == ["-R", "owner/repo"]));
390
391        let short_pr = GithubResource {
392            kind: GithubResourceKind::PullRequest,
393            ..short.clone()
394        };
395        let explicit_pr = GithubResource {
396            repository: Some("owner/repo".to_string()),
397            ..short_pr.clone()
398        };
399        let short_review_args = gh_pr_review_comments_args(&short_pr, "owner/repo").unwrap();
400        assert!(!short_review_args.iter().any(|argument| argument == "-R"));
401        let explicit_review_args = gh_pr_review_comments_args(&explicit_pr, "owner/repo").unwrap();
402        assert!(
403            !explicit_review_args.iter().any(|argument| argument == "-R"),
404            "the GraphQL request resolves owner and repository from its variables"
405        );
406        assert!(short_review_args
407            .iter()
408            .any(|argument| argument.starts_with("query=query AftReadPullRequestReviewComments")));
409    }
410
411    #[test]
412    fn fetcher_uses_structured_json_and_redacts_failures() {
413        let runner = FixtureRunner::default();
414        *runner.output.lock().unwrap() = vec![
415            Ok(GhCommandOutput {
416                success: true,
417                stdout: serde_json::to_vec(&json!({
418                    "number": 1,
419                    "title": "fixture",
420                    "url": "https://github.com/owner/repo/issues/1"
421                }))
422                .unwrap(),
423                stderr: Vec::new(),
424            }),
425            Ok(GhCommandOutput {
426                success: true,
427                stdout: b"[]".to_vec(),
428                stderr: Vec::new(),
429            }),
430        ];
431        let fetcher = GhCliFetcher::new(runner);
432        let request = GithubFetchRequest {
433            resource: GithubResource {
434                kind: GithubResourceKind::Issue,
435                number: 1,
436                repository: None,
437                comment_selector: None,
438            },
439            working_directory: PathBuf::from("/fixture"),
440        };
441        let document = fetcher.fetch(&request).unwrap();
442        assert_eq!(document.repository, "owner/repo");
443
444        let redacted = redact_gh_error("HTTP 401 token=ghp_secret github_pat_secret");
445        assert!(!redacted.contains("secret"));
446        assert!(redacted.contains("HTTP 401"));
447    }
448}