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.

360 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, 'deleted', rw.deleted_at IS NOT NULL))
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 /// The repository is deleted, not yet purged (`repo_workspaces.deleted_at`).
92 #[serde(default)]
93 deleted: u8,
94}
95
96/// A resolution as stored.
97#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
98pub(crate) struct StoredResolution {
99 pub repo_id: String,
100 #[serde(default)]
101 pub pull: Option<u32>,
102 #[serde(default)]
103 pub commit: Option<String>,
104 #[serde(default)]
105 pub pushed_by: Option<String>,
106}
107
108/// A work item, with what deciding who may see it needs: its origin's
109/// workspace and whether it is private.
110#[derive(Clone, Debug)]
111pub(crate) struct Held {
112 pub item: WorkItem,
113 /// The origin's id, workspace and privacy, while the repository is
114 /// known: one deleted for good no longer makes it public.
115 origin: Option<(String, String, bool)>,
116 /// Each linked repository's id with its workspace and privacy, while
117 /// known: what deciding who may see that it is linked needs.
118 known: Vec<(String, String, bool)>,
119 /// The repository of each of `item.resolutions`, by id, in order.
120 resolved_in: Vec<String>,
121}
122
123impl Held {
124 /// Whether `viewer` may read linked repository `repo_id`: one whose
125 /// workspace and privacy are not known is not shown.
126 pub(crate) fn may_read_repo(&self, viewer: Option<&User>, repo_id: &str) -> bool {
127 self.known
128 .iter()
129 .find(|(id, _, _)| id == repo_id)
130 .is_some_and(|(id, namespace, private)| repo_readable(viewer, id, Some(namespace), Some(u8::from(*private))))
131 }
132
133 /// The work item as `viewer` is shown it: only the linked repositories
134 /// and the resolutions in repositories they may read. Who may see a
135 /// work item is its workspace's and its origin's business; that it is
136 /// linked to a private repository is that repository's.
137 pub(crate) fn shown(self, viewer: Option<&User>) -> WorkItem {
138 let readable = |repo_id: &str| self.may_read_repo(viewer, repo_id);
139 let repos: Vec<ItemRepo> = self.item.repos.iter().filter(|repo| readable(&repo.repo_id)).cloned().collect();
140 let resolutions: Vec<Resolution> = self
141 .item
142 .resolutions
143 .iter()
144 .zip(&self.resolved_in)
145 .filter(|(_, repo_id)| readable(repo_id))
146 .map(|(resolution, _)| resolution.clone())
147 .collect();
148 WorkItem { repos, resolutions, ..self.item }
149 }
150
151 pub(crate) fn origin(&self) -> Option<RepoRef<'_>> {
152 self.origin.as_ref().map(|(id, namespace, private)| RepoRef { id, namespace, private: *private })
153 }
154
155 /// Every repository it is linked to, its origin first.
156 pub(crate) fn repo_ids(&self) -> Vec<String> {
157 self.item.repos.iter().map(|repo| repo.repo_id.clone()).collect()
158 }
159
160 /// The repository its events are filed under: its origin, or none for
161 /// one filed in the workspace. Never another linked repository: its
162 /// readers may not see a members-only work item, and events filed
163 /// under a repository are shown to whoever reads it. The others hear
164 /// of it through the event's `repoIds`.
165 pub(crate) fn event_repo(&self) -> Option<&str> {
166 self.item.repos.iter().find(|repo| repo.role == LinkRole::Origin).map(|repo| repo.repo_id.as_str())
167 }
168
169 pub(crate) fn may(&self, viewer: Option<&User>, capability: Capability) -> bool {
170 access::can_item(viewer, &self.item.workspace, self.origin(), capability)
171 }
172
173 /// `Ok` when `viewer` may do `capability` on it; not found when they
174 /// may not see it.
175 pub(crate) fn allowed(&self, viewer: Option<&User>, capability: Capability) -> Outcome<()> {
176 match access::check_item(viewer, &self.item.workspace, self.origin(), capability) {
177 Ok(()) => Outcome::Ok(()),
178 Err(Denied::NotFound) => no_item(),
179 Err(Denied::Forbidden) => Outcome::fail(FailureCode::Forbidden, access::needs_on_item(capability, &self.item.workspace)),
180 }
181 }
182}
183
184/// Whether `viewer` may read repository `repo_id`, as `repo_workspaces`
185/// records it (its `workspace` and `private`): one not recorded is not.
186pub(crate) fn repo_readable(viewer: Option<&User>, repo_id: &str, workspace: Option<&str>, private: Option<u8>) -> bool {
187 workspace.is_some_and(|namespace| {
188 access::can(viewer, RepoRef { id: repo_id, namespace, private: private != Some(0) }, Capability::Read)
189 })
190}
191
192pub(crate) fn no_item<T>() -> Outcome<T> {
193 Outcome::fail(FailureCode::NotFound, "Work item not found.")
194}
195
196/// A workspace's slug as given, or `repo`'s, lowercase.
197pub(crate) fn workspace_of(workspace: &str, repo: Option<&RepoPath>) -> String {
198 let given = workspace.trim();
199 match (given.is_empty(), repo) {
200 (true, Some(repo)) => repo.namespace.to_lowercase(),
201 _ => given.to_lowercase(),
202 }
203}
204
205/// `owner/name` of a repository.
206pub(crate) fn path_of(repo: &Repo) -> String {
207 format!("{}/{}", repo.namespace, repo.name)
208}
209
210impl ItemRow {
211 /// The work item, once it has a workspace and a number: one moved from
212 /// a repository's issues has neither until the migration job runs.
213 pub(crate) fn held(self) -> Option<Held> {
214 let workspace = self.workspace?;
215 let number = self.number?;
216 let links: Vec<LinkRow> = self.links.as_deref().and_then(|json| serde_json::from_str(json).ok()).unwrap_or_default();
217 let origin = links.iter().find(|link| link.role == LinkRole::Origin).and_then(|link| {
218 let namespace = link.workspace.clone()?;
219 Some((link.repo_id.clone(), namespace, link.private != Some(0)))
220 });
221 let known: Vec<(String, String, bool)> = links
222 .iter()
223 .filter_map(|link| Some((link.repo_id.clone(), link.workspace.clone()?, link.private != Some(0))))
224 .collect();
225 let mut repos: Vec<ItemRepo> = links
226 .into_iter()
227 .map(|link| ItemRepo {
228 repo_id: link.repo_id,
229 path: link.path,
230 role: link.role,
231 legacy_number: link.legacy_number,
232 landed: link.landed != 0,
233 deleted: link.deleted != 0,
234 })
235 .collect();
236 sort_repos(&mut repos);
237 let stored: Vec<StoredResolution> =
238 self.resolutions.as_deref().and_then(|json| serde_json::from_str(json).ok()).unwrap_or_default();
239 let resolved_in: Vec<String> = stored.iter().map(|resolution| resolution.repo_id.clone()).collect();
240 let resolutions = stored
241 .into_iter()
242 .map(|resolution| Resolution {
243 repo: repos
244 .iter()
245 .find(|repo| repo.repo_id == resolution.repo_id)
246 .map_or_else(|| resolution.repo_id.clone(), |repo| repo.path.clone()),
247 pull: resolution.pull,
248 commit: resolution.commit,
249 pushed_by: resolution.pushed_by,
250 })
251 .collect();
252 Some(Held {
253 item: WorkItem {
254 id: self.id,
255 workspace,
256 number,
257 title: self.title,
258 body: self.body,
259 labels: serde_json::from_str(&self.labels).unwrap_or_default(),
260 state: self.state,
261 reason: self.reason,
262 resolutions,
263 author: user(self.author_id, self.author_name),
264 requested_by: requester(self.requested_by_id, self.requested_by_name),
265 created_at: self.created_at,
266 updated_at: self.updated_at,
267 closed_at: self.closed_at,
268 pull_count: self.pull_count,
269 comment_count: self.comment_count,
270 assignees: serde_json::from_str(&self.assignees).unwrap_or_default(),
271 blocked_by: self.blocked_numbers.as_deref().and_then(|json| serde_json::from_str(json).ok()).unwrap_or_default(),
272 agent: self.agent,
273 milestone: milestone_ref(self.milestone, self.milestone_title),
274 repos,
275 },
276 origin,
277 known,
278 resolved_in,
279 })
280 }
281}
282
283/// A work item of `acme` linked as `links` (`[repo_id, role, workspace,
284/// private]`, a workspace of "" for a repository not known) and resolved
285/// in `resolved` (repository ids), for tests.
286#[cfg(test)]
287pub(crate) fn held_for_test(number: u32, links: &[(&str, &str, &str, bool)], resolved: &[&str]) -> Held {
288 let links: Vec<serde_json::Value> = links
289 .iter()
290 .map(|(id, role, workspace, private)| {
291 serde_json::json!({
292 "repo_id": id, "path": format!("{workspace}/{id}"), "role": role, "landed": 0,
293 "workspace": if workspace.is_empty() { serde_json::Value::Null } else { (*workspace).into() },
294 "private": u8::from(*private),
295 })
296 })
297 .collect();
298 let resolutions: Vec<serde_json::Value> = resolved.iter().map(|id| serde_json::json!({ "repo_id": id, "pull": 1 })).collect();
299 let row: ItemRow = serde_json::from_value(serde_json::json!({
300 "id": format!("wki_{number}"), "workspace": "acme", "number": number, "title": format!("Item {number}"), "body": "",
301 "labels": "[]", "state": "open", "reason": null, "resolutions": serde_json::to_string(&resolutions).unwrap(),
302 "author_id": "usr_1", "author_name": "ana", "created_at": "", "updated_at": "", "closed_at": null, "assignees": "[]",
303 "links": serde_json::to_string(&links).unwrap(),
304 }))
305 .unwrap();
306 row.held().unwrap()
307}
308
309/// A verified person, a member (with the default Write) of `member_of`
310/// if given, for tests.
311#[cfg(test)]
312pub(crate) fn person_for_test(id: &str, member_of: Option<&str>) -> User {
313 User {
314 id: id.to_owned(),
315 username: id.trim_start_matches("usr_").to_owned(),
316 verified: true,
317 workspaces: member_of.map(g1t_contracts::Membership::member).into_iter().collect(),
318 ..User::default()
319 }
320}
321
322#[cfg(test)]
323mod tests {
324 use super::*;
325
326 #[test]
327 fn events_are_filed_under_the_origin_only() {
328 let filed = held_for_test(1, &[("rep_web", "origin", "acme", false), ("rep_api", "target", "acme", false)], &[]);
329 assert_eq!(filed.event_repo(), Some("rep_web"));
330 // Filed in the workspace: none, not the public repository it targets.
331 let members_only = held_for_test(2, &[("rep_public", "target", "acme", false)], &[]);
332 assert_eq!(members_only.event_repo(), None);
333 assert_eq!(members_only.repo_ids(), vec!["rep_public".to_owned()]);
334 }
335
336 #[test]
337 fn a_viewer_is_shown_only_the_repositories_they_may_read() {
338 let held = held_for_test(
339 3,
340 &[("rep_web", "origin", "acme", false), ("rep_secret", "target", "acme", true), ("rep_gone", "touched", "", false)],
341 &["rep_web", "rep_secret"],
342 );
343 let shown = held.clone().shown(None);
344 assert_eq!(shown.repos.iter().map(|repo| repo.repo_id.as_str()).collect::<Vec<_>>(), vec!["rep_web"]);
345 assert_eq!(shown.resolutions.len(), 1);
346 assert_eq!(shown.resolutions[0].repo, "acme/rep_web");
347 // A member who reads every repository sees them all, but for one
348 // that is not known.
349 let member = person_for_test("usr_2", Some("acme"));
350 let shown = held.shown(Some(&member));
351 assert_eq!(shown.repos.len(), 2);
352 assert_eq!(shown.resolutions.len(), 2);
353 }
354
355 #[test]
356 fn a_workspace_is_the_one_given_or_its_repositorys() {
357 assert_eq!(workspace_of(" ", Some(&RepoPath { namespace: "Acme".into(), name: "web".into() })), "acme");
358 assert_eq!(workspace_of("Globex", None), "globex");
359 }
360}