Skip to main content

gitee_cli_rs/cmd/
pr.rs

1use std::io::Write;
2
3use super::{confirm, join_flags, resolve_milestone_opt, Ctx};
4use crate::api::pulls::{CreatePr, EditPr, PrCommentFilter, PrFilter};
5use crate::cli::PrCmd;
6use crate::error::{GiteeError, Result};
7use crate::models::{MergeMethod, PrCommentKind, PrState};
8use crate::out;
9
10pub fn execute(ctx: &Ctx, cmd: PrCmd) -> Result<()> {
11    match cmd {
12        PrCmd::List { list, author } => {
13            let repo = ctx.repo()?;
14            let filter = PrFilter {
15                state: list.state.as_deref(),
16                author: author.as_deref(),
17                limit: list.limit,
18                ..Default::default()
19            };
20            let items = ctx.client.pulls(repo).list(&filter)?;
21            let mut out = std::io::stdout().lock();
22            ctx.out
23                .render(&mut out, &items, |w| out::pr_table(w, &items))?;
24        }
25        PrCmd::Status { limit } => {
26            let repo = ctx.repo()?;
27            let me = ctx.me()?;
28            let login = me.login.as_str();
29            let open = Some("open");
30            let created = ctx.client.pulls(repo).list(&PrFilter {
31                state: open,
32                author: Some(login),
33                limit: limit.limit,
34                ..Default::default()
35            })?;
36            let assigned = ctx.client.pulls(repo).list(&PrFilter {
37                state: open,
38                assignee: Some(login),
39                limit: limit.limit,
40                ..Default::default()
41            })?;
42            let awaiting_test = ctx.client.pulls(repo).list(&PrFilter {
43                state: open,
44                tester: Some(login),
45                limit: limit.limit,
46                ..Default::default()
47            })?;
48            let status = out::PrStatus {
49                created,
50                assigned,
51                awaiting_test,
52            };
53            let mut out = std::io::stdout().lock();
54            ctx.out
55                .render(&mut out, &status, |w| out::pr_status(w, &status))?;
56        }
57        PrCmd::View { number, web, merged } => {
58            let repo = ctx.repo()?;
59            if web {
60                let url = crate::web::pull_url(&ctx.host, repo, number);
61                return crate::web::open_or_print(&url);
62            }
63            if merged {
64                if ctx.client.pulls(repo).is_merged(number)? {
65                    return Ok(());
66                }
67                std::process::exit(1);
68            }
69            let mut pr = ctx.client.pulls(repo).get(number)?;
70            let files = ctx.client.pulls(repo).files(number)?;
71            pr.files = Some(files);
72            pr.merged = ctx.client.pulls(repo).is_merged(number).ok();
73            let mut out = std::io::stdout().lock();
74            ctx.out.render(&mut out, &pr, |w| out::one_pr(w, &pr))?;
75        }
76        PrCmd::Diff { number } => {
77            let repo = ctx.repo()?;
78            let files = ctx.client.pulls(repo).files(number)?;
79            let mut out = std::io::stdout().lock();
80            ctx.out
81                .render(&mut out, &files, |w| out::pr_diff(w, &files))?;
82        }
83        PrCmd::Commits { number, limit } => {
84            let repo = ctx.repo()?;
85            let items = ctx.client.pulls(repo).commits(number, limit.limit)?;
86            let mut out = std::io::stdout().lock();
87            ctx.out
88                .render(&mut out, &items, |w| out::pr_commits(w, &items))?;
89        }
90        PrCmd::Checkout { number } => checkout_pr(ctx, number)?,
91        PrCmd::Edit {
92            number,
93            title,
94            body,
95            assignee,
96            tester,
97            label,
98            milestone,
99        } => {
100            let repo = ctx.repo()?;
101            let milestone_number = resolve_milestone_opt(ctx, repo, milestone.as_deref())?;
102            let labels = join_flags(&label);
103            let assignees = join_flags(&assignee);
104            let testers = join_flags(&tester);
105            let req = EditPr {
106                title: title.as_deref(),
107                body: body.as_deref(),
108                labels: labels.as_deref(),
109                assignees: assignees.as_deref(),
110                testers: testers.as_deref(),
111                milestone_number,
112            };
113            let pr = ctx.client.pulls(repo).edit(number, &req)?;
114            let mut out = std::io::stdout().lock();
115            ctx.out.render(&mut out, &pr, |w| out::one_pr(w, &pr))?;
116        }
117        PrCmd::Create {
118            title,
119            body,
120            head,
121            base,
122            fill,
123            assignee,
124            tester,
125            label,
126            milestone,
127            close_issue,
128            draft,
129        } => {
130            // `--preview` short-circuits before any git/API work: print intent and exit 0.
131            if ctx.preview {
132                let repo = ctx.repo()?;
133                let head = head.clone().unwrap_or_else(|| "<current-branch>".into());
134                let base = base.clone().unwrap_or_else(|| repo.name.clone() + " default branch");
135                let title = title.clone().unwrap_or_default();
136                let body = body.clone().unwrap_or_default();
137                let repo_str = format!("{}/{}", repo.owner, repo.name);
138                let mut details: Vec<(&str, &str)> = vec![
139                    ("repo", &repo_str),
140                    ("title", &title),
141                    ("head", &head),
142                    ("base", &base),
143                ];
144                if !body.is_empty() {
145                    details.push(("body", &body));
146                }
147                if fill {
148                    details.push(("fill", "true"));
149                }
150                if draft {
151                    details.push(("draft", "true"));
152                }
153                println!("{}", super::preview_line("create pull request", &details));
154                return Ok(());
155            }
156            if super::interactive::should_run_interactive_create(title.as_deref(), fill)
157                && !super::interactive::stdin_is_tty()
158            {
159                return Err(super::interactive::missing_title_usage("pr create", true));
160            }
161            let repo = ctx.repo()?;
162            let head = match head {
163                Some(h) => h,
164                None => current_branch()?,
165            };
166            let base = match base {
167                Some(b) => b,
168                None => {
169                    let o = repo.owner.as_str();
170                    let r = repo.name.as_str();
171                    ctx.client
172                        .repos()
173                        .get(o, r)?
174                        .default_branch
175                        .unwrap_or_else(|| "master".to_string())
176                }
177            };
178            let mut title = title;
179            let mut body = body;
180            if fill {
181                let subjects = git_log_subjects(&base, &head)?;
182                let (ft, fb) = fill_from_subjects(&subjects)?;
183                if title.is_none() {
184                    title = Some(ft);
185                }
186                if body.is_none() {
187                    body = Some(fb);
188                }
189            }
190            if super::interactive::should_run_interactive_create(title.as_deref(), fill) {
191                if !super::interactive::stdin_is_tty() {
192                    return Err(super::interactive::missing_title_usage("pr create", true));
193                }
194                title = Some(super::interactive::prompt_title(None)?);
195                if body.is_none() {
196                    let initial =
197                        fetch_pr_template(ctx, repo, &base)?.unwrap_or_default();
198                    let editor = super::interactive::resolve_editor_from_env_and_config()?;
199                    body = super::interactive::edit_body_in_editor(&initial, &editor)?;
200                }
201            }
202            let title = title.ok_or_else(|| {
203                super::interactive::missing_title_usage("pr create", true)
204            })?;
205            if body.is_none() {
206                body = fetch_pr_template(ctx, repo, &base)?;
207            }
208            let milestone_number = resolve_milestone_opt(ctx, repo, milestone.as_deref())?;
209            let labels = join_flags(&label);
210            let assignees = join_flags(&assignee);
211            let testers = join_flags(&tester);
212            let req = CreatePr {
213                title: &title,
214                head: &head,
215                base: &base,
216                body: body.as_deref(),
217                labels: labels.as_deref(),
218                assignees: assignees.as_deref(),
219                testers: testers.as_deref(),
220                milestone_number,
221                issue: close_issue.as_deref(),
222                close_related_issue: close_issue.is_some(),
223                draft,
224            };
225            let pr = ctx.client.pulls(repo).create(&req)?;
226            let mut out = std::io::stdout().lock();
227            ctx.out.render(&mut out, &pr, |w| out::one_pr(w, &pr))?;
228        }
229        PrCmd::Ready { number, undo } => {
230            let repo = ctx.repo()?;
231            let target_draft = undo;
232            let action = if undo {
233                format!("convert pull request !{number} to draft")
234            } else {
235                format!("mark pull request !{number} as ready")
236            };
237            if ctx.preview {
238                println!(
239                    "{}",
240                    super::preview_line(
241                        &action,
242                        &[("repo", &format!("{}/{}", repo.owner, repo.name))],
243                    )
244                );
245                return Ok(());
246            }
247            let change = ctx
248                .client
249                .pulls(repo)
250                .set_draft_idempotent(number, target_draft)?;
251            let state_word = if undo { "draft" } else { "ready" };
252            render_idempotent_pr(ctx, change, state_word, number)?;
253        }
254        PrCmd::Merge {
255            number,
256            squash,
257            rebase,
258            no_close_issue,
259        } => {
260            let repo = ctx.repo()?;
261            let method = if rebase {
262                MergeMethod::Rebase
263            } else if squash {
264                MergeMethod::Squash
265            } else {
266                MergeMethod::Merge
267            };
268            if ctx.preview {
269                println!("{}", super::preview_line(
270                    &format!("merge pull request !{number} ({})", method.as_str()),
271                    &[
272                        ("repo", &format!("{}/{}", repo.owner, repo.name)),
273                        ("close_related_issue", if no_close_issue { "false" } else { "true" }),
274                    ],
275                ));
276                return Ok(());
277            }
278            let change = ctx
279                .client
280                .pulls(repo)
281                .merge_idempotent(number, method, !no_close_issue)?;
282            let mut out = std::io::stdout().lock();
283            match change {
284                crate::api::StateChange::Changed(()) => {
285                    writeln!(out, "Merged pull request !{number}")?;
286                }
287                crate::api::StateChange::Already(()) => {
288                    writeln!(out, "Pull request !{number} already merged")?;
289                }
290            }
291        }
292        PrCmd::Comment(crate::cli::PrCommentCmd::Create {
293            number,
294            body,
295            path,
296            position,
297            commit_id,
298        }) => {
299            let repo = ctx.repo()?;
300            if ctx.preview {
301                let repo_str = format!("{}/{}", repo.owner, repo.name);
302                let pos_str = position.map(|p| p.to_string());
303                let mut details: Vec<(&str, &str)> =
304                    vec![("repo", &repo_str), ("body", &body.body)];
305                if let Some(ref p) = path {
306                    details.push(("path", p.as_str()));
307                }
308                if let Some(ref p) = pos_str {
309                    details.push(("position", p.as_str()));
310                }
311                if let Some(ref c) = commit_id {
312                    details.push(("commit_id", c.as_str()));
313                }
314                println!(
315                    "{}",
316                    super::preview_line(
317                        &format!("create comment on pull request !{number}"),
318                        &details,
319                    )
320                );
321                return Ok(());
322            }
323            let positional = crate::api::pulls::PrCommentPositional {
324                path: path.as_deref(),
325                position,
326                commit_id: commit_id.as_deref(),
327            };
328            let c = ctx
329                .client
330                .pulls(repo)
331                .comment(number, &body.body, &positional)?;
332            let mut out = std::io::stdout().lock();
333            ctx.out
334                .render(&mut out, &c, |w| out::pr_comment_line(w, &c))?;
335        }
336        PrCmd::Comment(crate::cli::PrCommentCmd::List {
337            number,
338            comment_type,
339            limit,
340        }) => {
341            let repo = ctx.repo()?;
342            let kind = match comment_type.as_deref() {
343                Some(s) => Some(PrCommentKind::from_cli(s).ok_or_else(|| {
344                    GiteeError::Usage(format!(
345                        "unsupported comment type '{s}' (want diff|general)"
346                    ))
347                })?),
348                None => None,
349            };
350            let items = ctx.client.pulls(repo).list_comments(
351                number,
352                &PrCommentFilter {
353                    kind,
354                    limit: limit.limit,
355                },
356            )?;
357            let mut out = std::io::stdout().lock();
358            ctx.out
359                .render(&mut out, &items, |w| out::pr_comment_table(w, &items))?;
360        }
361        PrCmd::Comment(crate::cli::PrCommentCmd::Edit {
362            target,
363            last,
364            body,
365        }) => {
366            let repo = ctx.repo()?;
367            if ctx.preview {
368                let action = if last {
369                    format!("edit latest comment on pull request {target}")
370                } else {
371                    format!("edit comment {target}")
372                };
373                println!(
374                    "{}",
375                    super::preview_line(
376                        &action,
377                        &[("repo", &format!("{}/{}", repo.owner, repo.name))],
378                    )
379                );
380                return Ok(());
381            }
382            let ops = ctx.client.pulls(repo);
383            let c = if last {
384                let me = ctx.me()?;
385                match body {
386                    Some(b) => ops.update_latest_comment(target, &me.login, &b)?,
387                    None => {
388                        // Non-TTY without -m: usage error before listing.
389                        if !super::interactive::stdin_is_tty() {
390                            return Err(GiteeError::Usage(
391                                "pr comment edit needs --body/-m".into(),
392                            ));
393                        }
394                        let existing = ops.latest_comment(target, &me.login)?;
395                        let body_text = super::interactive::resolve_optional_body(
396                            None,
397                            &existing.body,
398                            "pr comment edit",
399                        )?;
400                        ops.update_comment(existing.id, &body_text)?
401                    }
402                }
403            } else {
404                let body_text = super::interactive::resolve_optional_body(
405                    body,
406                    "",
407                    "pr comment edit",
408                )?;
409                ops.update_comment(target, &body_text)?
410            };
411            let mut out = std::io::stdout().lock();
412            ctx.out
413                .render(&mut out, &c, |w| out::pr_comment_line(w, &c))?;
414        }
415        PrCmd::Comment(crate::cli::PrCommentCmd::Delete {
416            target,
417            last,
418            yes,
419        }) => {
420            let repo = ctx.repo()?;
421            if ctx.preview {
422                let action = if last {
423                    format!("delete latest comment on pull request {target}")
424                } else {
425                    format!("delete comment {target}")
426                };
427                println!(
428                    "{}",
429                    super::preview_line(
430                        &action,
431                        &[("repo", &format!("{}/{}", repo.owner, repo.name))],
432                    )
433                );
434                return Ok(());
435            }
436            let confirm_msg = if last {
437                format!("Delete latest comment on pull request {target}")
438            } else {
439                format!("Delete comment {target}")
440            };
441            confirm(&confirm_msg, yes)?;
442            let ops = ctx.client.pulls(repo);
443            let change = if last {
444                let me = ctx.me()?;
445                ops.delete_latest_comment(target, &me.login)?
446            } else {
447                ops.delete_comment(target)?
448            };
449            // 404 / already-gone is silent (idempotent); only announce real deletes.
450            if change.was_changed() {
451                let mut out = std::io::stdout().lock();
452                if last {
453                    writeln!(out, "Deleted latest comment on pull request {target}")?;
454                } else {
455                    writeln!(out, "Deleted comment {target}")?;
456                }
457            }
458        }
459        PrCmd::Label(crate::cli::PrLabelCmd::List { number }) => {
460            let repo = ctx.repo()?;
461            let items = ctx.client.pulls(repo).list_labels(number)?;
462            let mut out = std::io::stdout().lock();
463            ctx.out
464                .render(&mut out, &items, |w| out::label_table(w, &items))?;
465        }
466        PrCmd::Label(crate::cli::PrLabelCmd::Add { number, labels }) => {
467            let repo = ctx.repo()?;
468            let joined = labels.join(", ");
469            if ctx.preview {
470                println!(
471                    "{}",
472                    super::preview_line(
473                        &format!("add labels on pull request !{number}"),
474                        &[
475                            ("repo", &format!("{}/{}", repo.owner, repo.name)),
476                            ("labels", &joined),
477                        ],
478                    )
479                );
480                return Ok(());
481            }
482            let refs: Vec<&str> = labels.iter().map(String::as_str).collect();
483            let change = ctx
484                .client
485                .pulls(repo)
486                .add_labels_idempotent(number, &refs)?;
487            if let crate::api::StateChange::Changed(items) = change {
488                let mut out = std::io::stdout().lock();
489                ctx.out
490                    .render(&mut out, &items, |w| out::label_table(w, &items))?;
491            }
492        }
493        PrCmd::Label(crate::cli::PrLabelCmd::Remove { number, labels }) => {
494            let repo = ctx.repo()?;
495            let joined = labels.join(", ");
496            if ctx.preview {
497                println!(
498                    "{}",
499                    super::preview_line(
500                        &format!("remove labels on pull request !{number}"),
501                        &[
502                            ("repo", &format!("{}/{}", repo.owner, repo.name)),
503                            ("labels", &joined),
504                        ],
505                    )
506                );
507                return Ok(());
508            }
509            let refs: Vec<&str> = labels.iter().map(String::as_str).collect();
510            let change = ctx
511                .client
512                .pulls(repo)
513                .remove_labels_idempotent(number, &refs)?;
514            if change.was_changed() {
515                let mut out = std::io::stdout().lock();
516                writeln!(out, "Removed labels [{joined}] from pull request !{number}")?;
517            }
518        }
519        PrCmd::Assignee(crate::cli::PrAssigneeCmd::List { number }) => {
520            let repo = ctx.repo()?;
521            let items = ctx.client.pulls(repo).list_assignees(number)?;
522            let mut out = std::io::stdout().lock();
523            ctx.out
524                .render(&mut out, &items, |w| out::assignee_table(w, &items))?;
525        }
526        PrCmd::Assignee(crate::cli::PrAssigneeCmd::Add { number, users }) => {
527            let repo = ctx.repo()?;
528            let joined = users.join(", ");
529            if ctx.preview {
530                println!(
531                    "{}",
532                    super::preview_line(
533                        &format!("add assignees on pull request !{number}"),
534                        &[
535                            ("repo", &format!("{}/{}", repo.owner, repo.name)),
536                            ("assignees", &joined),
537                        ],
538                    )
539                );
540                return Ok(());
541            }
542            let refs: Vec<&str> = users.iter().map(String::as_str).collect();
543            let change = ctx
544                .client
545                .pulls(repo)
546                .add_assignees_idempotent(number, &refs)?;
547            if let crate::api::StateChange::Changed(pr) = change {
548                let items = pr.assignees.unwrap_or_default();
549                let mut out = std::io::stdout().lock();
550                ctx.out
551                    .render(&mut out, &items, |w| out::assignee_table(w, &items))?;
552            }
553        }
554        PrCmd::Assignee(crate::cli::PrAssigneeCmd::Remove { number, users }) => {
555            let repo = ctx.repo()?;
556            let joined = users.join(", ");
557            if ctx.preview {
558                println!(
559                    "{}",
560                    super::preview_line(
561                        &format!("remove assignees on pull request !{number}"),
562                        &[
563                            ("repo", &format!("{}/{}", repo.owner, repo.name)),
564                            ("assignees", &joined),
565                        ],
566                    )
567                );
568                return Ok(());
569            }
570            let refs: Vec<&str> = users.iter().map(String::as_str).collect();
571            let change = ctx
572                .client
573                .pulls(repo)
574                .remove_assignees_idempotent(number, &refs)?;
575            if change.was_changed() {
576                let mut out = std::io::stdout().lock();
577                writeln!(
578                    out,
579                    "Removed assignees [{joined}] from pull request !{number}"
580                )?;
581            }
582        }
583        PrCmd::Tester(crate::cli::PrTesterCmd::List { number }) => {
584            let repo = ctx.repo()?;
585            let items = ctx.client.pulls(repo).list_testers(number)?;
586            let mut out = std::io::stdout().lock();
587            ctx.out
588                .render(&mut out, &items, |w| out::tester_table(w, &items))?;
589        }
590        PrCmd::Tester(crate::cli::PrTesterCmd::Add { number, users }) => {
591            let repo = ctx.repo()?;
592            let joined = users.join(", ");
593            if ctx.preview {
594                println!(
595                    "{}",
596                    super::preview_line(
597                        &format!("add testers on pull request !{number}"),
598                        &[
599                            ("repo", &format!("{}/{}", repo.owner, repo.name)),
600                            ("testers", &joined),
601                        ],
602                    )
603                );
604                return Ok(());
605            }
606            let refs: Vec<&str> = users.iter().map(String::as_str).collect();
607            let change = ctx
608                .client
609                .pulls(repo)
610                .add_testers_idempotent(number, &refs)?;
611            if let crate::api::StateChange::Changed(pr) = change {
612                let items = pr.testers.unwrap_or_default();
613                let mut out = std::io::stdout().lock();
614                ctx.out
615                    .render(&mut out, &items, |w| out::tester_table(w, &items))?;
616            }
617        }
618        PrCmd::Tester(crate::cli::PrTesterCmd::Remove { number, users }) => {
619            let repo = ctx.repo()?;
620            let joined = users.join(", ");
621            if ctx.preview {
622                println!(
623                    "{}",
624                    super::preview_line(
625                        &format!("remove testers on pull request !{number}"),
626                        &[
627                            ("repo", &format!("{}/{}", repo.owner, repo.name)),
628                            ("testers", &joined),
629                        ],
630                    )
631                );
632                return Ok(());
633            }
634            let refs: Vec<&str> = users.iter().map(String::as_str).collect();
635            let change = ctx
636                .client
637                .pulls(repo)
638                .remove_testers_idempotent(number, &refs)?;
639            if change.was_changed() {
640                let mut out = std::io::stdout().lock();
641                writeln!(
642                    out,
643                    "Removed testers [{joined}] from pull request !{number}"
644                )?;
645            }
646        }
647        PrCmd::Approve { number, force } => {
648            let repo = ctx.repo()?;
649            ctx.client.pulls(repo).approve(number, force)?;
650            let mut out = std::io::stdout().lock();
651            writeln!(out, "Approved pull request !{number}")?;
652        }
653        PrCmd::Test { number, force } => {
654            let repo = ctx.repo()?;
655            ctx.client.pulls(repo).test(number, force)?;
656            let confirm = serde_json::json!({
657                "number": number,
658                "tested": true,
659                "force": force,
660            });
661            let mut out = std::io::stdout().lock();
662            ctx.out.render(&mut out, &confirm, |w| {
663                writeln!(w, "Marked pull request !{number} as tested")
664            })?;
665        }
666        PrCmd::Close { number } => {
667            let repo = ctx.repo()?;
668            if ctx.preview {
669                println!("{}", super::preview_line(
670                    &format!("close pull request !{number}"),
671                    &[("repo", &format!("{}/{}", repo.owner, repo.name))],
672                ));
673                return Ok(());
674            }
675            let change = ctx
676                .client
677                .pulls(repo)
678                .set_state_idempotent(number, PrState::Closed)?;
679            render_idempotent_pr(ctx, change, "closed", number)?;
680        }
681        PrCmd::Reopen { number } => {
682            let repo = ctx.repo()?;
683            if ctx.preview {
684                println!("{}", super::preview_line(
685                    &format!("reopen pull request !{number}"),
686                    &[("repo", &format!("{}/{}", repo.owner, repo.name))],
687                ));
688                return Ok(());
689            }
690            let change = ctx
691                .client
692                .pulls(repo)
693                .set_state_idempotent(number, PrState::Open)?;
694            render_idempotent_pr(ctx, change, "open", number)?;
695        }
696        PrCmd::Link { number, issue } => {
697            let repo = ctx.repo()?;
698            let tag = format!("#{issue}");
699            let linked = ctx.client.pulls(repo).link(number, &tag)?;
700            let mut out = std::io::stdout().lock();
701            if linked {
702                writeln!(out, "Linked issue {tag} on pull request !{number}")?;
703            } else {
704                writeln!(out, "Pull request !{number} already references {tag}")?;
705            }
706        }
707    }
708    Ok(())
709}
710
711fn checkout_pr(ctx: &Ctx, number: i64) -> Result<()> {
712    let repo = ctx.repo()?;
713    let pr = ctx.client.pulls(repo).get(number)?;
714    let head_ref = pr.head.git_ref.trim();
715    if head_ref.is_empty() {
716        return Err(GiteeError::Usage(format!(
717            "pull request !{number} has no head ref"
718        )));
719    }
720
721    let o = repo.owner.as_str();
722    let r = repo.name.as_str();
723    let base = ctx.client.repos().get(o, r)?;
724    let fetch_url = pr
725        .head
726        .repo
727        .as_ref()
728        .and_then(|hr| hr.fetch_url())
729        .unwrap_or_else(|| base.preferred_url(true));
730    let branch = format!("pr-{number}");
731    let refspec = format!("+{head_ref}:{branch}");
732
733    let fetch = std::process::Command::new("git")
734        .args(["fetch", &fetch_url, &refspec])
735        .output()
736        .map_err(|e| GiteeError::Usage(format!("git fetch: {e}")))?;
737    if !fetch.status.success() {
738        let stderr = String::from_utf8_lossy(&fetch.stderr);
739        return Err(GiteeError::Usage(format!(
740            "git fetch failed: {}",
741            stderr.trim()
742        )));
743    }
744
745    let checkout = std::process::Command::new("git")
746        .args(["checkout", &branch])
747        .output()
748        .map_err(|e| GiteeError::Usage(format!("git checkout: {e}")))?;
749    if !checkout.status.success() {
750        let stderr = String::from_utf8_lossy(&checkout.stderr);
751        return Err(GiteeError::Usage(format!(
752            "git checkout failed: {}",
753            stderr.trim()
754        )));
755    }
756
757    let mut out = std::io::stdout().lock();
758    writeln!(
759        out,
760        "Checked out branch '{branch}' for pull request !{number}"
761    )?;
762    writeln!(
763        out,
764        "Hint: run `git log --oneline {}..{}` to see PR commits",
765        pr.base.git_ref, branch
766    )?;
767    Ok(())
768}
769
770/// Commit subjects in `base..head`, oldest first, for --fill.
771fn git_log_subjects(base: &str, head: &str) -> Result<Vec<String>> {
772    let range = format!("{base}..{head}");
773    let out = std::process::Command::new("git")
774        .args(["log", "--reverse", "--format=%s", &range])
775        .output()
776        .map_err(|e| GiteeError::Usage(format!("git: {e}")))?;
777    if !out.status.success() {
778        let stderr = String::from_utf8_lossy(&out.stderr);
779        return Err(GiteeError::Usage(format!(
780            "git log {range} failed: {}",
781            stderr.trim()
782        )));
783    }
784    Ok(String::from_utf8_lossy(&out.stdout)
785        .lines()
786        .filter(|l| !l.is_empty())
787        .map(str::to_string)
788        .collect())
789}
790
791/// gh --fill semantics: title = first (oldest) commit subject, body = the
792/// commit list as markdown bullets.
793fn fill_from_subjects(subjects: &[String]) -> Result<(String, String)> {
794    let first = subjects.first().ok_or_else(|| {
795        GiteeError::Usage("no commits in base..head range; nothing to --fill from".into())
796    })?;
797    let body = subjects
798        .iter()
799        .map(|s| format!("- {s}"))
800        .collect::<Vec<_>>()
801        .join("\n");
802    Ok((first.clone(), body))
803}
804
805/// Template prefill, tried in order on the base ref; missing template = no body.
806fn fetch_pr_template(ctx: &Ctx, repo: &crate::repo::Repo, base: &str) -> Result<Option<String>> {
807    for path in [
808        ".gitee/PULL_REQUEST_TEMPLATE.md",
809        "PULL_REQUEST_TEMPLATE.md",
810    ] {
811        if let Some(c) = ctx
812            .client
813            .repos()
814            .file_contents(&repo.owner, &repo.name, path, base)?
815        {
816            return Ok(Some(c));
817        }
818    }
819    Ok(None)
820}
821
822fn current_branch() -> Result<String> {
823    let out = std::process::Command::new("git")
824        .args(["rev-parse", "--abbrev-ref", "HEAD"])
825        .output()
826        .map_err(|e| GiteeError::Usage(format!("git: {e}")))?;
827    if !out.status.success() {
828        return Err(GiteeError::Usage(
829            "could not determine current branch (pass --head)".into(),
830        ));
831    }
832    let b = String::from_utf8_lossy(&out.stdout).trim().to_string();
833    if b.is_empty() || b == "HEAD" {
834        return Err(GiteeError::Usage(
835            "detached HEAD; pass --head <branch>".into(),
836        ));
837    }
838    Ok(b)
839}
840
841/// Render an idempotent PR result (close / reopen / ready / ready --undo).
842/// On `Already`, print a human "already <word>" line (or a structured `--json`
843/// envelope with `state`, `draft`, and `message`). On `Changed`, render the
844/// updated PR through the normal renderer.
845fn render_idempotent_pr(
846    ctx: &Ctx,
847    change: crate::api::StateChange<crate::models::PullRequest>,
848    state_word: &str,
849    number: i64,
850) -> Result<()> {
851    use crate::api::StateChange;
852    let mut out = std::io::stdout().lock();
853    match change {
854        StateChange::Changed(pr) => {
855            ctx.out.render(&mut out, &pr, |w| out::one_pr(w, &pr))?;
856        }
857        StateChange::Already(pr) => {
858            if ctx.out.json.is_some() {
859                let envelope = serde_json::json!({
860                    "number": pr.number,
861                    "state": pr.state.as_str(),
862                    "draft": pr.draft.unwrap_or(false),
863                    "message": format!("already {state_word}"),
864                });
865                writeln!(out, "{}", serde_json::to_string_pretty(&envelope).unwrap())?;
866            } else {
867                writeln!(out, "Pull request !{number} already {state_word}")?;
868            }
869        }
870    }
871    Ok(())
872}
873
874#[cfg(test)]
875mod fill_tests {
876    #[test]
877    fn fill_from_subjects_single_commit() {
878        let (title, body) = super::fill_from_subjects(&["Add paging".to_string()]).unwrap();
879        assert_eq!(title, "Add paging");
880        assert_eq!(body, "- Add paging");
881    }
882
883    #[test]
884    fn fill_from_subjects_uses_oldest_subject_as_title() {
885        let subjects = vec!["First commit".to_string(), "Second commit".to_string()];
886        let (title, body) = super::fill_from_subjects(&subjects).unwrap();
887        assert_eq!(title, "First commit");
888        assert_eq!(body, "- First commit\n- Second commit");
889    }
890
891    #[test]
892    fn fill_from_subjects_empty_is_usage_error() {
893        assert!(super::fill_from_subjects(&[]).is_err());
894    }
895}