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.

353 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 // No `before` on the default branch: the repository's first push.
117 first: default && event.data["before"].as_str().is_none(),
118 },
119 )
120 .await?;
121 if pushed.landed.is_empty() && pushed.commits.is_empty() {
122 return Ok(Vec::new());
123 }
124 let actor: Viewer =
125 g1t_kit::call(&self.identity, "user_by_id", &g1t_contracts::identity::UserIdArgs { id: actor_id.to_owned() }).await?;
126 let Some(actor) = actor else { return Ok(Vec::new()) };
127 let repo: Outcome<Repo> =
128 g1t_kit::call(&self.repos, "get_by_id", &GetByIdArgs { id: repo_id.to_owned(), viewer: Some(actor.clone()) }).await?;
129 let Outcome::Ok(repo) = repo else { return Ok(Vec::new()) };
130
131 let mut merged = Vec::new();
132 for row in candidates.iter().filter(|row| pushed.landed.contains(&row.head_commit)) {
133 let Some(pull) = self.pull_by_id(&row.id).await? else { continue };
134 let landed = Landed {
135 commit: after.to_owned(),
136 previous: event.data["before"].as_str().map(str::to_owned),
137 method: if row.head_commit == after { MergeMethod::FastForward } else { MergeMethod::Merge },
138 rebased: Vec::new(),
139 };
140 let recorded = self.record_merge(&repo, pull, &actor, false, landed, Some(branch)).await?;
141 if recorded.status != PullStatus::Merged {
142 continue;
143 }
144 // Waiting in the merge queue: it has nothing left to land.
145 if self.leave(repo_id, &recorded, QueueState::Removed, Some("A push merged it.")).await? {
146 self.publish_as(
147 "queue.changed",
148 repo_id,
149 None,
150 g1t_contracts::events::QueueChanged { repo_id: repo_id.to_owned() },
151 )
152 .await?;
153 }
154 merged.push(recorded.id);
155 }
156
157 if closes_issues {
158 let path = RepoPath { namespace: repo.namespace.clone(), name: repo.name.clone() };
159 for (number, commit) in commit_closes(&pushed.commits, &path) {
160 self.close_by_commit(&repo, number, &commit, &actor).await?;
161 }
162 }
163 Ok(merged)
164 }
165
166 /// Closes an open issue as completed by a commit pushed to the default
167 /// branch: says so on the issue, and lets go of what waited on it.
168 async fn close_by_commit(&self, repo: &Repo, number: u32, commit: &str, actor: &User) -> Result<()> {
169 let Some(issue) = self.issue(&repo.id, number).await?.filter(|issue| issue.state == State::Open) else {
170 return Ok(());
171 };
172 let now = rfc3339(now_ms());
173 let closed = self
174 .db
175 .prepare(CLOSE_BY_COMMIT)
176 .bind(&[
177 commit.into(),
178 actor.username.as_str().into(),
179 now.as_str().into(),
180 now.as_str().into(),
181 issue.id.as_str().into(),
182 ])?
183 .first::<crate::rows::ValueRow>(None)
184 .await?;
185 if closed.is_none() {
186 return Ok(());
187 }
188 self.publish(
189 "issue.closed",
190 &repo.id,
191 actor,
192 IssueEvent {
193 reason: Some(IssueReason::Completed.as_str()),
194 commit: Some(commit.to_owned()),
195 ..Self::issue_event(&issue)
196 },
197 )
198 .await?;
199 self.note(&repo.id, number, (&actor.id, &actor.username), &format!("closed this in {commit}")).await?;
200 self.issue_resolved(&repo.id, number, actor, true, None).await;
201 Ok(())
202 }
203
204 /// `closed_by_commit`: the issues a commit closed (`close_by_commit`),
205 /// for anyone who may read the repository.
206 pub(crate) async fn closed_by_commit(&self, a: ClosedByCommitArgs) -> Result<Outcome<Vec<ClosedIssue>>> {
207 let Some(commit) = crate::commit_comments::valid_commit(&a.commit).filter(|commit| commit.len() >= 40) else {
208 return Ok(Outcome::Ok(Vec::new()));
209 };
210 let repo = match self.repo(&a.repo, &a.viewer).await? {
211 Outcome::Ok(repo) => repo,
212 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
213 };
214 Ok(Outcome::Ok(
215 self.db
216 .prepare(CLOSED_BY_COMMIT)
217 .bind(&[repo.id.as_str().into(), commit.into()])?
218 .all()
219 .await?
220 .results::<ClosedIssue>()?,
221 ))
222 }
223}
224
225#[cfg(test)]
226mod tests {
227 use g1t_contracts::repos::PushedCommit;
228 use rusqlite::{Connection, OptionalExtension, params};
229
230 use super::*;
231
232 /// The work database as the migrations make it.
233 fn database() -> Connection {
234 let db = Connection::open_in_memory().unwrap();
235 let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("migrations");
236 let mut paths: Vec<_> = std::fs::read_dir(dir)
237 .unwrap()
238 .map(|entry| entry.unwrap().path())
239 .filter(|path| path.extension().is_some_and(|ext| ext == "sql"))
240 .collect();
241 paths.sort();
242 for path in paths {
243 db.execute_batch(&std::fs::read_to_string(path).unwrap()).unwrap();
244 }
245 db
246 }
247
248 fn pull(db: &Connection, id: &str, number: u32, status: &str, base: Option<&str>, head: Option<&str>) {
249 db.execute(
250 "INSERT INTO pulls (id, repo_id, number, title, agent, runtime, author_id, author_name, status,
251 base_branch, head_commit, created_at, updated_at)
252 VALUES (?1, 'repo', ?2, 'Change', 'codex', 'external', 'user', 'user', ?3, ?4, ?5, 'now', 'now')",
253 params![id, number, status, base, head],
254 )
255 .unwrap();
256 }
257
258 fn candidates(db: &Connection, base: Option<&str>, landed: Option<u32>) -> Vec<String> {
259 let mut statement = db.prepare(CANDIDATES).unwrap();
260 let mut ids: Vec<String> = statement
261 .query_map(params!["repo", base, landed, MAX_CANDIDATES], |row| row.get(0))
262 .unwrap()
263 .map(Result::unwrap)
264 .collect();
265 ids.sort();
266 ids
267 }
268
269 #[test]
270 fn the_open_pull_requests_into_the_branch_are_looked_at() {
271 let db = database();
272 pull(&db, "open", 1, "open", None, Some("h1"));
273 pull(&db, "draft", 2, "draft", None, Some("h2"));
274 pull(&db, "closed", 3, "closed", None, Some("h3"));
275 pull(&db, "merged", 4, "merged", None, Some("h4"));
276 pull(&db, "empty", 5, "open", None, None);
277 pull(&db, "release", 6, "open", Some("release"), Some("h6"));
278 assert_eq!(candidates(&db, None, None), vec!["draft", "open"]);
279 assert_eq!(candidates(&db, Some("release"), None), vec!["release"]);
280 // The pull request the push landed is recorded by whoever landed it.
281 assert_eq!(candidates(&db, None, Some(1)), vec!["draft"]);
282 }
283
284 #[test]
285 fn the_entry_the_merge_queue_is_landing_is_left_to_it() {
286 let db = database();
287 pull(&db, "landing", 1, "open", None, Some("h1"));
288 pull(&db, "waiting", 2, "open", None, Some("h2"));
289 db.execute_batch(
290 "INSERT INTO queue_entries (id, state, repo_id, pull_id, number, enqueued_by, created_at, landing_commit)
291 VALUES ('e1', 'passed', 'repo', 'landing', 1, '{}', 'now', 'c1');
292 INSERT INTO queue_entries (id, state, repo_id, pull_id, number, enqueued_by, created_at)
293 VALUES ('e2', 'waiting', 'repo', 'waiting', 2, '{}', 'now');",
294 )
295 .unwrap();
296 assert_eq!(candidates(&db, None, None), vec!["waiting"]);
297 }
298
299 #[test]
300 fn a_commit_closes_an_open_issue_once_and_says_which() {
301 let db = database();
302 db.execute_batch(
303 "INSERT INTO issues (id, repo_id, number, title, body, labels, state, author_id, author_name, created_at, updated_at)
304 VALUES ('iss', 'repo', 7, 'Broken header', '', '[]', 'open', 'user', 'user', 'now', 'now');",
305 )
306 .unwrap();
307 let close = |commit: &str| {
308 db.query_row(CLOSE_BY_COMMIT, params![commit, "ana", "later", "later", "iss"], |row| row.get::<_, String>(0))
309 .optional()
310 .unwrap()
311 };
312 assert_eq!(close("c1").as_deref(), Some("iss"));
313 // Already closed: a later commit naming it changes nothing.
314 assert_eq!(close("c2"), None);
315 let (state, commit, by, resolved_by): (String, String, String, Option<u32>) = db
316 .query_row("SELECT state, resolved_commit, resolved_pushed_by, resolved_by FROM issues WHERE id = 'iss'", [], |row| {
317 Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?))
318 })
319 .unwrap();
320 assert_eq!((state.as_str(), commit.as_str(), by.as_str(), resolved_by), ("closed", "c1", "ana", None));
321 let closed: Vec<(u32, String)> = db
322 .prepare(CLOSED_BY_COMMIT)
323 .unwrap()
324 .query_map(params!["repo", "c1"], |row| Ok((row.get(0)?, row.get(1)?)))
325 .unwrap()
326 .map(Result::unwrap)
327 .collect();
328 assert_eq!(closed, vec![(7, "Broken header".to_owned())]);
329 }
330
331 fn commit(hash: &str, message: &str) -> PushedCommit {
332 PushedCommit { hash: hash.into(), message: message.into() }
333 }
334
335 #[test]
336 fn each_issue_is_closed_by_the_first_commit_that_names_it() {
337 let repo = RepoPath { namespace: "acme".into(), name: "web".into() };
338 let commits = [
339 commit("a", "Fix the header\n\nFixes #12, closes #3"),
340 commit("b", "Tidy up (#12)\n\nResolves #12 and resolves acme/web#7"),
341 commit("c", "Mention #9 without a keyword; see `fixes #10` in code"),
342 commit("d", "Closes other/repo#4"),
343 ];
344 assert_eq!(commit_closes(&commits, &repo), vec![(12, "a".into()), (3, "a".into()), (7, "b".into())]);
345 }
346
347 #[test]
348 fn no_more_than_a_pull_request_closes() {
349 let repo = RepoPath { namespace: "acme".into(), name: "web".into() };
350 let message: String = (1..=80).map(|n| format!("Fixes #{n}\n")).collect();
351 assert_eq!(commit_closes(&[commit("a", &message)], &repo).len(), closing::MAX_CLOSES);
352 }
353}