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.

351 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 an issue
11//! after a closing keyword (`Fixes #12`, closing.rs) closes it, where the
12//! repository closes issues that way, and the issue says which commit and
13//! who pushed it.
14
15use g1t_contracts::events::{Event, IssueEvent};
16use g1t_contracts::repos::{GetByIdArgs, Landed, Pushed, PushedArgs, Repo, RepoPath};
17use g1t_contracts::rules::MergeMethod;
18use g1t_contracts::time::rfc3339;
19use g1t_contracts::work::*;
20use g1t_contracts::{Outcome, User, Viewer};
21use g1t_kit::now_ms;
22use serde::Deserialize;
23use worker::Result;
24use worker::wasm_bindgen::JsValue;
25
26use crate::{Work, closing};
27
28/// How many open pull requests into a branch are looked at after a push.
29const MAX_CANDIDATES: u32 = 100;
30
31/// An open pull request into the pushed branch, and the head it is at.
32#[derive(Deserialize)]
33struct CandidateRow {
34 id: String,
35 head_commit: String,
36}
37
38/// The open pull requests into a branch (`None`: the default branch) that
39/// a push may have merged: those with a head, but not the one the push
40/// landed, nor one the merge queue is landing.
41const CANDIDATES: &str = "SELECT id, head_commit FROM pulls
42 WHERE repo_id = ?1 AND status IN ('draft', 'open') AND base_branch IS ?2 AND head_commit IS NOT NULL
43 AND number IS NOT ?3
44 AND NOT EXISTS (SELECT 1 FROM queue_entries q
45 WHERE q.pull_id = pulls.id AND q.state = 'passed' AND q.landing_commit IS NOT NULL)
46 ORDER BY updated_at DESC LIMIT ?4";
47
48/// Closes an open issue as done by a commit (`?1`), pushed by `?2`.
49const CLOSE_BY_COMMIT: &str = "UPDATE issues
50 SET state = 'closed', reason = 'completed', resolved_by = NULL, resolved_commit = ?1,
51 resolved_pushed_by = ?2, closed_at = ?3, updated_at = ?4
52 WHERE id = ?5 AND state = 'open' RETURNING id AS value";
53
54/// The issues of a repository a commit closed.
55const CLOSED_BY_COMMIT: &str = "SELECT number, title FROM issues WHERE repo_id = ?1 AND resolved_commit = ?2
56 ORDER BY number LIMIT 50";
57
58/// The issues a push's commits close, each with the first commit, oldest
59/// first, that names it: in the order named, each once, no more than one
60/// pull request may close.
61pub(crate) fn commit_closes(commits: &[g1t_contracts::repos::PushedCommit], repo: &RepoPath) -> Vec<(u32, String)> {
62 let mut closes: Vec<(u32, String)> = Vec::new();
63 for commit in commits {
64 for number in closing::references(&commit.message, repo) {
65 if !closes.iter().any(|(named, _)| *named == number) {
66 closes.push((number, commit.hash.clone()));
67 }
68 }
69 }
70 closes.truncate(closing::MAX_CLOSES);
71 closes
72}
73
74impl Work {
75 /// After a push moved `branch` of a repository: records the pull
76 /// requests into it that the push merged, and, on the default branch,
77 /// closes the issues its commits name. Returns the pull requests it
78 /// recorded as merged. Never fails the push's handling: a problem is
79 /// logged.
80 pub(crate) async fn after_branch_push(&self, event: &Event, repo_id: &str, branch: &str, after: &str) -> Vec<String> {
81 match self.settle_push(event, repo_id, branch, after).await {
82 Ok(merged) => merged,
83 Err(error) => {
84 worker::console_error!("push to {branch} of {repo_id}: merges and closes not recorded: {error}");
85 Vec::new()
86 }
87 }
88 }
89
90 async fn settle_push(&self, event: &Event, repo_id: &str, branch: &str, after: &str) -> Result<Vec<String>> {
91 // Who pushed is who merged; a push nobody made (an import) settles nothing.
92 let Some(actor_id) = event.actor.as_deref() else { return Ok(Vec::new()) };
93 let default = event.data["defaultBranch"].as_bool() == Some(true);
94 let landed_pull = event.data["landedPull"].as_u64().and_then(|n| u32::try_from(n).ok());
95 let base = if default { JsValue::NULL } else { branch.into() };
96 let candidates = self
97 .db
98 .prepare(CANDIDATES)
99 .bind(&[repo_id.into(), base, landed_pull.map_or(JsValue::NULL, Into::into), MAX_CANDIDATES.into()])?
100 .all()
101 .await?
102 .results::<CandidateRow>()?;
103 let closes_issues = default && self.settings(repo_id).await?.auto_close_issues;
104 if candidates.is_empty() && !closes_issues {
105 return Ok(Vec::new());
106 }
107 let pushed: Pushed = g1t_kit::call(
108 &self.repos,
109 "pushed",
110 &PushedArgs {
111 repo_id: repo_id.to_owned(),
112 before: event.data["before"].as_str().map(str::to_owned),
113 after: after.to_owned(),
114 heads: candidates.iter().map(|row| row.head_commit.clone()).collect(),
115 messages: closes_issues,
116 },
117 )
118 .await?;
119 if pushed.landed.is_empty() && pushed.commits.is_empty() {
120 return Ok(Vec::new());
121 }
122 let actor: Viewer =
123 g1t_kit::call(&self.identity, "user_by_id", &g1t_contracts::identity::UserIdArgs { id: actor_id.to_owned() }).await?;
124 let Some(actor) = actor else { return Ok(Vec::new()) };
125 let repo: Outcome<Repo> =
126 g1t_kit::call(&self.repos, "get_by_id", &GetByIdArgs { id: repo_id.to_owned(), viewer: Some(actor.clone()) }).await?;
127 let Outcome::Ok(repo) = repo else { return Ok(Vec::new()) };
128
129 let mut merged = Vec::new();
130 for row in candidates.iter().filter(|row| pushed.landed.contains(&row.head_commit)) {
131 let Some(pull) = self.pull_by_id(&row.id).await? else { continue };
132 let landed = Landed {
133 commit: after.to_owned(),
134 previous: event.data["before"].as_str().map(str::to_owned),
135 method: if row.head_commit == after { MergeMethod::FastForward } else { MergeMethod::Merge },
136 rebased: Vec::new(),
137 };
138 let recorded = self.record_merge(&repo, pull, &actor, false, landed, Some(branch)).await?;
139 if recorded.status != PullStatus::Merged {
140 continue;
141 }
142 // Waiting in the merge queue: it has nothing left to land.
143 if self.leave(repo_id, &recorded, QueueState::Removed, Some("A push merged it.")).await? {
144 self.publish_as(
145 "queue.changed",
146 repo_id,
147 None,
148 g1t_contracts::events::QueueChanged { repo_id: repo_id.to_owned() },
149 )
150 .await?;
151 }
152 merged.push(recorded.id);
153 }
154
155 if closes_issues {
156 let path = RepoPath { namespace: repo.namespace.clone(), name: repo.name.clone() };
157 for (number, commit) in commit_closes(&pushed.commits, &path) {
158 self.close_by_commit(&repo, number, &commit, &actor).await?;
159 }
160 }
161 Ok(merged)
162 }
163
164 /// Closes an open issue as completed by a commit pushed to the default
165 /// branch: says so on the issue, and lets go of what waited on it.
166 async fn close_by_commit(&self, repo: &Repo, number: u32, commit: &str, actor: &User) -> Result<()> {
167 let Some(issue) = self.issue(&repo.id, number).await?.filter(|issue| issue.state == State::Open) else {
168 return Ok(());
169 };
170 let now = rfc3339(now_ms());
171 let closed = self
172 .db
173 .prepare(CLOSE_BY_COMMIT)
174 .bind(&[
175 commit.into(),
176 actor.username.as_str().into(),
177 now.as_str().into(),
178 now.as_str().into(),
179 issue.id.as_str().into(),
180 ])?
181 .first::<crate::rows::ValueRow>(None)
182 .await?;
183 if closed.is_none() {
184 return Ok(());
185 }
186 self.publish(
187 "issue.closed",
188 &repo.id,
189 actor,
190 IssueEvent {
191 reason: Some(IssueReason::Completed.as_str()),
192 commit: Some(commit.to_owned()),
193 ..Self::issue_event(&issue)
194 },
195 )
196 .await?;
197 self.note(&repo.id, number, (&actor.id, &actor.username), &format!("closed this in {commit}")).await?;
198 self.issue_resolved(&repo.id, number, actor, true, None).await;
199 Ok(())
200 }
201
202 /// `closed_by_commit`: the issues a commit closed (`close_by_commit`),
203 /// for anyone who may read the repository.
204 pub(crate) async fn closed_by_commit(&self, a: ClosedByCommitArgs) -> Result<Outcome<Vec<ClosedIssue>>> {
205 let Some(commit) = crate::commit_comments::valid_commit(&a.commit).filter(|commit| commit.len() >= 40) else {
206 return Ok(Outcome::Ok(Vec::new()));
207 };
208 let repo = match self.repo(&a.repo, &a.viewer).await? {
209 Outcome::Ok(repo) => repo,
210 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
211 };
212 Ok(Outcome::Ok(
213 self.db
214 .prepare(CLOSED_BY_COMMIT)
215 .bind(&[repo.id.as_str().into(), commit.into()])?
216 .all()
217 .await?
218 .results::<ClosedIssue>()?,
219 ))
220 }
221}
222
223#[cfg(test)]
224mod tests {
225 use g1t_contracts::repos::PushedCommit;
226 use rusqlite::{Connection, OptionalExtension, params};
227
228 use super::*;
229
230 /// The work database as the migrations make it.
231 fn database() -> Connection {
232 let db = Connection::open_in_memory().unwrap();
233 let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("migrations");
234 let mut paths: Vec<_> = std::fs::read_dir(dir)
235 .unwrap()
236 .map(|entry| entry.unwrap().path())
237 .filter(|path| path.extension().is_some_and(|ext| ext == "sql"))
238 .collect();
239 paths.sort();
240 for path in paths {
241 db.execute_batch(&std::fs::read_to_string(path).unwrap()).unwrap();
242 }
243 db
244 }
245
246 fn pull(db: &Connection, id: &str, number: u32, status: &str, base: Option<&str>, head: Option<&str>) {
247 db.execute(
248 "INSERT INTO pulls (id, repo_id, number, title, agent, runtime, author_id, author_name, status,
249 base_branch, head_commit, created_at, updated_at)
250 VALUES (?1, 'repo', ?2, 'Change', 'codex', 'external', 'user', 'user', ?3, ?4, ?5, 'now', 'now')",
251 params![id, number, status, base, head],
252 )
253 .unwrap();
254 }
255
256 fn candidates(db: &Connection, base: Option<&str>, landed: Option<u32>) -> Vec<String> {
257 let mut statement = db.prepare(CANDIDATES).unwrap();
258 let mut ids: Vec<String> = statement
259 .query_map(params!["repo", base, landed, MAX_CANDIDATES], |row| row.get(0))
260 .unwrap()
261 .map(Result::unwrap)
262 .collect();
263 ids.sort();
264 ids
265 }
266
267 #[test]
268 fn the_open_pull_requests_into_the_branch_are_looked_at() {
269 let db = database();
270 pull(&db, "open", 1, "open", None, Some("h1"));
271 pull(&db, "draft", 2, "draft", None, Some("h2"));
272 pull(&db, "closed", 3, "closed", None, Some("h3"));
273 pull(&db, "merged", 4, "merged", None, Some("h4"));
274 pull(&db, "empty", 5, "open", None, None);
275 pull(&db, "release", 6, "open", Some("release"), Some("h6"));
276 assert_eq!(candidates(&db, None, None), vec!["draft", "open"]);
277 assert_eq!(candidates(&db, Some("release"), None), vec!["release"]);
278 // The pull request the push landed is recorded by whoever landed it.
279 assert_eq!(candidates(&db, None, Some(1)), vec!["draft"]);
280 }
281
282 #[test]
283 fn the_entry_the_merge_queue_is_landing_is_left_to_it() {
284 let db = database();
285 pull(&db, "landing", 1, "open", None, Some("h1"));
286 pull(&db, "waiting", 2, "open", None, Some("h2"));
287 db.execute_batch(
288 "INSERT INTO queue_entries (id, state, repo_id, pull_id, number, enqueued_by, created_at, landing_commit)
289 VALUES ('e1', 'passed', 'repo', 'landing', 1, '{}', 'now', 'c1');
290 INSERT INTO queue_entries (id, state, repo_id, pull_id, number, enqueued_by, created_at)
291 VALUES ('e2', 'waiting', 'repo', 'waiting', 2, '{}', 'now');",
292 )
293 .unwrap();
294 assert_eq!(candidates(&db, None, None), vec!["waiting"]);
295 }
296
297 #[test]
298 fn a_commit_closes_an_open_issue_once_and_says_which() {
299 let db = database();
300 db.execute_batch(
301 "INSERT INTO issues (id, repo_id, number, title, body, labels, state, author_id, author_name, created_at, updated_at)
302 VALUES ('iss', 'repo', 7, 'Broken header', '', '[]', 'open', 'user', 'user', 'now', 'now');",
303 )
304 .unwrap();
305 let close = |commit: &str| {
306 db.query_row(CLOSE_BY_COMMIT, params![commit, "ana", "later", "later", "iss"], |row| row.get::<_, String>(0))
307 .optional()
308 .unwrap()
309 };
310 assert_eq!(close("c1").as_deref(), Some("iss"));
311 // Already closed: a later commit naming it changes nothing.
312 assert_eq!(close("c2"), None);
313 let (state, commit, by, resolved_by): (String, String, String, Option<u32>) = db
314 .query_row("SELECT state, resolved_commit, resolved_pushed_by, resolved_by FROM issues WHERE id = 'iss'", [], |row| {
315 Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?))
316 })
317 .unwrap();
318 assert_eq!((state.as_str(), commit.as_str(), by.as_str(), resolved_by), ("closed", "c1", "ana", None));
319 let closed: Vec<(u32, String)> = db
320 .prepare(CLOSED_BY_COMMIT)
321 .unwrap()
322 .query_map(params!["repo", "c1"], |row| Ok((row.get(0)?, row.get(1)?)))
323 .unwrap()
324 .map(Result::unwrap)
325 .collect();
326 assert_eq!(closed, vec![(7, "Broken header".to_owned())]);
327 }
328
329 fn commit(hash: &str, message: &str) -> PushedCommit {
330 PushedCommit { hash: hash.into(), message: message.into() }
331 }
332
333 #[test]
334 fn each_issue_is_closed_by_the_first_commit_that_names_it() {
335 let repo = RepoPath { namespace: "acme".into(), name: "web".into() };
336 let commits = [
337 commit("a", "Fix the header\n\nFixes #12, closes #3"),
338 commit("b", "Tidy up (#12)\n\nResolves #12 and resolves acme/web#7"),
339 commit("c", "Mention #9 without a keyword; see `fixes #10` in code"),
340 commit("d", "Closes other/repo#4"),
341 ];
342 assert_eq!(commit_closes(&commits, &repo), vec![(12, "a".into()), (3, "a".into()), (7, "b".into())]);
343 }
344
345 #[test]
346 fn no_more_than_a_pull_request_closes() {
347 let repo = RepoPath { namespace: "acme".into(), name: "web".into() };
348 let message: String = (1..=80).map(|n| format!("Fixes #{n}\n")).collect();
349 assert_eq!(commit_closes(&[commit("a", &message)], &repo).len(), closing::MAX_CLOSES);
350 }
351}