Skip to main content

release_kit/
issue.rs

1//! Starting work from a forge issue: the pure half.
2//!
3//! An issue becomes a branch name at the forge, never in this binary's
4//! imagination. GitHub names the branch server-side, so nothing here
5//! renders one. GitLab exposes no such endpoint — its branch-name
6//! template is a project setting the web UI applies — so rk reproduces
7//! `Issue.to_branch_name` exactly, and this module is where that
8//! reproduction lives. Spawning stays in the handler, exactly as
9//! [`crate::branches`] declares for the branch half.
10
11use 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
20/// GitLab's documented default when a project sets no template.
21///
22/// Kept for the report and the prose. The rendering follows the code path
23/// GitLab takes when the template is absent, which joins the present
24/// values rather than substituting this string.
25pub const GITLAB_DEFAULT_TEMPLATE: &str = "%{id}-%{title}";
26
27/// The longest branch name GitLab renders from an issue, in characters.
28const GITLAB_NAME_CAP: usize = 100;
29
30/// What the operator named, and where it points.
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct Reference {
33    /// The issue number, as the forge counts it: a GitHub number or a
34    /// GitLab iid.
35    pub number: u64,
36    /// The project path the reference names, where it carried one.
37    pub repo: Option<String>,
38    /// The host the reference names, where it carried one.
39    pub host: Option<String>,
40}
41
42/// The forms a reference is accepted in, for a refusal message.
43const ACCEPTED_FORMS: &str = "a number, #<number>, or the forge's issue URL (…/issues/<number> on GitHub, …/-/issues/<number> on GitLab)";
44
45/// Parse what the operator typed into the issue it names.
46///
47/// A bare number names no project, so it always agrees with the clone. A
48/// URL names both, and [`agrees`] is what holds it to the clone the verb
49/// was pointed at.
50///
51/// # Errors
52///
53/// A string matching none of the accepted forms, or naming issue zero:
54/// no forge numbers an issue zero, and a zero is what a failed parse
55/// produces.
56pub 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    // A query or a fragment is not part of the path the forge routes on.
71    let path = path
72        .split(['?', '#'])
73        .next()
74        .unwrap_or_default()
75        .trim_end_matches('/');
76    // GitLab nests projects under groups, so every segment before the
77    // separator belongs to the project path.
78    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
93/// One parsed reference, refusing issue zero.
94fn 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
106/// Whether a reference names the clone the verb was pointed at.
107///
108/// The ordinary mistake this guards is an agent pasting a URL while
109/// sitting in another checkout: the branch would be minted on one project
110/// and seated in another.
111///
112/// # Errors
113///
114/// A reference whose project path or host disagrees with the detected
115/// remote.
116pub 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            // The two hosts are compared as they are written. An
120            // instance served under a separate SSH endpoint writes them
121            // differently for one project, and resolving that mapping
122            // means asking the forge CLI — a network call this check
123            // deliberately does not make, because it runs before the
124            // CLI gate so that a wrong clone costs one local read. The
125            // issue number names the same issue and skips the question.
126            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/// A rendered branch name, and whether any character fell outside the
142/// transliteration table.
143#[derive(Debug, Clone, PartialEq, Eq)]
144pub struct Rendered {
145    /// The name as rendered.
146    pub name: String,
147    /// Whether a character was replaced rather than approximated, so the
148    /// name can differ from the one GitLab's own button produces.
149    pub approximated: bool,
150}
151
152/// Render a GitLab branch name for one issue, the way GitLab renders it.
153///
154/// This reproduces `Issue.to_branch_name`. The order is the source's own:
155/// a confidential issue keeps its title out of the branch and ignores the
156/// template entirely, the three template parameters are parameterized
157/// first, an absent template joins the present values, an unresolved
158/// placeholder stays in the text, and a name over 100 characters is cut
159/// and loses its trailing partial segment.
160#[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    // GitLab renders this before anything else, so a branch name never
169    // leaks a confidential title, and the template never applies.
170    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
194/// Replace every placeholder the template names.
195///
196/// A placeholder resolving to nothing is left in the text unchanged,
197/// which is what `Gitlab::StringPlaceholderReplacer` does, and an unknown
198/// placeholder is left for the same reason. Neither is an error here: the
199/// grammar check one step later refuses the name and names the template.
200fn 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
228/// Cut a name over the cap and drop the trailing partial segment, which
229/// is `sub(/-[^-]*\Z/, '')` in the source. A capped name carrying no `-`
230/// is left as the cut produced it, because that substitution matches
231/// nothing.
232fn 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/// Rails `String#parameterize`, the one GitLab calls.
244///
245/// In order: transliterate to an ASCII approximation, replace every run
246/// of characters outside `[A-Za-z0-9_-]` with `-`, squeeze repeated
247/// separators into one, drop a leading and a trailing separator, and
248/// downcase unless the case is preserved.
249#[must_use]
250pub fn parameterize(text: &str, preserve_case: bool) -> String {
251    parameterize_reporting(text, preserve_case).name
252}
253
254/// [`parameterize`], reporting whether the transliteration table covered
255/// every character it was given.
256#[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            // Rails transliterates an uncovered character to `?`, which
267            // its own run replacement then turns into the separator. The
268            // flag records that the table, not the source text, decided
269            // it, so the report can say the name may differ.
270            approximated = true;
271            transliterated.push('?');
272        }
273    }
274    // Every run outside [A-Za-z0-9_-] becomes one separator.
275    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    // Repeated separators squeeze into one, and a leading and a trailing
287    // separator go.
288    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
310/// The ASCII approximation of one character, over Latin-1 Supplement and
311/// Latin Extended-A. A character outside the table answers `None`.
312fn 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
320/// Latin-1 Supplement and Latin Extended-A, from `U+00C0` upward, one
321/// entry per code point. An empty entry is a code point the table does
322/// not approximate.
323const TRANSLITERATIONS: [&str; 192] = [
324    // U+00C0..U+00FF
325    "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", // U+0100..U+017F
329    "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/// What the forge already carries for one issue.
339#[derive(Debug, Clone, PartialEq, Eq)]
340pub enum Minted {
341    /// The forge already carries a branch for this issue.
342    Already {
343        /// The branch the verb adopts.
344        branch: String,
345        /// Every other branch linked to the same issue.
346        others: Vec<String>,
347    },
348    /// The forge carries none.
349    Absent,
350    /// The forge could not answer; nothing is created.
351    Unknown {
352        /// What the answer was, for the report.
353        detail: String,
354    },
355}
356
357/// Read a GitHub `linkedBranches` answer.
358///
359/// More than one node keeps one and reports the rest: an issue with two
360/// linked branches is a state rk did not create, and picking from it
361/// silently would look like a choice rk made. The one kept is the first
362/// in sort order, which is a rule rather than whatever order the API
363/// answered in.
364///
365/// Every node must carry a ref name, and the connection must be whole: a
366/// node that does not, or a page the query did not reach, is unknown
367/// rather than absent, because absence is what authorizes a mint.
368#[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    // Complete only where the answer says so. A `hasNextPage` that is
379    // missing, null, or not a boolean says nothing, and absence is what
380    // authorizes a mint.
381    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
419/// How many linked branches one read carries. An issue with more is a
420/// state this verb reports rather than guesses at.
421const LINKED_BRANCH_PAGE: u32 = 100;
422
423/// Whether the landed grammar admits a branch name.
424///
425/// A named re-export, so the call site reads as intent.
426/// [`crate::worktree::matches_grammar`] stays the single owner.
427#[must_use]
428pub fn admissible(branch: &str) -> bool {
429    crate::worktree::matches_grammar(branch)
430}
431
432/// What one issue resolves to, before anything is seated.
433#[derive(Debug, Clone, PartialEq, Eq)]
434pub struct Resolved {
435    /// The issue, as the forge numbers it.
436    pub number: u64,
437    /// The issue's title, for the report.
438    pub title: String,
439    /// The branch the seat takes.
440    ///
441    /// Absent for one case alone: a GitHub preview of an issue with no
442    /// linked branch. The server names that branch at the moment it mints
443    /// it, so no honest preview can print a name.
444    pub branch: Option<String>,
445    /// Where the name came from: `already` for one the forge carried,
446    /// `forge` for a fresh mint, `pending` for a name the forge has not
447    /// been asked to make yet.
448    pub origin: &'static str,
449    /// Other branches the forge links to the same issue, GitHub only.
450    pub others: Vec<String>,
451    /// A note the report prints: a template that was read, a title the
452    /// transliteration table did not cover, a confidential issue.
453    pub detail: Option<String>,
454}
455
456/// Resolve one issue to its branch, minting at the forge under `apply`.
457///
458/// Every failure returns before any local mutation, so a forge that
459/// refuses, rate-limits, or answers nothing leaves the clone as it was.
460///
461/// # Errors
462///
463/// A forge call that does not run or does not answer, and — on GitLab —
464/// a project template rendering a name the landed grammar refuses.
465pub 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
472/// What one call asks the forge for.
473pub struct Ask<'a> {
474    /// The forge to act on.
475    pub forge: Forge,
476    /// The project path.
477    pub repo: &'a str,
478    /// The issue, as the operator named it.
479    pub reference: &'a Reference,
480    /// The remote branch a new branch starts from.
481    pub base: Option<&'a str>,
482    /// The API host to name explicitly, where one has to be named.
483    ///
484    /// `glab api` resolves its host from the working directory and falls
485    /// back to gitlab.com, which is right for a clone whose remote it
486    /// can read and wrong for one with no remote: there, a self-managed
487    /// issue URL would be acted on at gitlab.com. So this carries the
488    /// reference's host and not the remote's — a remote names a
489    /// transport host, which an instance may serve under another name.
490    pub host: Option<&'a str>,
491    /// Whether to write, at the forge and afterwards.
492    pub apply: bool,
493    /// Every local refusal the seat carries, run where the forge lets rk
494    /// know the name before it writes.
495    pub seatable: &'a dyn Fn(&str) -> Result<(), RkError>,
496}
497
498/// The GitHub path: one GraphQL read, a mint where nothing is linked, and
499/// the same read again for the name the server chose.
500fn 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                // `number` is `Int!`, and only `-F` renders an integer as
522                // a JSON number; `-f` would send the string "57".
523                "-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            // No `--name`: an omitted name is a request for the forge's
560            // own, which the mutation documents as the issue number and
561            // title. No `--checkout` either: the seat belongs to the verb
562            // and to the recorded mode.
563            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            // The read is the authority, not the line the mint printed,
570            // and it is the same path the idempotent second run takes.
571            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
590/// The read GitHub answers both before and after a mint.
591const 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
593/// What the GitLab reads settled, before the branch itself is looked at.
594struct Planned {
595    /// The issue's own iid, as GitLab counts it.
596    iid: u64,
597    /// The issue's title, for the report.
598    title: String,
599    /// The name GitLab's own rules produce.
600    name: String,
601    /// The project's default branch, the ref a mint starts from.
602    default_branch: String,
603    /// What the report says about how the name came out.
604    detail: Option<String>,
605}
606
607/// The GitLab reads: the project, the issue, and — only where the template
608/// names the creator — the user. The name is rendered and judged here, so
609/// a template nobody can land through fails before any write.
610fn 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    // A template the answer does not carry is not the same as one the
626    // project does not set: the first is a partial read, and rendering
627    // the default name from it would quietly ignore a template that
628    // exists. GitLab answers an unset template as null.
629    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    // The default branch is the ref a mint starts from where `--base`
640    // names none. Inventing one could create the branch from the wrong
641    // commit, which is a wrong remote write rather than a failed read.
642    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    // Every one of these is decoded strictly. `confidential` is the
662    // reason: a missing or mistyped field defaulted to public would put
663    // the issue's own title into a branch name anyone can read, and a
664    // partial answer is exactly when that happens.
665    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    // The user read happens only where the template names the creator,
669    // which keeps the ordinary case at three calls.
670    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    // GitLab links a branch to an issue by name, and it matches on the
691    // iid followed by a hyphen. A template can render a name the landed
692    // grammar admits and GitLab links to nothing — `feat/%{id}-%{title}`
693    // is the ordinary case — and the verb would then report a link that
694    // does not exist.
695    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
707/// What a rendered name has to satisfy for the landed grammar.
708const GRAMMAR_REFUSED: &str = "a template whose names match <type>/<slug> or <issue-id>-<slug>";
709
710/// Whether GitLab links a branch of this name to the issue.
711///
712/// GitLab matches the issue's own iid followed by a hyphen at the start
713/// of the name, so a prefix of any other shape links to nothing.
714#[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
722/// What a rendered name has to satisfy for GitLab to link it.
723fn 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
729/// A rendered name this verb cannot use, named with its cause.
730fn 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
744/// What the report says about how a GitLab name came out. Each note is a
745/// state the operator must see rather than one rk decides quietly.
746fn 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
766/// The GitLab path: plan the name from the project's own rules, then read
767/// the branch and create it where it is absent.
768fn 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    // One read answers the whole question. Every admissible name carries
774    // the issue's link prefix, so the prefix search is a superset of the
775    // exact name: it finds the branch this rendering would produce, and
776    // it finds one an earlier title or template produced instead.
777    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        // The name is known before the write here, unlike GitHub's, so
801        // every local refusal the seat carries runs first. A branch
802        // created at the forge and then refused locally would leave a
803        // remote change no report accounts for.
804        (ask.seatable)(&planned.name)?;
805        // POST /projects/:id/repository/branches takes the name and the
806        // ref, and resolves no template — which is why the reads exist.
807        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        // GitLab links an issue and a branch by name, so the name rk
825        // rendered is the name that now exists, and no read-back follows.
826        branch: Some(planned.name),
827        origin,
828        others: Vec::new(),
829        detail: planned.detail,
830    })
831}
832
833/// The branch to take, and every other one linked to the same issue.
834///
835/// The rendering this run produced wins where the forge carries it, so a
836/// steady project keeps taking the same branch. Otherwise the first name
837/// in sort order wins, which is a rule rather than whatever order the
838/// API answered in, and the rest are reported.
839fn 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
849/// One field the API documents, or a failure naming it.
850///
851/// A field this verb reads is never defaulted: the shape of the answer
852/// decides what the branch is called and whether the title may appear in
853/// it, so a partial answer stops the run rather than being filled in.
854fn 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
866/// Every branch the project carries under this issue's link prefix.
867///
868/// The Branches API's `search` takes `^term` for a starts-with match, so
869/// one read answers what the issue already owns. An empty list is the
870/// only proof of absence: a call that fails, or output that does not
871/// parse, is unknown — and acting on unknown as if it were absence is
872/// what creates a second branch for one issue.
873///
874/// # Errors
875///
876/// The call failing, classified from the forge's own answer, and a body
877/// that is not the array the API documents.
878fn linked_branches(
879    cli: &Path,
880    target: &Path,
881    encoded: &str,
882    iid: u64,
883    host: &[&str],
884) -> Result<Vec<String>, RkError> {
885    // A list endpoint answers one page of twenty by default, and this
886    // read is the authority on what the issue owns: a second page left
887    // unread would read as absence.
888    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        // A member the API documents as carrying a name and that does
903        // not is unknown, not absent. Skipping it would turn a partial
904        // answer into proof that the issue owns nothing.
905        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        // The forge's own search decides what it matched; the link rule
911        // decides what belongs to this issue.
912        if links_to(name, iid) {
913            names.push(name.to_owned());
914        }
915    }
916    Ok(names)
917}
918
919/// The `--hostname` pair a GitLab call carries, where a host is known.
920fn host_args(host: Option<&str>) -> Vec<&str> {
921    host.map_or_else(Vec::new, |host| vec!["--hostname", host])
922}
923
924/// One `glab api` argument list: the verb, the host where one is known,
925/// and the rest.
926fn 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
933/// An owned argument list as the borrowed one [`forge_call`] takes.
934fn borrowed(args: &[String]) -> Vec<&str> {
935    args.iter().map(String::as_str).collect()
936}
937
938/// One forge CLI call, in the shape [`crate::branches::merged_request_for`]
939/// already uses: the target's directory, and both pagers silenced.
940///
941/// A non-zero exit is returned rather than raised, because a caller reads
942/// a not-found as an answer.
943fn 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
961/// A successful call's body, parsed.
962fn 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
968/// A call that had to succeed, whose body nothing reads.
969fn 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
980/// A forge call that failed, leaving nothing behind.
981///
982/// The reason is read from the forge CLI's own answer rather than
983/// asserted. Only a failure the forge itself reports as transient is
984/// [`Reason::ForgeTemporary`], because that reason tells the operator a
985/// rerun can cure it — and a rerun cures neither a logged-out CLI nor a
986/// permission the account does not have.
987fn forge_failure(message: String) -> RkError {
988    forge_failure_from(message, "")
989}
990
991/// [`forge_failure`], classified from the forge CLI's stderr.
992fn 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
1007/// The reason a forge CLI's own answer carries, and what fixes it.
1008///
1009/// `gh` renders an HTTP status as `HTTP <code>` and `glab` as
1010/// `<code> <phrase>`, so both spellings are matched. A status nothing
1011/// recognizes stays [`Reason::SubprocessFailed`] rather than claiming a
1012/// retry will help.
1013fn 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
1059/// The last non-empty stderr line, for a one-line detail.
1060fn 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
1069/// Percent-encode everything outside the unreserved set, so a branch name
1070/// carrying `/` reaches the API as one path segment.
1071fn 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    /// The Rails documentation's own example, which exercises
1164    /// transliteration, the run replacement, the squeeze, and the trim in
1165    /// one string.
1166    #[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    /// GitLab leaves a placeholder it cannot resolve in the text, so rk
1203    /// does too. The grammar check one step later is where it fails.
1204    #[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        // Sorted, so the choice is a rule rather than the order the
1271        // forge answered in.
1272        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    /// A realistic template that renders a name the landed grammar
1288    /// refuses, because `feature` is not a Conventional Commit type. This
1289    /// is the case the verb stops on before anything is created.
1290    #[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}