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.

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