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.

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