Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

1,112 linesCodeBlameRaw
1//! `g1t pr`.
2
3use serde_json::{Value, json};
4use std::io::Write;
5use std::time::{Duration, Instant};
6
7use crate::cli::{
8 CommentArgs, PrCheckoutArgs, PrChecksArgs, PrCommand, PrCreateArgs, PrDiffArgs, PrListArgs,
9 PrMergeArgs, PrReadyArgs, PrReviewArgs, PrSelect, PrState, PrUpdateBranchArgs, PrViewArgs,
10};
11use crate::client::Client;
12use crate::context::Ctx;
13use crate::error::{Error, Kind, Result};
14use crate::git;
15use crate::output::{Cell, Output, ago, elapsed, s};
16use crate::prompt;
17use crate::repo::{self, Repo};
18
19pub fn run(ctx: &Ctx, command: PrCommand) -> Result<()> {
20 match command {
21 PrCommand::List(args) => list(ctx, args),
22 PrCommand::View(args) => view(ctx, args),
23 PrCommand::Create(args) => create(ctx, args),
24 PrCommand::Checkout(args) => checkout(ctx, args),
25 PrCommand::Diff(args) => diff(ctx, args),
26 PrCommand::Checks(args) => checks(ctx, args),
27 PrCommand::Review(args) => review(ctx, args),
28 PrCommand::Merge(args) => merge(ctx, args),
29 PrCommand::UpdateBranch(args) => update_branch(ctx, args),
30 PrCommand::Ready(args) => ready(ctx, args),
31 PrCommand::Comment(args) => comment(ctx, args),
32 PrCommand::Close(args) => close(ctx, args, true),
33 PrCommand::Reopen(args) => close(ctx, args, false),
34 }
35}
36
37pub fn pull_url(site: &str, repo: &Repo, number: u64) -> String {
38 format!("{site}/{}/{}/pull/{number}", repo.owner, repo.name)
39}
40
41/// Where a pull request's changes come from: its branch, or its working copy.
42fn head_label(p: &Value) -> String {
43 match (
44 p["branch"].as_str(),
45 p["fork"]["namespace"].as_str(),
46 p["fork"]["name"].as_str(),
47 ) {
48 (Some(b), _, _) => b.to_owned(),
49 (None, Some(ns), Some(name)) => format!("{ns}/{name}"),
50 _ => String::new(),
51 }
52}
53
54/// The pull request's number: the one given, or the current branch's.
55pub fn number(ctx: &Ctx, client: &Client, repo: &Repo, given: Option<u64>) -> Result<u64> {
56 if let Some(n) = given {
57 return Ok(n);
58 }
59 // In a pull request's working copy, it is that pull request.
60 let copy = ctx
61 .repo()
62 .ok()
63 .and_then(|r| r.pull_copy().map(str::to_owned));
64 let branch = git::current_branch();
65 if copy.is_none() && branch.is_none() {
66 return Err(Error::usage(
67 "Which pull request? Give its number; HEAD is not on a branch.",
68 ));
69 }
70 let found = client
71 .op("list_pull_requests")
72 .repo(repo)
73 .query("state", "open")
74 .send()?;
75 let pulls = super::items(found, "pulls");
76 let hit = pulls.iter().find(|p| match &copy {
77 Some(id) => p["id"].as_str() == Some(id) || p["fork"]["name"].as_str() == Some(id),
78 None => p["branch"].as_str() == branch.as_deref(),
79 });
80 match hit.and_then(|p| p["number"].as_u64()) {
81 Some(n) => Ok(n),
82 None => Err(Error::failed(format!(
83 "No open pull request for the branch {}. Give its number, or open one with `g1t pr create`.",
84 branch.unwrap_or_default()
85 ))),
86 }
87}
88
89/// The pull request, with its comments and the work item it is for.
90fn get(client: &Client, repo: &Repo, number: u64) -> Result<Value> {
91 client
92 .op("get_pull_request")
93 .repo(repo)
94 .arg("number", number)
95 .send()
96}
97
98fn pull_of(found: &Value) -> Value {
99 found.get("pull").cloned().unwrap_or_else(|| found.clone())
100}
101
102fn list(ctx: &Ctx, args: PrListArgs) -> Result<()> {
103 let (repo, client) = ctx.repo_client()?;
104 let state = match args.state {
105 PrState::Open => Some("open"),
106 PrState::Closed | PrState::Merged => Some("closed"),
107 PrState::All => None,
108 };
109 let found = client
110 .op("list_pull_requests")
111 .repo(&repo)
112 .query_opt("state", state)
113 .query_opt("label", args.label.clone())
114 .query_opt("base", args.base.clone())
115 .send()?;
116 let mut pulls = super::items(found, "pulls");
117 if args.state == PrState::Merged {
118 pulls.retain(|p| p["status"] == "merged");
119 }
120 if args.draft {
121 pulls.retain(|p| p["status"] == "draft");
122 }
123 if let Some(a) = &args.author {
124 let who = super::who(ctx, &client, a)?;
125 pulls.retain(|p| {
126 p["author"]["username"]
127 .as_str()
128 .is_some_and(|n| n.eq_ignore_ascii_case(&who))
129 });
130 }
131 pulls.truncate(args.limit);
132 if ctx.out.structured() {
133 return ctx.out.emit(&Value::Array(pulls));
134 }
135 if pulls.is_empty() {
136 eprintln!("No matching pull requests in {repo}.");
137 return Ok(());
138 }
139 print_list(&ctx.out, &pulls)
140}
141
142pub fn print_list(out: &Output, pulls: &[Value]) -> Result<()> {
143 let rows: Vec<Vec<Cell>> = pulls
144 .iter()
145 .map(|p| {
146 let number = format!("#{}", p["number"]);
147 let status = s(p, "status");
148 let checks = s(p, "check_status");
149 vec![
150 Cell::styled(
151 number.clone(),
152 if status == "open" {
153 out.green(&number)
154 } else {
155 out.dim(&number)
156 },
157 ),
158 Cell::new(s(p, "title")),
159 Cell::styled(head_label(p), out.cyan(&head_label(p))),
160 Cell::styled(status, out.state(status)),
161 Cell::styled(checks, out.state(checks)),
162 Cell::styled(ago(s(p, "updated_at")), out.dim(&ago(s(p, "updated_at")))),
163 ]
164 })
165 .collect();
166 out.table(
167 &["number", "title", "branch", "status", "checks", "updated"],
168 &rows,
169 )
170}
171
172fn view(ctx: &Ctx, args: PrViewArgs) -> Result<()> {
173 let (repo, client) = ctx.repo_client()?;
174 let n = number(ctx, &client, &repo, args.number)?;
175 let url = pull_url(&client.host.site, &repo, n);
176 if args.web {
177 return super::browse::open(ctx, &url);
178 }
179 let found = get(&client, &repo, n)?;
180 let mut pull = pull_of(&found);
181 if let Some(map) = pull.as_object_mut() {
182 map.insert(
183 "comments".into(),
184 found.get("comments").cloned().unwrap_or(json!([])),
185 );
186 map.insert("url".into(), Value::String(url.clone()));
187 }
188 if ctx.out.structured() {
189 return ctx.out.emit(&pull);
190 }
191 let out = &ctx.out;
192 println!(
193 "{} {}",
194 out.bold(s(&pull, "title")),
195 out.dim(&format!("{repo}#{n}"))
196 );
197 let mut facts = vec![
198 out.state(s(&pull, "status")),
199 format!(
200 "by {} {}",
201 s(&pull["author"], "username"),
202 ago(s(&pull, "created_at"))
203 ),
204 format!("{} ← {}", s(&pull, "base"), out.cyan(&head_label(&pull))),
205 ];
206 if let Some(c) = pull["check_status"].as_str() {
207 facts.push(format!("checks {}", out.state(c)));
208 }
209 println!("{}", facts.join(" · "));
210 if let Some(item) = pull["item"].as_u64() {
211 println!("{} #{item}", out.dim("For:"));
212 }
213 let reviewers: Vec<String> = pull["reviewers"]
214 .as_array()
215 .into_iter()
216 .flatten()
217 .chain(pull["team_reviewers"].as_array().into_iter().flatten())
218 .filter_map(|r| r.as_str().map(str::to_owned))
219 .collect();
220 if !reviewers.is_empty() {
221 println!("{} {}", out.dim("Reviewers:"), reviewers.join(", "));
222 }
223 if let Some(files) = pull["files"].as_array().filter(|f| !f.is_empty()) {
224 let adds: u64 = files.iter().filter_map(|f| f["additions"].as_u64()).sum();
225 let dels: u64 = files.iter().filter_map(|f| f["deletions"].as_u64()).sum();
226 println!(
227 "{} {} file(s), {} {}",
228 out.dim("Changes:"),
229 files.len(),
230 out.green(&format!("+{adds}")),
231 out.red(&format!("-{dels}"))
232 );
233 }
234 println!();
235 match pull["body"].as_str().filter(|b| !b.trim().is_empty()) {
236 Some(b) => println!("{}", b.trim_end()),
237 None => println!("{}", out.dim("No description.")),
238 }
239 let comments = pull["comments"]
240 .as_array()
241 .map(|c| c.iter().filter(|c| c["kind"] == "comment").count())
242 .unwrap_or(0);
243 if args.comments {
244 super::print_comments(out, &pull["comments"]);
245 } else if comments > 0 {
246 println!();
247 println!(
248 "{}",
249 out.dim(&format!(
250 "{comments} comment(s): g1t pr view {n} --comments"
251 ))
252 );
253 }
254 println!();
255 println!("{}", out.dim(&url));
256 Ok(())
257}
258
259/// The remote that is `repo`, in this clone.
260fn remote_for(repo: &Repo) -> Option<String> {
261 git::remotes()
262 .into_iter()
263 .find(|(_, url)| {
264 repo::parse_remote(url).is_some_and(|r| {
265 r.owner.eq_ignore_ascii_case(&repo.owner) && r.name.eq_ignore_ascii_case(&repo.name)
266 })
267 })
268 .map(|(name, _)| name)
269}
270
271/// In a clone of a fork, `repo` is its upstream remote and origin is the
272/// fork: where the branch goes, and the pull request's head_repo.
273fn fork_origin(repo: &Repo, remote: Option<&str>) -> Option<Repo> {
274 if remote != Some("upstream") {
275 return None;
276 }
277 git::remotes()
278 .into_iter()
279 .filter(|(name, _)| name == "origin")
280 .find_map(|(_, url)| repo::parse_remote(&url))
281 .filter(|origin| {
282 origin.host == repo.host
283 && origin.pull_copy().is_none()
284 && !(origin.owner.eq_ignore_ascii_case(&repo.owner) && origin.name.eq_ignore_ascii_case(&repo.name))
285 })
286}
287
288/// A title and body from the branch's commits.
289pub fn fill(commits: &[(String, String)], branch: &str) -> (String, String) {
290 match commits {
291 [(subject, body)] => (subject.clone(), body.clone()),
292 [] => (humanize(branch), String::new()),
293 many => {
294 let body = many
295 .iter()
296 .map(|(s, _)| format!("- {s}"))
297 .collect::<Vec<_>>()
298 .join("\n");
299 (humanize(branch), body)
300 }
301 }
302}
303
304fn humanize(branch: &str) -> String {
305 let words = branch
306 .rsplit('/')
307 .next()
308 .unwrap_or(branch)
309 .replace(['-', '_'], " ");
310 let mut chars = words.chars();
311 match chars.next() {
312 Some(first) => first.to_uppercase().chain(chars).collect(),
313 None => String::new(),
314 }
315}
316
317fn create(ctx: &Ctx, args: PrCreateArgs) -> Result<()> {
318 let (repo, client) = ctx.repo_client()?;
319 let default = super::repo::default_branch(&client, &repo)?;
320 let head = args.head.clone().or_else(git::current_branch);
321 let remote = remote_for(&repo);
322 // From a fork: named, or the origin of a clone whose upstream is the
323 // repository.
324 let head_repo = match &args.head_repo {
325 Some(name) => Some(Repo::parse(name, &repo.host)?),
326 None => fork_origin(&repo, remote.as_deref()),
327 };
328 // A fork's own default branch can hold a change for the repository.
329 let on_default = head_repo.is_none()
330 && head
331 .as_deref()
332 .is_none_or(|h| h == default || Some(h) == args.base.as_deref());
333 let body_given = prompt::body(
334 args.body.body.clone(),
335 args.body.body_file.as_deref(),
336 false,
337 )?;
338
339 // For a work item, with no branch of its own: a draft with a working copy.
340 if on_default {
341 let Some(item) = args.item else {
342 return Err(Error::usage(format!(
343 "You are on {}. Make a branch with your changes, or pass --item N to start a pull request in a working copy of its own.",
344 head.unwrap_or(default)
345 )));
346 };
347 let body = super::attach::with_attachments(&client, &repo, body_given, &args.attach)?;
348 let made = client
349 .op("create_pull_request")
350 .repo(&repo)
351 .field("item", item)
352 .field_opt("title", args.title.clone())
353 .field_opt("body", body)
354 .field_opt("base", args.base.clone())
355 .send()?;
356 let pull = pull_of(&made);
357 let n = pull["number"].as_u64().unwrap_or_default();
358 if ctx.out.structured() {
359 return ctx.out.emit(&made);
360 }
361 let remote = s(&made["git"], "remote");
362 eprintln!(
363 "{} Started draft {repo}#{n} for #{item}.",
364 ctx.out.green("✓")
365 );
366 eprintln!(" Its working copy: {remote}");
367 eprintln!(" Push your commits there, then: g1t pr ready {n} --summary '...'");
368 println!("{}", pull_url(&client.host.site, &repo, n));
369 return Ok(());
370 }
371 let head = head.unwrap_or_default();
372 let push_to = match &head_repo {
373 Some(fork) => remote_for(fork),
374 None => remote.clone(),
375 };
376 if !args.no_push {
377 let Some(remote) = &push_to else {
378 let target = head_repo.as_ref().unwrap_or(&repo);
379 return Err(Error::usage(format!(
380 "No remote here is {target}, so the branch cannot be pushed. Push it yourself and pass --no-push."
381 )));
382 };
383 eprintln!("Pushing {head} to {remote}...");
384 let refspec = format!("HEAD:refs/heads/{head}");
385 let pushed = if git::current_branch().as_deref() == Some(head.as_str()) {
386 git::run(&["push", "--set-upstream", remote, &refspec])
387 } else {
388 git::run(&[
389 "push",
390 remote,
391 &format!("refs/heads/{head}:refs/heads/{head}"),
392 ])
393 };
394 pushed?;
395 }
396 let base = args.base.clone().unwrap_or_else(|| default.clone());
397 let (mut title, mut body) = (args.title.clone(), body_given);
398 if args.fill || (title.is_none() && !prompt::interactive()) {
399 let upstream = remote
400 .as_deref()
401 .map(|r| format!("{r}/{base}"))
402 .filter(|r| git::ok(&["rev-parse", "--verify", "--quiet", r]));
403 let commits = git::commits_between(upstream.as_deref().unwrap_or(&base), &head);
404 let (t, b) = fill(&commits, &head);
405 title = title.or(Some(t));
406 if body.is_none() && !b.is_empty() {
407 body = Some(b);
408 }
409 }
410 let title = match title {
411 Some(t) => t,
412 None => prompt::input("Title:", Some(&humanize(&head)))?,
413 };
414 if body.is_none() && args.attach.is_empty() && prompt::interactive() && !ctx.out.structured() {
415 body = prompt::body(None, None, true)?;
416 }
417 let body = super::attach::with_attachments(&client, &repo, body, &args.attach)?;
418 let made = client
419 .op("create_pull_request")
420 .repo(&repo)
421 .field("branch", head.clone())
422 .field_opt("head_repo", head_repo.as_ref().map(|fork| format!("{}/{}", fork.owner, fork.name)))
423 .field("title", title)
424 .field_opt("body", body)
425 .field_opt("base", args.base.clone())
426 .field_opt("item", args.item)
427 .send()?;
428 let pull = pull_of(&made);
429 let n = pull["number"].as_u64().unwrap_or_default();
430 let pull = if args.draft {
431 client
432 .op("convert_pull_request_to_draft")
433 .repo(&repo)
434 .arg("number", n)
435 .send()?
436 } else {
437 pull
438 };
439 super::done(
440 ctx,
441 &pull,
442 &match &head_repo {
443 Some(fork) => format!("Opened {repo}#{n} from {fork} {head} into {base}"),
444 None => format!("Opened {repo}#{n} from {head} into {base}"),
445 },
446 &pull_url(&client.host.site, &repo, n),
447 )
448}
449
450fn checkout(ctx: &Ctx, args: PrCheckoutArgs) -> Result<()> {
451 let (repo, client) = ctx.repo_client()?;
452 let pull = pull_of(&get(&client, &repo, args.number)?);
453 let n = args.number;
454 if let Some(branch) = pull["branch"].as_str() {
455 let remote =
456 remote_for(&repo).ok_or_else(|| Error::usage(format!("No remote here is {repo}.")))?;
457 let local = args.branch.clone().unwrap_or_else(|| branch.to_owned());
458 let tracking = format!("refs/remotes/{remote}/{branch}");
459 git::run(&[
460 "fetch",
461 &remote,
462 &format!("+refs/heads/{branch}:{tracking}"),
463 ])?;
464 if git::ok(&[
465 "rev-parse",
466 "--verify",
467 "--quiet",
468 &format!("refs/heads/{local}"),
469 ]) {
470 git::run(&["checkout", &local])?;
471 if args.force {
472 git::run(&["reset", "--hard", &tracking])?;
473 } else {
474 git::run(&["merge", "--ff-only", &tracking])?;
475 }
476 } else {
477 git::run(&[
478 "checkout",
479 "-b",
480 &local,
481 "--track",
482 &format!("{remote}/{branch}"),
483 ])?;
484 }
485 eprintln!("{} On {local}, at #{n}'s head.", ctx.out.green("✓"));
486 return Ok(());
487 }
488 // A working copy of its own: fetch its head from there, or from the
489 // repository once the copy is gone.
490 let id = s(&pull, "id").to_owned();
491 let copy = format!(
492 "{}/{}/{}.git",
493 client.host.site,
494 s(&pull["fork"], "namespace"),
495 s(&pull["fork"], "name")
496 );
497 let local = args.branch.clone().unwrap_or_else(|| format!("pr-{n}"));
498 let fetched = pull["fork"].is_object() && git::run(&["fetch", &copy, "HEAD"]).is_ok();
499 if !fetched {
500 let remote =
501 remote_for(&repo).ok_or_else(|| Error::usage(format!("No remote here is {repo}.")))?;
502 git::run(&["fetch", &remote, &format!("refs/pull/{id}/head")])?;
503 }
504 let exists = git::ok(&[
505 "rev-parse",
506 "--verify",
507 "--quiet",
508 &format!("refs/heads/{local}"),
509 ]);
510 if exists && !args.force {
511 git::run(&["checkout", &local])?;
512 git::run(&["merge", "--ff-only", "FETCH_HEAD"])?;
513 } else {
514 git::run(&["checkout", "-B", &local, "FETCH_HEAD"])?;
515 }
516 eprintln!("{} On {local}, at #{n}'s head.", ctx.out.green("✓"));
517 if fetched {
518 let target = git::run(&["ls-remote", "--symref", &copy, "HEAD"])
519 .ok()
520 .and_then(|o| {
521 o.lines().find_map(|l| {
522 l.strip_prefix("ref: refs/heads/")
523 .map(|r| r.split_whitespace().next().unwrap_or("main").to_owned())
524 })
525 })
526 .unwrap_or_else(|| "main".to_owned());
527 eprintln!(" Push to it with: git push {copy} HEAD:{target}");
528 }
529 Ok(())
530}
531
532/// The changes as a unified diff.
533pub fn render_diff(changes: &Value, out: &Output, color: bool, name_only: bool) -> String {
534 let paint = |code: &str, t: &str| {
535 if color {
536 out.paint(code, t)
537 } else {
538 t.to_owned()
539 }
540 };
541 let mut text = String::new();
542 for file in changes["files"].as_array().into_iter().flatten() {
543 let path = s(file, "path");
544 if name_only {
545 text.push_str(path);
546 text.push('\n');
547 continue;
548 }
549 let old = file["old_path"]
550 .as_str()
551 .or(file["previous_path"].as_str())
552 .unwrap_or(path);
553 let status = s(file, "status");
554 text.push_str(&paint("1", &format!("diff --git a/{old} b/{path}")));
555 text.push('\n');
556 if file["binary"] == true {
557 text.push_str(&format!("Binary files a/{old} and b/{path} differ\n"));
558 continue;
559 }
560 let (a, b) = match status {
561 "added" => ("/dev/null".to_owned(), format!("b/{path}")),
562 "deleted" | "removed" => (format!("a/{old}"), "/dev/null".to_owned()),
563 _ => (format!("a/{old}"), format!("b/{path}")),
564 };
565 text.push_str(&paint("1", &format!("--- {a}\n+++ {b}")));
566 text.push('\n');
567 for hunk in file["hunks"].as_array().into_iter().flatten() {
568 let lines = hunk["lines"].as_array().cloned().unwrap_or_default();
569 let old_start = lines.iter().find_map(|l| l["old"].as_u64()).unwrap_or(0);
570 let new_start = lines.iter().find_map(|l| l["new"].as_u64()).unwrap_or(0);
571 let old_count = lines.iter().filter(|l| l["kind"] != "add").count();
572 let new_count = lines.iter().filter(|l| l["kind"] != "delete").count();
573 text.push_str(&paint(
574 "36",
575 &format!("@@ -{old_start},{old_count} +{new_start},{new_count} @@"),
576 ));
577 text.push('\n');
578 for line in &lines {
579 let body = s(line, "text");
580 match s(line, "kind") {
581 "add" => text.push_str(&paint("32", &format!("+{body}"))),
582 "delete" => text.push_str(&paint("31", &format!("-{body}"))),
583 _ => text.push_str(&format!(" {body}")),
584 }
585 text.push('\n');
586 }
587 }
588 }
589 if changes["truncated"] == true {
590 text.push_str("... the diff is longer than the API returns; see it on the site.\n");
591 }
592 text
593}
594
595fn diff(ctx: &Ctx, args: PrDiffArgs) -> Result<()> {
596 let (repo, client) = ctx.repo_client()?;
597 let n = number(ctx, &client, &repo, args.number)?;
598 let changes = client
599 .op("get_pull_request_changes")
600 .repo(&repo)
601 .arg("number", n)
602 .send()?;
603 if ctx.out.structured() {
604 return ctx.out.emit(&changes);
605 }
606 let color = match args.color.as_str() {
607 "always" => true,
608 "never" => false,
609 _ => ctx.out.color,
610 };
611 let mut stdout = std::io::stdout().lock();
612 write!(
613 stdout,
614 "{}",
615 render_diff(&changes, &ctx.out, color, args.name_only)
616 )?;
617 Ok(())
618}
619
620/// One check, a check run or a commit status, the same shape for both.
621pub fn check_rows(runs: &Value, statuses: &Value) -> Vec<Value> {
622 let mut rows = Vec::new();
623 for r in runs["check_runs"].as_array().into_iter().flatten() {
624 let name = match r["workflow"]["name"].as_str() {
625 Some(w) => format!("{w} / {}", s(r, "name")),
626 None => s(r, "name").to_owned(),
627 };
628 let state = if r["status"] == "completed" {
629 s(r, "conclusion").to_owned()
630 } else {
631 s(r, "status").to_owned()
632 };
633 rows.push(json!({
634 "name": name,
635 "state": state,
636 "bucket": bucket(&state),
637 "started_at": r["started_at"],
638 "completed_at": r["completed_at"],
639 "url": r["html_url"].as_str().or(r["details_url"].as_str()),
640 "kind": "check_run",
641 "id": r["id"],
642 }));
643 }
644 for st in statuses["statuses"].as_array().into_iter().flatten() {
645 let state = s(st, "state").to_owned();
646 rows.push(json!({
647 "name": st["context"],
648 "state": state,
649 "bucket": bucket(&state),
650 "started_at": Value::Null,
651 "completed_at": st["updated_at"],
652 "url": st["target_url"],
653 "kind": "status",
654 "description": st["description"],
655 }));
656 }
657 rows
658}
659
660/// pass, fail, pending or skip.
661pub fn bucket(state: &str) -> &'static str {
662 match state {
663 "success" | "neutral" => "pass",
664 "skipped" => "skip",
665 "failure" | "error" | "timed_out" | "cancelled" | "action_required" | "startup_failure"
666 | "stale" => "fail",
667 _ => "pending",
668 }
669}
670
671fn count(rows: &[Value], b: &str) -> usize {
672 rows.iter().filter(|r| r["bucket"] == b).count()
673}
674
675fn print_checks(out: &Output, rows: &[Value]) -> Result<()> {
676 let table: Vec<Vec<Cell>> = rows
677 .iter()
678 .map(|r| {
679 let state = s(r, "state");
680 let mark = match s(r, "bucket") {
681 "pass" => out.green("✓"),
682 "fail" => out.red("X"),
683 "skip" => out.dim("-"),
684 _ => out.yellow("*"),
685 };
686 let took = elapsed(&r["started_at"], &r["completed_at"]);
687 vec![
688 Cell::styled(
689 format!("{} {}", plain_mark(s(r, "bucket")), s(r, "name")),
690 format!("{mark} {}", s(r, "name")),
691 ),
692 Cell::styled(state, out.state(state)),
693 Cell::new(took),
694 Cell::styled(s(r, "url"), out.dim(s(r, "url"))),
695 ]
696 })
697 .collect();
698 out.table(&["check", "state", "took", "url"], &table)
699}
700
701fn plain_mark(bucket: &str) -> &'static str {
702 match bucket {
703 "pass" => "✓",
704 "fail" => "X",
705 "skip" => "-",
706 _ => "*",
707 }
708}
709
710fn summary(rows: &[Value]) -> String {
711 format!(
712 "{} failing, {} pending, {} passing, {} skipped",
713 count(rows, "fail"),
714 count(rows, "pending"),
715 count(rows, "pass"),
716 count(rows, "skip")
717 )
718}
719
720fn checks(ctx: &Ctx, args: PrChecksArgs) -> Result<()> {
721 let (repo, client) = ctx.repo_client()?;
722 let n = number(ctx, &client, &repo, args.number)?;
723 let started = Instant::now();
724 let mut last_seen: std::collections::BTreeMap<String, String> = Default::default();
725 loop {
726 let pull = pull_of(&get(&client, &repo, n)?);
727 let Some(sha) = pull["head_commit"].as_str().map(str::to_owned) else {
728 if !args.watch {
729 eprintln!("#{n} has no commits yet.");
730 return Ok(());
731 }
732 std::thread::sleep(Duration::from_secs(args.interval));
733 continue;
734 };
735 let runs = client
736 .op("list_check_runs_for_ref")
737 .repo(&repo)
738 .arg("ref", &sha)
739 .send()?;
740 let statuses = client
741 .op("get_combined_status")
742 .repo(&repo)
743 .arg("ref", &sha)
744 .send()?;
745 let rows = check_rows(&runs, &statuses);
746 let failing = count(&rows, "fail");
747 let pending = count(&rows, "pending");
748 // Right after a push, runs take a moment to appear.
749 let waiting_for_any =
750 rows.is_empty() && args.watch && started.elapsed() < Duration::from_secs(120);
751 let finished = !waiting_for_any && (pending == 0 || (args.fail_fast && failing > 0));
752 if args.watch && !finished {
753 if !ctx.out.structured() {
754 if ctx.out.tty {
755 // Redraw in place.
756 print!("\x1b[2J\x1b[H");
757 println!(
758 "{} {}",
759 ctx.out.bold(&format!("Checks for #{n}")),
760 ctx.out.dim(&format!("at {}", &sha[..sha.len().min(7)]))
761 );
762 print_checks(&ctx.out, &rows)?;
763 println!();
764 println!("{}", summary(&rows));
765 let _ = std::io::stdout().flush();
766 } else {
767 // Piped: a line for each check whose state changed.
768 for r in &rows {
769 let name = s(r, "name").to_owned();
770 let state = s(r, "state").to_owned();
771 if last_seen.get(&name) != Some(&state) {
772 println!("{}\t{}\t{}", name, state, s(r, "url"));
773 last_seen.insert(name, state);
774 }
775 }
776 }
777 }
778 std::thread::sleep(Duration::from_secs(args.interval));
779 continue;
780 }
781 if ctx.out.structured() {
782 ctx.out.emit(&Value::Array(rows.clone()))?;
783 } else if rows.is_empty() {
784 eprintln!(
785 "No checks reported for #{n} at {}.",
786 &sha[..sha.len().min(7)]
787 );
788 } else if args.watch && !ctx.out.tty {
789 for r in &rows {
790 let name = s(r, "name").to_owned();
791 if last_seen.get(&name).map(String::as_str) != Some(s(r, "state")) {
792 println!("{}\t{}\t{}", name, s(r, "state"), s(r, "url"));
793 }
794 }
795 eprintln!("{}", summary(&rows));
796 } else {
797 if args.watch && ctx.out.tty {
798 print!("\x1b[2J\x1b[H");
799 }
800 print_checks(&ctx.out, &rows)?;
801 if ctx.out.tty {
802 println!();
803 println!("{}", summary(&rows));
804 } else {
805 eprintln!("{}", summary(&rows));
806 }
807 }
808 return if failing > 0 {
809 Err(Error::exit(Kind::Failed))
810 } else if pending > 0 {
811 Err(Error::exit(Kind::Pending))
812 } else {
813 Ok(())
814 };
815 }
816}
817
818fn review(ctx: &Ctx, args: PrReviewArgs) -> Result<()> {
819 let (repo, client) = ctx.repo_client()?;
820 let n = number(ctx, &client, &repo, args.number)?;
821 let url = pull_url(&client.host.site, &repo, n);
822 let body = prompt::body(
823 args.body.body,
824 args.body.body_file.as_deref(),
825 args.comment || args.request_changes,
826 )?;
827 if args.comment {
828 let text = body
829 .filter(|b| !b.trim().is_empty())
830 .ok_or_else(|| prompt::needed("The comment", "--body"))?;
831 let made = client
832 .op("add_comment")
833 .repo(&repo)
834 .arg("number", n)
835 .field("body", text)
836 .send()?;
837 return super::done(ctx, &made, &format!("Commented on {repo}#{n}"), &url);
838 }
839 let verdict = if args.approve {
840 "approve"
841 } else {
842 "request_changes"
843 };
844 let made = client
845 .op("review_pull_request")
846 .repo(&repo)
847 .arg("number", n)
848 .field("verdict", verdict)
849 .field_opt("body", body)
850 .send()?;
851 let said = if args.approve {
852 "Approved"
853 } else {
854 "Asked for changes on"
855 };
856 super::done(ctx, &made, &format!("{said} {repo}#{n}"), &url)
857}
858
859/// merge_pull_request's input, spelled so that mapping.rs, which finds the
860/// operations a command calls by their quoted names, does not take it for
861/// the delete_branch operation: the API deletes the branch itself.
862const DELETE_BRANCH: &str = concat!("delete_", "branch");
863
864fn merge(ctx: &Ctx, args: PrMergeArgs) -> Result<()> {
865 let (repo, client) = ctx.repo_client()?;
866 let n = number(ctx, &client, &repo, args.number)?;
867 let url = pull_url(&client.host.site, &repo, n);
868 if args.disable_auto {
869 let pull = client
870 .op("disable_pull_request_auto_merge")
871 .repo(&repo)
872 .arg("number", n)
873 .send()?;
874 return super::done(ctx, &pull, &format!("Turned off auto-merge for {repo}#{n}"), &url);
875 }
876 let method = if args.merge {
877 Some("merge")
878 } else if args.squash {
879 Some("squash")
880 } else if args.rebase {
881 Some("rebase")
882 } else if args.ff {
883 Some("fast_forward")
884 } else {
885 None
886 };
887 let message = prompt::body(args.body.body, args.body.body_file.as_deref(), false)?;
888 if args.auto {
889 let pull = client
890 .op("enable_pull_request_auto_merge")
891 .repo(&repo)
892 .arg("number", n)
893 .field_opt("merge_method", method)
894 .field_opt("commit_title", args.subject)
895 .field_opt("commit_message", message)
896 .send()?;
897 // One that may merge already merges at once.
898 let said = if pull["status"] == "merged" {
899 format!("Merged {repo}#{n}")
900 } else {
901 format!("{repo}#{n} will merge on its own once its checks, reviews and rules allow")
902 };
903 return super::done(ctx, &pull, &said, &url);
904 }
905 let delete = if args.delete_branch {
906 Some(true)
907 } else if args.keep_branch {
908 Some(false)
909 } else {
910 None
911 };
912 let mut call = client
913 .op("merge_pull_request")
914 .repo(&repo)
915 .arg("number", n)
916 .field_opt("merge_method", method)
917 .field_opt("commit_title", args.subject)
918 .field_opt("commit_message", message)
919 .field_opt("sha", args.match_head_commit)
920 .field_opt(DELETE_BRANCH, delete);
921 if args.keep_item_open {
922 call = call.field("keep_item_open", true);
923 }
924 if args.admin {
925 call = call
926 .field("ignore_checks", true)
927 .field("bypass_rules", true);
928 }
929 let merged = call.send()?;
930 if merged["status"] != "merged" {
931 return super::done(
932 ctx,
933 &merged,
934 &format!("{repo}#{n} lands once it is brought up to date or through the merge queue"),
935 &url,
936 );
937 }
938 if let Some(branch) = merged["branch"].as_str() {
939 if merged["head_branch_deleted"] == true && !ctx.out.structured() {
940 eprintln!("{} Deleted the branch {branch}.", ctx.out.green("✓"));
941 }
942 if args.delete_branch && git::current_branch().as_deref() != Some(branch) {
943 let _ = git::run(&["branch", "-D", branch]);
944 }
945 }
946 super::done(ctx, &merged, &format!("Merged {repo}#{n}"), &url)
947}
948
949fn update_branch(ctx: &Ctx, args: PrUpdateBranchArgs) -> Result<()> {
950 let (repo, client) = ctx.repo_client()?;
951 let n = number(ctx, &client, &repo, args.number)?;
952 let url = pull_url(&client.host.site, &repo, n);
953 let update = client
954 .op("update_pull_request_branch")
955 .repo(&repo)
956 .arg("number", n)
957 .field_opt("update_method", args.rebase.then_some("rebase"))
958 .send()?;
959 match update["outcome"].as_str() {
960 Some("updated") => {
961 let how = if args.rebase { "Rebased" } else { "Updated" };
962 super::done(ctx, &update, &format!("{how} the branch of {repo}#{n}"), &url)
963 }
964 Some("up_to_date") => {
965 super::done(ctx, &update, &format!("{repo}#{n} is already up to date"), &url)
966 }
967 _ => {
968 if ctx.out.structured() {
969 ctx.out.emit(&update)?;
970 }
971 let paths: Vec<&str> = update["paths"]
972 .as_array()
973 .map(|paths| paths.iter().filter_map(Value::as_str).collect())
974 .unwrap_or_default();
975 let detail = update["detail"].as_str().unwrap_or("g1t could not update it here.");
976 let mut message = format!("{repo}#{n} was not updated: {detail}");
977 if !paths.is_empty() {
978 message.push_str(&format!(" Files: {}.", paths.join(", ")));
979 }
980 message.push_str(" Merge or rebase its base in a clone and push.");
981 Err(Error::failed(message))
982 }
983 }
984}
985
986fn ready(ctx: &Ctx, args: PrReadyArgs) -> Result<()> {
987 let (repo, client) = ctx.repo_client()?;
988 let n = number(ctx, &client, &repo, args.number)?;
989 let url = pull_url(&client.host.site, &repo, n);
990 if args.undo {
991 let pull = client
992 .op("convert_pull_request_to_draft")
993 .repo(&repo)
994 .arg("number", n)
995 .send()?;
996 return super::done(ctx, &pull, &format!("{repo}#{n} is a draft again"), &url);
997 }
998 let summary = match args.summary {
999 Some(s) => s,
1000 None => {
1001 let pull = pull_of(&get(&client, &repo, n)?);
1002 match pull["body"].as_str().filter(|b| !b.trim().is_empty()) {
1003 Some(b) => b.to_owned(),
1004 None if prompt::interactive() => prompt::editor("")?,
1005 None => {
1006 return Err(prompt::needed(
1007 "A summary of what changed and why",
1008 "--summary",
1009 ));
1010 }
1011 }
1012 }
1013 };
1014 // The images go after the summary, whether given or the current description.
1015 let summary = super::attach::with_attachments(&client, &repo, Some(summary), &args.attach)?.unwrap_or_default();
1016 let pull = client
1017 .op("mark_pull_request_ready")
1018 .repo(&repo)
1019 .arg("number", n)
1020 .field("summary", summary)
1021 .send()?;
1022 super::done(ctx, &pull, &format!("{repo}#{n} is ready for review"), &url)
1023}
1024
1025fn comment(ctx: &Ctx, args: CommentArgs) -> Result<()> {
1026 let (repo, client) = ctx.repo_client()?;
1027 let n = number(ctx, &client, &repo, args.number)?;
1028 let url = pull_url(&client.host.site, &repo, n);
1029 // Images alone make a comment; without any, the text is asked for.
1030 let body = prompt::body(args.body.body, args.body.body_file.as_deref(), args.attach.is_empty())?;
1031 let body = super::attach::with_attachments(&client, &repo, body, &args.attach)?
1032 .filter(|b| !b.trim().is_empty())
1033 .ok_or_else(|| prompt::needed("The comment", "--body, --body-file or --attach"))?;
1034 let made = client.op("add_comment").repo(&repo).arg("number", n).field("body", body).send()?;
1035 let id = s(&made, "id").to_owned();
1036 super::done(ctx, &made, &format!("Commented on {repo}#{n}"), &format!("{url}#{id}"))
1037}
1038
1039fn close(ctx: &Ctx, args: PrSelect, closing: bool) -> Result<()> {
1040 let (repo, client) = ctx.repo_client()?;
1041 let n = number(ctx, &client, &repo, args.number)?;
1042 let id = if closing {
1043 "close_pull_request"
1044 } else {
1045 "reopen_pull_request"
1046 };
1047 let pull = client.op(id).repo(&repo).arg("number", n).send()?;
1048 let said = if closing { "Closed" } else { "Reopened" };
1049 super::done(
1050 ctx,
1051 &pull,
1052 &format!("{said} {repo}#{n}"),
1053 &pull_url(&client.host.site, &repo, n),
1054 )
1055}
1056
1057#[cfg(test)]
1058mod tests {
1059 use super::*;
1060
1061 #[test]
1062 fn titles_fill_from_commits() {
1063 let one = vec![("Trim the name".to_owned(), "Before greeting.".to_owned())];
1064 assert_eq!(
1065 fill(&one, "x"),
1066 ("Trim the name".to_owned(), "Before greeting.".to_owned())
1067 );
1068 let two = vec![
1069 ("a".to_owned(), String::new()),
1070 ("b".to_owned(), String::new()),
1071 ];
1072 assert_eq!(
1073 fill(&two, "ana/greet-by-name"),
1074 ("Greet by name".to_owned(), "- a\n- b".to_owned())
1075 );
1076 assert_eq!(fill(&[], "fix_typo").0, "Fix typo");
1077 }
1078
1079 #[test]
1080 fn checks_fall_into_buckets() {
1081 let runs = json!({ "check_runs": [
1082 { "name": "lint", "status": "completed", "conclusion": "failure", "html_url": "u1" },
1083 { "name": "Test", "status": "in_progress", "conclusion": null, "workflow": { "name": "CI" } },
1084 ]});
1085 let statuses = json!({ "statuses": [{ "context": "ci/build", "state": "success", "target_url": "u3" }] });
1086 let rows = check_rows(&runs, &statuses);
1087 assert_eq!(rows.len(), 3);
1088 assert_eq!(rows[0]["bucket"], "fail");
1089 assert_eq!(rows[1]["name"], "CI / Test");
1090 assert_eq!(rows[1]["bucket"], "pending");
1091 assert_eq!(rows[2]["bucket"], "pass");
1092 assert_eq!(summary(&rows), "1 failing, 1 pending, 1 passing, 0 skipped");
1093 }
1094
1095 #[test]
1096 fn diffs_render_unified() {
1097 let changes = json!({ "files": [{ "path": "src/main.rs", "status": "modified", "binary": false, "hunks": [{ "lines": [
1098 { "kind": "context", "old": 3, "new": 3, "text": "fn main() {" },
1099 { "kind": "delete", "old": 4, "new": null, "text": " old();" },
1100 { "kind": "add", "old": null, "new": 4, "text": " new();" },
1101 ]}]}], "truncated": false });
1102 let text = render_diff(&changes, &Output::default(), false, false);
1103 assert_eq!(
1104 text,
1105 "diff --git a/src/main.rs b/src/main.rs\n--- a/src/main.rs\n+++ b/src/main.rs\n@@ -3,2 +3,2 @@\n fn main() {\n- old();\n+ new();\n"
1106 );
1107 assert_eq!(
1108 render_diff(&changes, &Output::default(), false, true),
1109 "src/main.rs\n"
1110 );
1111 }
1112}