Skip to main content

speccoder_runtime/github/
mod.rs

1//! GitHub, built in: its REST and GraphQL APIs, over TLS, as the
2//! person — a token from the environment or the one `speccoder auth login`
3//! kept in the credential store. No `gh` needed. Reading is free (issues, a
4//! branch's pull request); writing is `pr`, which a person confirms.
5//! github.com and Enterprise hosts alike: the host is the remote's.
6
7pub mod auth;
8pub mod http;
9
10use std::path::Path;
11
12use serde::Serialize;
13use serde_json::{Value, json};
14use speccoder_core::{
15    AuthReport, CommandData, CommandResult, CommandStatus, IssueEntry, IssuesReport,
16};
17
18use http::Api;
19
20/// How speccoder reaches GitHub: where its API answers and whose token it
21/// uses. Commands take it from the environment ([`Ctx::from_env`]); tests
22/// point it at a stand-in ([`Ctx::fixed`]).
23#[derive(Debug, Clone, Default)]
24pub struct Ctx {
25    base: Option<String>,
26    fixed: Option<Option<auth::Secret>>,
27}
28
29impl Ctx {
30    /// `SPECCODER_GITHUB_API` (a proxy) if set; tokens from the
31    /// environment, else the keychain.
32    #[must_use]
33    pub fn from_env() -> Self {
34        Self {
35            base: std::env::var("SPECCODER_GITHUB_API")
36                .ok()
37                .filter(|b| !b.trim().is_empty()),
38            fixed: None,
39        }
40    }
41
42    /// The API at `base` with `token` (none: signed out); never the
43    /// environment or the keychain.
44    #[must_use]
45    pub fn fixed(base: &str, token: Option<&str>) -> Self {
46        Self {
47            base: Some(base.to_owned()),
48            fixed: Some(token.map(auth::Secret::new)),
49        }
50    }
51
52    /// The token for `host`, and where it came from.
53    #[must_use]
54    pub fn token(&self, host: &str) -> Option<(auth::Secret, auth::Source)> {
55        match &self.fixed {
56            Some(t) => t.clone().map(|t| (t, auth::Source::Env("GH_TOKEN"))),
57            None => auth::token(host),
58        }
59    }
60
61    fn api(&self, host: &str, token: auth::Secret) -> Api {
62        Api::new(host, token, self.base.as_deref())
63    }
64
65    fn signed_in(&self, host: &str) -> Result<Api, String> {
66        self.token(host)
67            .map(|(t, _)| self.api(host, t))
68            .ok_or_else(|| {
69                format!(
70                    "not signed in to {host}: `{}` (or set GH_TOKEN)",
71                    login_command(host)
72                )
73            })
74    }
75
76    /// Whether a token is kept in the keychain (not for a fixed context).
77    fn keeps(&self) -> bool {
78        self.fixed.is_none()
79    }
80}
81
82/// A repository on a GitHub host, as a git remote names it.
83#[derive(Debug, Clone, PartialEq, Eq)]
84pub struct Repo {
85    /// `github.com` or an Enterprise hostname.
86    pub host: String,
87    /// The owner (user or organisation).
88    pub owner: String,
89    /// The repository's name.
90    pub name: String,
91}
92
93impl Repo {
94    /// `owner/name`.
95    #[must_use]
96    pub fn slug(&self) -> String {
97        format!("{}/{}", self.owner, self.name)
98    }
99
100    /// Its address on the web.
101    #[must_use]
102    pub fn url(&self) -> String {
103        format!("https://{}/{}/{}", self.host, self.owner, self.name)
104    }
105
106    /// Reads a remote URL: `https://host/o/r(.git)`, `git@host:o/r(.git)`,
107    /// `ssh://git@host[:port]/o/r(.git)`. `None`: it names no `o/r`.
108    #[must_use]
109    pub fn parse(url: &str) -> Option<Self> {
110        let url = url.trim();
111        let (host, path) = if let Some(rest) = url
112            .strip_prefix("https://")
113            .or_else(|| url.strip_prefix("http://"))
114            .or_else(|| url.strip_prefix("ssh://"))
115            .or_else(|| url.strip_prefix("git://"))
116        {
117            let (authority, path) = rest.split_once('/')?;
118            let host = authority.rsplit('@').next()?.split(':').next()?;
119            (host, path)
120        } else {
121            // scp-like: user@host:owner/name
122            let (left, path) = url.split_once(':')?;
123            (left.rsplit('@').next()?, path)
124        };
125        let path = path.trim_matches('/').trim_end_matches(".git");
126        let mut parts = path.split('/');
127        let (owner, name) = (parts.next()?, parts.next()?);
128        if parts.next().is_some() || owner.is_empty() || name.is_empty() || host.is_empty() {
129            return None;
130        }
131        Some(Self {
132            host: host.to_ascii_lowercase(),
133            owner: owner.to_owned(),
134            name: name.to_owned(),
135        })
136    }
137}
138
139/// The repository `remote` (default `origin`, else the first) names.
140///
141/// # Errors
142///
143/// No remote, or one that names no GitHub repository.
144pub fn repo(root: &Path, remote: Option<&str>) -> Result<Repo, String> {
145    let git = crate::git::Git::at(root);
146    let remotes = git.line(&["remote"]).unwrap_or_default();
147    let names: Vec<&str> = remotes
148        .lines()
149        .map(str::trim)
150        .filter(|r| !r.is_empty())
151        .collect();
152    let chosen = match remote {
153        Some(r) => r,
154        None if names.contains(&"origin") => "origin",
155        None => match names.first() {
156            Some(r) => r,
157            None => {
158                return Err("this repository has no remote: `git remote add origin <url>` (GitHub's address for it)".to_owned());
159            }
160        },
161    };
162    let url = git
163        .line(&["remote", "get-url", chosen])
164        .map_err(|_| format!("no git remote `{chosen}`"))?;
165    Repo::parse(&url)
166        .ok_or_else(|| format!("the remote `{chosen}` ({url}) names no GitHub repository"))
167}
168
169/// What GitHub is to this repository.
170#[derive(Debug, Clone, Default, Serialize)]
171pub struct Status {
172    /// Signed in and the repository readable.
173    pub ready: bool,
174    /// `github.com` or the Enterprise host.
175    pub host: Option<String>,
176    /// Who speccoder acts as.
177    pub login: Option<String>,
178    /// Where the token came from (`GH_TOKEN`, `the keychain`).
179    pub source: Option<String>,
180    /// `owner/name`.
181    pub repo: Option<String>,
182    /// The repository's address.
183    pub url: Option<String>,
184    /// Why not, and what to do.
185    pub reason: Option<String>,
186    /// The command that fixes it, when one does.
187    pub fix: Option<String>,
188}
189
190/// Whether speccoder can reach this repository on GitHub, and as whom.
191#[must_use]
192pub fn status(ctx: &Ctx, root: &Path) -> Status {
193    let r = match repo(root, None) {
194        Ok(r) => r,
195        Err(e) => {
196            return Status {
197                fix: Some("git remote add origin <url>".to_owned()),
198                reason: Some(e),
199                ..Status::default()
200            };
201        }
202    };
203    let base = Status {
204        host: Some(r.host.clone()),
205        repo: Some(r.slug()),
206        url: Some(r.url()),
207        ..Status::default()
208    };
209    let Some((token, source)) = ctx.token(&r.host) else {
210        return Status {
211            reason: Some(format!("not signed in to {}", r.host)),
212            fix: Some(login_command(&r.host)),
213            ..base
214        };
215    };
216    let api = ctx.api(&r.host, token);
217    let login = match api.get("/user") {
218        Ok(u) => u["login"].as_str().map(str::to_owned),
219        Err(e) => {
220            return Status {
221                source: Some(source.word().to_owned()),
222                fix: Some(login_command(&r.host)),
223                reason: Some(e),
224                ..base
225            };
226        }
227    };
228    match api.get(&format!("/repos/{}", r.slug())) {
229        Ok(_) => Status {
230            ready: true,
231            login,
232            source: Some(source.word().to_owned()),
233            ..base
234        },
235        Err(e) => Status {
236            login,
237            source: Some(source.word().to_owned()),
238            reason: Some(e),
239            ..base
240        },
241    }
242}
243
244/// `speccoder auth login` for `host` (the host named when not github.com).
245fn login_command(host: &str) -> String {
246    if host == "github.com" {
247        "speccoder auth login".to_owned()
248    } else {
249        format!("speccoder auth login --host {host}")
250    }
251}
252
253/// One issue, read in full.
254#[derive(Debug, Clone, Serialize)]
255pub struct Issue {
256    /// `#n`.
257    pub number: u64,
258    /// Its title.
259    pub title: String,
260    /// Its text.
261    pub body: String,
262    /// Its address.
263    pub url: String,
264    /// Label names.
265    pub labels: Vec<String>,
266}
267
268fn names(v: &Value, key: &str) -> Vec<String> {
269    v.as_array()
270        .into_iter()
271        .flatten()
272        .filter_map(|x| x[key].as_str().map(str::to_owned))
273        .collect()
274}
275
276/// The repository's open issues (pull requests aside), assigned to the
277/// person first, then the most recently updated; `features` maps an issue
278/// URL to the feature whose spec names it.
279///
280/// # Errors
281///
282/// No GitHub remote, not signed in, or GitHub's refusal.
283pub fn issues(
284    ctx: &Ctx,
285    root: &Path,
286    features: &std::collections::BTreeMap<String, String>,
287) -> Result<IssuesReport, String> {
288    let r = repo(root, None)?;
289    let api = ctx.signed_in(&r.host)?;
290    let raw = api.get(&format!(
291        "/repos/{}/issues?state=open&per_page=100&sort=updated",
292        r.slug()
293    ))?;
294    let me = api
295        .get("/user")
296        .ok()
297        .and_then(|u| u["login"].as_str().map(str::to_owned));
298    let mut issues: Vec<IssueEntry> = raw
299        .as_array()
300        .into_iter()
301        .flatten()
302        .filter(|i| i.get("pull_request").is_none())
303        .map(|i| {
304            let url = i["html_url"].as_str().unwrap_or_default().to_owned();
305            let assignees = names(&i["assignees"], "login");
306            IssueEntry {
307                number: i["number"].as_u64().unwrap_or_default(),
308                title: i["title"].as_str().unwrap_or_default().to_owned(),
309                labels: names(&i["labels"], "name"),
310                mine: me.as_ref().is_some_and(|m| assignees.contains(m)),
311                assignees,
312                updated: i["updated_at"].as_str().map(str::to_owned),
313                feature: features.get(&url).cloned(),
314                url,
315            }
316        })
317        .collect();
318    issues.sort_by(|a, b| b.mine.cmp(&a.mine).then_with(|| b.updated.cmp(&a.updated)));
319    Ok(IssuesReport {
320        repo: Some(r.slug()),
321        issues,
322    })
323}
324
325/// One issue by number, `#n` or address.
326///
327/// # Errors
328///
329/// Not a reference, or GitHub's reason.
330pub fn issue(ctx: &Ctx, root: &Path, reference: &str) -> Result<Issue, String> {
331    let r = repo(root, None)?;
332    let reference = reference.trim().trim_start_matches('#');
333    let number = if reference.chars().all(|c| c.is_ascii_digit()) && !reference.is_empty() {
334        reference.to_owned()
335    } else {
336        match reference
337            .strip_prefix("https://")
338            .and_then(|rest| rest.split_once("/issues/"))
339            .map(|(_, n)| n.trim_end_matches('/'))
340            .filter(|n| !n.is_empty() && n.chars().all(|c| c.is_ascii_digit()))
341        {
342            Some(n) => n.to_owned(),
343            None => {
344                return Err(format!(
345                    "`{reference}` is no issue: a number (42, #42) or its address"
346                ));
347            }
348        }
349    };
350    let api = ctx.signed_in(&r.host)?;
351    let i = api.get(&format!("/repos/{}/issues/{number}", r.slug()))?;
352    if i.get("pull_request").is_some() {
353        return Err(format!("#{number} is a pull request, not an issue"));
354    }
355    Ok(Issue {
356        number: i["number"].as_u64().unwrap_or_default(),
357        title: i["title"].as_str().unwrap_or_default().to_owned(),
358        body: i["body"].as_str().unwrap_or_default().to_owned(),
359        url: i["html_url"].as_str().unwrap_or_default().to_owned(),
360        labels: names(&i["labels"], "name"),
361    })
362}
363
364/// What a feature started from `issue` is asked to specify: its title, its
365/// text, and its address — which the spec keeps, so `pr` can close it.
366#[must_use]
367pub fn description(issue: &Issue) -> String {
368    let body = issue.body.trim();
369    if body.is_empty() {
370        format!("{} (GitHub issue {})", issue.title, issue.url)
371    } else {
372        format!("{}\n\n{body}\n\nGitHub issue: {}", issue.title, issue.url)
373    }
374}
375
376/// GitHub issue addresses in `text` (a spec), in order, once each.
377#[must_use]
378pub fn issue_urls(text: &str) -> Vec<String> {
379    let mut out: Vec<String> = Vec::new();
380    for (i, _) in text.match_indices("https://") {
381        let rest = &text[i..];
382        let end = rest
383            .find(|c: char| {
384                c.is_whitespace() || matches!(c, ')' | ']' | '>' | '"' | '\'' | ',' | '`')
385            })
386            .unwrap_or(rest.len());
387        let url = rest[..end].trim_end_matches(['.', ';', ':']);
388        // https://host/owner/repo/issues/123 — on github.com or an enterprise host.
389        let parts: Vec<&str> = url.trim_start_matches("https://").split('/').collect();
390        let issue = parts.len() == 5
391            && parts[3] == "issues"
392            && !parts[4].is_empty()
393            && parts[4].chars().all(|c| c.is_ascii_digit());
394        if issue && !out.iter().any(|u| u == url) {
395            out.push(url.to_owned());
396        }
397    }
398    out
399}
400
401/// A branch's pull request.
402#[derive(Debug, Clone, Serialize)]
403pub struct Pull {
404    /// `#n`.
405    pub number: u64,
406    /// Its title.
407    pub title: String,
408    /// Its address.
409    pub url: String,
410    /// `OPEN`, `CLOSED` or `MERGED`.
411    pub state: String,
412    /// A draft.
413    pub draft: bool,
414    /// `APPROVED`, `CHANGES_REQUESTED`, `REVIEW_REQUIRED`, or none.
415    pub review: Option<String>,
416    /// Checks that passed, failed, and are still running.
417    pub checks: (u32, u32, u32),
418}
419
420/// One query: the newest pull request whose head is the branch, its review
421/// decision and its checks' rollup.
422const PR_FOR: &str = "query($owner:String!,$name:String!,$head:String!){repository(owner:$owner,name:$name){pullRequests(headRefName:$head,first:1,orderBy:{field:CREATED_AT,direction:DESC}){nodes{number title url state isDraft reviewDecision commits(last:1){nodes{commit{statusCheckRollup{contexts(first:100){nodes{__typename ... on CheckRun{status conclusion} ... on StatusContext{state}}}}}}}}}}}";
423
424/// The pull request whose head is `branch`, if there is one.
425///
426/// # Errors
427///
428/// No GitHub remote, not signed in, or GitHub's refusal.
429pub fn pr_for(ctx: &Ctx, root: &Path, branch: &str) -> Result<Option<Pull>, String> {
430    pr_in(ctx, &repo(root, None)?, branch)
431}
432
433/// [`pr_for`] in `r` (the repository a given remote names).
434///
435/// # Errors
436///
437/// Not signed in, or GitHub's refusal.
438pub fn pr_in(ctx: &Ctx, r: &Repo, branch: &str) -> Result<Option<Pull>, String> {
439    let api = ctx.signed_in(&r.host)?;
440    let data = api.graphql(
441        PR_FOR,
442        json!({ "owner": r.owner, "name": r.name, "head": branch }),
443    )?;
444    let Some(p) = data["repository"]["pullRequests"]["nodes"]
445        .as_array()
446        .and_then(|n| n.first())
447    else {
448        return Ok(None);
449    };
450    let (mut ok, mut bad, mut running) = (0, 0, 0);
451    let contexts = &p["commits"]["nodes"][0]["commit"]["statusCheckRollup"]["contexts"]["nodes"];
452    for c in contexts.as_array().into_iter().flatten() {
453        // Check runs have `status`/`conclusion`; status contexts have `state`.
454        let conclusion = c["conclusion"]
455            .as_str()
456            .or_else(|| c["state"].as_str())
457            .unwrap_or("");
458        let status = c["status"].as_str().unwrap_or("COMPLETED");
459        match (status, conclusion) {
460            (s, _) if s != "COMPLETED" => running += 1,
461            (_, "SUCCESS" | "NEUTRAL" | "SKIPPED") => ok += 1,
462            (_, "PENDING" | "EXPECTED") => running += 1,
463            _ => bad += 1,
464        }
465    }
466    Ok(Some(Pull {
467        number: p["number"].as_u64().unwrap_or_default(),
468        title: p["title"].as_str().unwrap_or_default().to_owned(),
469        url: p["url"].as_str().unwrap_or_default().to_owned(),
470        state: p["state"].as_str().unwrap_or_default().to_owned(),
471        draft: p["isDraft"].as_bool().unwrap_or(false),
472        review: p["reviewDecision"]
473            .as_str()
474            .filter(|s| !s.is_empty())
475            .map(str::to_owned),
476        checks: (ok, bad, running),
477    }))
478}
479
480/// Opens a draft pull request from `head` into `base`: its number and
481/// address.
482///
483/// # Errors
484///
485/// Not signed in, or GitHub's refusal.
486pub fn open_draft(
487    ctx: &Ctx,
488    repo: &Repo,
489    base: &str,
490    head: &str,
491    title: &str,
492    body: &str,
493) -> Result<(u64, String), String> {
494    let api = ctx.signed_in(&repo.host)?;
495    let p = api.rest(
496        reqwest::Method::POST,
497        &format!("/repos/{}/pulls", repo.slug()),
498        Some(&json!({ "title": title, "head": head, "base": base, "body": body, "draft": true })),
499    )?;
500    Ok((
501        p["number"].as_u64().unwrap_or_default(),
502        p["html_url"].as_str().unwrap_or_default().to_owned(),
503    ))
504}
505
506/// The comment that tells an issue a feature is being specified from it;
507/// the hidden marker keeps it said once per feature.
508#[must_use]
509pub fn announcement(feature: &str, spec: &str, branch: &str) -> String {
510    format!(
511        "<!-- speccoder:feature {feature} -->\nBeing specified as **`{feature}`** with [speccoder](https://hupe1980.github.io/speccoder/): the spec is `{spec}`, the work goes to `{branch}`, and its draft pull request will close this issue — every task with the evidence behind it."
512    )
513}
514
515/// What [`announce`] did.
516#[derive(Debug, Clone, PartialEq, Eq)]
517pub enum Announced {
518    /// The comment's address.
519    Commented(String),
520    /// The feature had said so already.
521    Already,
522}
523
524/// Comments on issue `number` that `feature` is being specified from it
525/// (once per feature), and assigns it to the person when `assign`.
526///
527/// # Errors
528///
529/// No GitHub remote, not signed in, or GitHub's refusal.
530pub fn announce(
531    ctx: &Ctx,
532    root: &Path,
533    number: u64,
534    feature: &str,
535    spec: &str,
536    assign: bool,
537) -> Result<Announced, String> {
538    let r = repo(root, None)?;
539    let api = ctx.signed_in(&r.host)?;
540    let marker = format!("<!-- speccoder:feature {feature} -->");
541    let comments = api.get(&format!(
542        "/repos/{}/issues/{number}/comments?per_page=100",
543        r.slug()
544    ))?;
545    let said = comments
546        .as_array()
547        .into_iter()
548        .flatten()
549        .any(|c| c["body"].as_str().is_some_and(|b| b.contains(&marker)));
550    let out = if said {
551        Announced::Already
552    } else {
553        let branch = format!("speccoder/{feature}");
554        let c = api.rest(
555            reqwest::Method::POST,
556            &format!("/repos/{}/issues/{number}/comments", r.slug()),
557            Some(&json!({ "body": announcement(feature, spec, &branch) })),
558        )?;
559        Announced::Commented(c["html_url"].as_str().unwrap_or_default().to_owned())
560    };
561    if assign {
562        let me = api.get("/user")?;
563        let login = me["login"].as_str().unwrap_or_default();
564        api.rest(
565            reqwest::Method::POST,
566            &format!("/repos/{}/issues/{number}/assignees", r.slug()),
567            Some(&json!({ "assignees": [login] })),
568        )?;
569    }
570    Ok(out)
571}
572
573/// The issue number in `reference` (`42`, `#42`, its address).
574#[must_use]
575pub fn issue_number(reference: &str) -> Option<u64> {
576    reference
577        .trim()
578        .trim_end_matches('/')
579        .trim_start_matches('#')
580        .rsplit('/')
581        .next()
582        .and_then(|n| n.parse::<u64>().ok())
583}
584
585/// `speccoder issue comment N [--assign]`: tells issue `reference` that the
586/// current feature (or `feature`) is being specified from it.
587#[must_use]
588pub fn comment(
589    ctx: &Ctx,
590    root: &Path,
591    feature: Option<&str>,
592    reference: &str,
593    assign: bool,
594) -> CommandResult {
595    let Some(number) = issue_number(reference) else {
596        return CommandResult::new(
597            "issue",
598            CommandStatus::UsageError,
599            &format!("`{reference}` is no issue: a number (42, #42) or its address"),
600        )
601        .with_code("USAGE");
602    };
603    let inspected = crate::inspect(root, feature);
604    let Some(CommandData::Inspect(snapshot)) = inspected.data() else {
605        return CommandResult::carried("issue", &inspected);
606    };
607    let Some(f) = snapshot.feature.as_ref() else {
608        return CommandResult::new("issue", CommandStatus::SelectionNeeded, inspected.message());
609    };
610    let format = crate::format::of(snapshot);
611    let spec = format.requirements_file().map_or_else(
612        || f.feature.dir.clone(),
613        |file| format!("{}/{file}", f.feature.dir.trim_end_matches('/')),
614    );
615    let assigned = if assign {
616        ", and assigned it to you"
617    } else {
618        ""
619    };
620    match announce(ctx, root, number, &f.feature.name, &spec, assign) {
621        Ok(Announced::Commented(url)) => CommandResult::ok(
622            "issue",
623            &format!(
624                "commented on #{number} that it is being specified as {}{assigned}: {url}",
625                f.feature.name
626            ),
627        ),
628        Ok(Announced::Already) => CommandResult::ok(
629            "issue",
630            &format!(
631                "#{number} already says it is being specified as {}{assigned}",
632                f.feature.name
633            ),
634        ),
635        Err(e) => refused("issue", &e),
636    }
637}
638
639/// Whether the signed-in account can see the repository at `root` — after a
640/// sign-in through the app, whether the app is installed there. `None`: it
641/// can; else what to do.
642#[must_use]
643pub fn reach(ctx: &Ctx, root: &Path) -> Option<String> {
644    let r = repo(root, None).ok()?;
645    let api = ctx.signed_in(&r.host).ok()?;
646    match api.get(&format!("/repos/{}", r.slug())) {
647        Ok(_) => None,
648        Err(e) if e.contains("was not found") && r.host == "github.com" => Some(format!(
649            "{} cannot be reached yet: install the {} app on it — {}",
650            r.slug(),
651            auth::APP_SLUG,
652            auth::install_url()
653        )),
654        Err(e) => Some(e),
655    }
656}
657
658/// Brings pull request `number`'s title and description up to date.
659///
660/// # Errors
661///
662/// Not signed in, or GitHub's refusal.
663pub fn update(ctx: &Ctx, repo: &Repo, number: u64, title: &str, body: &str) -> Result<(), String> {
664    let api = ctx.signed_in(&repo.host)?;
665    api.rest(
666        reqwest::Method::PATCH,
667        &format!("/repos/{}/pulls/{number}", repo.slug()),
668        Some(&json!({ "title": title, "body": body })),
669    )
670    .map(|_| ())
671}
672
673/// The issue URLs each feature's spec names: issue URL → feature.
674#[must_use]
675pub fn started(root: &Path) -> std::collections::BTreeMap<String, String> {
676    let mut out = std::collections::BTreeMap::new();
677    let listed = crate::current::list(root, None);
678    let Some(CommandData::Features(f)) = listed.data() else {
679        return out;
680    };
681    for feature in &f.features {
682        for (_, _, text) in crate::documents::sources(root, Some(&feature.name)) {
683            for url in issue_urls(&text) {
684                out.entry(url).or_insert_with(|| feature.name.clone());
685            }
686        }
687    }
688    out
689}
690
691/// `speccoder issue`: the open issues.
692#[must_use]
693pub fn list(ctx: &Ctx, root: &Path) -> CommandResult {
694    match issues(ctx, root, &started(root)) {
695        Ok(report) => {
696            let n = report.issues.len();
697            let msg = format!(
698                "{n} open issue{}{}",
699                if n == 1 { "" } else { "s" },
700                report
701                    .repo
702                    .as_deref()
703                    .map(|r| format!(" in {r}"))
704                    .unwrap_or_default()
705            );
706            CommandResult::new("issue", CommandStatus::Ok, &msg)
707                .with_data(CommandData::Issues(Box::new(report)))
708        }
709        Err(e) => refused("issue", &e),
710    }
711}
712
713/// A GitHub failure as a result: its code and fix.
714#[must_use]
715pub fn refused(command: &str, reason: &str) -> CommandResult {
716    let code = if reason.contains("has no remote") || reason.contains("no git remote") {
717        "NO-REMOTE"
718    } else if reason.contains("not signed in") || reason.contains("refused the token") {
719        "GITHUB-AUTH"
720    } else {
721        "GITHUB-FAILED"
722    };
723    // The code's own status: a missing remote is a git precondition.
724    let status = if code == "NO-REMOTE" {
725        CommandStatus::GitPrecondition
726    } else {
727        CommandStatus::Error
728    };
729    CommandResult::new(command, status, &format!("GitHub: {reason}")).with_code(code)
730}
731
732/// `speccoder auth status`: who speccoder is on `host` (default: the
733/// repository's, else github.com).
734#[must_use]
735pub fn auth_status(ctx: &Ctx, root: &Path, host: Option<&str>) -> CommandResult {
736    let host = host.map_or_else(
737        || repo(root, None).map_or_else(|_| "github.com".to_owned(), |r| r.host),
738        str::to_owned,
739    );
740    let Some((token, source)) = ctx.token(&host) else {
741        return CommandResult::new(
742            "auth",
743            CommandStatus::Ok,
744            &format!("not signed in to {host}: `{}`", login_command(&host)),
745        )
746        .with_data(CommandData::Auth(Box::new(AuthReport {
747            host,
748            login: None,
749            source: None,
750        })));
751    };
752    match ctx.api(&host, token).get("/user") {
753        Ok(u) => {
754            let login = u["login"].as_str().map(str::to_owned);
755            CommandResult::new(
756                "auth",
757                CommandStatus::Ok,
758                &format!(
759                    "signed in to {host} as {} (the token from {})",
760                    login.as_deref().unwrap_or("?"),
761                    source.word()
762                ),
763            )
764            .with_data(CommandData::Auth(Box::new(AuthReport {
765                host,
766                login,
767                source: Some(source.word().to_owned()),
768            })))
769        }
770        Err(e) => refused("auth", &e),
771    }
772}
773
774/// `speccoder auth login --with-token`: checks `token` against `host`, then
775/// keeps it in the credential store.
776#[must_use]
777pub fn login_with_token(ctx: &Ctx, host: &str, token: &str) -> CommandResult {
778    let token = auth::Secret::new(token);
779    if token.expose().is_empty() {
780        return CommandResult::new(
781            "auth",
782            CommandStatus::UsageError,
783            "no token on stdin: `echo $TOKEN | speccoder auth login --with-token`",
784        )
785        .with_code("USAGE");
786    }
787    let login = match ctx.api(host, token.clone()).get("/user") {
788        Ok(u) => u["login"].as_str().unwrap_or_default().to_owned(),
789        Err(e) => return refused("auth", &e),
790    };
791    if ctx.keeps()
792        && let Err(e) = auth::store(host, &token)
793    {
794        return CommandResult::new("auth", CommandStatus::ConfigError, &e).with_code("GITHUB-AUTH");
795    }
796    CommandResult::new(
797        "auth",
798        CommandStatus::Ok,
799        &format!("signed in to {host} as {login}; the token is in the keychain"),
800    )
801    .with_data(CommandData::Auth(Box::new(AuthReport {
802        host: host.to_owned(),
803        login: Some(login),
804        source: Some(auth::Source::Keychain.word().to_owned()),
805    })))
806}
807
808/// `speccoder auth logout`: forgets `host`'s token.
809#[must_use]
810pub fn logout(host: &str) -> CommandResult {
811    match auth::forget(host) {
812        Ok(true) => CommandResult::ok(
813            "auth",
814            &format!("signed out of {host}: the token is gone from the keychain"),
815        ),
816        Ok(false) => CommandResult::ok("auth", &format!("no token for {host} in the keychain")),
817        Err(e) => CommandResult::new("auth", CommandStatus::ConfigError, &e),
818    }
819}
820
821#[cfg(test)]
822mod tests {
823    use super::*;
824
825    #[test]
826    fn remotes_of_every_shape_name_their_repository() {
827        for (url, slug, host) in [
828            ("https://github.com/o/r.git", "o/r", "github.com"),
829            ("https://github.com/o/r", "o/r", "github.com"),
830            ("git@github.com:o/r.git", "o/r", "github.com"),
831            ("ssh://git@github.com/o/r.git", "o/r", "github.com"),
832            ("ssh://git@GHE.corp:2222/team/svc", "team/svc", "ghe.corp"),
833            ("https://token@github.com/o/r.git", "o/r", "github.com"),
834        ] {
835            let r = Repo::parse(url).unwrap_or_else(|| panic!("{url}"));
836            assert_eq!((r.slug().as_str(), r.host.as_str()), (slug, host), "{url}");
837        }
838        assert!(Repo::parse("/local/path").is_none());
839        assert!(Repo::parse("https://github.com/o").is_none());
840        assert!(Repo::parse("https://github.com/o/r/extra").is_none());
841    }
842
843    #[test]
844    fn issue_urls_are_found_once_in_order() {
845        let spec = "**Input**: Greet (GitHub issue https://github.com/o/r/issues/42).\n\
846                    See https://github.com/o/r/issues/42 and https://ghe.corp/o/r/issues/7;\n\
847                    not https://github.com/o/r/pull/3 nor https://github.com/o/r/issues/ nor https://github.com/o/r/issues/4x.";
848        assert_eq!(
849            issue_urls(spec),
850            [
851                "https://github.com/o/r/issues/42",
852                "https://ghe.corp/o/r/issues/7"
853            ]
854        );
855    }
856
857    #[test]
858    fn a_description_keeps_the_issue_s_address() {
859        let i = Issue {
860            number: 42,
861            title: "Greet people".into(),
862            body: "By name.".into(),
863            url: "https://github.com/o/r/issues/42".into(),
864            labels: vec![],
865        };
866        let d = description(&i);
867        assert!(
868            d.starts_with("Greet people\n\nBy name.")
869                && d.ends_with("GitHub issue: https://github.com/o/r/issues/42"),
870            "{d}"
871        );
872        assert_eq!(issue_urls(&d), ["https://github.com/o/r/issues/42"]);
873    }
874}