Skip to main content

release_kit/commands/
issue.rs

1//! `rk issue start`: one command from an issue to the branch the forge
2//! named, seated the way the recorded mode says.
3//!
4//! Not a worktree verb, because the worktree verbs are mode-free by
5//! design and starting from an issue is not: branches mode has no
6//! worktree to add. The order of the run is what makes the refusals
7//! cheap — the local reads and the reference check come first, the
8//! forge-CLI gate next, and the network last — so every failure but a
9//! forge outage costs one local process and leaves the clone untouched.
10
11use camino::{Utf8Path, Utf8PathBuf};
12use serde::Serialize;
13
14use crate::cli::issue::{IssueAction, IssueArgs};
15use crate::detect::{self, Forge};
16use crate::diagnostic::{Diagnostic, Reason};
17use crate::error::RkError;
18use crate::issue::{self, Resolved};
19use crate::landing::manifest::{self, CheckoutMode};
20use crate::output::Output;
21use crate::probes;
22use crate::setup::context::resolve_cli;
23
24/// One `rk issue start` report.
25#[derive(Debug, Serialize)]
26struct StartReport {
27    /// The shape version of this JSON.
28    schema: &'static str,
29    /// `preview` or `apply`.
30    mode: &'static str,
31    /// The forge acted on.
32    forge: &'static str,
33    /// The project path.
34    repo: String,
35    /// The issue, as the forge numbers it.
36    issue: u64,
37    /// The issue's title.
38    title: String,
39    /// The branch, where one is known.
40    #[serde(skip_serializing_if = "Option::is_none")]
41    branch: Option<String>,
42    /// Where the name came from: `already`, `forge`, or `pending`.
43    origin: &'static str,
44    /// The recorded checkout mode this run seated by.
45    checkout_mode: &'static str,
46    /// The worktree path, under worktree mode.
47    #[serde(skip_serializing_if = "Option::is_none")]
48    path: Option<String>,
49    /// The branch checked out in place, under branches mode.
50    #[serde(skip_serializing_if = "Option::is_none")]
51    checkout: Option<String>,
52    /// Every other branch the forge links to this issue.
53    #[serde(skip_serializing_if = "Vec::is_empty")]
54    others: Vec<String>,
55    /// A state the operator must see rather than one rk decided quietly.
56    #[serde(skip_serializing_if = "Option::is_none")]
57    detail: Option<String>,
58    /// What to run next.
59    next: Vec<String>,
60}
61
62/// Dispatch the issue surface.
63///
64/// # Errors
65///
66/// Refuses a target that is not a repository, a reference that names
67/// another project, an undetected forge, a forge CLI below its floor, a
68/// GitLab template rendering a name the landed grammar refuses, and every
69/// seating refusal `rk worktree add` already carries.
70pub fn run(args: &IssueArgs) -> Result<(), RkError> {
71    match &args.action {
72        IssueAction::Start {
73            issue,
74            target,
75            forge,
76            repo,
77            workflow,
78            base,
79            apply,
80            json,
81        } => start(
82            target,
83            issue,
84            &Overrides {
85                forge: forge.as_deref(),
86                repo: repo.as_deref(),
87                workflow: workflow.as_deref(),
88                base: base.as_deref(),
89            },
90            *apply,
91            Output::new(*json),
92        ),
93    }
94}
95
96/// What the operator overrode, where detection or the record would
97/// otherwise decide.
98struct Overrides<'a> {
99    /// The forge, over the detected one.
100    forge: Option<&'a str>,
101    /// The project path, over the detected one.
102    repo: Option<&'a str>,
103    /// The workflow mode, over the recorded one.
104    workflow: Option<&'a str>,
105    /// The commit-ish a new branch starts from.
106    base: Option<&'a str>,
107}
108
109/// Everything the local reads settled, before the first forge call.
110struct Ground {
111    /// The forge to act on.
112    forge: Forge,
113    /// The project path.
114    repo: String,
115    /// The API host to name explicitly, where one has to be named.
116    ///
117    /// The reference's host alone, because an issue URL is a web address
118    /// and its host is the instance. A remote's host is a transport
119    /// host, which a self-managed instance may serve under a separate
120    /// name — `git@ssh.example.com` for `example.com` — so naming it
121    /// would send the API calls somewhere the forge CLI already resolves
122    /// correctly from the working directory.
123    api_host: Option<String>,
124    /// The mode the seat follows.
125    workflow: CheckoutMode,
126    /// Where the mode came from, for the report.
127    workflow_source: &'static str,
128}
129
130/// Refuse a coordinate that disagrees with one the clone already knows.
131///
132/// A coordinate the clone does not know is absent rather than
133/// contradicted, which is what leaves an override its real job.
134fn contradicts(what: &str, chosen: Option<&str>, known: Option<&str>) -> Result<(), RkError> {
135    let (Some(chosen), Some(known)) = (chosen, known) else {
136        return Ok(());
137    };
138    if chosen == known {
139        return Ok(());
140    }
141    Err(RkError::Usage(format!(
142        "the {what} to act on is {chosen} and this clone's is {known}; the branch would be minted on one project and seated in another"
143    )))
144}
145
146/// The mode the seat follows, and what decided it.
147///
148/// The mode is a landing parameter, changed through the landing verbs
149/// alone, so a runtime flag states it rather than sets it. Where it
150/// disagrees with the record, one clone would work in a mode the
151/// committed project policy does not carry.
152fn mode_of(
153    target: &Utf8Path,
154    named: Option<&str>,
155) -> Result<(CheckoutMode, &'static str), RkError> {
156    let recorded = manifest::load(target)?.map(|held| held.git.checkout_mode);
157    match (named, recorded) {
158        (Some(raw), Some(held)) => {
159            if CheckoutMode::parse(raw)? != held {
160                return Err(RkError::refusal(
161                    Diagnostic::new(
162                        Reason::StateDrift,
163                        format!(
164                            "--checkout-mode {raw} disagrees with the landing record, which states {}",
165                            held.as_str()
166                        ),
167                    )
168                    .expected("a flag that states the recorded mode, or no flag at all")
169                    .action("rk upgrade --checkout-mode <mode> --apply changes the recorded mode")
170                    .target_state("unchanged"),
171                ));
172            }
173            Ok((held, "the landing record, restated by --checkout-mode"))
174        }
175        (Some(raw), None) => Ok((CheckoutMode::parse(raw)?, "the --checkout-mode flag")),
176        (None, Some(held)) => Ok((held, "the landing record")),
177        // A target with no record is treated as the convention's own
178        // mode rather than as branches: the record's serde default exists
179        // for records written before the parameter, not for targets that
180        // never landed.
181        (None, None) => Ok((
182            CheckoutMode::LinkedWorktree,
183            "the default, with no landing record",
184        )),
185    }
186}
187
188/// Refuse a forge on a host this verb's calls would not reach.
189///
190/// Every GitHub call here goes to the CLI's default host. An enterprise
191/// remote reaches this verb only through `--forge`, and acting on the
192/// wrong host is worse than saying plainly that this verb carries one.
193fn reachable(forge: Forge, host: Option<&str>) -> Result<(), RkError> {
194    let Some(host) = host else { return Ok(()) };
195    if forge != Forge::Github || host.eq_ignore_ascii_case("github.com") {
196        return Ok(());
197    }
198    Err(RkError::refusal(
199        Diagnostic::new(
200            Reason::ForgeUnsupported,
201            format!("this clone's origin is {host}, and rk issue start reaches github.com alone"),
202        )
203        .expected("a github.com remote, or a GitLab project")
204        .action(
205            "start the branch with gh issue develop --repo <host>/<owner>/<name>, then rk worktree add it",
206        )
207        .target_state("unchanged"),
208    ))
209}
210
211/// Read the target, the reference, and the recorded mode. Nothing here
212/// touches the network, so every refusal below costs one local read.
213fn ground(
214    target: &Utf8Path,
215    reference: &issue::Reference,
216    overrides: &Overrides<'_>,
217) -> Result<Ground, RkError> {
218    if !target.is_dir() {
219        return Err(RkError::missing(
220            Diagnostic::new(
221                Reason::TargetNotFound,
222                format!("target {target} is not a directory"),
223            )
224            .expected("an existing repository to act on"),
225        ));
226    }
227    let named = overrides
228        .forge
229        .map(|name| {
230            Forge::parse(name).ok_or_else(|| {
231                RkError::Usage(format!(
232                    "unknown forge '{name}'; the forges are: github, gitlab"
233                ))
234            })
235        })
236        .transpose()?;
237    let detected = detect::detect(target.as_std_path());
238    // The reference is held to the clone before anything else: an agent
239    // pasting a URL while sitting in another checkout would otherwise
240    // mint on one project and seat in another.
241    issue::agrees(reference, &detected).map_err(RkError::Usage)?;
242    let Some(forge) = named.or(detected.forge) else {
243        let diagnostic = detected
244            .host
245            .as_ref()
246            .map_or_else(
247                || {
248                    Diagnostic::new(
249                        Reason::ForgeUndetected,
250                        "no forge detected: the target has no origin remote",
251                    )
252                },
253                |host| {
254                    Diagnostic::new(
255                        Reason::ForgeUndetected,
256                        format!("no forge detected: the host {host} is not recognized"),
257                    )
258                },
259            )
260            .expected("a github.com or gitlab remote, or an override")
261            .action("pass --forge <github|gitlab>, and --repo <path> if the remote is absent");
262        return Err(if detected.host.is_some() {
263            RkError::refusal(diagnostic)
264        } else {
265            RkError::missing(diagnostic)
266        });
267    };
268    // The reference names a host too, and it is authoritative where the
269    // clone has none: an issue URL for another host must not be acted on
270    // at the CLI's default one.
271    reachable(
272        forge,
273        detected.host.as_deref().or(reference.host.as_deref()),
274    )?;
275    // An override supplies a coordinate detection could not, and never
276    // replaces one it could: minting on the project the operator named
277    // and seating the branch in the clone they are standing in is the
278    // same cross-project mistake the reference check refuses.
279    contradicts(
280        "forge",
281        named.map(Forge::as_str),
282        detected.forge.map(Forge::as_str),
283    )?;
284    let Some(repo) = overrides
285        .repo
286        .map(str::to_owned)
287        .or_else(|| reference.repo.clone())
288        .or_else(|| detected.repo.clone())
289    else {
290        return Err(RkError::missing(
291            Diagnostic::new(
292                Reason::ForgeUndetected,
293                "no repository detected: the target has no origin remote",
294            )
295            .expected("an origin remote naming the project")
296            .action("pass --repo <owner/name>"),
297        ));
298    };
299    contradicts("repository", Some(repo.as_str()), detected.repo.as_deref())?;
300    contradicts("repository", Some(repo.as_str()), reference.repo.as_deref())?;
301    let (workflow, workflow_source) = mode_of(target, overrides.workflow)?;
302    Ok(Ground {
303        forge,
304        repo,
305        api_host: reference.host.clone(),
306        workflow,
307        workflow_source,
308    })
309}
310
311/// Mint the issue's branch at the forge and seat it.
312fn start(
313    target: &Utf8Path,
314    reference: &str,
315    overrides: &Overrides<'_>,
316    apply: bool,
317    out: Output,
318) -> Result<(), RkError> {
319    let reference = issue::parse_reference(reference).map_err(RkError::Usage)?;
320    let ground = ground(target, &reference, overrides)?;
321    // The target is a repository before anything reaches the network.
322    // Without this, a mint could succeed and the run then fail on a
323    // local prerequisite, leaving a remote branch no report accounts for.
324    let main = crate::commands::worktree::main_checkout(target)?;
325    // The gate before the first forge call, so a stale CLI costs one
326    // local process rather than a half-finished remote change.
327    probes::require_forge_cli(ground.forge)?;
328    let cli = resolve_cli(ground.forge)?;
329    // Where the forge lets rk know the name before it writes, every
330    // local refusal the seat carries runs first.
331    let seatable = |branch: &str| -> Result<(), RkError> {
332        match ground.workflow {
333            CheckoutMode::LinkedWorktree => {
334                crate::commands::worktree::plan_seat(target, branch, overrides.base, false)
335                    .map(|_| ())
336            }
337            CheckoutMode::MainWorktree => branch_seatable(&main, branch),
338        }
339    };
340    let resolved = issue::resolve(
341        &cli,
342        target.as_std_path(),
343        &issue::Ask {
344            forge: ground.forge,
345            repo: &ground.repo,
346            reference: &reference,
347            host: ground.api_host.as_deref(),
348            base: overrides.base,
349            apply,
350            seatable: &seatable,
351        },
352    )?;
353    match ground.workflow {
354        CheckoutMode::LinkedWorktree => {
355            seat_worktree(target, &ground, &resolved, overrides.base, apply, out)
356        }
357        CheckoutMode::MainWorktree => seat_branch(&main, &ground, &resolved, apply, out),
358    }
359}
360
361/// Worktree mode: the derived sibling path, through the same planning and
362/// the same refusals `rk worktree add` uses.
363fn seat_worktree(
364    target: &Utf8Path,
365    ground: &Ground,
366    resolved: &Resolved,
367    base: Option<&str>,
368    apply: bool,
369    out: Output,
370) -> Result<(), RkError> {
371    let Some(branch) = resolved.branch.as_deref() else {
372        return report(out, ground, resolved, None, None, apply);
373    };
374    let seat = crate::commands::worktree::plan_seat(target, branch, base, apply)?;
375    let mut note = None;
376    let path = match seat {
377        crate::commands::worktree::Seat::Satisfied { path } => path,
378        crate::commands::worktree::Seat::Fresh {
379            path,
380            source,
381            detail,
382        } => {
383            // An apply refreshes first, and `detail` is set only where
384            // that refresh failed. A remote-tracking ref left over from
385            // an older fetch is not the tip the forge holds now, so it
386            // is not something to seat from and call success.
387            if apply && let Some(why) = detail {
388                return Err(stale_refs(branch, resolved, &why));
389            }
390            // The forge holds this branch, so the seat comes from its
391            // real tip — an adopted local branch, or the remote-tracking
392            // ref. Anything else would build a same-named branch sharing
393            // none of the forge's history. A preview says so, because it
394            // does not fetch and the refs it reads may simply be stale;
395            // an apply fetched first, so there it is a refusal.
396            if !matches!(source.kind, "adopted" | "remote") {
397                if apply {
398                    return Err(unreachable_tip(branch, resolved));
399                }
400                note = Some(format!(
401                    "origin/{branch} is not in this clone yet; the apply fetches first, and refuses rather than seat a branch from the trunk"
402                ));
403            }
404            if apply {
405                crate::commands::worktree::create_seat(target, &source)?;
406            }
407            path
408        }
409    };
410    report_with(out, ground, resolved, Some(path), None, apply, note)
411}
412
413/// The branch the forge holds is not reachable locally, so no seat is
414/// made from something else that happens to share its name.
415fn unreachable_tip(branch: &str, resolved: &Resolved) -> RkError {
416    RkError::refusal(
417        Diagnostic::new(
418            Reason::StateDrift,
419            format!("the forge carries {branch} and this clone cannot reach its tip"),
420        )
421        .expected(format!(
422            "origin/{branch} present, or {branch} already local"
423        ))
424        .action("git fetch origin, then rerun")
425        .target_state(format!(
426            "unchanged; issue #{} keeps its branch at the forge",
427            resolved.number
428        )),
429    )
430}
431
432/// What the branches-mode checkout needs, checked before the forge is
433/// written to.
434///
435/// `git switch` refuses a branch another worktree has checked out, and it
436/// refuses to move a working tree whose changes it would lose. Both are
437/// knowable here, and a branch created at the forge and then refused
438/// locally is a remote change no report accounts for.
439///
440/// The second check is deliberately stricter than git, which permits a
441/// switch whose changes do not conflict. Whether they conflict is not
442/// knowable without doing the switch, and this mode seats a branch the
443/// operator is about to start work on: a clean checkout is what that
444/// asks for, and the remedy is one command.
445fn branch_seatable(main: &Utf8Path, branch: &str) -> Result<(), RkError> {
446    let trunk = crate::config::trunk_of(main.as_std_path())?;
447    if let Some(seat) = crate::commands::worktree::seat_of(main, branch)? {
448        if seat != main {
449            return Err(RkError::refusal(
450                Diagnostic::new(
451                    Reason::StateDrift,
452                    format!(
453                        "branch {branch} is checked out at {seat}, and one branch has one seat"
454                    ),
455                )
456                .expected("the branch free, or already in the main checkout")
457                .action(format!("git -C {seat} switch {trunk}, then rerun"))
458                .target_state("unchanged"),
459            ));
460        }
461        // The branch is already seated here, so nothing is checked out
462        // over anything: the switch is a no-op and carries no risk.
463        return Ok(());
464    }
465    let held = crate::commands::worktree::git(main, &["status", "--porcelain"])?;
466    // A probe that cannot answer counts as dirty: this runs before a
467    // remote write, so the closed direction is the safe one.
468    if !held.status.success() || !held.stdout.is_empty() {
469        return Err(RkError::refusal(
470            Diagnostic::new(
471                Reason::StateDrift,
472                format!("{main} carries uncommitted work, and this mode checks {branch} out there"),
473            )
474            .expected("a clean main checkout to seat the branch in")
475            .action("commit or stash the work, then rerun")
476            .target_state("unchanged"),
477        ));
478    }
479    Ok(())
480}
481
482/// The refresh failed, so no local ref is proof of what the forge holds.
483fn stale_refs(branch: &str, resolved: &Resolved, why: &str) -> RkError {
484    RkError::refusal(
485        Diagnostic::new(
486            Reason::StateDrift,
487            format!(
488                "this clone could not refresh from the forge, so its {branch} may be stale: {why}"
489            ),
490        )
491        .expected("a fetch that answered, so the seat starts from the tip the forge holds")
492        .action("git fetch origin, then rerun")
493        .target_state(format!(
494            "unchanged; issue #{} keeps its branch at the forge",
495            resolved.number
496        )),
497    )
498}
499
500/// Branches mode: the branch checked out in the main checkout.
501///
502/// The main checkout is where this mode works branches, so the switch
503/// runs there whichever of the repository's worktrees `--target` named.
504///
505/// Both forges create the branch on the remote, so an apply always sees
506/// the same case — a remote tip with no local branch — unless a previous
507/// run already made one.
508fn seat_branch(
509    main: &Utf8Path,
510    ground: &Ground,
511    resolved: &Resolved,
512    apply: bool,
513    out: Output,
514) -> Result<(), RkError> {
515    let Some(branch) = resolved.branch.as_deref() else {
516        return report(out, ground, resolved, None, None, apply);
517    };
518    if !apply {
519        return report(out, ground, resolved, None, Some(branch.to_owned()), false);
520    }
521    // Through the shared runner, which scrubs the hook variables: a run
522    // from inside a git hook must act on the named checkout and never on
523    // the hook's own repository.
524    let git = |args: &[&str]| crate::commands::worktree::git(main, args);
525    let fetched = git(&["fetch", "origin"])?;
526    if !fetched.status.success() {
527        return Err(stale_refs(branch, resolved, &last_line(&fetched.stderr)));
528    }
529    let local = git(&[
530        "rev-parse",
531        "--verify",
532        "--quiet",
533        "--end-of-options",
534        &format!("refs/heads/{branch}^{{commit}}"),
535    ])?;
536    let switched = if local.status.success() {
537        git(&["switch", branch])?
538    } else {
539        git(&[
540            "switch",
541            "--track",
542            "-c",
543            branch,
544            &format!("refs/remotes/origin/{branch}"),
545        ])?
546    };
547    if !switched.status.success() {
548        // git refuses a dirty switch itself, so its own last line is the
549        // honest reason rather than one rk invents.
550        return Err(RkError::subprocess(
551            Diagnostic::new(
552                Reason::SubprocessFailed,
553                format!(
554                    "git refused to check out {branch}: {}",
555                    last_line(&switched.stderr)
556                ),
557            )
558            .expected("a working tree the checkout can move")
559            .target_state("the branch exists on the forge and is not checked out here"),
560        ));
561    }
562    report(out, ground, resolved, None, Some(branch.to_owned()), true)
563}
564
565/// One report, in either mode.
566fn report(
567    out: Output,
568    ground: &Ground,
569    resolved: &Resolved,
570    path: Option<Utf8PathBuf>,
571    checkout: Option<String>,
572    apply: bool,
573) -> Result<(), RkError> {
574    report_with(out, ground, resolved, path, checkout, apply, None)
575}
576
577/// [`report`], carrying a note the seating step raised.
578fn report_with(
579    out: Output,
580    ground: &Ground,
581    resolved: &Resolved,
582    path: Option<Utf8PathBuf>,
583    checkout: Option<String>,
584    apply: bool,
585    note: Option<String>,
586) -> Result<(), RkError> {
587    let mode = if apply { "apply" } else { "preview" };
588    out.result_line(format!("issue:  #{} {}", resolved.number, resolved.title));
589    out.result_line(format!(
590        "branch: {}  ({})",
591        resolved.branch.as_deref().unwrap_or("named by the forge"),
592        match resolved.origin {
593            "already" => "already linked at the forge",
594            "forge" => "minted at the forge",
595            _ => "not minted yet",
596        }
597    ));
598    out.result_line(format!(
599        "seat:   {} ({} says so)",
600        path.as_ref().map_or_else(
601            || checkout.as_deref().map_or_else(
602                || "unknown".to_owned(),
603                |branch| format!("checkout {branch}")
604            ),
605            ToString::to_string
606        ),
607        ground.workflow_source
608    ));
609    if !resolved.others.is_empty() {
610        out.warn(format!(
611            "the issue carries other linked branches, and the first was taken: {}",
612            resolved.others.join(", ")
613        ));
614    }
615    let detail = match (resolved.detail.clone(), note) {
616        (Some(had), Some(note)) => Some(format!("{had}; {note}")),
617        (Some(one), None) | (None, Some(one)) => Some(one),
618        (None, None) => None,
619    };
620    if let Some(detail) = &detail {
621        out.warn(detail);
622    }
623    let next = next_lines(ground, resolved, path.as_ref(), apply);
624    out.next(&next);
625    out.emit(&StartReport {
626        schema: "rk.issue-start/2",
627        mode,
628        forge: ground.forge.as_str(),
629        repo: ground.repo.clone(),
630        issue: resolved.number,
631        title: resolved.title.clone(),
632        branch: resolved.branch.clone(),
633        origin: resolved.origin,
634        checkout_mode: ground.workflow.as_str(),
635        path: path.map(|path| path.to_string()),
636        checkout,
637        others: resolved.others.clone(),
638        detail,
639        next,
640    })
641}
642
643/// What to run next, which differs by mode and by whether this ran.
644fn next_lines(
645    ground: &Ground,
646    resolved: &Resolved,
647    path: Option<&Utf8PathBuf>,
648    apply: bool,
649) -> Vec<String> {
650    if !apply {
651        return vec![format!(
652            "rk issue start {} --apply mints the branch and seats it",
653            resolved.number
654        )];
655    }
656    match (ground.workflow, path) {
657        (CheckoutMode::LinkedWorktree, Some(path)) => vec![
658            format!("cd {path}"),
659            "rk worktree list reports every seat".to_owned(),
660        ],
661        _ => vec!["rk status reports what this target carries".to_owned()],
662    }
663}
664
665/// The last non-empty stderr line, for a one-line reason.
666fn last_line(bytes: &[u8]) -> String {
667    String::from_utf8_lossy(bytes)
668        .lines()
669        .rev()
670        .find(|line| !line.trim().is_empty())
671        .unwrap_or("no output")
672        .to_owned()
673}