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.

206 linesCodeBlameRaw
1//! A work item's repositories (`work_item_repos`): its origin, its targets
2//! and those it touched, and where each repository is (`repo_workspaces`).
3
4use g1t_contracts::access::Capability;
5use g1t_contracts::events::WorkItemEvent;
6use g1t_contracts::repos::{Repo, RepoPath};
7use g1t_contracts::time::rfc3339;
8use g1t_contracts::work::*;
9use g1t_contracts::{FailureCode, Outcome, User};
10use g1t_kit::now_ms;
11use worker::Result;
12
13use super::rows::{Held, no_item, path_of};
14use crate::rows::ValueRow;
15use crate::{UNVERIFIED, Work};
16
17/// Records where repository `?1` is: workspace `?2`, path `?3`, and
18/// whether it is private (`?4`). It is there, so not deleted.
19pub(crate) const REMEMBER_REPO: &str = "INSERT INTO repo_workspaces (repo_id, workspace, path, private)
20 VALUES (?1, ?2, ?3, ?4)
21 ON CONFLICT (repo_id) DO UPDATE SET workspace = excluded.workspace, path = excluded.path, private = excluded.private,
22 deleted_at = NULL";
23
24/// `repo.deleted` (`?1` its id, at `?2`): its links say it is deleted
25/// until `repo.restored` records it again ([`REMEMBER_REPO`]) or
26/// `repo.purged` drops its row.
27pub(crate) const MARK_DELETED: &str = "UPDATE repo_workspaces SET deleted_at = COALESCE(deleted_at, ?2) WHERE repo_id = ?1";
28
29/// Linking a work item (`?1`) that a change in repository `?2` is part of,
30/// at `?3`, by `?4`, unless it is linked already.
31pub(crate) const PART_SQL: &str = "INSERT INTO work_item_repos (item_id, repo_id, role, added_at, added_by)
32 VALUES (?1, ?2, 'touched', ?3, ?4) ON CONFLICT (item_id, repo_id) DO NOTHING RETURNING item_id AS value";
33
34/// Closes work item `?1` at `?2` once what landed for it is all it waits
35/// for: open, landed somewhere, and no target left to land in (one was
36/// just unlinked or made something else). One that never landed stays
37/// open: it has not been done anywhere.
38pub(crate) const CLOSE_IF_LANDED: &str = "UPDATE work_items SET state = 'closed', reason = 'completed', closed_at = ?2, updated_at = ?2
39 WHERE id = ?1 AND state = 'open' AND resolutions <> '[]'
40 AND NOT EXISTS (SELECT 1 FROM work_item_repos WHERE item_id = ?1 AND role = 'target' AND landed_at IS NULL)
41 RETURNING id AS value";
42
43impl Work {
44 /// Records which workspace `repo` is in, its path and whether it is
45 /// private, as repos just said.
46 pub(super) async fn remember_repo(&self, repo: &Repo) -> Result<()> {
47 self.db
48 .prepare(REMEMBER_REPO)
49 .bind(&[
50 repo.id.as_str().into(),
51 repo.namespace.to_lowercase().into(),
52 path_of(repo).into(),
53 u32::from(repo.is_private).into(),
54 ])?
55 .run()
56 .await?;
57 Ok(())
58 }
59
60 /// The workspace repository `repo_id` is in: as recorded, or as repos
61 /// says when it is not yet.
62 pub(super) async fn repo_workspace(&self, repo_id: &str) -> Result<Option<String>> {
63 let recorded = self
64 .db
65 .prepare("SELECT workspace AS value FROM repo_workspaces WHERE repo_id = ?")
66 .bind(&[repo_id.into()])?
67 .first::<ValueRow>(None)
68 .await?;
69 if let Some(row) = recorded {
70 return Ok(Some(row.value));
71 }
72 let path: Option<RepoPath> =
73 g1t_kit::call(&self.repos, "path_by_id", &g1t_contracts::repos::PathByIdArgs { id: repo_id.to_owned() }).await?;
74 Ok(path.map(|path| path.namespace.to_lowercase()))
75 }
76
77 /// Links `repo` to a work item it now has a pull request for, as
78 /// touched, unless it is linked already, and says so.
79 pub(super) async fn touch_item(&self, held: &Held, repo: &Repo, actor: &User) -> Result<()> {
80 let linked = self
81 .db
82 .prepare(PART_SQL)
83 .bind(&[held.item.id.as_str().into(), repo.id.as_str().into(), rfc3339(now_ms()).into(), actor.id.as_str().into()])?
84 .first::<ValueRow>(None)
85 .await?;
86 if linked.is_none() {
87 return Ok(());
88 }
89 let Some(after) = self.item_by_id(&held.item.id).await? else {
90 return Ok(());
91 };
92 self.publish_item(
93 "work_item.linked",
94 &after,
95 Some(actor),
96 WorkItemEvent { repo: Some(path_of(repo)), role: Some(LinkRole::Touched), ..Self::item_event(&after) },
97 )
98 .await
99 }
100
101 pub(crate) async fn link_work_item(&self, a: LinkWorkItemArgs) -> Result<Outcome<WorkItem>> {
102 self.relink(a, false).await
103 }
104
105 pub(crate) async fn unlink_work_item(&self, a: LinkWorkItemArgs) -> Result<Outcome<WorkItem>> {
106 self.relink(a, true).await
107 }
108
109 async fn relink(&self, a: LinkWorkItemArgs, unlink: bool) -> Result<Outcome<WorkItem>> {
110 if !a.actor.verified {
111 return Ok(Outcome::fail(FailureCode::Forbidden, UNVERIFIED));
112 }
113 let held = check!(self.item_for(&Some(a.actor.clone()), &a.workspace, a.number, Capability::Triage).await?);
114 let repo = check!(self.repo(&a.repo, &Some(a.actor.clone())).await?);
115 let path = path_of(&repo);
116 if !repo.namespace.eq_ignore_ascii_case(&held.item.workspace) {
117 return Ok(Outcome::fail(FailureCode::Invalid, format!("{path} is not a repository of {}.", held.item.workspace)));
118 }
119 self.remember_repo(&repo).await?;
120 let current = held.item.link(&repo.id).cloned();
121 if current.as_ref().is_some_and(|link| link.role == LinkRole::Origin) {
122 return Ok(Outcome::fail(
123 FailureCode::Conflict,
124 format!("{path} is where this work item was filed; that link stays as it is."),
125 ));
126 }
127 let now = rfc3339(now_ms());
128 let (kind, role, text) = if unlink {
129 let Some(link) = current else {
130 return Ok(Outcome::fail(FailureCode::NotFound, format!("This work item is not linked to {path}.")));
131 };
132 self.db
133 .prepare("DELETE FROM work_item_repos WHERE item_id = ? AND repo_id = ? AND role <> 'origin'")
134 .bind(&[held.item.id.as_str().into(), repo.id.as_str().into()])?
135 .run()
136 .await?;
137 ("work_item.unlinked", link.role, format!("unlinked {path}"))
138 } else {
139 if a.role == LinkRole::Origin {
140 return Ok(Outcome::fail(FailureCode::Invalid, "A work item's origin is where it was filed; link a target instead."));
141 }
142 if current.as_ref().is_some_and(|link| link.role == a.role) {
143 return Ok(Outcome::Ok(held.shown(Some(&a.actor))));
144 }
145 self.db
146 .prepare(
147 "INSERT INTO work_item_repos (item_id, repo_id, role, path, added_at, added_by)
148 VALUES (?1, ?2, ?3, ?4, ?5, ?6)
149 ON CONFLICT (item_id, repo_id) DO UPDATE SET role = excluded.role WHERE work_item_repos.role <> 'origin'",
150 )
151 .bind(&[
152 held.item.id.as_str().into(),
153 repo.id.as_str().into(),
154 a.role.as_str().into(),
155 path.as_str().into(),
156 now.as_str().into(),
157 a.actor.id.as_str().into(),
158 ])?
159 .run()
160 .await?;
161 let text = match a.role {
162 LinkRole::Target => format!("added {path} as a target"),
163 _ => format!("linked {path}"),
164 };
165 ("work_item.linked", a.role, text)
166 };
167 self.db
168 .prepare("UPDATE work_items SET updated_at = ? WHERE id = ?")
169 .bind(&[now.as_str().into(), held.item.id.as_str().into()])?
170 .run()
171 .await?;
172 // A target no longer one: what landed may be all it waited for.
173 let closed = if role == LinkRole::Target && (unlink || a.role != LinkRole::Target) {
174 self.db
175 .prepare(CLOSE_IF_LANDED)
176 .bind(&[held.item.id.as_str().into(), now.as_str().into()])?
177 .first::<ValueRow>(None)
178 .await?
179 .is_some()
180 } else {
181 false
182 };
183 let Some(after) = self.item_by_id(&held.item.id).await? else {
184 return Ok(no_item());
185 };
186 // An unlinked repository still hears that it was.
187 let mut event = Self::item_event(&after);
188 if unlink {
189 event.repo_ids.push(repo.id.clone());
190 }
191 self.publish_item(kind, &after, Some(&a.actor), WorkItemEvent { repo: Some(path), role: Some(role), ..event }).await?;
192 self.note_item(&after.item.id, &a.actor, &text).await?;
193 if closed {
194 self.publish_item(
195 "work_item.closed",
196 &after,
197 Some(&a.actor),
198 WorkItemEvent { reason: Some(CloseReason::Completed.as_str()), missing: Some(Vec::new()), ..Self::item_event(&after) },
199 )
200 .await?;
201 self.note_item(&after.item.id, &a.actor, "closed this: it has landed in every target it has").await?;
202 self.item_resolved(&after, &a.actor, true, None).await;
203 }
204 Ok(Outcome::Ok(after.shown(Some(&a.actor))))
205 }
206}