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.

336 linesCodeBlameRaw
1//! The work items a pull request or a commit says it closes or is part of,
2//! and what landing them does.
3//!
4//! A closing keyword followed by a reference (`Fixes #12`, `closes: #3`,
5//! `Resolves flagon-io/g1t#40`) names a work item to close; `Part of #12`
6//! or `Refs #12` one it is part of (`g1t_contracts::work::refs`). Each is
7//! read in the repository the pull request or commit is in, and only work
8//! items of that repository's workspace count: another workspace's are
9//! never closed from here.
10//!
11//! When a pull request merges into the default branch, or a commit naming
12//! a work item is pushed there, where the repository closes work items
13//! that way (`RepoSettings::auto_close_items`), the repository's link to
14//! each work item it closes is marked landed and the resolution recorded
15//! ([`LAND_SQL`]); the work item closes once it has landed in every target
16//! it has, or at once when it has none. What it is part of is linked
17//! (`touched`), and closes nothing.
18
19use g1t_contracts::events::WorkItemEvent;
20use g1t_contracts::repos::Repo;
21use g1t_contracts::time::rfc3339;
22use g1t_contracts::work::refs::{self, Ref, RefScope};
23use g1t_contracts::work::*;
24use g1t_contracts::User;
25use g1t_kit::now_ms;
26use worker::Result;
27use worker::wasm_bindgen::JsValue;
28
29use crate::Work;
30use crate::items::{Held, Named, StoredResolution, landing_note, path_of};
31use crate::rows::{By, ValueRow};
32
33/// The most work items one pull request or commit closes this way.
34pub(crate) const MAX_CLOSES: usize = refs::MAX_REFS;
35
36/// What landed: a pull request merged, or a commit pushed.
37#[derive(Clone, Debug, Default)]
38pub(crate) struct Landing {
39 pub pull: Option<u32>,
40 pub commit: Option<String>,
41 pub pushed_by: Option<String>,
42}
43
44/// Landing a work item (`?1`) in repository `?2` at `?3`, by `?4` (an
45/// account id), resolved by `?5` (a stored resolution, JSON), whose pull
46/// request and commit are `?6` and `?7`, in one batch: linked (as touched,
47/// unless it is already), marked landed there, the resolution recorded
48/// once, and closed when no target is left to land.
49pub(crate) const LAND_SQL: [&str; 4] = [
50 "INSERT INTO work_item_repos (item_id, repo_id, role, added_at, added_by)
51 VALUES (?1, ?2, 'touched', ?3, ?4) ON CONFLICT (item_id, repo_id) DO NOTHING",
52 "UPDATE work_item_repos SET landed_at = COALESCE(landed_at, ?3) WHERE item_id = ?1 AND repo_id = ?2",
53 "UPDATE work_items SET resolutions = json_insert(resolutions, '$[#]', json(?5)), updated_at = ?3
54 WHERE id = ?1 AND NOT EXISTS (SELECT 1 FROM json_each(work_items.resolutions) j
55 WHERE json_extract(j.value, '$.repo_id') = ?2 AND json_extract(j.value, '$.pull') IS ?6
56 AND json_extract(j.value, '$.commit') IS ?7)",
57 "UPDATE work_items SET state = 'closed', reason = 'completed', closed_at = ?3, updated_at = ?3
58 WHERE id = ?1 AND state = 'open'
59 AND NOT EXISTS (SELECT 1 FROM work_item_repos WHERE item_id = ?1 AND role = 'target' AND landed_at IS NULL)
60 RETURNING id AS value",
61];
62
63/// Linking a work item (`?1`) that a change in repository `?2` is part of,
64/// at `?3`, by `?4`, unless it is linked already.
65pub(crate) const PART_SQL: &str = "INSERT INTO work_item_repos (item_id, repo_id, role, added_at, added_by)
66 VALUES (?1, ?2, 'touched', ?3, ?4) ON CONFLICT (item_id, repo_id) DO NOTHING RETURNING item_id AS value";
67
68/// The work items a text names, by id and number, in the order named.
69#[derive(Clone, Debug, Default, PartialEq, Eq)]
70pub(crate) struct NamedItems {
71 pub closes: Vec<(String, u32)>,
72 pub parts: Vec<(String, u32)>,
73}
74
75impl NamedItems {
76 pub(crate) fn close_ids(&self) -> Vec<String> {
77 self.closes.iter().map(|(id, _)| id.clone()).collect()
78 }
79
80 pub(crate) fn part_ids(&self) -> Vec<String> {
81 self.parts.iter().map(|(id, _)| id.clone()).collect()
82 }
83
84 pub(crate) fn close_numbers(&self) -> Vec<u32> {
85 self.closes.iter().map(|(_, number)| *number).collect()
86 }
87
88 pub(crate) fn part_numbers(&self) -> Vec<u32> {
89 self.parts.iter().map(|(_, number)| *number).collect()
90 }
91}
92
93impl Work {
94 /// The work item `reference` names, read in `repo`: its id, when it is
95 /// one of the workspace's work items.
96 pub(crate) async fn ref_item(&self, repo: &Repo, reference: &Ref) -> Result<Option<String>> {
97 let workspace = repo.namespace.to_lowercase();
98 let (repo_id, in_repo) = match &reference.scope {
99 RefScope::Here => (Some(repo.id.clone()), true),
100 RefScope::Workspace(named) => {
101 if *named != workspace {
102 return Ok(None);
103 }
104 (None, false)
105 }
106 RefScope::Repo(path) => {
107 if !path.namespace.eq_ignore_ascii_case(&workspace) {
108 return Ok(None);
109 }
110 let named = format!("{}/{}", path.namespace, path.name);
111 if named.eq_ignore_ascii_case(&path_of(repo)) {
112 (Some(repo.id.clone()), true)
113 } else {
114 let known = self
115 .db
116 .prepare("SELECT repo_id AS value FROM repo_workspaces WHERE lower(path) = lower(?)")
117 .bind(&[named.as_str().into()])?
118 .first::<ValueRow>(None)
119 .await?;
120 match known {
121 Some(row) => (Some(row.value), true),
122 None => (None, false),
123 }
124 }
125 }
126 };
127 let found = self.lookup_ref(repo_id.as_deref(), &workspace, reference.number).await?;
128 Ok(match found.named(in_repo) {
129 Some(Named::Item(id)) => Some(id),
130 _ => None,
131 })
132 }
133
134 /// The work items `text`, written in `repo`, closes and is part of,
135 /// with `own` (a pull request's own work item) closed first.
136 pub(crate) async fn named_items(&self, repo: &Repo, own: Option<&str>, text: &str) -> Result<NamedItems> {
137 let named = refs::named(text);
138 let mut closes: Vec<String> = own.map(str::to_owned).into_iter().collect();
139 for reference in &named.closes {
140 if let Some(id) = self.ref_item(repo, reference).await?
141 && !closes.contains(&id)
142 {
143 closes.push(id);
144 }
145 }
146 let mut parts: Vec<String> = Vec::new();
147 for reference in &named.parts {
148 if let Some(id) = self.ref_item(repo, reference).await?
149 && !closes.contains(&id)
150 && !parts.contains(&id)
151 {
152 parts.push(id);
153 }
154 }
155 closes.truncate(MAX_CLOSES);
156 parts.truncate(MAX_CLOSES);
157 let mut all = closes.clone();
158 all.extend(parts.iter().cloned());
159 let held = self.items_by_ids(&all).await?;
160 let number = |id: &String| held.iter().find(|held| held.item.id == *id).map(|held| (id.clone(), held.item.number));
161 Ok(NamedItems { closes: closes.iter().filter_map(number).collect(), parts: parts.iter().filter_map(number).collect() })
162 }
163
164 /// The work items a pull request closes and is part of as its title and
165 /// description say now: none closed where it does not merge into the
166 /// default branch, where the repository does not close work items that
167 /// way, or where it is kept open (`keep_open`), when its own work item
168 /// is only something it is part of.
169 pub(crate) async fn items_to_close(&self, pull: &Pull, repo: &Repo, settings: &RepoSettings, keep_open: bool) -> Result<NamedItems> {
170 let own = self.pull_item_id(&pull.id).await?;
171 let text = format!("{}\n{}", pull.title, pull.body.as_deref().unwrap_or_default());
172 let mut named = self.named_items(repo, own.as_deref(), &text).await?;
173 if keep_open || !settings.auto_close_items || !pull.targets_default(&repo.default_branch) {
174 let closed = std::mem::take(&mut named.closes);
175 let mut parts = if keep_open { closed } else { Vec::new() };
176 parts.extend(named.parts);
177 named.parts = parts;
178 }
179 Ok(named)
180 }
181
182 /// The id of a pull request's own work item, if it has one.
183 pub(crate) async fn pull_item_id(&self, pull_id: &str) -> Result<Option<String>> {
184 Ok(self
185 .db
186 .prepare("SELECT item_id AS value FROM pulls WHERE id = ? AND item_id IS NOT NULL")
187 .bind(&[pull_id.into()])?
188 .first::<ValueRow>(None)
189 .await?
190 .map(|row| row.value))
191 }
192
193 /// Lands `closes` (work items, by id) in `repo` by `landing`, and links
194 /// `parts`: see the module's notes. Says so on each, with the session
195 /// it was done in when one is known; `agent` when an agent pushed for
196 /// `actor` (the note is then g1t's, for them). Returns the work items
197 /// it closed.
198 #[allow(clippy::too_many_arguments)]
199 pub(crate) async fn land(
200 &self,
201 repo: &Repo,
202 landing: &Landing,
203 actor: &User,
204 closes: &[String],
205 parts: &[String],
206 session: Option<&str>,
207 agent: bool,
208 ) -> Result<Vec<Held>> {
209 let now = rfc3339(now_ms());
210 let path = path_of(repo);
211 let by = match By::from(actor) {
212 by if agent && by.acting_for.is_none() && by.id == actor.id => By {
213 id: g1t_contracts::identity::AGENT_ID,
214 name: g1t_contracts::identity::AGENT_NAME,
215 acting_for: Some((&actor.id, &actor.username)),
216 },
217 by => by,
218 };
219 let stored = StoredResolution {
220 repo_id: repo.id.clone(),
221 pull: landing.pull,
222 commit: landing.commit.clone(),
223 pushed_by: landing.pushed_by.clone(),
224 };
225 let resolution = Resolution {
226 repo: path.clone(),
227 pull: landing.pull,
228 commit: landing.commit.clone(),
229 pushed_by: landing.pushed_by.clone(),
230 };
231 let what = landing_note(&path, landing.pull, landing.commit.as_deref());
232 let mut closed = Vec::new();
233 for id in closes {
234 let Some(before) = self.item_by_id(id).await? else { continue };
235 let binds: [JsValue; 7] = [
236 id.as_str().into(),
237 repo.id.as_str().into(),
238 now.as_str().into(),
239 actor.id.as_str().into(),
240 serde_json::to_string(&stored)?.into(),
241 landing.pull.map_or(JsValue::NULL, JsValue::from),
242 landing.commit.as_deref().map_or(JsValue::NULL, JsValue::from),
243 ];
244 let statements = LAND_SQL
245 .iter()
246 .map(|sql| self.db.prepare(*sql).bind(&binds[..g1t_kit::rename::parameters(sql)]))
247 .collect::<Result<Vec<_>>>()?;
248 let results = self.db.batch(statements).await?;
249 let closed_now = results
250 .get(3)
251 .map(|result| result.results::<ValueRow>())
252 .transpose()?
253 .is_some_and(|rows| !rows.is_empty());
254 let Some(held) = self.item_by_id(id).await? else { continue };
255 if closed_now {
256 self.publish_item(
257 "work_item.closed",
258 &held,
259 Some(actor),
260 WorkItemEvent {
261 reason: Some(CloseReason::Completed.as_str()),
262 resolution: Some(resolution.clone()),
263 missing: Some(Vec::new()),
264 session: session.map(str::to_owned),
265 ..Self::item_event(&held)
266 },
267 )
268 .await?;
269 let text = match landing.pull {
270 Some(_) => format!("closed this by merging {what}"),
271 None => format!("closed this in {what}"),
272 };
273 self.note_item(id, by, &crate::pushed::closed_note(&text, session)).await?;
274 self.item_resolved(&held, actor, true, landing.pull).await;
275 closed.push(held);
276 continue;
277 }
278 self.publish_item(
279 "work_item.landed",
280 &held,
281 Some(actor),
282 WorkItemEvent {
283 resolution: Some(resolution.clone()),
284 missing: Some(held.item.missing()),
285 session: session.map(str::to_owned),
286 ..Self::item_event(&held)
287 },
288 )
289 .await?;
290 if before.item.state == State::Open {
291 let missing = held.item.missing();
292 let text = if missing.is_empty() {
293 format!("landed {what}")
294 } else {
295 format!("landed {what}; still to land in {}", missing.join(", "))
296 };
297 self.note_item(id, by, &crate::pushed::closed_note(&text, session)).await?;
298 // Merged for it: what it blocks hears, though it stays open.
299 self.item_resolved(&held, actor, false, landing.pull).await;
300 }
301 }
302 for id in parts {
303 let linked = self
304 .db
305 .prepare(PART_SQL)
306 .bind(&[id.as_str().into(), repo.id.as_str().into(), now.as_str().into(), actor.id.as_str().into()])?
307 .first::<ValueRow>(None)
308 .await?;
309 if linked.is_none() {
310 continue;
311 }
312 let Some(held) = self.item_by_id(id).await? else { continue };
313 self.publish_item(
314 "work_item.linked",
315 &held,
316 Some(actor),
317 WorkItemEvent { repo: Some(path.clone()), role: Some(LinkRole::Touched), ..Self::item_event(&held) },
318 )
319 .await?;
320 self.note_item(id, by, &format!("linked {path}: {what} is part of this")).await?;
321 }
322 Ok(closed)
323 }
324}
325
326#[cfg(test)]
327mod tests {
328 use super::*;
329
330 #[test]
331 fn landing_statements_take_the_item_repository_and_what_landed() {
332 let counts: Vec<usize> = LAND_SQL.iter().map(|sql| g1t_kit::rename::parameters(sql)).collect();
333 assert_eq!(counts, vec![4, 3, 7, 3]);
334 assert_eq!(g1t_kit::rename::parameters(PART_SQL), 4);
335 }
336}