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.

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