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

@ -70,6 +70,12 @@ impl Approvals {
})
}
/// Insert a new pending approval row. `fetched_sha` may be supplied
/// when the sha is already known at submission time (e.g. `MergeConfigPr`
/// fetches the PR head before inserting), making the insert + sha-set
/// atomic. Pass `None` when the sha is resolved after insertion (e.g.
/// `ApplyCommit`'s `git_fetch_to_tag` step) and call [`set_fetched_sha`]
/// separately.
pub fn submit_kind(
&self,
agent: &str,
@ -77,19 +83,22 @@ impl Approvals {
commit_ref: &str,
description: Option<&str>,
submitter: &str,
fetched_sha: Option<&str>,
) -> Result<i64> {
let conn = self.conn.lock().unwrap();
conn.execute(
"INSERT INTO approvals
(agent, kind, commit_ref, requested_at, status, description, submitter)
VALUES (?1, ?2, ?3, ?4, 'pending', ?5, ?6)",
(agent, kind, commit_ref, requested_at, status, description, submitter,
fetched_sha)
VALUES (?1, ?2, ?3, ?4, 'pending', ?5, ?6, ?7)",
params![
agent,
kind.as_str(),
commit_ref,
now_unix(),
description,
submitter
submitter,
fetched_sha,
],
)?;
Ok(conn.last_insert_rowid())
@ -415,6 +424,7 @@ mod tests {
"",
Some("scaffold"),
"bitburner",
None,
)
.expect("submit init_config");
let pending = db
@ -428,11 +438,11 @@ mod tests {
#[test]
fn mixed_kinds_all_listed() {
let (_dir, _path, db) = open_temp();
db.submit_kind("a", ApprovalKind::ApplyCommit, "deadbeef", None, "a")
db.submit_kind("a", ApprovalKind::ApplyCommit, "deadbeef", None, "a", None)
.unwrap();
db.submit_kind("b", ApprovalKind::Spawn, "", None, "b")
db.submit_kind("b", ApprovalKind::Spawn, "", None, "b", None)
.unwrap();
db.submit_kind("c", ApprovalKind::InitConfig, "", None, "c")
db.submit_kind("c", ApprovalKind::InitConfig, "", None, "c", None)
.unwrap();
let pending = db.pending().expect("pending");
assert_eq!(pending.len(), 3, "all three kinds must be visible");
@ -451,6 +461,7 @@ mod tests {
"cafef00d",
Some("test"),
"bitburner",
None,
)
.unwrap();
let row = db.mark_cancelled(id, "manager").expect("cancel");
@ -470,7 +481,7 @@ mod tests {
// final — re-cancelling errors instead of silently overwriting.
let (_dir, _path, db) = open_temp();
let id = db
.submit_kind("a", ApprovalKind::Spawn, "deadbeef", None, "a")
.submit_kind("a", ApprovalKind::Spawn, "deadbeef", None, "a", None)
.unwrap();
db.mark_cancelled(id, "manager").expect("first cancel");
let err = db
@ -485,7 +496,14 @@ mod tests {
// whole list — collect_lenient skips it instead of failing.
let (_dir, path, db) = open_temp();
let good = db
.submit_kind("good", ApprovalKind::ApplyCommit, "cafe", None, "good")
.submit_kind(
"good",
ApprovalKind::ApplyCommit,
"cafe",
None,
"good",
None,
)
.unwrap();
let raw = Connection::open(&path).unwrap();
raw.execute(
@ -508,7 +526,14 @@ mod tests {
// fall back to the root agent.
let (_dir, path, db) = open_temp();
let id = db
.submit_kind("child", ApprovalKind::ApplyCommit, "cafe", None, "parent")
.submit_kind(
"child",
ApprovalKind::ApplyCommit,
"cafe",
None,
"parent",
None,
)
.unwrap();
assert_eq!(db.submitter_of(id).unwrap().as_deref(), Some("parent"));
@ -522,4 +547,25 @@ mod tests {
let legacy_id = raw.last_insert_rowid();
assert_eq!(db.submitter_of(legacy_id).unwrap(), None);
}
#[test]
fn fetched_sha_in_insert_is_readable_via_get() {
// `submit_kind` with `Some(sha)` must store it atomically in the
// INSERT — the `get()` row must reflect it without a separate
// `set_fetched_sha` call. This is the MergeConfigPr path.
let (_dir, _path, db) = open_temp();
let sha = "abc1234567890abc1234567890abc1234567890ab";
let id = db
.submit_kind(
"janet",
ApprovalKind::MergeConfigPr,
"42",
None,
"ruth",
Some(sha),
)
.unwrap();
let row = db.get(id).unwrap().expect("row must exist");
assert_eq!(row.fetched_sha.as_deref(), Some(sha));
}
}