1use std::process::Command;
9
10use crate::config::{Attribution, Config};
11use crate::dispatch::{remove_worktree, wt_git};
12use crate::store::{Result, Run, RunState, Store};
13use crate::todo;
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum Target {
18 Push,
20 Pr,
22}
23
24pub fn publish(
28 store: &Store,
29 home: &std::path::Path,
30 cfg: &Config,
31 runs: Vec<Run>,
32 target: Target,
33 mut done: impl FnMut(&Run, &std::result::Result<String, String>),
34) -> Result<()> {
35 let mut blocked: Vec<String> = Vec::new();
36 for mut run in runs {
37 if blocked.contains(&run.project) {
38 done(
39 &run,
40 &Err("skipped: an earlier run in this project failed".into()),
41 );
42 continue;
43 }
44 match publish_one(home, cfg, &run, target) {
45 Ok(outcome) => {
46 run.enter(match target {
47 Target::Push => RunState::Pushed,
48 Target::Pr => RunState::PrOpen,
49 });
50 run.outcome = Some(outcome.clone());
51 run.error = None;
52 store.update_run(&run)?;
55 let report = match remove_worktree(&run.repo, &run.worktree, &run.branch) {
56 Ok(()) => outcome,
57 Err(e) => {
58 run.error = Some(format!("cleanup: {e}"));
59 store.update_run(&run)?;
60 format!("{outcome}; warning: worktree not removed: {e}")
61 }
62 };
63 done(&run, &Ok(report));
64 }
65 Err(e) => {
66 run.error = Some(format!("publish: {e}"));
67 store.update_run(&run)?;
68 blocked.push(run.project.clone());
69 done(&run, &Err(e.to_string()));
70 }
71 }
72 }
73 Ok(())
74}
75
76pub fn land_local(store: &Store, home: &std::path::Path, run: &mut Run) -> Result<(bool, String)> {
82 let passed = run.state == RunState::Ready && run.verify_ok == Some(true) && run.error.is_none();
83 let refused = if !passed {
84 Some(match (&run.verify, run.verify_ok) {
85 (None, _) => format!("`{}` states no verify command", run.project),
86 (_, Some(false)) => "verify failed".to_string(),
87 _ => run
88 .error
89 .clone()
90 .unwrap_or_else(|| format!("run is {}", run.state.name())),
91 })
92 } else {
93 let tree = match &run.reviewed_tree {
94 Some(t) => t.clone(),
95 None => crate::dispatch::tree(run)?,
96 };
97 let dir = home.join("runs").join(run.id.to_string());
98 std::fs::create_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?;
99 let file = dir.join("change.patch");
100 let output = format!("--output={}", crate::dispatch::path_arg(&file)?);
104 wt_git(
105 run,
106 &[
107 "diff",
108 "--binary",
109 "--no-color",
110 "--no-ext-diff",
111 "--no-textconv",
112 "--src-prefix=a/",
113 "--dst-prefix=b/",
114 &output,
115 &run.base,
116 &tree,
117 ],
118 )?;
119 if std::fs::metadata(&file).map_or(0, |m| m.len()) == 0 {
120 Some("no changes".to_string())
121 } else {
122 crate::dispatch::clone_unchanged(store, &run.repo)?;
123 match crate::dispatch::git(
125 &run.repo,
126 &["apply", "--binary", crate::dispatch::path_arg(&file)?],
127 ) {
128 Ok(_) => None,
129 Err(e) => Some(format!(
130 "does not apply to the clone: {e}; patch: {}",
131 file.display()
132 )),
133 }
134 }
135 };
136 match refused {
137 None => {
138 remove_worktree(&run.repo, &run.worktree, &run.branch)?;
139 run.enter(RunState::Applied);
140 let draft = crate::dispatch::changelog_draft(home, run.id);
141 run.outcome = Some(match draft.is_file() {
142 true => format!(
143 "applied to {} unstaged; changelog entries in {}",
144 run.repo.display(),
145 draft.display()
146 ),
147 false if crate::dispatch::wants_changelog(run) => format!(
148 "applied to {} unstaged; no changelog entry drafted",
149 run.repo.display()
150 ),
151 false => format!("applied to {} unstaged", run.repo.display()),
152 });
153 store.update_run(run)?;
154 Ok((true, run.outcome.clone().unwrap_or_default()))
155 }
156 Some(why) => {
157 run.outcome = Some(format!("discarded: {why}"));
158 crate::dispatch::discard(store, run, false)?;
159 Ok((false, format!("discarded: {why}")))
160 }
161 }
162}
163
164fn publish_one(home: &std::path::Path, cfg: &Config, run: &Run, target: Target) -> Result<String> {
165 let wt = &run.worktree;
166 if !wt.is_dir() {
167 return Err(format!("{} no longer exists", wt.display()).into());
168 }
169 let (Some(approved), Some(approved_head)) = (&run.approved_tree, &run.approved_head) else {
170 return Err(format!(
171 "run #{} has no approved tree and head to publish; approve it again",
172 run.id
173 )
174 .into());
175 };
176 let head = wt_git(run, &["rev-parse", "HEAD"])?;
177 if &head == approved_head && &crate::dispatch::tree(run)? != approved {
180 return Err(format!(
181 "the worktree changed after it was approved by {}; read \
182 `pma review {}` and approve it again, or reject it",
183 run.approved_by.as_deref().unwrap_or("?"),
184 run.id
185 )
186 .into());
187 }
188 if target == Target::Pr && which("gh").is_none() {
189 return Err("`pma pr` needs `gh` on PATH; `pma push` pushes without it".into());
190 }
191 if approved == &wt_git(run, &["rev-parse", &format!("{}^{{tree}}", run.base)])? {
192 return Err("nothing to publish: no changes".into());
193 }
194 let upstream = format!("origin/{}", run.default_branch);
195 if target == Target::Push {
196 wt_git(run, &["fetch", "--quiet", "origin"])?;
197 if head != run.base
200 && &head != approved_head
201 && wt_git(run, &["merge-base", "--is-ancestor", "HEAD", &upstream]).is_ok()
202 {
203 let sha = wt_git(run, &["rev-parse", "--short", "HEAD"])?;
204 return Ok(format!("already pushed {sha} to {}", run.default_branch));
205 }
206 }
207 let message = message(cfg, run);
208
209 wt_git(
215 run,
216 &[
217 "checkout",
218 "--quiet",
219 "--force",
220 "-B",
221 &run.branch,
222 &run.base,
223 ],
224 )?;
225 wt_git(run, &["read-tree", "--reset", "-u", approved])?;
226 wt_git(run, &["commit", "--quiet", "-m", &message])?;
227 if target == Target::Push
228 && let Err(e) = wt_git(run, &["rebase", "--quiet", &upstream])
229 {
230 let _ = wt_git(run, &["rebase", "--abort"]);
231 return Err(format!(
232 "rebase onto {upstream} failed: {e}. Reject the run and dispatch the \
233 task again, or rebase it in {} and approve it again",
234 wt.display()
235 )
236 .into());
237 }
238
239 if !crate::dispatch::without_item(&run.task_key) {
242 let path = wt.join("TODO.md");
243 let text =
244 std::fs::read_to_string(&path).map_err(|e| format!("{}: {e}", path.display()))?;
245 if let Some(edited) = todo::mark_done(&text, &run.task_key, &run.text) {
246 std::fs::write(&path, edited).map_err(|e| format!("{}: {e}", path.display()))?;
247 wt_git(run, &["add", "TODO.md"])?;
248 wt_git(run, &["commit", "--quiet", "--amend", "--no-edit"])?;
249 }
250 }
251 if wt_git(run, &["rev-list", "--count", &format!("{upstream}..HEAD")])? == "0" {
252 return Err("nothing to publish: no changes".into());
253 }
254 if let Some(command) = &run.verify {
258 let timeout = std::time::Duration::from_secs(cfg.timeout as u64 * 60);
259 let agent_env = home.join("agent-env");
260 let log = home
261 .join("runs")
262 .join(run.id.to_string())
263 .join("verify-publish.log");
264 let _ = std::fs::create_dir_all(log.parent().unwrap_or(&agent_env));
265 let sandbox = cfg.project(&run.project).sandbox;
266 match crate::dispatch::verify_once(command, wt, sandbox, &agent_env, &log, timeout) {
267 Ok((Some(true), _)) => {}
268 Ok((Some(false), _)) => {
269 return Err(format!(
270 "`{command}` failed on the integrated tree; see {}",
271 log.display()
272 )
273 .into());
274 }
275 Ok((None, _)) => {
276 return Err(format!("`{command}` timed out on the integrated tree").into());
277 }
278 Err(e) => return Err(format!("`{command}` could not run: {e}").into()),
279 }
280 }
281
282 let outcome = match target {
285 Target::Push => {
286 wt_git(
287 run,
288 &[
289 "push",
290 "--quiet",
291 "origin",
292 &format!("HEAD:refs/heads/{}", run.default_branch),
293 ],
294 )?;
295 let sha = wt_git(run, &["rev-parse", "--short", "HEAD"])?;
296 format!("pushed {sha} to {}", run.default_branch)
297 }
298 Target::Pr => {
299 let pushed = wt_git(run, &["fetch", "--quiet", "origin", &run.branch]).is_ok()
302 && wt_git(
303 run,
304 &["rev-parse", &format!("origin/{}^{{tree}}", run.branch)],
305 )? == wt_git(run, &["rev-parse", "HEAD^{tree}"])?;
306 if !pushed {
307 wt_git(run, &["push", "--quiet", "-u", "origin", &run.branch])?;
308 }
309 let open = gh_in(
312 wt,
313 &[
314 "pr",
315 "list",
316 "--head",
317 &run.branch,
318 "--state",
319 "open",
320 "--json",
321 "url",
322 "--jq",
323 ".[0].url // empty",
324 ],
325 )?;
326 if open.is_empty() {
327 let (title, body) = message.split_once("\n\n").unwrap_or((&message, ""));
328 gh_in(
329 wt,
330 &[
331 "pr",
332 "create",
333 "--base",
334 &run.default_branch,
335 "--head",
336 &run.branch,
337 "--title",
338 title,
339 "--body",
340 body,
341 ],
342 )?
343 } else {
344 open
345 }
346 }
347 };
348 Ok(outcome)
349}
350
351pub fn settle(
357 store: &Store,
358 mut done: impl FnMut(&Run, &std::result::Result<String, String>),
359) -> Result<()> {
360 for mut run in store.runs()? {
361 if run.state != RunState::PrOpen {
362 continue;
363 }
364 let url = run.outcome.clone().unwrap_or_default();
365 let read = crate::sync::gh(&[
366 "pr",
367 "view",
368 &url,
369 "--json",
370 "state,reviewDecision,comments,reviews",
371 "--jq",
372 "[.state, .reviewDecision, (.comments | length), (.reviews | length)] | @tsv",
373 ]);
374 let read = match read {
375 Ok(s) => s,
376 Err(e) => {
377 done(&run, &Err(e));
378 continue;
379 }
380 };
381 let mut fields = read.trim().split('\t');
382 let state = fields.next().unwrap_or_default();
383 let decision = fields.next().unwrap_or_default();
384 let comments: usize = fields.next().and_then(|n| n.parse().ok()).unwrap_or(0);
385 let reviews: usize = fields.next().and_then(|n| n.parse().ok()).unwrap_or(0);
386 let outcome = match state {
387 "MERGED" => {
388 run.enter(RunState::Merged);
390 format!("pull request merged: {url}")
391 }
392 "CLOSED" => {
393 run.enter(RunState::Closed);
394 run.error = Some("pull request closed without merging".into());
395 format!("pull request closed without merging: {url}")
396 }
397 _ => {
398 if let Some(activity) = activity(decision, comments, reviews) {
399 done(&run, &Ok(format!("pull request open: {activity}: {url}")));
400 }
401 continue;
402 }
403 };
404 store.update_run(&run)?;
405 done(&run, &Ok(outcome));
406 }
407 Ok(())
408}
409
410fn activity(decision: &str, comments: usize, reviews: usize) -> Option<String> {
414 let plural = |n: usize, one: &str| format!("{n} {one}{}", if n == 1 { "" } else { "s" });
415 let mut parts = Vec::new();
416 match decision {
417 "APPROVED" => parts.push("approved".to_string()),
418 "CHANGES_REQUESTED" => parts.push("changes requested".to_string()),
419 _ => {}
420 }
421 if comments > 0 {
422 parts.push(plural(comments, "comment"));
423 }
424 if reviews > 0 {
425 parts.push(plural(reviews, "review"));
426 }
427 (!parts.is_empty()).then(|| parts.join(", "))
428}
429
430fn gh_in(dir: &std::path::Path, args: &[&str]) -> Result<String> {
432 let out = Command::new("gh")
433 .args(args)
434 .current_dir(dir)
435 .output()
436 .map_err(|e| format!("gh: {e}"))?;
437 if !out.status.success() {
438 return Err(format!(
439 "gh {}: {}",
440 args[..2].join(" "),
441 String::from_utf8_lossy(&out.stderr).trim()
442 )
443 .into());
444 }
445 Ok(String::from_utf8_lossy(&out.stdout).trim().to_string())
446}
447
448fn message(cfg: &Config, run: &Run) -> String {
450 let mut body = Vec::new();
451 if let Some(n) = run.gh {
452 body.push(format!("Closes #{n}"));
453 }
454 if cfg.attribution == Attribution::CoAuthor && run.agent == "claude" {
457 body.push("Co-Authored-By: Claude <noreply@anthropic.com>".into());
458 }
459 if body.is_empty() {
460 run.text.clone()
461 } else {
462 format!("{}\n\n{}", run.text, body.join("\n\n"))
463 }
464}
465
466fn which(program: &str) -> Option<std::path::PathBuf> {
467 std::env::var_os("PATH").and_then(|paths| {
468 std::env::split_paths(&paths)
469 .map(|d| d.join(program))
470 .find(|p| p.is_file())
471 })
472}
473
474#[cfg(test)]
475mod tests {
476 use super::*;
477
478 #[test]
479 fn activity_on_an_open_pull_request_reads_as_github_shows_it() {
480 assert_eq!(activity("", 0, 0), None);
481 assert_eq!(activity("REVIEW_REQUIRED", 0, 0), None);
482 assert_eq!(
483 activity("CHANGES_REQUESTED", 2, 1).as_deref(),
484 Some("changes requested, 2 comments, 1 review")
485 );
486 assert_eq!(
487 activity("APPROVED", 0, 1).as_deref(),
488 Some("approved, 1 review")
489 );
490 assert_eq!(activity("", 1, 0).as_deref(), Some("1 comment"));
491 }
492}