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/// A pull request's diff against its merge base and its head commit,
365/// fetched read-only (no checkout of the contributor's branch, no CI
366/// token): what `speccoder bug --candidate pr:N` judges.
367///
368/// # Errors
369///
370/// No repository, not signed in, or GitHub's reason.
371pub fn pull_diff(ctx: &Ctx, root: &Path, number: u64) -> Result<(String, String), String> {
372    let r = repo(root, None)?;
373    let api = ctx.signed_in(&r.host)?;
374    let pull = api.get(&format!("/repos/{}/pulls/{number}", r.slug()))?;
375    let head = pull["head"]["sha"].as_str().unwrap_or_default().to_owned();
376    let diff = api.get_diff(&format!("/repos/{}/pulls/{number}", r.slug()))?;
377    Ok((diff, head))
378}
379
380/// The repository's `owner/repo`, if it has a GitHub remote.
381#[must_use]
382pub fn slug_of(root: &Path) -> Option<String> {
383    repo(root, None).ok().map(|r| r.slug())
384}
385
386/// What a feature started from `issue` is asked to specify: its title, its
387/// text, and its address — which the spec keeps, so `pr` can close it.
388#[must_use]
389pub fn description(issue: &Issue) -> String {
390    let body = issue.body.trim();
391    if body.is_empty() {
392        format!("{} (GitHub issue {})", issue.title, issue.url)
393    } else {
394        format!("{}\n\n{body}\n\nGitHub issue: {}", issue.title, issue.url)
395    }
396}
397
398/// GitHub issue addresses in `text` (a spec), in order, once each.
399#[must_use]
400pub fn issue_urls(text: &str) -> Vec<String> {
401    let mut out: Vec<String> = Vec::new();
402    for (i, _) in text.match_indices("https://") {
403        let rest = &text[i..];
404        let end = rest
405            .find(|c: char| {
406                c.is_whitespace() || matches!(c, ')' | ']' | '>' | '"' | '\'' | ',' | '`')
407            })
408            .unwrap_or(rest.len());
409        let url = rest[..end].trim_end_matches(['.', ';', ':']);
410        // https://host/owner/repo/issues/123 — on github.com or an enterprise host.
411        let parts: Vec<&str> = url.trim_start_matches("https://").split('/').collect();
412        let issue = parts.len() == 5
413            && parts[3] == "issues"
414            && !parts[4].is_empty()
415            && parts[4].chars().all(|c| c.is_ascii_digit());
416        if issue && !out.iter().any(|u| u == url) {
417            out.push(url.to_owned());
418        }
419    }
420    out
421}
422
423/// A branch's pull request.
424#[derive(Debug, Clone, Serialize)]
425pub struct Pull {
426    /// `#n`.
427    pub number: u64,
428    /// Its title.
429    pub title: String,
430    /// Its address.
431    pub url: String,
432    /// `OPEN`, `CLOSED` or `MERGED`.
433    pub state: String,
434    /// A draft.
435    pub draft: bool,
436    /// `APPROVED`, `CHANGES_REQUESTED`, `REVIEW_REQUIRED`, or none.
437    pub review: Option<String>,
438    /// Checks that passed, failed, and are still running.
439    pub checks: (u32, u32, u32),
440}
441
442/// One query: the newest pull request whose head is the branch, its review
443/// decision and its checks' rollup.
444const 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}}}}}}}}}}}";
445
446/// The pull request whose head is `branch`, if there is one.
447///
448/// # Errors
449///
450/// No GitHub remote, not signed in, or GitHub's refusal.
451pub fn pr_for(ctx: &Ctx, root: &Path, branch: &str) -> Result<Option<Pull>, String> {
452    pr_in(ctx, &repo(root, None)?, branch)
453}
454
455/// [`pr_for`] in `r` (the repository a given remote names).
456///
457/// # Errors
458///
459/// Not signed in, or GitHub's refusal.
460pub fn pr_in(ctx: &Ctx, r: &Repo, branch: &str) -> Result<Option<Pull>, String> {
461    let api = ctx.signed_in(&r.host)?;
462    let data = api.graphql(
463        PR_FOR,
464        json!({ "owner": r.owner, "name": r.name, "head": branch }),
465    )?;
466    let Some(p) = data["repository"]["pullRequests"]["nodes"]
467        .as_array()
468        .and_then(|n| n.first())
469    else {
470        return Ok(None);
471    };
472    let (mut ok, mut bad, mut running) = (0, 0, 0);
473    let contexts = &p["commits"]["nodes"][0]["commit"]["statusCheckRollup"]["contexts"]["nodes"];
474    for c in contexts.as_array().into_iter().flatten() {
475        // Check runs have `status`/`conclusion`; status contexts have `state`.
476        let conclusion = c["conclusion"]
477            .as_str()
478            .or_else(|| c["state"].as_str())
479            .unwrap_or("");
480        let status = c["status"].as_str().unwrap_or("COMPLETED");
481        match (status, conclusion) {
482            (s, _) if s != "COMPLETED" => running += 1,
483            (_, "SUCCESS" | "NEUTRAL" | "SKIPPED") => ok += 1,
484            (_, "PENDING" | "EXPECTED") => running += 1,
485            _ => bad += 1,
486        }
487    }
488    Ok(Some(Pull {
489        number: p["number"].as_u64().unwrap_or_default(),
490        title: p["title"].as_str().unwrap_or_default().to_owned(),
491        url: p["url"].as_str().unwrap_or_default().to_owned(),
492        state: p["state"].as_str().unwrap_or_default().to_owned(),
493        draft: p["isDraft"].as_bool().unwrap_or(false),
494        review: p["reviewDecision"]
495            .as_str()
496            .filter(|s| !s.is_empty())
497            .map(str::to_owned),
498        checks: (ok, bad, running),
499    }))
500}
501
502/// Opens a draft pull request from `head` into `base`: its number and
503/// address.
504///
505/// # Errors
506///
507/// Not signed in, or GitHub's refusal.
508pub fn open_draft(
509    ctx: &Ctx,
510    repo: &Repo,
511    base: &str,
512    head: &str,
513    title: &str,
514    body: &str,
515) -> Result<(u64, String), String> {
516    let api = ctx.signed_in(&repo.host)?;
517    let p = api.rest(
518        reqwest::Method::POST,
519        &format!("/repos/{}/pulls", repo.slug()),
520        Some(&json!({ "title": title, "head": head, "base": base, "body": body, "draft": true })),
521    )?;
522    Ok((
523        p["number"].as_u64().unwrap_or_default(),
524        p["html_url"].as_str().unwrap_or_default().to_owned(),
525    ))
526}
527
528/// The comment that tells an issue a feature is being specified from it;
529/// the hidden marker keeps it said once per feature.
530#[must_use]
531pub fn announcement(feature: &str, spec: &str, branch: &str) -> String {
532    format!(
533        "<!-- 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."
534    )
535}
536
537/// What [`announce`] did.
538#[derive(Debug, Clone, PartialEq, Eq)]
539pub enum Announced {
540    /// The comment's address.
541    Commented(String),
542    /// The feature had said so already.
543    Already,
544}
545
546/// Comments on issue `number` that `feature` is being specified from it
547/// (once per feature), and assigns it to the person when `assign`.
548///
549/// # Errors
550///
551/// No GitHub remote, not signed in, or GitHub's refusal.
552pub fn announce(
553    ctx: &Ctx,
554    root: &Path,
555    number: u64,
556    feature: &str,
557    spec: &str,
558    assign: bool,
559) -> Result<Announced, String> {
560    let r = repo(root, None)?;
561    let api = ctx.signed_in(&r.host)?;
562    let marker = format!("<!-- speccoder:feature {feature} -->");
563    let comments = api.get(&format!(
564        "/repos/{}/issues/{number}/comments?per_page=100",
565        r.slug()
566    ))?;
567    let said = comments
568        .as_array()
569        .into_iter()
570        .flatten()
571        .any(|c| c["body"].as_str().is_some_and(|b| b.contains(&marker)));
572    let out = if said {
573        Announced::Already
574    } else {
575        let branch = format!("speccoder/{feature}");
576        let c = api.rest(
577            reqwest::Method::POST,
578            &format!("/repos/{}/issues/{number}/comments", r.slug()),
579            Some(&json!({ "body": announcement(feature, spec, &branch) })),
580        )?;
581        Announced::Commented(c["html_url"].as_str().unwrap_or_default().to_owned())
582    };
583    if assign {
584        let me = api.get("/user")?;
585        let login = me["login"].as_str().unwrap_or_default();
586        api.rest(
587            reqwest::Method::POST,
588            &format!("/repos/{}/issues/{number}/assignees", r.slug()),
589            Some(&json!({ "assignees": [login] })),
590        )?;
591    }
592    Ok(out)
593}
594
595/// The issue number in `reference` (`42`, `#42`, its address).
596#[must_use]
597pub fn issue_number(reference: &str) -> Option<u64> {
598    reference
599        .trim()
600        .trim_end_matches('/')
601        .trim_start_matches('#')
602        .rsplit('/')
603        .next()
604        .and_then(|n| n.parse::<u64>().ok())
605}
606
607/// `speccoder issue comment N [--assign]`: tells issue `reference` that the
608/// current feature (or `feature`) is being specified from it.
609#[must_use]
610pub fn comment(
611    ctx: &Ctx,
612    root: &Path,
613    feature: Option<&str>,
614    reference: &str,
615    assign: bool,
616) -> CommandResult {
617    let Some(number) = issue_number(reference) else {
618        return CommandResult::new(
619            "issue",
620            CommandStatus::UsageError,
621            &format!("`{reference}` is no issue: a number (42, #42) or its address"),
622        )
623        .with_code("USAGE");
624    };
625    let inspected = crate::inspect(root, feature);
626    let Some(CommandData::Inspect(snapshot)) = inspected.data() else {
627        return CommandResult::carried("issue", &inspected);
628    };
629    let Some(f) = snapshot.feature.as_ref() else {
630        return CommandResult::new("issue", CommandStatus::SelectionNeeded, inspected.message());
631    };
632    let format = crate::format::of(snapshot);
633    let spec = format.requirements_file().map_or_else(
634        || f.feature.dir.clone(),
635        |file| format!("{}/{file}", f.feature.dir.trim_end_matches('/')),
636    );
637    let assigned = if assign {
638        ", and assigned it to you"
639    } else {
640        ""
641    };
642    match announce(ctx, root, number, &f.feature.name, &spec, assign) {
643        Ok(Announced::Commented(url)) => CommandResult::ok(
644            "issue",
645            &format!(
646                "commented on #{number} that it is being specified as {}{assigned}: {url}",
647                f.feature.name
648            ),
649        ),
650        Ok(Announced::Already) => CommandResult::ok(
651            "issue",
652            &format!(
653                "#{number} already says it is being specified as {}{assigned}",
654                f.feature.name
655            ),
656        ),
657        Err(e) => refused("issue", &e),
658    }
659}
660
661/// Whether the signed-in account can see the repository at `root` — after a
662/// sign-in through the app, whether the app is installed there. `None`: it
663/// can; else what to do.
664#[must_use]
665pub fn reach(ctx: &Ctx, root: &Path) -> Option<String> {
666    let r = repo(root, None).ok()?;
667    let api = ctx.signed_in(&r.host).ok()?;
668    match api.get(&format!("/repos/{}", r.slug())) {
669        Ok(_) => None,
670        Err(e) if e.contains("was not found") && r.host == "github.com" => Some(format!(
671            "{} cannot be reached yet: install the {} app on it — {}",
672            r.slug(),
673            auth::APP_SLUG,
674            auth::install_url()
675        )),
676        Err(e) => Some(e),
677    }
678}
679
680/// Brings pull request `number`'s title and description up to date.
681///
682/// # Errors
683///
684/// Not signed in, or GitHub's refusal.
685pub fn update(ctx: &Ctx, repo: &Repo, number: u64, title: &str, body: &str) -> Result<(), String> {
686    let api = ctx.signed_in(&repo.host)?;
687    api.rest(
688        reqwest::Method::PATCH,
689        &format!("/repos/{}/pulls/{number}", repo.slug()),
690        Some(&json!({ "title": title, "body": body })),
691    )
692    .map(|_| ())
693}
694
695/// The issue URLs each feature's spec names: issue URL → feature.
696#[must_use]
697pub fn started(root: &Path) -> std::collections::BTreeMap<String, String> {
698    let mut out = std::collections::BTreeMap::new();
699    let listed = crate::current::list(root, None);
700    let Some(CommandData::Features(f)) = listed.data() else {
701        return out;
702    };
703    for feature in &f.features {
704        for (_, _, text) in crate::documents::sources(root, Some(&feature.name)) {
705            for url in issue_urls(&text) {
706                out.entry(url).or_insert_with(|| feature.name.clone());
707            }
708        }
709    }
710    out
711}
712
713/// `speccoder issue`: the open issues.
714#[must_use]
715pub fn list(ctx: &Ctx, root: &Path) -> CommandResult {
716    match issues(ctx, root, &started(root)) {
717        Ok(report) => {
718            let n = report.issues.len();
719            let msg = format!(
720                "{n} open issue{}{}",
721                if n == 1 { "" } else { "s" },
722                report
723                    .repo
724                    .as_deref()
725                    .map(|r| format!(" in {r}"))
726                    .unwrap_or_default()
727            );
728            CommandResult::new("issue", CommandStatus::Ok, &msg)
729                .with_data(CommandData::Issues(Box::new(report)))
730        }
731        Err(e) => refused("issue", &e),
732    }
733}
734
735/// A GitHub failure as a result: its code and fix.
736#[must_use]
737pub fn refused(command: &str, reason: &str) -> CommandResult {
738    let code = if reason.contains("has no remote") || reason.contains("no git remote") {
739        "NO-REMOTE"
740    } else if reason.contains("not signed in") || reason.contains("refused the token") {
741        "GITHUB-AUTH"
742    } else {
743        "GITHUB-FAILED"
744    };
745    // The code's own status: a missing remote is a git precondition.
746    let status = if code == "NO-REMOTE" {
747        CommandStatus::GitPrecondition
748    } else {
749        CommandStatus::Error
750    };
751    CommandResult::new(command, status, &format!("GitHub: {reason}")).with_code(code)
752}
753
754/// `speccoder auth status`: who speccoder is on `host` (default: the
755/// repository's, else github.com).
756#[must_use]
757pub fn auth_status(ctx: &Ctx, root: &Path, host: Option<&str>) -> CommandResult {
758    let host = host.map_or_else(
759        || repo(root, None).map_or_else(|_| "github.com".to_owned(), |r| r.host),
760        str::to_owned,
761    );
762    let Some((token, source)) = ctx.token(&host) else {
763        return CommandResult::new(
764            "auth",
765            CommandStatus::Ok,
766            &format!("not signed in to {host}: `{}`", login_command(&host)),
767        )
768        .with_data(CommandData::Auth(Box::new(AuthReport {
769            host,
770            login: None,
771            source: None,
772        })));
773    };
774    match ctx.api(&host, token).get("/user") {
775        Ok(u) => {
776            let login = u["login"].as_str().map(str::to_owned);
777            CommandResult::new(
778                "auth",
779                CommandStatus::Ok,
780                &format!(
781                    "signed in to {host} as {} (the token from {})",
782                    login.as_deref().unwrap_or("?"),
783                    source.word()
784                ),
785            )
786            .with_data(CommandData::Auth(Box::new(AuthReport {
787                host,
788                login,
789                source: Some(source.word().to_owned()),
790            })))
791        }
792        Err(e) => refused("auth", &e),
793    }
794}
795
796/// `speccoder auth login --with-token`: checks `token` against `host`, then
797/// keeps it in the credential store.
798#[must_use]
799pub fn login_with_token(ctx: &Ctx, host: &str, token: &str) -> CommandResult {
800    let token = auth::Secret::new(token);
801    if token.expose().is_empty() {
802        return CommandResult::new(
803            "auth",
804            CommandStatus::UsageError,
805            "no token on stdin: `echo $TOKEN | speccoder auth login --with-token`",
806        )
807        .with_code("USAGE");
808    }
809    let login = match ctx.api(host, token.clone()).get("/user") {
810        Ok(u) => u["login"].as_str().unwrap_or_default().to_owned(),
811        Err(e) => return refused("auth", &e),
812    };
813    if ctx.keeps()
814        && let Err(e) = auth::store(host, &token)
815    {
816        return CommandResult::new("auth", CommandStatus::ConfigError, &e).with_code("GITHUB-AUTH");
817    }
818    CommandResult::new(
819        "auth",
820        CommandStatus::Ok,
821        &format!("signed in to {host} as {login}; the token is in the keychain"),
822    )
823    .with_data(CommandData::Auth(Box::new(AuthReport {
824        host: host.to_owned(),
825        login: Some(login),
826        source: Some(auth::Source::Keychain.word().to_owned()),
827    })))
828}
829
830/// `speccoder auth logout`: forgets `host`'s token.
831#[must_use]
832pub fn logout(host: &str) -> CommandResult {
833    match auth::forget(host) {
834        Ok(true) => CommandResult::ok(
835            "auth",
836            &format!("signed out of {host}: the token is gone from the keychain"),
837        ),
838        Ok(false) => CommandResult::ok("auth", &format!("no token for {host} in the keychain")),
839        Err(e) => CommandResult::new("auth", CommandStatus::ConfigError, &e),
840    }
841}
842
843#[cfg(test)]
844mod tests {
845    use super::*;
846
847    #[test]
848    fn remotes_of_every_shape_name_their_repository() {
849        for (url, slug, host) in [
850            ("https://github.com/o/r.git", "o/r", "github.com"),
851            ("https://github.com/o/r", "o/r", "github.com"),
852            ("git@github.com:o/r.git", "o/r", "github.com"),
853            ("ssh://git@github.com/o/r.git", "o/r", "github.com"),
854            ("ssh://git@GHE.corp:2222/team/svc", "team/svc", "ghe.corp"),
855            ("https://token@github.com/o/r.git", "o/r", "github.com"),
856        ] {
857            let r = Repo::parse(url).unwrap_or_else(|| panic!("{url}"));
858            assert_eq!((r.slug().as_str(), r.host.as_str()), (slug, host), "{url}");
859        }
860        assert!(Repo::parse("/local/path").is_none());
861        assert!(Repo::parse("https://github.com/o").is_none());
862        assert!(Repo::parse("https://github.com/o/r/extra").is_none());
863    }
864
865    #[test]
866    fn issue_urls_are_found_once_in_order() {
867        let spec = "**Input**: Greet (GitHub issue https://github.com/o/r/issues/42).\n\
868                    See https://github.com/o/r/issues/42 and https://ghe.corp/o/r/issues/7;\n\
869                    not https://github.com/o/r/pull/3 nor https://github.com/o/r/issues/ nor https://github.com/o/r/issues/4x.";
870        assert_eq!(
871            issue_urls(spec),
872            [
873                "https://github.com/o/r/issues/42",
874                "https://ghe.corp/o/r/issues/7"
875            ]
876        );
877    }
878
879    #[test]
880    fn a_description_keeps_the_issue_s_address() {
881        let i = Issue {
882            number: 42,
883            title: "Greet people".into(),
884            body: "By name.".into(),
885            url: "https://github.com/o/r/issues/42".into(),
886            labels: vec![],
887        };
888        let d = description(&i);
889        assert!(
890            d.starts_with("Greet people\n\nBy name.")
891                && d.ends_with("GitHub issue: https://github.com/o/r/issues/42"),
892            "{d}"
893        );
894        assert_eq!(issue_urls(&d), ["https://github.com/o/r/issues/42"]);
895    }
896}