fix(#2375): pr_is_open state check at submission + atomic fetched_sha INSERT

Two hardening items from argus's review of #2374:

1. PR state check at submission:
   - Add `pr_is_open(repo, pr)` to forge/pr_merge.rs using
     `repo_get_pull_request` + `StateType` — early error if the PR is
     already closed or merged instead of queuing a card that fails later
   - Call it in `submit_merge_config_pr` before fetching the head sha

2. Atomic fetched_sha INSERT:
   - Add `fetched_sha: Option<&str>` to `Approvals::submit_kind` so
     the sha can be included in the INSERT rather than a follow-up UPDATE
   - MergeConfigPr already knows the sha before inserting the row
     (pr_head_sha runs first) → pass `Some(&sha)`, drop the separate
     `set_fetched_sha` call → truly atomic
   - ApplyCommit still needs two writes (sha resolved by git_fetch_to_tag
     after the row exists) → pass `None`, `set_fetched_sha` unchanged
   - All other callers (InitConfig, Spawn, UpdateMetaInputs,
     SchedulePrompt) pass `None` — no behavioural change
   - Add `fetched_sha_in_insert_is_readable_via_get` test covering the
     MergeConfigPr path
This commit is contained in:
atlas 2026-07-11 10:27:20 +02:00 committed by mara
commit 96eda4ed6b
7 changed files with 102 additions and 15 deletions

View file

@ -83,6 +83,7 @@ pub(super) fn handle_request_update_meta_inputs(
&commit_ref,
description,
requester,
None,
)
.map_err(|e| anyhow::anyhow!("{e:#}"))
{
@ -162,7 +163,21 @@ async fn submit_merge_config_pr(
);
}
let repo = crate::forge::config_repo(agent);
// Verify the PR is still open before queueing an approval that would
// fail at approve time anyway (a closed/merged PR has no live head ref
// for the drift gate to compare against).
if !crate::forge::pr_is_open(&repo, pr_number)
.await
.map_err(|e| anyhow::anyhow!("check PR state for {agent} PR #{pr_number}: {e}"))?
{
anyhow::bail!(
"PR #{pr_number} on {repo} is closed or already merged — \
request_merge_config_pr requires an open PR"
);
}
// Fetch the current PR head sha — becomes the "reviewed" sha.
// Submitted together with the approval row (atomic single INSERT) so a
// crash between submit and set_fetched_sha cannot leave a stranded row.
let sha = crate::forge::pr_head_sha(&repo, pr_number)
.await
.map_err(|e| anyhow::anyhow!("fetch PR head sha for {agent} PR #{pr_number}: {e}"))?;
@ -174,12 +189,9 @@ async fn submit_merge_config_pr(
&pr_number.to_string(),
description,
submitter,
Some(&sha), // atomic: sha inserted with the row, not in a separate UPDATE
)
.map_err(|e| anyhow::anyhow!("queue merge_config_pr approval row: {e:#}"))?;
coord
.approvals
.set_fetched_sha(id, &sha)
.map_err(|e| anyhow::anyhow!("persist fetched_sha: {e:#}"))?;
let sha_short = sha[..sha.len().min(12)].to_owned();
coord.emit_approval_added(crate::coordinator::ApprovalAdded {
id,
@ -249,6 +261,7 @@ pub(crate) fn submit_init_config(
// parent); it's also the submitter the approval events route
// back to. No declared parent = operator-initiated path.
parent.unwrap_or("operator"),
None, // no sha for InitConfig
)
.map_err(|e| anyhow::anyhow!("queue approval row: {e:#}"))?;
tracing::info!(%id, %name, "init_config approval queued");
@ -309,6 +322,7 @@ pub(crate) async fn submit_apply_commit(
commit_ref,
description,
submitter,
None, // sha resolved after git_fetch_to_tag below; set via set_fetched_sha
)
.map_err(|e| anyhow::anyhow!("queue approval row: {e:#}"))?;
let tag = format!("proposal/{id}");

View file

@ -62,6 +62,7 @@ pub(super) fn handle_request_schedule_prompt(
&commit_ref,
payload.description.as_deref(),
requester,
None,
) {
Ok(id) => id,
Err(e) => {