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
364#[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#[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 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#[derive(Debug, Clone, Serialize)]
403pub struct Pull {
404 pub number: u64,
406 pub title: String,
408 pub url: String,
410 pub state: String,
412 pub draft: bool,
414 pub review: Option<String>,
416 pub checks: (u32, u32, u32),
418}
419
420const 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
424pub fn pr_for(ctx: &Ctx, root: &Path, branch: &str) -> Result<Option<Pull>, String> {
430 pr_in(ctx, &repo(root, None)?, branch)
431}
432
433pub 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 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
480pub 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#[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#[derive(Debug, Clone, PartialEq, Eq)]
517pub enum Announced {
518 Commented(String),
520 Already,
522}
523
524pub 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#[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#[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#[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
658pub 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#[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#[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#[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 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#[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#[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#[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}