Skip to main content

nomoreide_core/
github_manager.rs

1//! The GitHub REST client.
2//!
3//! Most responses are passed through as GitHub sent them: an agent reading an
4//! issue wants the issue, and a struct here would silently drop every field
5//! GitHub adds later. Only the shapes the reference *reshapes* — a pull
6//! request, a commit's checks — are modelled, because those are the ones a
7//! caller sees differently from what the API returned.
8//!
9//! The ETag revalidation cache the reference keeps at module scope is not here
10//! yet: the only Rust caller is the MCP server, which is a fresh process per
11//! tool call, so a cache could never be read. It belongs with the daemon's
12//! GitHub routes, where it is what keeps the dashboard's polling affordable.
13
14use regex::Regex;
15use serde::Serialize;
16use serde_json::{json, Map, Value};
17use std::fmt;
18
19const GITHUB_API: &str = "https://api.github.com";
20const API_VERSION: &str = "2022-11-28";
21const JSON_ACCEPT: &str = "application/vnd.github+json";
22const DIFF_ACCEPT: &str = "application/vnd.github.diff";
23// GitHub rejects API requests that do not identify their client. The former
24// Node transport supplied a user agent implicitly; reqwest does not, so the
25// native client must make it explicit or every valid token looks forbidden.
26const USER_AGENT: &str = concat!("NoMoreIDE/", env!("CARGO_PKG_VERSION"));
27
28/// A base URL an environment variable is allowed to move, which exists so the
29/// parity gates can point a runtime at a stub they control.
30///
31/// Only loopback is honoured, and anything else falls back rather than
32/// failing: these requests carry a bearer token or return one, and an override
33/// that could name any host would turn one environment variable into a way to
34/// post the user's credential somewhere else.
35pub(crate) fn loopback_override(variable: &str, fallback: &str) -> String {
36    let Ok(override_value) = std::env::var(variable) else {
37        return fallback.to_string();
38    };
39    let override_value = override_value.trim();
40    let rest = match override_value
41        .strip_prefix("http://")
42        .or_else(|| override_value.strip_prefix("https://"))
43    {
44        Some(rest) => rest,
45        None => return fallback.to_string(),
46    };
47    let authority = rest.split(['/', '?', '#']).next().unwrap_or_default();
48    let host = match authority.rsplit_once(':') {
49        Some((host, port)) if port.chars().all(|c| c.is_ascii_digit()) => host,
50        _ => authority,
51    };
52    if !matches!(host, "127.0.0.1" | "localhost" | "[::1]" | "::1") {
53        return fallback.to_string();
54    }
55    override_value.trim_end_matches('/').to_string()
56}
57
58/// Where API calls go. GitHub itself, unless the override names loopback.
59pub fn api_base() -> String {
60    loopback_override("NOMOREIDE_GITHUB_API_BASE", GITHUB_API)
61}
62
63#[derive(Debug, Clone)]
64pub struct GithubApiError {
65    pub message: String,
66    pub status: u16,
67    pub path: String,
68}
69
70impl fmt::Display for GithubApiError {
71    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
72        write!(formatter, "{}", self.message)
73    }
74}
75
76impl std::error::Error for GithubApiError {}
77
78impl GithubApiError {
79    fn transport(error: reqwest::Error, path: &str) -> Self {
80        Self {
81            message: error.to_string(),
82            status: 0,
83            path: path.to_string(),
84        }
85    }
86}
87
88/// A pull request as the reference reports one: GitHub's own fields, except
89/// that a merged pull request is named "merged" rather than "closed", and the
90/// two fields GitHub omits on a list response are filled in.
91#[derive(Debug, Clone, Serialize)]
92pub struct GithubPr {
93    pub number: i64,
94    pub title: String,
95    pub state: String,
96    pub body: Value,
97    pub html_url: String,
98    pub head: Value,
99    pub base: Value,
100    pub user: Value,
101    pub created_at: String,
102    pub updated_at: String,
103    pub merged_at: Value,
104    pub draft: bool,
105    pub mergeable: Value,
106}
107
108/// One commit's checks, as the reference reports them.
109///
110/// `sha` and `total_count` are optional because the reference builds this
111/// object out of values that can be `undefined` — a pull request head with no
112/// sha, a payload with no count — and `JSON.stringify` drops a key whose value
113/// is `undefined` rather than writing null.
114/// A branch comparison as GitHub reports it, reshaped.
115///
116/// `head_sha` comes from the *last* commit in the range rather than from a
117/// field of its own: GitHub's compare response names the merge base and the
118/// two endpoints, but the sha this is wanted for is the tip of the branch
119/// being proposed, which is the last commit it listed.
120#[derive(Debug, Clone, Serialize)]
121#[serde(rename_all = "camelCase")]
122pub struct GithubCompareSummary {
123    /// `Option`, not a null: the reference copies these two straight off
124    /// GitHub's payload, so a field it did not send becomes `undefined` and
125    /// disappears from the answer rather than showing up as null.
126    pub status: Option<Value>,
127    pub ahead_by: Option<Value>,
128    pub head_sha: Option<String>,
129    pub commits: Vec<Value>,
130    pub files: Vec<Value>,
131}
132
133#[derive(Debug, Clone, Serialize)]
134#[serde(rename_all = "camelCase")]
135pub struct CommitCiStatus {
136    #[serde(skip_serializing_if = "Option::is_none")]
137    pub sha: Option<String>,
138    pub state: String,
139    #[serde(skip_serializing_if = "Option::is_none")]
140    pub total_count: Option<i64>,
141    pub runs: Vec<Value>,
142}
143
144pub struct GithubManager {
145    token: String,
146    owner: String,
147    repo: String,
148    base_url: String,
149}
150
151impl GithubManager {
152    pub fn new(
153        token: impl Into<String>,
154        owner: impl Into<String>,
155        repo: impl Into<String>,
156    ) -> Self {
157        Self {
158            token: token.into(),
159            owner: owner.into(),
160            repo: repo.into(),
161            base_url: api_base(),
162        }
163    }
164
165    /// Owner and repository of a github.com remote, or None for any other host
166    /// or shape. Both spellings git writes are accepted; nothing else is
167    /// guessed at, because guessing wrong would send a token to the wrong repo.
168    pub fn parse_remote_url(remote_url: &str) -> Option<(String, String)> {
169        let trimmed = remote_url.trim();
170        let https = Regex::new(r"^https?://(?:[^@]+@)?github\.com/([^/]+)/([^/]+?)(?:\.git)?/?$")
171            .expect("static regex");
172        if let Some(captures) = https.captures(trimmed) {
173            return Some((captures[1].to_string(), captures[2].to_string()));
174        }
175        let ssh =
176            Regex::new(r"^git@github\.com:([^/]+)/([^/]+?)(?:\.git)?$").expect("static regex");
177        ssh.captures(trimmed)
178            .map(|captures| (captures[1].to_string(), captures[2].to_string()))
179    }
180
181    fn repo_path(&self, suffix: &str) -> String {
182        format!("/repos/{}/{}{suffix}", self.owner, self.repo)
183    }
184
185    /// The account the token speaks for. Passed through as GitHub sent it —
186    /// callers read `login` and `avatar_url`, and a struct here would drop the
187    /// rest of a payload the dashboard may grow into.
188    pub async fn viewer(&self) -> Result<Value, GithubApiError> {
189        self.get("/user").await
190    }
191
192    /// The repository itself: default branch, visibility, permissions.
193    pub async fn repo_info(&self) -> Result<Value, GithubApiError> {
194        self.get(&self.repo_path("")).await
195    }
196
197    /// The repository's branches, reshaped down to what a base-branch picker
198    /// needs. GitHub sends far more per branch, and this list can be a hundred
199    /// of them.
200    ///
201    /// A branch with no `commit` object is a payload GitHub does not produce;
202    /// the reference reads straight through it and raises a `TypeError`, so
203    /// this raises too rather than inventing an empty sha that would later be
204    /// asked for as a real one.
205    pub async fn list_branches(&self) -> Result<Vec<Value>, GithubApiError> {
206        let path = self.repo_path("/branches?per_page=100");
207        let data = self.get(&path).await?;
208        array(&data)
209            .iter()
210            .map(|branch| {
211                let commit = branch.get("commit").ok_or_else(|| GithubApiError {
212                    message: "Cannot read properties of undefined (reading 'sha')".into(),
213                    status: 0,
214                    path: path.clone(),
215                })?;
216                let mut sha = Map::new();
217                if let Some(value) = commit.get("sha") {
218                    sha.insert("sha".into(), value.clone());
219                }
220                let mut out = Map::new();
221                copy(&mut out, branch, "name");
222                copy(&mut out, branch, "protected");
223                out.insert("commit".into(), Value::Object(sha));
224                Ok(Value::Object(out))
225            })
226            .collect()
227    }
228
229    /// The files one pull request touches, reshaped: `filename` is renamed to
230    /// `path`, and the counts and the patch are carried through as sent — a
231    /// field GitHub omitted stays omitted, because a huge diff arrives with no
232    /// `patch` at all and a `null` there would read as "no changes".
233    pub async fn list_pr_files(&self, number: i64) -> Result<Vec<Value>, GithubApiError> {
234        let data = self
235            .get(&self.repo_path(&format!("/pulls/{number}/files?per_page=100")))
236            .await?;
237        Ok(array(&data)
238            .iter()
239            .map(|file| {
240                let mut out = Map::new();
241                if let Some(value) = file.get("filename") {
242                    out.insert("path".into(), value.clone());
243                }
244                for key in [
245                    "status",
246                    "additions",
247                    "deletions",
248                    "changes",
249                    "patch",
250                    "blob_url",
251                ] {
252                    copy(&mut out, file, key);
253                }
254                Value::Object(out)
255            })
256            .collect())
257    }
258
259    pub async fn list_pr_reviews(&self, number: i64) -> Result<Vec<Value>, GithubApiError> {
260        let data = self
261            .get(&self.repo_path(&format!("/pulls/{number}/reviews?per_page=100")))
262            .await?;
263        Ok(array(&data).to_vec())
264    }
265
266    /// The jobs of one run, with the same envelope rule as
267    /// [`Self::list_workflow_runs`].
268    pub async fn list_workflow_run_jobs(
269        &self,
270        run_id: i64,
271    ) -> Result<Option<Value>, GithubApiError> {
272        let data = self
273            .get(&self.repo_path(&format!("/actions/runs/{run_id}/jobs?per_page=100")))
274            .await?;
275        Ok(data.get("jobs").cloned())
276    }
277
278    /// `page` and `state` arrive already rendered, because the reference puts
279    /// whatever the caller sent into the URL — a half-typed page number reaches
280    /// GitHub as GitHub's problem, not as a refusal here.
281    /// What `head` adds on top of `base`, according to GitHub.
282    ///
283    /// Both refs are escaped on the way into the path. A branch name may
284    /// contain a `/` — `feat/thing` is the usual spelling — and an unescaped
285    /// one would split the path segment and ask about a repository that does
286    /// not exist.
287    pub async fn compare_branches(
288        &self,
289        base: &str,
290        head: &str,
291    ) -> Result<GithubCompareSummary, GithubApiError> {
292        let path = self.repo_path(&format!(
293            "/compare/{}...{}",
294            encode_uri_component(base),
295            encode_uri_component(head)
296        ));
297        let data = self.get(&path).await?;
298        let commits = array(data.get("commits").unwrap_or(&Value::Null)).to_vec();
299        Ok(GithubCompareSummary {
300            status: data.get("status").cloned(),
301            ahead_by: data.get("ahead_by").cloned(),
302            head_sha: commits
303                .last()
304                .and_then(|commit| commit.get("sha"))
305                .and_then(Value::as_str)
306                .map(str::to_string),
307            commits: commits
308                .iter()
309                .map(|commit| {
310                    let mut out = Map::new();
311                    copy(&mut out, commit, "sha");
312                    out.insert(
313                        "message".into(),
314                        Value::String(first_line(
315                            commit
316                                .get("commit")
317                                .and_then(|inner| inner.get("message"))
318                                .and_then(Value::as_str)
319                                .unwrap_or_default(),
320                        )),
321                    );
322                    Value::Object(out)
323                })
324                .collect(),
325            // A comparison too large for GitHub to enumerate carries no
326            // `files` at all, which the reference reads as none.
327            files: array(data.get("files").unwrap_or(&Value::Null))
328                .iter()
329                .map(|file| {
330                    let mut out = Map::new();
331                    if let Some(value) = file.get("filename") {
332                        out.insert("path".into(), value.clone());
333                    }
334                    for key in ["status", "additions", "deletions", "changes"] {
335                        copy(&mut out, file, key);
336                    }
337                    Value::Object(out)
338                })
339                .collect(),
340        })
341    }
342
343    pub async fn list_prs(&self, state: &str, page: &str) -> Result<Vec<GithubPr>, GithubApiError> {
344        let path = self.repo_path(&format!("/pulls?state={state}&per_page=30&page={page}"));
345        let data = self.get(&path).await?;
346        Ok(array(&data).iter().map(normalize_pr).collect())
347    }
348
349    pub async fn get_pr(&self, number: i64) -> Result<GithubPr, GithubApiError> {
350        let data = self
351            .get(&self.repo_path(&format!("/pulls/{number}")))
352            .await?;
353        Ok(normalize_pr(&data))
354    }
355
356    pub async fn pr_diff(&self, number: i64) -> Result<String, GithubApiError> {
357        self.text(&self.repo_path(&format!("/pulls/{number}")))
358            .await
359    }
360
361    pub async fn create_pr(
362        &self,
363        title: &str,
364        body: Option<&str>,
365        head: &str,
366        base: &str,
367        draft: bool,
368    ) -> Result<GithubPr, GithubApiError> {
369        let mut payload = Map::new();
370        payload.insert("title".into(), json!(title));
371        // Absent rather than null: the reference spreads an optional field in
372        // only when it has one, and GitHub reads a null title as a change.
373        if let Some(body) = body {
374            payload.insert("body".into(), json!(body));
375        }
376        payload.insert("head".into(), json!(head));
377        payload.insert("base".into(), json!(base));
378        payload.insert("draft".into(), json!(draft));
379        let data = self
380            .send(
381                "POST",
382                &self.repo_path("/pulls"),
383                Some(Value::Object(payload)),
384            )
385            .await?;
386        Ok(normalize_pr(&data))
387    }
388
389    /// Squash by default — the common "Squash & merge" button. GitHub answers
390    /// 405 when the pull request is not mergeable (conflicts, failing required
391    /// checks, branch protection), and that message reaches the caller.
392    pub async fn merge_pr(
393        &self,
394        number: i64,
395        method: &str,
396        commit_title: Option<&str>,
397        commit_message: Option<&str>,
398    ) -> Result<Value, GithubApiError> {
399        let mut payload = Map::new();
400        payload.insert("merge_method".into(), json!(method));
401        if let Some(title) = commit_title.filter(|value| !value.is_empty()) {
402            payload.insert("commit_title".into(), json!(title));
403        }
404        if let Some(message) = commit_message.filter(|value| !value.is_empty()) {
405            payload.insert("commit_message".into(), json!(message));
406        }
407        self.send(
408            "PUT",
409            &self.repo_path(&format!("/pulls/{number}/merge")),
410            Some(Value::Object(payload)),
411        )
412        .await
413    }
414
415    /// The issues endpoint answers with pull requests too, and they are dropped
416    /// here — a caller asking for issues did not ask for those.
417    pub async fn list_issues(&self, state: &str, page: &str) -> Result<Vec<Value>, GithubApiError> {
418        let path = self.repo_path(&format!("/issues?state={state}&per_page=30&page={page}"));
419        let data = self.get(&path).await?;
420        Ok(array(&data)
421            .iter()
422            .filter(|issue| !has_content(issue.get("pull_request")))
423            .cloned()
424            .collect())
425    }
426
427    pub async fn get_issue(&self, number: i64) -> Result<Value, GithubApiError> {
428        self.get(&self.repo_path(&format!("/issues/{number}")))
429            .await
430    }
431
432    pub async fn create_issue(
433        &self,
434        title: &str,
435        body: Option<&str>,
436    ) -> Result<Value, GithubApiError> {
437        let mut payload = Map::new();
438        payload.insert("title".into(), json!(title));
439        if let Some(body) = body {
440            payload.insert("body".into(), json!(body));
441        }
442        self.send(
443            "POST",
444            &self.repo_path("/issues"),
445            Some(Value::Object(payload)),
446        )
447        .await
448    }
449
450    pub async fn list_issue_comments(&self, number: i64) -> Result<Vec<Value>, GithubApiError> {
451        let path = self.repo_path(&format!("/issues/{number}/comments?per_page=100"));
452        Ok(array(&self.get(&path).await?).to_vec())
453    }
454
455    pub async fn add_issue_comment(
456        &self,
457        number: i64,
458        body: &str,
459    ) -> Result<Value, GithubApiError> {
460        self.send(
461            "POST",
462            &self.repo_path(&format!("/issues/{number}/comments")),
463            Some(json!({ "body": body })),
464        )
465        .await
466    }
467
468    /// A commit nobody has run checks on, and a commit GitHub has never heard
469    /// of, are both answered rather than raised: neither has a CI state, and an
470    /// agent asking about a stale SHA does not need an exception for it.
471    /// One commit's checks, or "unknown" when GitHub has never heard of it.
472    ///
473    /// `sha` is optional because one caller has no sha to give: a pull request
474    /// whose head payload carries none. That call still happens — the reference
475    /// interpolates the missing value into the URL, asking after a commit
476    /// literally named `undefined` — and is reproduced here so both runtimes
477    /// make the same request. GitHub answers 404 to it either way.
478    pub async fn commit_checks(&self, sha: Option<&str>) -> Result<CommitCiStatus, GithubApiError> {
479        let path = self.repo_path(&format!(
480            "/commits/{}/check-runs?per_page=100",
481            sha.unwrap_or("undefined")
482        ));
483        match self.get(&path).await {
484            Ok(data) => {
485                // A payload with no `check_runs` is one the reference cannot
486                // read either: it counts them before looking at them, and
487                // raises rather than reporting a commit with no checks.
488                let runs = data
489                    .get("check_runs")
490                    .and_then(Value::as_array)
491                    .ok_or_else(|| GithubApiError {
492                        message: "Cannot read properties of undefined (reading 'length')".into(),
493                        status: 0,
494                        path: path.clone(),
495                    })?
496                    .clone();
497                Ok(CommitCiStatus {
498                    sha: sha.map(str::to_string),
499                    state: derive_state(&runs).to_string(),
500                    total_count: data.get("total_count").and_then(Value::as_i64),
501                    runs,
502                })
503            }
504            Err(error) if error.status == 404 => Ok(CommitCiStatus {
505                sha: sha.map(str::to_string),
506                state: "unknown".to_string(),
507                total_count: Some(0),
508                runs: Vec::new(),
509            }),
510            Err(error) => Err(error),
511        }
512    }
513
514    /// The runs GitHub reports, exactly as it reports them.
515    ///
516    /// `Option`, not an empty list: the reference reads one field out of the
517    /// envelope and passes it on, so a payload without that field yields
518    /// nothing at all — and a caller that renders "no runs" for a *missing*
519    /// field would be reporting a broken response as an empty one.
520    pub async fn list_workflow_runs(
521        &self,
522        branch: Option<&str>,
523        page: &str,
524    ) -> Result<Option<Value>, GithubApiError> {
525        // The branch is appended after the other two, so it is last in the
526        // query — which is what the request the reference makes looks like.
527        let mut query = format!("per_page=30&page={page}");
528        if let Some(branch) = branch {
529            query.push_str(&format!("&branch={branch}"));
530        }
531        let data = self
532            .get(&self.repo_path(&format!("/actions/runs?{query}")))
533            .await?;
534        Ok(data.get("workflow_runs").cloned())
535    }
536
537    async fn get(&self, path: &str) -> Result<Value, GithubApiError> {
538        self.send("GET", path, None).await
539    }
540
541    async fn send(
542        &self,
543        method: &str,
544        path: &str,
545        body: Option<Value>,
546    ) -> Result<Value, GithubApiError> {
547        let response = self.dispatch(method, path, body, JSON_ACCEPT).await?;
548        let status = response.status();
549        let reason = status.canonical_reason().unwrap_or("").to_string();
550        let text = response
551            .text()
552            .await
553            .map_err(|error| GithubApiError::transport(error, path))?;
554        if !status.is_success() {
555            // GitHub explains itself in a `message`; anything else that failed
556            // is named by its status line instead.
557            let message = serde_json::from_str::<Value>(&text)
558                .ok()
559                .and_then(|value| {
560                    value
561                        .get("message")
562                        .and_then(Value::as_str)
563                        .map(str::to_string)
564                })
565                .unwrap_or(reason);
566            return Err(GithubApiError {
567                message,
568                status: status.as_u16(),
569                path: path.to_string(),
570            });
571        }
572        serde_json::from_str(&text).map_err(|error| GithubApiError {
573            message: error.to_string(),
574            status: status.as_u16(),
575            path: path.to_string(),
576        })
577    }
578
579    /// A diff is text, and a failed one is whatever the server wrote — not a
580    /// `message` field, because a diff response was never JSON to begin with.
581    async fn text(&self, path: &str) -> Result<String, GithubApiError> {
582        let response = self.dispatch("GET", path, None, DIFF_ACCEPT).await?;
583        let status = response.status();
584        let reason = status.canonical_reason().unwrap_or("").to_string();
585        let text = response.text().await;
586        if !status.is_success() {
587            return Err(GithubApiError {
588                message: text.unwrap_or(reason),
589                status: status.as_u16(),
590                path: path.to_string(),
591            });
592        }
593        text.map_err(|error| GithubApiError::transport(error, path))
594    }
595
596    async fn dispatch(
597        &self,
598        method: &str,
599        path: &str,
600        body: Option<Value>,
601        accept: &str,
602    ) -> Result<reqwest::Response, GithubApiError> {
603        let url = format!("{}{path}", self.base_url);
604        let client = reqwest::Client::new();
605        let mut request = client
606            .request(
607                reqwest::Method::from_bytes(method.as_bytes()).expect("static method"),
608                &url,
609            )
610            .header("Authorization", format!("Bearer {}", self.token))
611            .header("Accept", accept)
612            .header("User-Agent", USER_AGENT)
613            .header("X-GitHub-Api-Version", API_VERSION);
614        if let Some(body) = body {
615            request = request.json(&body);
616        }
617        request
618            .send()
619            .await
620            .map_err(|error| GithubApiError::transport(error, path))
621    }
622}
623
624/// A commit subject: the first line of its message, trimmed. A body below it
625/// is not a title, and a title is what the caller is building.
626fn first_line(message: &str) -> String {
627    message
628        .split(['\r', '\n'])
629        .next()
630        .unwrap_or_default()
631        .trim()
632        .to_string()
633}
634
635/// `encodeURIComponent`, whose unreserved set is wider than a URL crate's
636/// default. Matching it matters because the resulting path is compared against
637/// the reference request for request.
638fn encode_uri_component(value: &str) -> String {
639    let mut out = String::with_capacity(value.len());
640    for byte in value.bytes() {
641        match byte {
642            b'A'..=b'Z'
643            | b'a'..=b'z'
644            | b'0'..=b'9'
645            | b'-'
646            | b'_'
647            | b'.'
648            | b'!'
649            | b'~'
650            | b'*'
651            | b'\''
652            | b'('
653            | b')' => out.push(byte as char),
654            other => out.push_str(&format!("%{other:02X}")),
655        }
656    }
657    out
658}
659
660fn array(value: &Value) -> &[Value] {
661    value.as_array().map(Vec::as_slice).unwrap_or_default()
662}
663
664/// Carry one field across only when it is there. A key the source object does
665/// not have becomes `undefined` on the reference's side, which `JSON.stringify`
666/// drops — so the way to match it is to not write the key at all. A key it sent
667/// as null is a value, and is copied.
668fn copy(out: &mut Map<String, Value>, source: &Value, key: &str) {
669    if let Some(value) = source.get(key) {
670        out.insert(key.to_string(), value.clone());
671    }
672}
673
674/// Whether the field is there at all. GitHub marks a pull request by *having* a
675/// `pull_request` object; an issue does not carry the key.
676fn has_content(value: Option<&Value>) -> bool {
677    !matches!(value, None | Some(Value::Null))
678}
679
680fn normalize_pr(pr: &Value) -> GithubPr {
681    let merged_at = pr.get("merged_at").cloned().unwrap_or(Value::Null);
682    // GitHub calls a merged pull request "closed"; the two are worth telling
683    // apart to anyone reading a list of them.
684    let state = if has_content(Some(&merged_at)) {
685        "merged".to_string()
686    } else {
687        pr.get("state")
688            .and_then(Value::as_str)
689            .unwrap_or_default()
690            .to_string()
691    };
692    GithubPr {
693        number: pr.get("number").and_then(Value::as_i64).unwrap_or_default(),
694        title: string_at(pr, "title"),
695        state,
696        body: pr.get("body").cloned().unwrap_or(Value::Null),
697        html_url: string_at(pr, "html_url"),
698        head: pr.get("head").cloned().unwrap_or(Value::Null),
699        base: pr.get("base").cloned().unwrap_or(Value::Null),
700        user: pr.get("user").cloned().unwrap_or(Value::Null),
701        created_at: string_at(pr, "created_at"),
702        updated_at: string_at(pr, "updated_at"),
703        merged_at,
704        // Absent on a list response, and false is the answer a caller needs.
705        draft: pr.get("draft").and_then(Value::as_bool).unwrap_or(false),
706        // Absent means GitHub has not computed it yet, which is not the same
707        // as "not mergeable" — so it stays null rather than becoming false.
708        mergeable: pr.get("mergeable").cloned().unwrap_or(Value::Null),
709    }
710}
711
712fn string_at(value: &Value, key: &str) -> String {
713    value
714        .get(key)
715        .and_then(Value::as_str)
716        .unwrap_or_default()
717        .to_string()
718}
719
720/// One state for a commit's whole check suite. Anything still running wins
721/// over anything finished, because the answer is not final yet.
722fn derive_state(runs: &[Value]) -> &'static str {
723    if runs.is_empty() {
724        return "unknown";
725    }
726    fn status(run: &Value) -> &str {
727        run.get("status").and_then(Value::as_str).unwrap_or("")
728    }
729    fn conclusion(run: &Value) -> &str {
730        run.get("conclusion").and_then(Value::as_str).unwrap_or("")
731    }
732    if runs
733        .iter()
734        .any(|run| matches!(status(run), "in_progress" | "queued"))
735    {
736        return "pending";
737    }
738    if runs
739        .iter()
740        .all(|run| matches!(conclusion(run), "success" | "skipped" | "neutral"))
741    {
742        return "success";
743    }
744    if runs
745        .iter()
746        .any(|run| matches!(conclusion(run), "failure" | "timed_out"))
747    {
748        return "failure";
749    }
750    "error"
751}
752
753#[cfg(test)]
754mod tests {
755    use super::*;
756    use std::io::{Read, Write};
757    use std::net::TcpListener;
758
759    #[test]
760    fn both_spellings_of_a_github_remote_name_the_same_repository() {
761        let expected = Some(("owner".to_string(), "repo".to_string()));
762        for remote in [
763            "https://github.com/owner/repo.git",
764            "https://github.com/owner/repo",
765            "https://github.com/owner/repo/",
766            "http://user@github.com/owner/repo.git",
767            "git@github.com:owner/repo.git",
768            "git@github.com:owner/repo",
769        ] {
770            assert_eq!(
771                GithubManager::parse_remote_url(remote),
772                expected,
773                "{remote}"
774            );
775        }
776    }
777
778    #[tokio::test]
779    async fn native_requests_identify_nomoreide_to_github() {
780        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
781        let address = listener.local_addr().unwrap();
782        let server = std::thread::spawn(move || {
783            let (mut stream, _) = listener.accept().unwrap();
784            let mut request = Vec::new();
785            let mut chunk = [0_u8; 1024];
786            while !request.windows(4).any(|window| window == b"\r\n\r\n") {
787                let count = stream.read(&mut chunk).unwrap();
788                if count == 0 {
789                    break;
790                }
791                request.extend_from_slice(&chunk[..count]);
792            }
793            let body = r#"{"login":"octocat"}"#;
794            write!(
795                stream,
796                "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
797                body.len(),
798                body,
799            )
800            .unwrap();
801            String::from_utf8(request).unwrap()
802        });
803        let manager = GithubManager {
804            token: "valid-token".into(),
805            owner: String::new(),
806            repo: String::new(),
807            base_url: format!("http://{address}"),
808        };
809
810        assert_eq!(manager.viewer().await.unwrap()["login"], "octocat");
811        let request = server.join().unwrap().to_ascii_lowercase();
812        assert!(request.contains(&format!("user-agent: {}", USER_AGENT.to_ascii_lowercase())));
813    }
814
815    /// A remote on another host is not a GitHub repository, and a token meant
816    /// for github.com must never be sent to one.
817    #[test]
818    fn a_remote_that_is_not_github_is_not_guessed_at() {
819        for remote in [
820            "https://gitlab.com/owner/repo.git",
821            "git@gitlab.com:owner/repo.git",
822            "https://github.com.evil.example/owner/repo.git",
823            "https://github.com/owner",
824            "",
825        ] {
826            assert_eq!(GithubManager::parse_remote_url(remote), None, "{remote}");
827        }
828    }
829
830    #[test]
831    fn a_merged_pull_request_is_named_merged_rather_than_closed() {
832        let merged =
833            normalize_pr(&json!({ "state": "closed", "merged_at": "2026-01-03T00:00:00Z" }));
834        assert_eq!(merged.state, "merged");
835        let closed = normalize_pr(&json!({ "state": "closed", "merged_at": null }));
836        assert_eq!(closed.state, "closed");
837    }
838
839    /// A list response omits both, and they mean different things: nobody has
840    /// marked it a draft, versus GitHub has not worked out whether it merges.
841    #[test]
842    fn the_two_fields_a_list_response_omits_get_different_defaults() {
843        let pr = normalize_pr(&json!({ "state": "open" }));
844        assert!(!pr.draft);
845        assert_eq!(pr.mergeable, Value::Null);
846    }
847
848    #[test]
849    fn a_check_suite_reports_the_state_that_is_not_final_first() {
850        let run =
851            |status: &str, conclusion: Value| json!({ "status": status, "conclusion": conclusion });
852        assert_eq!(derive_state(&[]), "unknown");
853        assert_eq!(
854            derive_state(&[
855                run("completed", json!("failure")),
856                run("queued", Value::Null)
857            ]),
858            "pending"
859        );
860        assert_eq!(
861            derive_state(&[
862                run("completed", json!("success")),
863                run("completed", json!("skipped"))
864            ]),
865            "success"
866        );
867        assert_eq!(
868            derive_state(&[
869                run("completed", json!("success")),
870                run("completed", json!("timed_out"))
871            ]),
872            "failure"
873        );
874        assert_eq!(
875            derive_state(&[run("completed", json!("cancelled"))]),
876            "error"
877        );
878    }
879
880    /// The override exists for the parity gate. Anything that is not loopback
881    /// would be a way to make one environment variable post the user's token
882    /// somewhere else, so it is ignored rather than obeyed.
883    #[test]
884    fn only_a_loopback_api_base_is_honoured() {
885        let cases = [
886            ("http://127.0.0.1:8080", "http://127.0.0.1:8080"),
887            ("http://localhost:1/", "http://localhost:1"),
888            ("http://attacker.example", GITHUB_API),
889            ("http://127.0.0.1.evil.example", GITHUB_API),
890            ("ftp://127.0.0.1", GITHUB_API),
891            ("not a url", GITHUB_API),
892            ("", GITHUB_API),
893        ];
894        for (value, expected) in cases {
895            std::env::set_var("NOMOREIDE_GITHUB_API_BASE", value);
896            assert_eq!(api_base(), expected, "{value}");
897        }
898        std::env::remove_var("NOMOREIDE_GITHUB_API_BASE");
899        assert_eq!(api_base(), GITHUB_API);
900    }
901}