1pub 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#[derive(Debug, Clone, Default)]
24pub struct Ctx {
25 base: Option<String>,
26 fixed: Option<Option<auth::Secret>>,
27}
28
29impl Ctx {
30 #[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 #[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 #[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 fn keeps(&self) -> bool {
78 self.fixed.is_none()
79 }
80}
81
82#[derive(Debug, Clone, PartialEq, Eq)]
84pub struct Repo {
85 pub host: String,
87 pub owner: String,
89 pub name: String,
91}
92
93impl Repo {
94 #[must_use]
96 pub fn slug(&self) -> String {
97 format!("{}/{}", self.owner, self.name)
98 }
99
100 #[must_use]
102 pub fn url(&self) -> String {
103 format!("https://{}/{}/{}", self.host, self.owner, self.name)
104 }
105
106 #[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 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
139pub 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#[derive(Debug, Clone, Default, Serialize)]
171pub struct Status {
172 pub ready: bool,
174 pub host: Option<String>,
176 pub login: Option<String>,
178 pub source: Option<String>,
180 pub repo: Option<String>,
182 pub url: Option<String>,
184 pub reason: Option<String>,
186 pub fix: Option<String>,
188}
189
190#[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
244fn 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#[derive(Debug, Clone, Serialize)]
255pub struct Issue {
256 pub number: u64,
258 pub title: String,
260 pub body: String,
262 pub url: String,
264 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
276pub 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
325pub 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
364pub 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#[must_use]
382pub fn slug_of(root: &Path) -> Option<String> {
383 repo(root, None).ok().map(|r| r.slug())
384}
385
386#[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#[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 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#[derive(Debug, Clone, Serialize)]
425pub struct Pull {
426 pub number: u64,
428 pub title: String,
430 pub url: String,
432 pub state: String,
434 pub draft: bool,
436 pub review: Option<String>,
438 pub checks: (u32, u32, u32),
440}
441
442const 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
446pub fn pr_for(ctx: &Ctx, root: &Path, branch: &str) -> Result<Option<Pull>, String> {
452 pr_in(ctx, &repo(root, None)?, branch)
453}
454
455pub 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 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
502pub 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#[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#[derive(Debug, Clone, PartialEq, Eq)]
539pub enum Announced {
540 Commented(String),
542 Already,
544}
545
546pub 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#[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#[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#[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
680pub 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#[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#[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#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
737pub struct RepoEntry {
738 pub slug: String,
740 pub description: Option<String>,
742 pub private: bool,
744 pub pushed: Option<String>,
746}
747
748pub fn repositories(ctx: &Ctx) -> Result<(String, Vec<RepoEntry>), String> {
755 let api = ctx.signed_in("github.com")?;
756 let me = api
757 .get("/user")?
758 .get("login")
759 .and_then(Value::as_str)
760 .unwrap_or_default()
761 .to_owned();
762 let raw = api.get("/user/repos?per_page=100&sort=updated")?;
763 let list = raw
764 .as_array()
765 .into_iter()
766 .flatten()
767 .filter_map(|r| {
768 Some(RepoEntry {
769 slug: r["full_name"].as_str()?.to_owned(),
770 description: r["description"].as_str().map(str::to_owned),
771 private: r["private"].as_bool().unwrap_or(false),
772 pushed: r["pushed_at"].as_str().map(str::to_owned),
773 })
774 })
775 .collect();
776 Ok((me, list))
777}
778
779#[must_use]
781pub fn valid_slug(slug: &str) -> bool {
782 let ok = |p: &str| {
783 !p.is_empty()
784 && !p.starts_with('.')
785 && p.chars()
786 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'))
787 };
788 matches!(slug.split_once('/'), Some((o, n)) if ok(o) && ok(n) && !n.contains('/'))
789}
790
791#[must_use]
797pub fn clone(_ctx: &Ctx, slug: &str, target: &Path) -> CommandResult {
798 let refuse = |status, m: &str| CommandResult::new("clone", status, m);
799 let expanded = match (target.strip_prefix("~"), std::env::var_os("HOME")) {
801 (Ok(rest), Some(home)) => std::path::PathBuf::from(home).join(rest),
802 _ => target.to_path_buf(),
803 };
804 let target = expanded.as_path();
805 if !target.is_absolute() {
806 return refuse(
807 CommandStatus::UsageError,
808 &format!(
809 "give the destination as an absolute path or ~/…, not {}",
810 target.display()
811 ),
812 )
813 .with_code("USAGE");
814 }
815 let mut probe = target.to_path_buf();
817 while !probe.is_dir() {
818 if !probe.pop() {
819 break;
820 }
821 }
822 if let Ok(Some(top)) = crate::git::Git::at(&probe).toplevel() {
823 return refuse(
824 CommandStatus::GitPrecondition,
825 &format!(
826 "{} is inside the repository {}",
827 target.display(),
828 top.display()
829 ),
830 )
831 .with_code("NEW-NESTED-REPOSITORY")
832 .with_fix(vec!["→ choose a folder outside it".to_owned()]);
833 }
834 if !valid_slug(slug) {
835 return refuse(
836 CommandStatus::UsageError,
837 &format!("`{slug}` is not a repository: give it as owner/name"),
838 )
839 .with_code("USAGE");
840 }
841 if target.exists() && std::fs::read_dir(target).map_or(true, |mut d| d.next().is_some()) {
842 return refuse(
843 CommandStatus::UsageError,
844 &format!("{} is not empty", target.display()),
845 )
846 .with_code("NEW-TARGET-NOT-EMPTY")
847 .with_fix(vec!["→ choose an empty folder or a new one".to_owned()]);
848 }
849 let Some(parent) = target.parent().filter(|p| !p.as_os_str().is_empty()) else {
850 return refuse(
851 CommandStatus::UsageError,
852 "the destination needs a parent folder",
853 );
854 };
855 if let Err(e) = std::fs::create_dir_all(parent) {
856 return refuse(
857 CommandStatus::Error,
858 &format!("cannot create {}: {e}", parent.display()),
859 );
860 }
861 let base = std::env::var("SPECCODER_GIT_BASE")
863 .ok()
864 .filter(|b| cfg!(debug_assertions) && !b.trim().is_empty())
865 .unwrap_or_else(|| "https://github.com".to_owned());
866 let url = format!("{}/{slug}.git", base.trim_end_matches('/'));
867 let dest = target.display().to_string();
868 match crate::git::Git::at(parent).run(&["clone", "--quiet", "--", &url, &dest]) {
869 Ok(_) => CommandResult::new(
870 "clone",
871 CommandStatus::Ok,
872 &format!("cloned {slug} into {dest}"),
873 )
874 .with_next(vec![speccoder_core::next::NextStep {
875 command: format!("speccoder init -C {dest}"),
876 why: "set speccoder up there".to_owned(),
877 }]),
878 Err(e) => {
881 let auth = [
882 "Authentication failed",
883 "could not read Username",
884 "terminal prompts disabled",
885 "Repository not found",
886 ]
887 .iter()
888 .any(|m| e.0.contains(m));
889 let why = if auth {
890 format!(
891 "git could not sign in to clone {slug}: a private repository needs your git credentials (a credential helper, or an SSH remote) — speccoder's sign-in only lists repositories: {}",
892 e.0
893 )
894 } else {
895 format!("git clone failed: {}", e.0)
896 };
897 refuse(CommandStatus::Error, &why).with_code("GITHUB-FAILED")
898 }
899 }
900}
901
902#[must_use]
904pub fn refused(command: &str, reason: &str) -> CommandResult {
905 let code = if reason.contains("has no remote") || reason.contains("no git remote") {
906 "NO-REMOTE"
907 } else if reason.contains("not signed in") || reason.contains("refused the token") {
908 "GITHUB-AUTH"
909 } else {
910 "GITHUB-FAILED"
911 };
912 let status = if code == "NO-REMOTE" {
914 CommandStatus::GitPrecondition
915 } else {
916 CommandStatus::Error
917 };
918 CommandResult::new(command, status, &format!("GitHub: {reason}")).with_code(code)
919}
920
921#[must_use]
924pub fn auth_status(ctx: &Ctx, root: &Path, host: Option<&str>) -> CommandResult {
925 let host = host.map_or_else(
926 || repo(root, None).map_or_else(|_| "github.com".to_owned(), |r| r.host),
927 str::to_owned,
928 );
929 let Some((token, source)) = ctx.token(&host) else {
930 return CommandResult::new(
931 "auth",
932 CommandStatus::Ok,
933 &format!("not signed in to {host}: `{}`", login_command(&host)),
934 )
935 .with_data(CommandData::Auth(Box::new(AuthReport {
936 host,
937 login: None,
938 source: None,
939 })));
940 };
941 match ctx.api(&host, token).get("/user") {
942 Ok(u) => {
943 let login = u["login"].as_str().map(str::to_owned);
944 CommandResult::new(
945 "auth",
946 CommandStatus::Ok,
947 &format!(
948 "signed in to {host} as {} (the token from {})",
949 login.as_deref().unwrap_or("?"),
950 source.word()
951 ),
952 )
953 .with_data(CommandData::Auth(Box::new(AuthReport {
954 host,
955 login,
956 source: Some(source.word().to_owned()),
957 })))
958 }
959 Err(e) => refused("auth", &e),
960 }
961}
962
963#[must_use]
966pub fn login_with_token(ctx: &Ctx, host: &str, token: &str) -> CommandResult {
967 let token = auth::Secret::new(token);
968 if token.expose().is_empty() {
969 return CommandResult::new(
970 "auth",
971 CommandStatus::UsageError,
972 "no token on stdin: `echo $TOKEN | speccoder auth login --with-token`",
973 )
974 .with_code("USAGE");
975 }
976 let login = match ctx.api(host, token.clone()).get("/user") {
977 Ok(u) => u["login"].as_str().unwrap_or_default().to_owned(),
978 Err(e) => return refused("auth", &e),
979 };
980 if ctx.keeps()
981 && let Err(e) = auth::store(host, &token)
982 {
983 return CommandResult::new("auth", CommandStatus::ConfigError, &e).with_code("GITHUB-AUTH");
984 }
985 CommandResult::new(
986 "auth",
987 CommandStatus::Ok,
988 &format!("signed in to {host} as {login}; the token is in the keychain"),
989 )
990 .with_data(CommandData::Auth(Box::new(AuthReport {
991 host: host.to_owned(),
992 login: Some(login),
993 source: Some(auth::Source::Keychain.word().to_owned()),
994 })))
995}
996
997#[must_use]
999pub fn logout(host: &str) -> CommandResult {
1000 match auth::forget(host) {
1001 Ok(true) => CommandResult::ok(
1002 "auth",
1003 &format!("signed out of {host}: the token is gone from the keychain"),
1004 ),
1005 Ok(false) => CommandResult::ok("auth", &format!("no token for {host} in the keychain")),
1006 Err(e) => CommandResult::new("auth", CommandStatus::ConfigError, &e),
1007 }
1008}
1009
1010#[cfg(test)]
1011mod tests {
1012 use super::*;
1013
1014 #[test]
1015 fn remotes_of_every_shape_name_their_repository() {
1016 for (url, slug, host) in [
1017 ("https://github.com/o/r.git", "o/r", "github.com"),
1018 ("https://github.com/o/r", "o/r", "github.com"),
1019 ("git@github.com:o/r.git", "o/r", "github.com"),
1020 ("ssh://git@github.com/o/r.git", "o/r", "github.com"),
1021 ("ssh://git@GHE.corp:2222/team/svc", "team/svc", "ghe.corp"),
1022 ("https://token@github.com/o/r.git", "o/r", "github.com"),
1023 ] {
1024 let r = Repo::parse(url).unwrap_or_else(|| panic!("{url}"));
1025 assert_eq!((r.slug().as_str(), r.host.as_str()), (slug, host), "{url}");
1026 }
1027 assert!(Repo::parse("/local/path").is_none());
1028 assert!(Repo::parse("https://github.com/o").is_none());
1029 assert!(Repo::parse("https://github.com/o/r/extra").is_none());
1030 }
1031
1032 #[test]
1033 fn issue_urls_are_found_once_in_order() {
1034 let spec = "**Input**: Greet (GitHub issue https://github.com/o/r/issues/42).\n\
1035 See https://github.com/o/r/issues/42 and https://ghe.corp/o/r/issues/7;\n\
1036 not https://github.com/o/r/pull/3 nor https://github.com/o/r/issues/ nor https://github.com/o/r/issues/4x.";
1037 assert_eq!(
1038 issue_urls(spec),
1039 [
1040 "https://github.com/o/r/issues/42",
1041 "https://ghe.corp/o/r/issues/7"
1042 ]
1043 );
1044 }
1045
1046 #[test]
1047 fn a_description_keeps_the_issue_s_address() {
1048 let i = Issue {
1049 number: 42,
1050 title: "Greet people".into(),
1051 body: "By name.".into(),
1052 url: "https://github.com/o/r/issues/42".into(),
1053 labels: vec![],
1054 };
1055 let d = description(&i);
1056 assert!(
1057 d.starts_with("Greet people\n\nBy name.")
1058 && d.ends_with("GitHub issue: https://github.com/o/r/issues/42"),
1059 "{d}"
1060 );
1061 assert_eq!(issue_urls(&d), ["https://github.com/o/r/issues/42"]);
1062 }
1063}