1use std::path::Path;
12use std::process::Output;
13
14use serde_json::Value;
15
16use crate::detect::{Detection, Forge};
17use crate::diagnostic::{Diagnostic, Reason};
18use crate::error::RkError;
19
20pub const GITLAB_DEFAULT_TEMPLATE: &str = "%{id}-%{title}";
26
27const GITLAB_NAME_CAP: usize = 100;
29
30#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct Reference {
33 pub number: u64,
36 pub repo: Option<String>,
38 pub host: Option<String>,
40}
41
42const ACCEPTED_FORMS: &str = "a number, #<number>, or the forge's issue URL (…/issues/<number> on GitHub, …/-/issues/<number> on GitLab)";
44
45pub fn parse_reference(text: &str) -> Result<Reference, String> {
57 let text = text.trim();
58 let bare = text.strip_prefix('#').unwrap_or(text);
59 if !bare.is_empty() && bare.chars().all(|c| c.is_ascii_digit()) {
60 let number = bare
61 .parse()
62 .map_err(|_| format!("'{text}' is not an issue number this forge can carry"))?;
63 return numbered(number, None, None, text);
64 }
65 let Some((host, path)) = crate::detect::split_remote(text) else {
66 return Err(format!(
67 "'{text}' is not an issue reference; pass {ACCEPTED_FORMS}"
68 ));
69 };
70 let path = path
72 .split(['?', '#'])
73 .next()
74 .unwrap_or_default()
75 .trim_end_matches('/');
76 let split = path
79 .rsplit_once("/-/issues/")
80 .or_else(|| path.rsplit_once("/issues/"));
81 let Some((repo, tail)) = split else {
82 return Err(format!("'{text}' names no issue; pass {ACCEPTED_FORMS}"));
83 };
84 let number = tail
85 .split('/')
86 .next()
87 .unwrap_or_default()
88 .parse()
89 .map_err(|_| format!("'{text}' names no issue number; pass {ACCEPTED_FORMS}"))?;
90 numbered(number, Some(repo.to_owned()), Some(host), text)
91}
92
93fn numbered(
95 number: u64,
96 repo: Option<String>,
97 host: Option<String>,
98 text: &str,
99) -> Result<Reference, String> {
100 if number == 0 {
101 return Err(format!("'{text}' names issue 0, which no forge carries"));
102 }
103 Ok(Reference { number, repo, host })
104}
105
106pub fn agrees(reference: &Reference, detected: &Detection) -> Result<(), String> {
117 if let (Some(named), Some(found)) = (reference.host.as_deref(), detected.host.as_deref()) {
118 if !named.eq_ignore_ascii_case(found) {
119 return Err(format!(
127 "the reference names {named} and this clone's origin is {found}; pass the issue number instead where one instance serves both names"
128 ));
129 }
130 }
131 if let (Some(named), Some(found)) = (reference.repo.as_deref(), detected.repo.as_deref()) {
132 if named != found {
133 return Err(format!(
134 "the reference names {named} and this clone's origin is {found}"
135 ));
136 }
137 }
138 Ok(())
139}
140
141#[derive(Debug, Clone, PartialEq, Eq)]
144pub struct Rendered {
145 pub name: String,
147 pub approximated: bool,
150}
151
152#[must_use]
161pub fn gitlab_branch_name(
162 iid: u64,
163 title: &str,
164 confidential: bool,
165 template: Option<&str>,
166 branch_creator: Option<&str>,
167) -> Rendered {
168 if confidential {
171 return cap(format!("{iid}-confidential-issue"), false);
172 }
173 let id = parameterize_reporting(&iid.to_string(), true);
174 let title = parameterize_reporting(title, false);
175 let creator = branch_creator.map(|name| parameterize_reporting(name, true));
176 let approximated =
177 id.approximated || title.approximated || creator.as_ref().is_some_and(|c| c.approximated);
178 let name = match template.filter(|text| !text.trim().is_empty()) {
179 None => [id.name, title.name]
180 .into_iter()
181 .filter(|part| !part.is_empty())
182 .collect::<Vec<_>>()
183 .join("-"),
184 Some(template) => substitute(
185 template,
186 &id.name,
187 &title.name,
188 creator.as_ref().map_or("", |c| c.name.as_str()),
189 ),
190 };
191 cap(name, approximated)
192}
193
194fn substitute(template: &str, id: &str, title: &str, creator: &str) -> String {
201 let mut name = String::with_capacity(template.len());
202 let mut rest = template;
203 while let Some(open) = rest.find("%{") {
204 name.push_str(&rest[..open]);
205 let after = &rest[open + 2..];
206 let Some(close) = after.find('}') else {
207 rest = &rest[open..];
208 break;
209 };
210 let key = &after[..close];
211 let value = match key {
212 "id" => id,
213 "title" => title,
214 "branch_creator" => creator,
215 _ => "",
216 };
217 if value.is_empty() {
218 name.push_str(&rest[open..=(open + 2 + close)]);
219 } else {
220 name.push_str(value);
221 }
222 rest = &after[close + 1..];
223 }
224 name.push_str(rest);
225 name
226}
227
228fn cap(name: String, approximated: bool) -> Rendered {
233 if name.chars().count() <= GITLAB_NAME_CAP {
234 return Rendered { name, approximated };
235 }
236 let cut: String = name.chars().take(GITLAB_NAME_CAP).collect();
237 let name = cut
238 .rfind('-')
239 .map_or_else(|| cut.clone(), |at| cut[..at].to_owned());
240 Rendered { name, approximated }
241}
242
243#[must_use]
250pub fn parameterize(text: &str, preserve_case: bool) -> String {
251 parameterize_reporting(text, preserve_case).name
252}
253
254#[must_use]
257pub fn parameterize_reporting(text: &str, preserve_case: bool) -> Rendered {
258 let mut approximated = false;
259 let mut transliterated = String::with_capacity(text.len());
260 for source in text.chars() {
261 if source.is_ascii() {
262 transliterated.push(source);
263 } else if let Some(ascii) = transliterate(source) {
264 transliterated.push_str(ascii);
265 } else {
266 approximated = true;
271 transliterated.push('?');
272 }
273 }
274 let mut replaced = String::with_capacity(transliterated.len());
276 let mut in_run = false;
277 for held in transliterated.chars() {
278 if held.is_ascii_alphanumeric() || matches!(held, '_' | '-') {
279 replaced.push(held);
280 in_run = false;
281 } else if !in_run {
282 replaced.push('-');
283 in_run = true;
284 }
285 }
286 let mut squeezed = String::with_capacity(replaced.len());
289 let mut last_was_separator = false;
290 for held in replaced.chars() {
291 if held == '-' {
292 if last_was_separator {
293 continue;
294 }
295 last_was_separator = true;
296 } else {
297 last_was_separator = false;
298 }
299 squeezed.push(held);
300 }
301 let trimmed = squeezed.trim_matches('-');
302 let name = if preserve_case {
303 trimmed.to_owned()
304 } else {
305 trimmed.to_lowercase()
306 };
307 Rendered { name, approximated }
308}
309
310fn transliterate(source: char) -> Option<&'static str> {
313 let index = (source as u32).checked_sub(0x00C0)? as usize;
314 TRANSLITERATIONS
315 .get(index)
316 .copied()
317 .filter(|s| !s.is_empty())
318}
319
320const TRANSLITERATIONS: [&str; 192] = [
324 "A", "A", "A", "A", "A", "A", "AE", "C", "E", "E", "E", "E", "I", "I", "I", "I", "D", "N", "O",
326 "O", "O", "O", "O", "x", "O", "U", "U", "U", "U", "Y", "Th", "ss", "a", "a", "a", "a", "a",
327 "a", "ae", "c", "e", "e", "e", "e", "i", "i", "i", "i", "d", "n", "o", "o", "o", "o", "o", "",
328 "o", "u", "u", "u", "u", "y", "th", "y", "A", "a", "A", "a", "A", "a", "C", "c", "C", "c", "C", "c", "C", "c", "D", "d", "D", "d", "E",
330 "e", "E", "e", "E", "e", "E", "e", "E", "e", "G", "g", "G", "g", "G", "g", "G", "g", "H", "h",
331 "H", "h", "I", "i", "I", "i", "I", "i", "I", "i", "I", "i", "IJ", "ij", "J", "j", "K", "k",
332 "k", "L", "l", "L", "l", "L", "l", "L", "l", "L", "l", "N", "n", "N", "n", "N", "n", "n", "NG",
333 "ng", "O", "o", "O", "o", "O", "o", "OE", "oe", "R", "r", "R", "r", "R", "r", "S", "s", "S",
334 "s", "S", "s", "S", "s", "T", "t", "T", "t", "T", "t", "U", "u", "U", "u", "U", "u", "U", "u",
335 "U", "u", "U", "u", "W", "w", "Y", "y", "Y", "Z", "z", "Z", "z", "Z", "z", "s",
336];
337
338#[derive(Debug, Clone, PartialEq, Eq)]
340pub enum Minted {
341 Already {
343 branch: String,
345 others: Vec<String>,
347 },
348 Absent,
350 Unknown {
352 detail: String,
354 },
355}
356
357#[must_use]
369pub fn linked_branch(body: &Value) -> Minted {
370 let nodes = body
371 .pointer("/data/repository/issue/linkedBranches/nodes")
372 .and_then(Value::as_array);
373 let Some(nodes) = nodes else {
374 return Minted::Unknown {
375 detail: "the answer carries no linkedBranches list".to_owned(),
376 };
377 };
378 match body
382 .pointer("/data/repository/issue/linkedBranches/pageInfo/hasNextPage")
383 .and_then(Value::as_bool)
384 {
385 Some(false) => {}
386 Some(true) => {
387 return Minted::Unknown {
388 detail: format!(
389 "the issue links more than the {LINKED_BRANCH_PAGE} branches one read carries"
390 ),
391 };
392 }
393 None => {
394 return Minted::Unknown {
395 detail: "the answer does not say whether it carries every linked branch".to_owned(),
396 };
397 }
398 }
399 let mut names = Vec::with_capacity(nodes.len());
400 for node in nodes {
401 let Some(name) = node.pointer("/ref/name").and_then(Value::as_str) else {
402 return Minted::Unknown {
403 detail: "a linked branch carries no ref name".to_owned(),
404 };
405 };
406 names.push(name.to_owned());
407 }
408 names.sort_unstable();
409 if names.is_empty() {
410 return Minted::Absent;
411 }
412 let branch = names.remove(0);
413 Minted::Already {
414 branch,
415 others: names,
416 }
417}
418
419const LINKED_BRANCH_PAGE: u32 = 100;
422
423#[must_use]
428pub fn admissible(branch: &str) -> bool {
429 crate::worktree::matches_grammar(branch)
430}
431
432#[derive(Debug, Clone, PartialEq, Eq)]
434pub struct Resolved {
435 pub number: u64,
437 pub title: String,
439 pub branch: Option<String>,
445 pub origin: &'static str,
449 pub others: Vec<String>,
451 pub detail: Option<String>,
454}
455
456pub fn resolve(cli: &Path, target: &Path, ask: &Ask<'_>) -> Result<Resolved, RkError> {
466 match ask.forge {
467 Forge::Github => resolve_github(cli, target, ask),
468 Forge::Gitlab => resolve_gitlab(cli, target, ask),
469 }
470}
471
472pub struct Ask<'a> {
474 pub forge: Forge,
476 pub repo: &'a str,
478 pub reference: &'a Reference,
480 pub base: Option<&'a str>,
482 pub host: Option<&'a str>,
491 pub apply: bool,
493 pub seatable: &'a dyn Fn(&str) -> Result<(), RkError>,
496}
497
498fn resolve_github(cli: &Path, target: &Path, ask: &Ask<'_>) -> Result<Resolved, RkError> {
501 let (repo, reference, base, apply) = (ask.repo, ask.reference, ask.base, ask.apply);
502 let Some((owner, name)) = repo.split_once('/') else {
503 return Err(RkError::Usage(format!(
504 "'{repo}' is not a GitHub project path; pass --repo <owner/name>"
505 )));
506 };
507 let number = reference.number.to_string();
508 let read = || -> Result<Value, RkError> {
509 let out = forge_call(
510 cli,
511 target,
512 &[
513 "api",
514 "graphql",
515 "-f",
516 &format!("query={LINKED_BRANCHES_QUERY}"),
517 "-F",
518 &format!("owner={owner}"),
519 "-F",
520 &format!("name={name}"),
521 "-F",
524 &format!("number={number}"),
525 ],
526 )?;
527 answered(&out, "the issue read")
528 };
529 let body = read()?;
530 let title = body
531 .pointer("/data/repository/issue/title")
532 .and_then(Value::as_str)
533 .unwrap_or_default()
534 .to_owned();
535 match linked_branch(&body) {
536 Minted::Already { branch, others } => Ok(Resolved {
537 number: reference.number,
538 title,
539 branch: Some(branch),
540 origin: "already",
541 others,
542 detail: None,
543 }),
544 Minted::Unknown { detail } => Err(forge_failure(format!(
545 "the issue read did not answer with linked branches: {detail}"
546 ))),
547 Minted::Absent if !apply => Ok(Resolved {
548 number: reference.number,
549 title,
550 branch: None,
551 origin: "pending",
552 others: Vec::new(),
553 detail: Some(
554 "GitHub names the branch when it mints it, so the exact name appears on the apply"
555 .to_owned(),
556 ),
557 }),
558 Minted::Absent => {
559 let mut args = vec!["issue", "develop", number.as_str(), "--repo", repo];
564 if let Some(base) = base {
565 args.push("--base");
566 args.push(base);
567 }
568 succeeded(&forge_call(cli, target, &args)?, "the mint")?;
569 let after = read()?;
572 match linked_branch(&after) {
573 Minted::Already { branch, others } => Ok(Resolved {
574 number: reference.number,
575 title,
576 branch: Some(branch),
577 origin: "forge",
578 others,
579 detail: None,
580 }),
581 _ => Err(forge_failure(
582 "the mint reported success and the issue still carries no linked branch"
583 .to_owned(),
584 )),
585 }
586 }
587 }
588}
589
590const LINKED_BRANCHES_QUERY: &str = "query($owner: String!, $name: String!, $number: Int!) { repository(owner: $owner, name: $name) { issue(number: $number) { title linkedBranches(first: 100) { pageInfo { hasNextPage } nodes { ref { name } } } } } }";
592
593struct Planned {
595 iid: u64,
597 title: String,
599 name: String,
601 default_branch: String,
603 detail: Option<String>,
605}
606
607fn plan_gitlab(
611 cli: &Path,
612 target: &Path,
613 encoded: &str,
614 reference: &Reference,
615 host: &[&str],
616) -> Result<Planned, RkError> {
617 let project = answered(
618 &forge_call(
619 cli,
620 target,
621 &borrowed(&api(host, &format!("projects/{encoded}"))),
622 )?,
623 "the project read",
624 )?;
625 let template = match project.get("issue_branch_template") {
630 Some(Value::Null) => None,
631 Some(Value::String(text)) if text.trim().is_empty() => None,
632 Some(Value::String(text)) => Some(text.clone()),
633 _ => {
634 return Err(forge_failure(
635 "the project read answered without a usable 'issue_branch_template'".to_owned(),
636 ));
637 }
638 };
639 let default_branch = required(&project, "default_branch", |held| {
643 held.as_str()
644 .filter(|name| !name.trim().is_empty())
645 .map(ToOwned::to_owned)
646 })
647 .map_err(|_| {
648 forge_failure("the project read answered without a usable 'default_branch'".to_owned())
649 })?;
650 let issue = answered(
651 &forge_call(
652 cli,
653 target,
654 &borrowed(&api(
655 host,
656 &format!("projects/{encoded}/issues/{}", reference.number),
657 )),
658 )?,
659 "the issue read",
660 )?;
661 let iid = required(&issue, "iid", Value::as_u64)?;
666 let title = required(&issue, "title", |held| held.as_str().map(ToOwned::to_owned))?;
667 let confidential = required(&issue, "confidential", Value::as_bool)?;
668 let creator = match template.as_deref() {
671 Some(text) if text.contains("%{branch_creator}") => {
672 let user = answered(
673 &forge_call(cli, target, &borrowed(&api(host, "user")))?,
674 "the user read",
675 )?;
676 user["username"].as_str().map(ToOwned::to_owned)
677 }
678 _ => None,
679 };
680 let rendered = gitlab_branch_name(
681 iid,
682 &title,
683 confidential,
684 template.as_deref(),
685 creator.as_deref(),
686 );
687 if !admissible(&rendered.name) {
688 return Err(refuse_template(&rendered.name, GRAMMAR_REFUSED));
689 }
690 if !links_to(&rendered.name, iid) {
696 return Err(refuse_template(&rendered.name, &link_refused(iid)));
697 }
698 Ok(Planned {
699 iid,
700 title,
701 detail: gitlab_detail(confidential, template.as_deref(), rendered.approximated),
702 name: rendered.name,
703 default_branch,
704 })
705}
706
707const GRAMMAR_REFUSED: &str = "a template whose names match <type>/<slug> or <issue-id>-<slug>";
709
710#[must_use]
715pub fn links_to(branch: &str, iid: u64) -> bool {
716 branch
717 .strip_prefix(&iid.to_string())
718 .and_then(|rest| rest.strip_prefix('-'))
719 .is_some_and(|slug| !slug.is_empty())
720}
721
722fn link_refused(iid: u64) -> String {
724 format!(
725 "a template whose names start with {iid}-, which is how GitLab links a branch to its issue"
726 )
727}
728
729fn refuse_template(name: &str, expected: &str) -> RkError {
731 RkError::refusal(
732 Diagnostic::new(
733 Reason::PrerequisiteUnmet,
734 format!("the project's issue_branch_template renders '{name}', which this verb cannot use"),
735 )
736 .expected(expected)
737 .action(
738 "change Settings > Repository > Branch defaults > Branch name template, or pass a branch to rk worktree add instead",
739 )
740 .target_state("unchanged"),
741 )
742}
743
744fn gitlab_detail(confidential: bool, template: Option<&str>, approximated: bool) -> Option<String> {
747 let mut notes = Vec::new();
748 if confidential {
749 notes.push(
750 "the issue is confidential, so GitLab keeps its title out of the branch and applies no template"
751 .to_owned(),
752 );
753 }
754 if let Some(text) = template {
755 notes.push(format!("the project's branch name template is '{text}'"));
756 }
757 if approximated {
758 notes.push(
759 "the title carries characters outside the transliteration table, so this name can differ from the one GitLab's own button produces"
760 .to_owned(),
761 );
762 }
763 (!notes.is_empty()).then(|| notes.join("; "))
764}
765
766fn resolve_gitlab(cli: &Path, target: &Path, ask: &Ask<'_>) -> Result<Resolved, RkError> {
769 let (reference, base, apply) = (ask.reference, ask.base, ask.apply);
770 let encoded = ask.repo.replace('/', "%2F");
771 let host = host_args(ask.host);
772 let planned = plan_gitlab(cli, target, &encoded, reference, &host)?;
773 let linked = linked_branches(cli, target, &encoded, planned.iid, &host)?;
778 if let Some((primary, others)) = pick(linked, &planned.name) {
779 let detail = if primary == planned.name {
780 planned.detail
781 } else {
782 let took =
783 format!("the forge already links '{primary}' to this issue, so it was taken");
784 Some(
785 planned
786 .detail
787 .map_or_else(|| took.clone(), |had| format!("{had}; {took}")),
788 )
789 };
790 return Ok(Resolved {
791 number: planned.iid,
792 title: planned.title,
793 branch: Some(primary),
794 origin: "already",
795 others,
796 detail,
797 });
798 }
799 let origin = if apply {
800 (ask.seatable)(&planned.name)?;
805 let start = base.unwrap_or(&planned.default_branch);
808 let mut args = api(&host, "--method");
809 args.push("POST".to_owned());
810 args.push(format!(
811 "projects/{encoded}/repository/branches?branch={}&ref={}",
812 encode(&planned.name),
813 encode(start)
814 ));
815 forge_call(cli, target, &borrowed(&args))
816 .and_then(|out| succeeded(&out, "the branch creation"))?;
817 "forge"
818 } else {
819 "pending"
820 };
821 Ok(Resolved {
822 number: planned.iid,
823 title: planned.title,
824 branch: Some(planned.name),
827 origin,
828 others: Vec::new(),
829 detail: planned.detail,
830 })
831}
832
833fn pick(mut linked: Vec<String>, rendered: &str) -> Option<(String, Vec<String>)> {
840 if linked.is_empty() {
841 return None;
842 }
843 linked.sort_unstable();
844 let at = linked.iter().position(|name| name == rendered).unwrap_or(0);
845 let primary = linked.remove(at);
846 Some((primary, linked))
847}
848
849fn required<T>(
855 body: &Value,
856 field: &str,
857 read: impl Fn(&Value) -> Option<T>,
858) -> Result<T, RkError> {
859 read(&body[field]).ok_or_else(|| {
860 forge_failure(format!(
861 "the issue read answered without a usable '{field}'"
862 ))
863 })
864}
865
866fn linked_branches(
879 cli: &Path,
880 target: &Path,
881 encoded: &str,
882 iid: u64,
883 host: &[&str],
884) -> Result<Vec<String>, RkError> {
885 let mut args = api(host, "--paginate");
889 args.push(format!(
890 "projects/{encoded}/repository/branches?search={}",
891 encode(&format!("^{iid}-"))
892 ));
893 let found = forge_call(cli, target, &borrowed(&args))?;
894 let body = answered(&found, "the linked branch read")?;
895 let Some(held) = body.as_array() else {
896 return Err(forge_failure(
897 "the linked branch read did not answer with a branch list".to_owned(),
898 ));
899 };
900 let mut names = Vec::with_capacity(held.len());
901 for branch in held {
902 let Some(name) = branch["name"].as_str() else {
906 return Err(forge_failure(
907 "a branch in the linked branch read carries no name".to_owned(),
908 ));
909 };
910 if links_to(name, iid) {
913 names.push(name.to_owned());
914 }
915 }
916 Ok(names)
917}
918
919fn host_args(host: Option<&str>) -> Vec<&str> {
921 host.map_or_else(Vec::new, |host| vec!["--hostname", host])
922}
923
924fn api(host: &[&str], rest: &str) -> Vec<String> {
927 let mut args = vec!["api".to_owned()];
928 args.extend(host.iter().map(|held| (*held).to_owned()));
929 args.push(rest.to_owned());
930 args
931}
932
933fn borrowed(args: &[String]) -> Vec<&str> {
935 args.iter().map(String::as_str).collect()
936}
937
938fn forge_call(cli: &Path, target: &Path, args: &[&str]) -> Result<Output, RkError> {
944 std::process::Command::new(cli)
945 .args(args)
946 .current_dir(target)
947 .env("GH_PAGER", "")
948 .env("GLAB_PAGER", "")
949 .output()
950 .map_err(|source| {
951 RkError::subprocess(
952 Diagnostic::new(
953 Reason::SubprocessSpawn,
954 format!("the forge CLI did not run: {source}"),
955 )
956 .target_state("unchanged"),
957 )
958 })
959}
960
961fn answered(out: &Output, what: &str) -> Result<Value, RkError> {
963 succeeded(out, what)?;
964 serde_json::from_slice(&out.stdout)
965 .map_err(|_| forge_failure(format!("{what} did not answer with JSON")))
966}
967
968fn succeeded(out: &Output, what: &str) -> Result<(), RkError> {
970 if out.status.success() {
971 return Ok(());
972 }
973 let stderr = String::from_utf8_lossy(&out.stderr);
974 Err(forge_failure_from(
975 format!("{what} failed: {}", last_line(&out.stderr)),
976 &stderr,
977 ))
978}
979
980fn forge_failure(message: String) -> RkError {
988 forge_failure_from(message, "")
989}
990
991fn forge_failure_from(message: String, stderr: &str) -> RkError {
993 let (reason, action) = classify_forge_answer(stderr);
994 let diagnostic = Diagnostic::new(reason, message)
995 .action(action)
996 .target_state("unchanged");
997 match reason {
998 Reason::ForgeAuthentication
999 | Reason::ForgePermission
1000 | Reason::ForgeRateLimit
1001 | Reason::RemoteConflict => RkError::refusal(diagnostic),
1002 Reason::TargetNotFound => RkError::missing(diagnostic),
1003 _ => RkError::subprocess(diagnostic),
1004 }
1005}
1006
1007fn classify_forge_answer(stderr: &str) -> (Reason, &'static str) {
1014 let text = stderr.to_ascii_lowercase();
1015 let status =
1016 |code: &str| text.contains(&format!("http {code}")) || text.contains(&format!("{code} "));
1017 if text.contains("rate limit") || status("429") {
1018 return (
1019 Reason::ForgeRateLimit,
1020 "wait for the forge's limit to reset, then rerun",
1021 );
1022 }
1023 if status("401") || text.contains("not logged in") || text.contains("authentication") {
1024 return (
1025 Reason::ForgeAuthentication,
1026 "authenticate the forge CLI, then rerun",
1027 );
1028 }
1029 if status("403") {
1030 return (
1031 Reason::ForgePermission,
1032 "grant this account access to the project, then rerun",
1033 );
1034 }
1035 if status("404") {
1036 return (
1037 Reason::TargetNotFound,
1038 "check the issue number and the project, then rerun",
1039 );
1040 }
1041 if status("409") {
1042 return (
1043 Reason::RemoteConflict,
1044 "read what the forge already carries, then rerun",
1045 );
1046 }
1047 if status("500") || status("502") || status("503") || status("504") {
1048 return (
1049 Reason::ForgeTemporary,
1050 "rerun; the forge failed transiently",
1051 );
1052 }
1053 (
1054 Reason::SubprocessFailed,
1055 "read the forge's own answer, then decide",
1056 )
1057}
1058
1059fn last_line(bytes: &[u8]) -> String {
1061 String::from_utf8_lossy(bytes)
1062 .lines()
1063 .rev()
1064 .find(|line| !line.trim().is_empty())
1065 .unwrap_or("no output")
1066 .to_owned()
1067}
1068
1069fn encode(text: &str) -> String {
1072 use std::fmt::Write as _;
1073 let mut out = String::with_capacity(text.len());
1074 for byte in text.bytes() {
1075 if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~') {
1076 out.push(byte as char);
1077 } else {
1078 let _ = write!(out, "%{byte:02X}");
1079 }
1080 }
1081 out
1082}
1083
1084#[cfg(test)]
1085mod tests {
1086 #![allow(clippy::expect_used)]
1087
1088 use super::{
1089 Minted, Reference, admissible, agrees, gitlab_branch_name, linked_branch, parameterize,
1090 parse_reference,
1091 };
1092 use crate::detect::Detection;
1093
1094 fn clone_of(host: &str, repo: &str) -> Detection {
1095 Detection {
1096 host: Some(host.to_owned()),
1097 repo: Some(repo.to_owned()),
1098 forge: None,
1099 }
1100 }
1101
1102 #[test]
1103 fn a_reference_parses_from_every_accepted_form() {
1104 assert_eq!(
1105 parse_reference("57").expect("a number parses"),
1106 Reference {
1107 number: 57,
1108 repo: None,
1109 host: None
1110 }
1111 );
1112 assert_eq!(
1113 parse_reference("#57").expect("a hashed number parses"),
1114 Reference {
1115 number: 57,
1116 repo: None,
1117 host: None
1118 }
1119 );
1120 assert_eq!(
1121 parse_reference("https://github.com/acme/widget/issues/57").expect("a URL parses"),
1122 Reference {
1123 number: 57,
1124 repo: Some("acme/widget".into()),
1125 host: Some("github.com".into())
1126 }
1127 );
1128 assert_eq!(
1129 parse_reference("https://gitlab.example.com/acme/widget/-/issues/57")
1130 .expect("a self-hosted URL parses"),
1131 Reference {
1132 number: 57,
1133 repo: Some("acme/widget".into()),
1134 host: Some("gitlab.example.com".into())
1135 }
1136 );
1137 assert!(parse_reference("nonsense").is_err());
1138 assert!(parse_reference("0").is_err(), "no forge carries issue 0");
1139 }
1140
1141 #[test]
1142 fn a_nested_gitlab_group_keeps_every_segment() {
1143 let parsed = parse_reference("https://gitlab.com/acme/team/widget/-/issues/57#note_9")
1144 .expect("a nested URL parses");
1145 assert_eq!(parsed.repo.as_deref(), Some("acme/team/widget"));
1146 assert_eq!(parsed.number, 57);
1147 }
1148
1149 #[test]
1150 fn a_reference_that_names_another_project_disagrees() {
1151 let parsed =
1152 parse_reference("https://github.com/other/thing/issues/1").expect("a URL parses");
1153 assert!(agrees(&parsed, &clone_of("github.com", "acme/widget")).is_err());
1154 }
1155
1156 #[test]
1157 fn a_bare_number_agrees_with_any_clone() {
1158 let parsed = parse_reference("57").expect("a number parses");
1159 assert!(agrees(&parsed, &clone_of("github.com", "acme/widget")).is_ok());
1160 assert!(agrees(&parsed, &clone_of("gitlab.com", "other/thing")).is_ok());
1161 }
1162
1163 #[test]
1167 fn parameterize_matches_the_documented_example() {
1168 assert_eq!(parameterize("^très|Jolie-- ", false), "tres-jolie");
1169 }
1170
1171 #[test]
1172 fn parameterize_preserves_case_when_asked() {
1173 assert_eq!(parameterize("Donald E. Knuth", true), "Donald-E-Knuth");
1174 assert_eq!(parameterize("Donald E. Knuth", false), "donald-e-knuth");
1175 }
1176
1177 #[test]
1178 fn a_name_renders_from_id_and_title_without_a_template() {
1179 let rendered = gitlab_branch_name(57, "Fix the CSV upload!", false, None, None);
1180 assert_eq!(rendered.name, "57-fix-the-csv-upload");
1181 assert!(!rendered.approximated);
1182 assert!(admissible(&rendered.name));
1183 }
1184
1185 #[test]
1186 fn an_empty_title_yields_the_bare_number() {
1187 assert_eq!(gitlab_branch_name(57, "", false, None, None).name, "57");
1188 }
1189
1190 #[test]
1191 fn a_template_substitutes_every_supported_variable() {
1192 let rendered = gitlab_branch_name(
1193 57,
1194 "Fix the CSV upload",
1195 false,
1196 Some("%{branch_creator}-%{id}-%{title}"),
1197 Some("Ada Lovelace"),
1198 );
1199 assert_eq!(rendered.name, "Ada-Lovelace-57-fix-the-csv-upload");
1200 }
1201
1202 #[test]
1205 fn an_unknown_placeholder_survives_into_the_name() {
1206 let rendered = gitlab_branch_name(57, "Upload", false, Some("%{author}-%{id}"), None);
1207 assert_eq!(rendered.name, "%{author}-57");
1208 assert!(!admissible(&rendered.name));
1209 }
1210
1211 #[test]
1212 fn a_confidential_issue_ignores_the_template() {
1213 let rendered = gitlab_branch_name(
1214 57,
1215 "The secret title",
1216 true,
1217 Some("%{id}-%{title}"),
1218 Some("ada"),
1219 );
1220 assert_eq!(rendered.name, "57-confidential-issue");
1221 assert!(admissible(&rendered.name));
1222 }
1223
1224 #[test]
1225 fn a_long_name_truncates_at_100_and_drops_the_partial_segment() {
1226 let title = "alpha bravo charlie delta echo foxtrot golf hotel india juliett kilo lima mike november oscar papa";
1227 let rendered = gitlab_branch_name(57, title, false, None, None);
1228 assert!(rendered.name.len() <= 100, "{}", rendered.name);
1229 assert!(
1230 rendered.name.ends_with("-oscar"),
1231 "the partial trailing segment is dropped: {}",
1232 rendered.name
1233 );
1234 assert!(
1235 !rendered.name.contains("papa"),
1236 "the cut segment does not survive: {}",
1237 rendered.name
1238 );
1239 }
1240
1241 #[test]
1242 fn a_title_outside_the_table_reports_approximated() {
1243 let rendered = gitlab_branch_name(57, "Исправить загрузку", false, None, None);
1244 assert!(rendered.approximated);
1245 assert_eq!(rendered.name, "57");
1246 }
1247
1248 #[test]
1249 fn a_linked_branch_answer_judges_absent_one_and_many() {
1250 let answer = |names: &[&str]| {
1251 let nodes: Vec<_> = names
1252 .iter()
1253 .map(|name| serde_json::json!({ "ref": { "name": name } }))
1254 .collect();
1255 serde_json::json!({
1256 "data": { "repository": { "issue": { "linkedBranches": {
1257 "pageInfo": { "hasNextPage": false },
1258 "nodes": nodes
1259 } } } }
1260 })
1261 };
1262 assert_eq!(linked_branch(&answer(&[])), Minted::Absent);
1263 assert_eq!(
1264 linked_branch(&answer(&["57-fix"])),
1265 Minted::Already {
1266 branch: "57-fix".into(),
1267 others: vec![]
1268 }
1269 );
1270 assert_eq!(
1273 linked_branch(&answer(&["57-fix-again", "57-fix"])),
1274 Minted::Already {
1275 branch: "57-fix".into(),
1276 others: vec!["57-fix-again".into()]
1277 }
1278 );
1279 }
1280
1281 #[test]
1282 fn a_malformed_linked_branch_answer_is_unknown() {
1283 let body = serde_json::json!({ "errors": [{ "message": "Could not resolve" }] });
1284 assert!(matches!(linked_branch(&body), Minted::Unknown { .. }));
1285 }
1286
1287 #[test]
1291 fn a_customized_template_can_render_a_name_the_grammar_refuses() {
1292 let rendered = gitlab_branch_name(
1293 57,
1294 "Fix the upload",
1295 false,
1296 Some("feature/%{id}-%{title}"),
1297 None,
1298 );
1299 assert_eq!(rendered.name, "feature/57-fix-the-upload");
1300 assert!(!admissible(&rendered.name));
1301 }
1302}