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#[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 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#[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#[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#[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}