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.

376 linesCodeBlameRaw
1//! What a push to a branch settles, as a merge from the site would.
2//!
3//! An open pull request into the branch whose head the push brought in was
4//! merged by it: it is recorded as merged by whoever pushed, through the
5//! same bookkeeping as any merge (`Work::record_merge`). A push that landed
6//! a pull request names it (`GitPush::landed_pull`): whoever landed it
7//! records that one, and the merge queue's entry being landed is left to
8//! the queue.
9//!
10//! A commit pushed to the default branch whose message names a work item
11//! after a closing keyword (`Fixes #12`, items/refs.rs) lands it there, where
12//! the repository closes work items that way, and the work item says which
13//! commit and who pushed it; `Part of #12` links it.
14
15use g1t_contracts::events::Event;
16use g1t_contracts::repos::{GetByIdArgs, Landed, Pushed, PushedArgs, Repo};
17use g1t_contracts::rules::MergeMethod;
18use g1t_contracts::work::*;
19use g1t_contracts::{Outcome, User, Viewer};
20use serde::Deserialize;
21use worker::Result;
22use worker::wasm_bindgen::JsValue;
23
24use crate::Work;
25use crate::items::{self, Held};
26
27/// How many open pull requests into a branch are looked at after a push.
28const MAX_CANDIDATES: u32 = 100;
29
30/// An open pull request into the pushed branch, and the head it is at.
31#[derive(Deserialize)]
32struct CandidateRow {
33 id: String,
34 head_commit: String,
35}
36
37/// The open pull requests into a branch (`None`: the default branch) that
38/// a push may have merged: those with a head, but not the one the push
39/// landed, nor one the merge queue is landing.
40const CANDIDATES: &str = "SELECT id, head_commit FROM pulls
41 WHERE repo_id = ?1 AND status IN ('draft', 'open') AND base_branch IS ?2 AND head_commit IS NOT NULL
42 AND number IS NOT ?3
43 AND NOT EXISTS (SELECT 1 FROM queue_entries q
44 WHERE q.pull_id = pulls.id AND q.state = 'passed' AND q.landing_commit IS NOT NULL)
45 ORDER BY updated_at DESC LIMIT ?4";
46
47/// A session (`?1`) of a repository (`?2`).
48const SESSION_IN_REPO: &str = "SELECT id AS value FROM sessions WHERE id = ?1 AND repo_id = ?2";
49
50/// The session, still going, of the caller (`?2`, or the person it acts
51/// for, `?3`) that works on work item `?1`: attached to it or tied to it
52/// since, in any repository; the one seen last.
53const CALLER_SESSION: &str = "SELECT id AS value FROM sessions
54 WHERE state = 'active' AND actor_id IN (?2, ?3)
55 AND (item_id = ?1 OR EXISTS (SELECT 1 FROM session_links l
56 WHERE l.session_id = sessions.id AND l.kind = 'item' AND l.item_id = ?1))
57 ORDER BY last_seen_at DESC LIMIT 1";
58
59/// A note of what closed a work item, and the session it was closed in.
60pub(crate) fn closed_note(note: &str, session: Option<&str>) -> String {
61 match session {
62 Some(session) => format!("{note} via session {session}"),
63 None => note.to_owned(),
64 }
65}
66
67/// References, each with the commit that named it.
68pub(crate) type CommitRefs = Vec<(refs::Ref, String)>;
69
70/// The references a push's commits name after a closing keyword, and after
71/// `Part of`, each with the first commit, oldest first, that names it: in
72/// the order named, each once, no more than one pull request may close.
73pub(crate) fn commit_refs(commits: &[g1t_contracts::repos::PushedCommit]) -> (CommitRefs, CommitRefs) {
74 let mut closes: CommitRefs = Vec::new();
75 let mut parts: CommitRefs = Vec::new();
76 for commit in commits {
77 let named = refs::named(&commit.message);
78 for reference in named.closes {
79 if !closes.iter().any(|(known, _)| *known == reference) {
80 closes.push((reference, commit.hash.clone()));
81 }
82 }
83 for reference in named.parts {
84 if !parts.iter().any(|(known, _)| *known == reference) {
85 parts.push((reference, commit.hash.clone()));
86 }
87 }
88 }
89 parts.retain(|(part, _)| !closes.iter().any(|(closed, _)| closed == part));
90 closes.truncate(items::MAX_CLOSES);
91 parts.truncate(items::MAX_CLOSES);
92 (closes, parts)
93}
94
95impl Work {
96 /// After a push moved `branch` of a repository: records the pull
97 /// requests into it that the push merged, and, on the default branch,
98 /// lands the work items its commits name. Returns the pull requests it
99 /// recorded as merged. Never fails the push's handling: a problem is
100 /// logged.
101 pub(crate) async fn after_branch_push(&self, event: &Event, repo_id: &str, branch: &str, after: &str) -> Vec<String> {
102 match self.settle_push(event, repo_id, branch, after).await {
103 Ok(merged) => merged,
104 Err(error) => {
105 worker::console_error!("push to {branch} of {repo_id}: merges and closes not recorded: {error}");
106 Vec::new()
107 }
108 }
109 }
110
111 async fn settle_push(&self, event: &Event, repo_id: &str, branch: &str, after: &str) -> Result<Vec<String>> {
112 // Who pushed is who merged; a push nobody made (an import) settles nothing.
113 let Some(actor_id) = event.actor.as_deref() else { return Ok(Vec::new()) };
114 let default = event.data["defaultBranch"].as_bool() == Some(true);
115 let landed_pull = event.data["landedPull"].as_u64().and_then(|n| u32::try_from(n).ok());
116 let base = if default { JsValue::NULL } else { branch.into() };
117 let candidates = self
118 .db
119 .prepare(CANDIDATES)
120 .bind(&[repo_id.into(), base, landed_pull.map_or(JsValue::NULL, Into::into), MAX_CANDIDATES.into()])?
121 .all()
122 .await?
123 .results::<CandidateRow>()?;
124 let closes_items = default && self.settings(repo_id).await?.auto_close_items;
125 if candidates.is_empty() && !closes_items {
126 return Ok(Vec::new());
127 }
128 let pushed: Pushed = g1t_kit::call(
129 &self.repos,
130 "pushed",
131 &PushedArgs {
132 repo_id: repo_id.to_owned(),
133 before: event.data["before"].as_str().map(str::to_owned),
134 after: after.to_owned(),
135 heads: candidates.iter().map(|row| row.head_commit.clone()).collect(),
136 messages: closes_items,
137 // No `before` on the default branch: the repository's first push.
138 first: default && event.data["before"].as_str().is_none(),
139 },
140 )
141 .await?;
142 if pushed.landed.is_empty() && pushed.commits.is_empty() {
143 return Ok(Vec::new());
144 }
145 let actor: Viewer =
146 g1t_kit::call(&self.identity, "user_by_id", &g1t_contracts::identity::UserIdArgs { id: actor_id.to_owned() }).await?;
147 let Some(actor) = actor else { return Ok(Vec::new()) };
148 let repo: Outcome<Repo> =
149 g1t_kit::call(&self.repos, "get_by_id", &GetByIdArgs { id: repo_id.to_owned(), viewer: Some(actor.clone()) }).await?;
150 let Outcome::Ok(repo) = repo else { return Ok(Vec::new()) };
151
152 let mut merged = Vec::new();
153 for row in candidates.iter().filter(|row| pushed.landed.contains(&row.head_commit)) {
154 let Some(pull) = self.pull_by_id(&row.id).await? else { continue };
155 let landed = Landed {
156 commit: after.to_owned(),
157 previous: event.data["before"].as_str().map(str::to_owned),
158 method: if row.head_commit == after { MergeMethod::FastForward } else { MergeMethod::Merge },
159 rebased: Vec::new(),
160 };
161 let recorded = self.record_merge(&repo, pull, &actor, false, landed, Some(branch)).await?;
162 if recorded.status != PullStatus::Merged {
163 continue;
164 }
165 // Waiting in the merge queue: it has nothing left to land.
166 if self.leave(repo_id, &recorded, QueueState::Removed, Some("A push merged it.")).await? {
167 self.publish_as(
168 "queue.changed",
169 repo_id,
170 None,
171 g1t_contracts::events::QueueChanged { repo_id: repo_id.to_owned() },
172 )
173 .await?;
174 }
175 merged.push(recorded.id);
176 }
177
178 // A session g1t started pushed it: what it closes is g1t's, for the pusher.
179 let agent = event.data["agent"].as_str().is_some();
180 if closes_items {
181 let (closes, parts) = commit_refs(&pushed.commits);
182 for (reference, commit) in closes.iter().map(|(r, c)| (r, c)).chain(parts.iter().map(|(r, c)| (r, c))) {
183 let Some(id) = items::referenced(self, &repo, reference).await? else { continue };
184 let closing = closes.iter().any(|(known, _)| known == reference);
185 // Only what the pusher may close, or see: anything else is a
186 // reference to nothing.
187 let Some(held) = items::by_id(self, &id).await? else { continue };
188 if !items::admits(&held, &[&actor], closing) {
189 continue;
190 }
191 let message = pushed.commits.iter().find(|pushed| pushed.hash == *commit).map(|pushed| pushed.message.as_str());
192 let session = self.trailer_session(&repo.id, message.unwrap_or_default()).await?;
193 let landing =
194 items::Landing { pull: None, commit: Some(commit.clone()), pushed_by: Some(actor.username.clone()) };
195 let (closes, parts) = if closing { (vec![id], Vec::new()) } else { (Vec::new(), vec![id]) };
196 items::land(self, &repo, &landing, &actor, &closes, &parts, session.as_deref(), agent).await?;
197 }
198 }
199 Ok(merged)
200 }
201
202 /// The session a commit message names in a `G1t-Session` trailer, when
203 /// it is one of this repository's.
204 pub(crate) async fn trailer_session(&self, repo_id: &str, message: &str) -> Result<Option<String>> {
205 let Some(id) = session_trailers(message).into_iter().next() else { return Ok(None) };
206 Ok(self
207 .db
208 .prepare(SESSION_IN_REPO)
209 .bind(&[id.as_str().into(), repo_id.into()])?
210 .first::<crate::rows::ValueRow>(None)
211 .await?
212 .map(|row| row.value))
213 }
214
215 /// The session closing a work item with a token: the caller's own
216 /// session on it that is still going, the one seen last. A person
217 /// signed in on the site closes it in none.
218 pub(crate) async fn caller_session(&self, held: &Held, actor: &User) -> Result<Option<String>> {
219 if actor.token.is_none() && actor.acting.is_none() {
220 return Ok(None);
221 }
222 let person = actor.acting.as_ref().map_or(actor.id.as_str(), |acting| acting.on_behalf_of.id.as_str());
223 Ok(self
224 .db
225 .prepare(CALLER_SESSION)
226 .bind(&[held.item.id.as_str().into(), actor.id.as_str().into(), person.into()])?
227 .first::<crate::rows::ValueRow>(None)
228 .await?
229 .map(|row| row.value))
230 }
231}
232
233#[cfg(test)]
234mod tests {
235 use g1t_contracts::repos::{PushedCommit, RepoPath};
236 use rusqlite::{Connection, OptionalExtension, params};
237
238 use super::*;
239
240 /// The work database as the migrations make it.
241 fn database() -> Connection {
242 crate::migrated::database()
243 }
244
245 fn pull(db: &Connection, id: &str, number: u32, status: &str, base: Option<&str>, head: Option<&str>) {
246 db.execute(
247 "INSERT INTO pulls (id, repo_id, number, title, agent, runtime, author_id, author_name, status,
248 base_branch, head_commit, created_at, updated_at)
249 VALUES (?1, 'repo', ?2, 'Change', 'codex', 'external', 'user', 'user', ?3, ?4, ?5, 'now', 'now')",
250 params![id, number, status, base, head],
251 )
252 .unwrap();
253 }
254
255 fn candidates(db: &Connection, base: Option<&str>, landed: Option<u32>) -> Vec<String> {
256 let mut statement = db.prepare(CANDIDATES).unwrap();
257 let mut ids: Vec<String> = statement
258 .query_map(params!["repo", base, landed, MAX_CANDIDATES], |row| row.get(0))
259 .unwrap()
260 .map(Result::unwrap)
261 .collect();
262 ids.sort();
263 ids
264 }
265
266 #[test]
267 fn the_open_pull_requests_into_the_branch_are_looked_at() {
268 let db = database();
269 pull(&db, "open", 1, "open", None, Some("h1"));
270 pull(&db, "draft", 2, "draft", None, Some("h2"));
271 pull(&db, "closed", 3, "closed", None, Some("h3"));
272 pull(&db, "merged", 4, "merged", None, Some("h4"));
273 pull(&db, "empty", 5, "open", None, None);
274 pull(&db, "release", 6, "open", Some("release"), Some("h6"));
275 assert_eq!(candidates(&db, None, None), vec!["draft", "open"]);
276 assert_eq!(candidates(&db, Some("release"), None), vec!["release"]);
277 // The pull request the push landed is recorded by whoever landed it.
278 assert_eq!(candidates(&db, None, Some(1)), vec!["draft"]);
279 }
280
281 #[test]
282 fn the_entry_the_merge_queue_is_landing_is_left_to_it() {
283 let db = database();
284 pull(&db, "landing", 1, "open", None, Some("h1"));
285 pull(&db, "waiting", 2, "open", None, Some("h2"));
286 db.execute_batch(
287 "INSERT INTO queue_entries (id, state, repo_id, pull_id, number, enqueued_by, created_at, landing_commit)
288 VALUES ('e1', 'passed', 'repo', 'landing', 1, '{}', 'now', 'c1');
289 INSERT INTO queue_entries (id, state, repo_id, pull_id, number, enqueued_by, created_at)
290 VALUES ('e2', 'waiting', 'repo', 'waiting', 2, '{}', 'now');",
291 )
292 .unwrap();
293 assert_eq!(candidates(&db, None, None), vec!["waiting"]);
294 }
295
296 fn commit(hash: &str, message: &str) -> PushedCommit {
297 PushedCommit { hash: hash.into(), message: message.into() }
298 }
299
300 fn here(number: u32) -> refs::Ref {
301 refs::Ref { scope: refs::RefScope::Here, number }
302 }
303
304 #[test]
305 fn each_reference_is_landed_by_the_first_commit_that_names_it() {
306 let commits = [
307 commit("a", "Fix the header\n\nFixes #12, closes #3"),
308 commit("b", "Tidy up (#12)\n\nResolves #12 and resolves acme/web#7. Part of #20"),
309 commit("c", "Mention #9 without a keyword; see `fixes #10` in code"),
310 commit("d", "Refs #3, part of #21"),
311 ];
312 let (closes, parts) = commit_refs(&commits);
313 let web = refs::Ref { scope: refs::RefScope::Repo(RepoPath { namespace: "acme".into(), name: "web".into() }), number: 7 };
314 assert_eq!(closes, vec![(here(12), "a".into()), (here(3), "a".into()), (web, "b".into())]);
315 // What a commit closes is not also only part of it.
316 assert_eq!(parts, vec![(here(20), "b".into()), (here(21), "d".into())]);
317 }
318
319 #[test]
320 fn no_more_than_a_pull_request_closes() {
321 let message: String = (1..=80).map(|n| format!("Fixes #{n}\n")).collect();
322 assert_eq!(commit_refs(&[commit("a", &message)]).0.len(), items::MAX_CLOSES);
323 }
324
325 #[test]
326 fn a_close_names_the_session_it_was_made_in() {
327 assert_eq!(closed_note("closed this in abc", Some("ses_1")), "closed this in abc via session ses_1");
328 assert_eq!(closed_note("closed this as completed", None), "closed this as completed");
329 }
330
331 #[test]
332 fn the_callers_session_on_the_work_item_is_the_one_still_going_seen_last() {
333 let db = database();
334 db.execute_batch(
335 "INSERT INTO work_items (id, workspace, number, title, body, author_id, author_name, created_at, updated_at)
336 VALUES ('wki_7', 'acme', 7, 't', '', 'u', 'u', '', ''), ('wki_8', 'acme', 8, 't', '', 'u', 'u', '', ''),
337 ('wki_9', 'acme', 9, 't', '', 'u', 'u', '', '');",
338 )
339 .unwrap();
340 let session = |id: &str, repo: &str, actor: &str, item: Option<&str>, state: &str, seen: &str| {
341 db.execute(
342 "INSERT INTO sessions (id, repo_id, item_id, actor_id, actor_name, harness, state, started_at, last_seen_at)
343 VALUES (?1, ?2, ?3, ?4, ?4, 'claude_code', ?5, 'then', ?6)",
344 params![id, repo, item, actor, state, seen],
345 )
346 .unwrap();
347 };
348 session("ses_old", "repo", "ana", Some("wki_7"), "active", "1");
349 // In another repository of the workspace, on the same work item.
350 session("ses_new", "other", "ana", Some("wki_7"), "active", "2");
351 session("ses_ended", "repo", "ana", Some("wki_7"), "ended", "3");
352 session("ses_other", "repo", "bo", Some("wki_7"), "active", "4");
353 session("ses_elsewhere", "repo", "ana", Some("wki_8"), "active", "5");
354 // Tied to work item 9 since it started, by a claim.
355 session("ses_claimed", "repo", "ana", None, "active", "6");
356 db.execute(
357 "INSERT INTO session_links (session_id, repo_id, kind, number, item_id, via, at)
358 VALUES ('ses_claimed', 'repo', 'item', 0, 'wki_9', 'claim', 'now')",
359 [],
360 )
361 .unwrap();
362 let caller = |item: &str, actor: &str, person: &str| -> Option<String> {
363 db.query_row(CALLER_SESSION, params![item, actor, person], |row| row.get(0)).optional().unwrap()
364 };
365 assert_eq!(caller("wki_7", "ana", "ana").as_deref(), Some("ses_new"));
366 // g1t acting for ana: ana's session.
367 assert_eq!(caller("wki_7", "g1t", "ana").as_deref(), Some("ses_new"));
368 assert_eq!(caller("wki_9", "ana", "ana").as_deref(), Some("ses_claimed"));
369 assert_eq!(caller("wki_7", "cy", "cy"), None);
370 let in_repo = |id: &str| -> Option<String> {
371 db.query_row(SESSION_IN_REPO, params![id, "repo"], |row| row.get(0)).optional().unwrap()
372 };
373 assert_eq!(in_repo("ses_old").as_deref(), Some("ses_old"));
374 assert_eq!(in_repo("ses_nowhere"), None);
375 }
376}