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.

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