Skip to main content

pma_core/
publish.rs

1//! Publishing approved runs, one of two ways, each its own command:
2//! `pma push` commits a run and pushes it to the default branch, and `pma pr`
3//! commits it and opens a pull request. Both remove the worktree afterwards. A
4//! run with a pull request stays `pr-open`, holding its task, until the pull
5//! request is merged or closed. A third way, `land_local`, writes a verified
6//! run into its clone as unstaged changes and commits nothing.
7
8use 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/// Where a run's commit goes.
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum Target {
18    /// Rebased onto the remote default branch and pushed to it.
19    Push,
20    /// Pushed as the run's own branch, with a pull request opened for it.
21    Pr,
22}
23
24/// Publishes approved runs in id order, project by project. A failure stops
25/// the rest of that project and leaves its runs approved; running the command
26/// again resumes them. `done` sees each run with its outcome.
27pub 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                // Saved before cleanup, so a cleanup failure cannot hide
53                // what was published.
54                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
76/// Writes a run that passed verify into its clone as unstaged changes, then
77/// removes its worktree; a run that did not pass is rejected. Nothing is
78/// staged or committed: the developer stages, commits or discards. Returns
79/// whether it was applied, with what to report. The patch stays in the run's
80/// log directory either way.
81pub 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        // Written by git rather than through `git`'s trimmed output, which
101        // drops a final context line that is a single space. The flags undo
102        // settings that make a diff `apply` cannot read.
103        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            // Without `--index`: the working tree only, all or nothing.
124            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    // Before any publish has committed, an edit since approval is someone's
178    // work, so it is refused rather than discarded below.
179    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        // An earlier push pushed these commits and stopped before recording
198        // it. Pushed commits keep their ids, so HEAD is in the upstream.
199        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    // Every attempt builds one commit of the approved tree on the base. A
210    // failed publish leaves its commits, and verify its output, in the
211    // worktree; the agent's own commits hold whatever their history held,
212    // and review showed only the net diff. `-B` also recovers an agent that
213    // switched branches.
214    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    // After the rebase, so ticks on nearby lines by tasks published together
240    // do not conflict.
241    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    // The rebase merged other work into this tree, and the tick above added
255    // a line. Two changes that each pass against the same base can fail
256    // together, and a clean rebase is not a semantic one.
257    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    // Verify ran code from the tree and could have installed a `pre-push`
283    // hook; `wt_git` compares the snapshot again before the push.
284    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            // A retry rebuilt its commits, so their ids differ from what an
300            // earlier attempt pushed. The same tree there means it pushed.
301            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            // A retry after `gh pr create` failed may find the pull request
310            // made anyway.
311            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
351/// Moves each `pr-open` run to `merged` when its pull request is merged, or
352/// to `closed` when it is closed without merging. An open pull request keeps
353/// the run `pr-open`; `done` still sees it when someone has reviewed or
354/// commented on it, as GitHub shows it. `done` also sees each run whose pull
355/// request `gh` cannot read, which stays `pr-open`.
356pub 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                // `published_at` keeps the time the pull request was opened.
389                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
410/// What has happened on an open pull request, in GitHub's terms: its review
411/// decision, and how many comments and reviews it has. `None` when nothing
412/// has.
413fn 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
430/// Runs `gh` in `dir`, so it finds the repository from the git remote.
431fn 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
448/// The task text as subject; `Closes #N` and the co-author trailer as body.
449fn 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    // Only a worker whose identity `pma` knows gets a trailer. Inventing an
455    // address for an unknown one would attribute the commit to nobody.
456    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}