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 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 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 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
770fn 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
791fn 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
805fn 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
841fn 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}