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.

356 linesCodeBlameRaw
1//! A work item as stored and as read: its row, its links and who may see
2//! it ([`Held`]).
3
4use g1t_contracts::access::{self, Capability, Denied, RepoRef};
5use g1t_contracts::repos::{Repo, RepoPath};
6use g1t_contracts::work::*;
7use g1t_contracts::{FailureCode, Outcome, User};
8use serde::{Deserialize, Serialize};
9
10use crate::rows::{milestone_ref, requester, user};
11
12/// A work item's columns, with what it is read with: its counts, agent,
13/// milestone's title, blockers by number and links.
14pub(crate) const ITEM_COLUMNS: &str = "work_items.*,
15 (SELECT count(*) FROM pulls WHERE pulls.item_id = work_items.id) AS pull_count,
16 (SELECT agent FROM pulls
17 WHERE pulls.item_id = work_items.id AND pulls.status IN ('draft', 'open')
18 AND pulls.fork_repo_id IS NOT NULL
19 ORDER BY pulls.created_at DESC LIMIT 1) AS agent,
20 (SELECT count(*) FROM comments
21 WHERE comments.item_id = work_items.id AND comments.kind = 'comment') AS comment_count,
22 (SELECT title FROM milestones
23 WHERE milestones.workspace = work_items.workspace AND milestones.number = work_items.milestone) AS milestone_title,
24 (SELECT json_group_array(number) FROM
25 (SELECT b.number AS number FROM json_each(work_items.blocked_by) j JOIN work_items b ON b.id = j.value
26 WHERE b.number IS NOT NULL ORDER BY j.key)) AS blocked_numbers,
27 (SELECT json_group_array(json_object('repo_id', l.repo_id, 'path', COALESCE(rw.path, l.path, ''), 'role', l.role,
28 'legacy_number', l.legacy_number, 'landed', l.landed_at IS NOT NULL, 'workspace', rw.workspace,
29 'private', rw.private))
30 FROM work_item_repos l LEFT JOIN repo_workspaces rw ON rw.repo_id = l.repo_id
31 WHERE l.item_id = work_items.id) AS links";
32
33#[derive(Deserialize)]
34pub(crate) struct ItemRow {
35 pub id: String,
36 pub workspace: Option<String>,
37 pub number: Option<u32>,
38 pub title: String,
39 pub body: String,
40 /// JSON array of label names.
41 pub labels: String,
42 pub state: State,
43 pub reason: Option<CloseReason>,
44 /// JSON array of `{ repo_id, pull, commit, pushed_by }`.
45 #[serde(default)]
46 pub resolutions: Option<String>,
47 pub author_id: String,
48 pub author_name: String,
49 #[serde(default)]
50 pub requested_by_id: Option<String>,
51 #[serde(default)]
52 pub requested_by_name: Option<String>,
53 pub created_at: String,
54 pub updated_at: String,
55 pub closed_at: Option<String>,
56 /// JSON array of usernames.
57 pub assignees: String,
58 #[serde(default)]
59 pub milestone: Option<u32>,
60 #[serde(default)]
61 pub milestone_title: Option<String>,
62 #[serde(default)]
63 pub pull_count: u32,
64 #[serde(default)]
65 pub comment_count: u32,
66 #[serde(default)]
67 pub agent: Option<String>,
68 /// JSON array of the numbers of the work items it is blocked by.
69 #[serde(default)]
70 pub blocked_numbers: Option<String>,
71 /// JSON array of [`LinkRow`].
72 #[serde(default)]
73 pub links: Option<String>,
74}
75
76/// A link as [`ITEM_COLUMNS`] reads it.
77#[derive(Deserialize)]
78struct LinkRow {
79 repo_id: String,
80 path: String,
81 role: LinkRole,
82 #[serde(default)]
83 legacy_number: Option<u32>,
84 #[serde(default)]
85 landed: u8,
86 /// Where the repository is, when known.
87 #[serde(default)]
88 workspace: Option<String>,
89 #[serde(default)]
90 private: Option<u8>,
91}
92
93/// A resolution as stored.
94#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
95pub(crate) struct StoredResolution {
96 pub repo_id: String,
97 #[serde(default)]
98 pub pull: Option<u32>,
99 #[serde(default)]
100 pub commit: Option<String>,
101 #[serde(default)]
102 pub pushed_by: Option<String>,
103}
104
105/// A work item, with what deciding who may see it needs: its origin's
106/// workspace and whether it is private.
107#[derive(Clone, Debug)]
108pub(crate) struct Held {
109 pub item: WorkItem,
110 /// The origin's id, workspace and privacy, while the repository is
111 /// known: one deleted for good no longer makes it public.
112 origin: Option<(String, String, bool)>,
113 /// Each linked repository's id with its workspace and privacy, while
114 /// known: what deciding who may see that it is linked needs.
115 known: Vec<(String, String, bool)>,
116 /// The repository of each of `item.resolutions`, by id, in order.
117 resolved_in: Vec<String>,
118}
119
120impl Held {
121 /// Whether `viewer` may read linked repository `repo_id`: one whose
122 /// workspace and privacy are not known is not shown.
123 pub(crate) fn may_read_repo(&self, viewer: Option<&User>, repo_id: &str) -> bool {
124 self.known
125 .iter()
126 .find(|(id, _, _)| id == repo_id)
127 .is_some_and(|(id, namespace, private)| repo_readable(viewer, id, Some(namespace), Some(u8::from(*private))))
128 }
129
130 /// The work item as `viewer` is shown it: only the linked repositories
131 /// and the resolutions in repositories they may read. Who may see a
132 /// work item is its workspace's and its origin's business; that it is
133 /// linked to a private repository is that repository's.
134 pub(crate) fn shown(self, viewer: Option<&User>) -> WorkItem {
135 let readable = |repo_id: &str| self.may_read_repo(viewer, repo_id);
136 let repos: Vec<ItemRepo> = self.item.repos.iter().filter(|repo| readable(&repo.repo_id)).cloned().collect();
137 let resolutions: Vec<Resolution> = self
138 .item
139 .resolutions
140 .iter()
141 .zip(&self.resolved_in)
142 .filter(|(_, repo_id)| readable(repo_id))
143 .map(|(resolution, _)| resolution.clone())
144 .collect();
145 WorkItem { repos, resolutions, ..self.item }
146 }
147
148 pub(crate) fn origin(&self) -> Option<RepoRef<'_>> {
149 self.origin.as_ref().map(|(id, namespace, private)| RepoRef { id, namespace, private: *private })
150 }
151
152 /// Every repository it is linked to, its origin first.
153 pub(crate) fn repo_ids(&self) -> Vec<String> {
154 self.item.repos.iter().map(|repo| repo.repo_id.clone()).collect()
155 }
156
157 /// The repository its events are filed under: its origin, or none for
158 /// one filed in the workspace. Never another linked repository: its
159 /// readers may not see a members-only work item, and events filed
160 /// under a repository are shown to whoever reads it. The others hear
161 /// of it through the event's `repoIds`.
162 pub(crate) fn event_repo(&self) -> Option<&str> {
163 self.item.repos.iter().find(|repo| repo.role == LinkRole::Origin).map(|repo| repo.repo_id.as_str())
164 }
165
166 pub(crate) fn may(&self, viewer: Option<&User>, capability: Capability) -> bool {
167 access::can_item(viewer, &self.item.workspace, self.origin(), capability)
168 }
169
170 /// `Ok` when `viewer` may do `capability` on it; not found when they
171 /// may not see it.
172 pub(crate) fn allowed(&self, viewer: Option<&User>, capability: Capability) -> Outcome<()> {
173 match access::check_item(viewer, &self.item.workspace, self.origin(), capability) {
174 Ok(()) => Outcome::Ok(()),
175 Err(Denied::NotFound) => no_item(),
176 Err(Denied::Forbidden) => Outcome::fail(FailureCode::Forbidden, access::needs_on_item(capability, &self.item.workspace)),
177 }
178 }
179}
180
181/// Whether `viewer` may read repository `repo_id`, as `repo_workspaces`
182/// records it (its `workspace` and `private`): one not recorded is not.
183pub(crate) fn repo_readable(viewer: Option<&User>, repo_id: &str, workspace: Option<&str>, private: Option<u8>) -> bool {
184 workspace.is_some_and(|namespace| {
185 access::can(viewer, RepoRef { id: repo_id, namespace, private: private != Some(0) }, Capability::Read)
186 })
187}
188
189pub(crate) fn no_item<T>() -> Outcome<T> {
190 Outcome::fail(FailureCode::NotFound, "Work item not found.")
191}
192
193/// A workspace's slug as given, or `repo`'s, lowercase.
194pub(crate) fn workspace_of(workspace: &str, repo: Option<&RepoPath>) -> String {
195 let given = workspace.trim();
196 match (given.is_empty(), repo) {
197 (true, Some(repo)) => repo.namespace.to_lowercase(),
198 _ => given.to_lowercase(),
199 }
200}
201
202/// `owner/name` of a repository.
203pub(crate) fn path_of(repo: &Repo) -> String {
204 format!("{}/{}", repo.namespace, repo.name)
205}
206
207impl ItemRow {
208 /// The work item, once it has a workspace and a number: one moved from
209 /// a repository's issues has neither until the migration job runs.
210 pub(crate) fn held(self) -> Option<Held> {
211 let workspace = self.workspace?;
212 let number = self.number?;
213 let links: Vec<LinkRow> = self.links.as_deref().and_then(|json| serde_json::from_str(json).ok()).unwrap_or_default();
214 let origin = links.iter().find(|link| link.role == LinkRole::Origin).and_then(|link| {
215 let namespace = link.workspace.clone()?;
216 Some((link.repo_id.clone(), namespace, link.private != Some(0)))
217 });
218 let known: Vec<(String, String, bool)> = links
219 .iter()
220 .filter_map(|link| Some((link.repo_id.clone(), link.workspace.clone()?, link.private != Some(0))))
221 .collect();
222 let mut repos: Vec<ItemRepo> = links
223 .into_iter()
224 .map(|link| ItemRepo {
225 repo_id: link.repo_id,
226 path: link.path,
227 role: link.role,
228 legacy_number: link.legacy_number,
229 landed: link.landed != 0,
230 })
231 .collect();
232 sort_repos(&mut repos);
233 let stored: Vec<StoredResolution> =
234 self.resolutions.as_deref().and_then(|json| serde_json::from_str(json).ok()).unwrap_or_default();
235 let resolved_in: Vec<String> = stored.iter().map(|resolution| resolution.repo_id.clone()).collect();
236 let resolutions = stored
237 .into_iter()
238 .map(|resolution| Resolution {
239 repo: repos
240 .iter()
241 .find(|repo| repo.repo_id == resolution.repo_id)
242 .map_or_else(|| resolution.repo_id.clone(), |repo| repo.path.clone()),
243 pull: resolution.pull,
244 commit: resolution.commit,
245 pushed_by: resolution.pushed_by,
246 })
247 .collect();
248 Some(Held {
249 item: WorkItem {
250 id: self.id,
251 workspace,
252 number,
253 title: self.title,
254 body: self.body,
255 labels: serde_json::from_str(&self.labels).unwrap_or_default(),
256 state: self.state,
257 reason: self.reason,
258 resolutions,
259 author: user(self.author_id, self.author_name),
260 requested_by: requester(self.requested_by_id, self.requested_by_name),
261 created_at: self.created_at,
262 updated_at: self.updated_at,
263 closed_at: self.closed_at,
264 pull_count: self.pull_count,
265 comment_count: self.comment_count,
266 assignees: serde_json::from_str(&self.assignees).unwrap_or_default(),
267 blocked_by: self.blocked_numbers.as_deref().and_then(|json| serde_json::from_str(json).ok()).unwrap_or_default(),
268 agent: self.agent,
269 milestone: milestone_ref(self.milestone, self.milestone_title),
270 repos,
271 },
272 origin,
273 known,
274 resolved_in,
275 })
276 }
277}
278
279/// A work item of `acme` linked as `links` (`[repo_id, role, workspace,
280/// private]`, a workspace of "" for a repository not known) and resolved
281/// in `resolved` (repository ids), for tests.
282#[cfg(test)]
283pub(crate) fn held_for_test(number: u32, links: &[(&str, &str, &str, bool)], resolved: &[&str]) -> Held {
284 let links: Vec<serde_json::Value> = links
285 .iter()
286 .map(|(id, role, workspace, private)| {
287 serde_json::json!({
288 "repo_id": id, "path": format!("{workspace}/{id}"), "role": role, "landed": 0,
289 "workspace": if workspace.is_empty() { serde_json::Value::Null } else { (*workspace).into() },
290 "private": u8::from(*private),
291 })
292 })
293 .collect();
294 let resolutions: Vec<serde_json::Value> = resolved.iter().map(|id| serde_json::json!({ "repo_id": id, "pull": 1 })).collect();
295 let row: ItemRow = serde_json::from_value(serde_json::json!({
296 "id": format!("wki_{number}"), "workspace": "acme", "number": number, "title": format!("Item {number}"), "body": "",
297 "labels": "[]", "state": "open", "reason": null, "resolutions": serde_json::to_string(&resolutions).unwrap(),
298 "author_id": "usr_1", "author_name": "ana", "created_at": "", "updated_at": "", "closed_at": null, "assignees": "[]",
299 "links": serde_json::to_string(&links).unwrap(),
300 }))
301 .unwrap();
302 row.held().unwrap()
303}
304
305/// A verified person, a member (with the default Write) of `member_of`
306/// if given, for tests.
307#[cfg(test)]
308pub(crate) fn person_for_test(id: &str, member_of: Option<&str>) -> User {
309 User {
310 id: id.to_owned(),
311 username: id.trim_start_matches("usr_").to_owned(),
312 verified: true,
313 workspaces: member_of.map(g1t_contracts::Membership::member).into_iter().collect(),
314 ..User::default()
315 }
316}
317
318#[cfg(test)]
319mod tests {
320 use super::*;
321
322 #[test]
323 fn events_are_filed_under_the_origin_only() {
324 let filed = held_for_test(1, &[("rep_web", "origin", "acme", false), ("rep_api", "target", "acme", false)], &[]);
325 assert_eq!(filed.event_repo(), Some("rep_web"));
326 // Filed in the workspace: none, not the public repository it targets.
327 let members_only = held_for_test(2, &[("rep_public", "target", "acme", false)], &[]);
328 assert_eq!(members_only.event_repo(), None);
329 assert_eq!(members_only.repo_ids(), vec!["rep_public".to_owned()]);
330 }
331
332 #[test]
333 fn a_viewer_is_shown_only_the_repositories_they_may_read() {
334 let held = held_for_test(
335 3,
336 &[("rep_web", "origin", "acme", false), ("rep_secret", "target", "acme", true), ("rep_gone", "touched", "", false)],
337 &["rep_web", "rep_secret"],
338 );
339 let shown = held.clone().shown(None);
340 assert_eq!(shown.repos.iter().map(|repo| repo.repo_id.as_str()).collect::<Vec<_>>(), vec!["rep_web"]);
341 assert_eq!(shown.resolutions.len(), 1);
342 assert_eq!(shown.resolutions[0].repo, "acme/rep_web");
343 // A member who reads every repository sees them all, but for one
344 // that is not known.
345 let member = person_for_test("usr_2", Some("acme"));
346 let shown = held.shown(Some(&member));
347 assert_eq!(shown.repos.len(), 2);
348 assert_eq!(shown.resolutions.len(), 2);
349 }
350
351 #[test]
352 fn a_workspace_is_the_one_given_or_its_repositorys() {
353 assert_eq!(workspace_of(" ", Some(&RepoPath { namespace: "Acme".into(), name: "web".into() })), "acme");
354 assert_eq!(workspace_of("Globex", None), "globex");
355 }
356}