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.

238 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}
114
115impl Held {
116 pub(crate) fn origin(&self) -> Option<RepoRef<'_>> {
117 self.origin.as_ref().map(|(id, namespace, private)| RepoRef { id, namespace, private: *private })
118 }
119
120 /// Every repository it is linked to, its origin first.
121 pub(crate) fn repo_ids(&self) -> Vec<String> {
122 self.item.repos.iter().map(|repo| repo.repo_id.clone()).collect()
123 }
124
125 /// The repository its events are filed under: its origin, else the
126 /// first it is linked to.
127 pub(crate) fn event_repo(&self) -> Option<&str> {
128 self.item.repos.first().map(|repo| repo.repo_id.as_str())
129 }
130
131 pub(crate) fn may(&self, viewer: Option<&User>, capability: Capability) -> bool {
132 access::can_item(viewer, &self.item.workspace, self.origin(), capability)
133 }
134
135 /// `Ok` when `viewer` may do `capability` on it; not found when they
136 /// may not see it.
137 pub(crate) fn allowed(&self, viewer: Option<&User>, capability: Capability) -> Outcome<()> {
138 match access::check_item(viewer, &self.item.workspace, self.origin(), capability) {
139 Ok(()) => Outcome::Ok(()),
140 Err(Denied::NotFound) => no_item(),
141 Err(Denied::Forbidden) => Outcome::fail(FailureCode::Forbidden, access::needs_on_item(capability, &self.item.workspace)),
142 }
143 }
144}
145
146pub(crate) fn no_item<T>() -> Outcome<T> {
147 Outcome::fail(FailureCode::NotFound, "Work item not found.")
148}
149
150/// A workspace's slug as given, or `repo`'s, lowercase.
151pub(crate) fn workspace_of(workspace: &str, repo: Option<&RepoPath>) -> String {
152 let given = workspace.trim();
153 match (given.is_empty(), repo) {
154 (true, Some(repo)) => repo.namespace.to_lowercase(),
155 _ => given.to_lowercase(),
156 }
157}
158
159/// `owner/name` of a repository.
160pub(crate) fn path_of(repo: &Repo) -> String {
161 format!("{}/{}", repo.namespace, repo.name)
162}
163
164impl ItemRow {
165 /// The work item, once it has a workspace and a number: one moved from
166 /// a repository's issues has neither until the migration job runs.
167 pub(crate) fn held(self) -> Option<Held> {
168 let workspace = self.workspace?;
169 let number = self.number?;
170 let links: Vec<LinkRow> = self.links.as_deref().and_then(|json| serde_json::from_str(json).ok()).unwrap_or_default();
171 let origin = links.iter().find(|link| link.role == LinkRole::Origin).and_then(|link| {
172 let namespace = link.workspace.clone()?;
173 Some((link.repo_id.clone(), namespace, link.private != Some(0)))
174 });
175 let mut repos: Vec<ItemRepo> = links
176 .into_iter()
177 .map(|link| ItemRepo {
178 repo_id: link.repo_id,
179 path: link.path,
180 role: link.role,
181 legacy_number: link.legacy_number,
182 landed: link.landed != 0,
183 })
184 .collect();
185 sort_repos(&mut repos);
186 let stored: Vec<StoredResolution> =
187 self.resolutions.as_deref().and_then(|json| serde_json::from_str(json).ok()).unwrap_or_default();
188 let resolutions = stored
189 .into_iter()
190 .map(|resolution| Resolution {
191 repo: repos
192 .iter()
193 .find(|repo| repo.repo_id == resolution.repo_id)
194 .map_or_else(|| resolution.repo_id.clone(), |repo| repo.path.clone()),
195 pull: resolution.pull,
196 commit: resolution.commit,
197 pushed_by: resolution.pushed_by,
198 })
199 .collect();
200 Some(Held {
201 item: WorkItem {
202 id: self.id,
203 workspace,
204 number,
205 title: self.title,
206 body: self.body,
207 labels: serde_json::from_str(&self.labels).unwrap_or_default(),
208 state: self.state,
209 reason: self.reason,
210 resolutions,
211 author: user(self.author_id, self.author_name),
212 requested_by: requester(self.requested_by_id, self.requested_by_name),
213 created_at: self.created_at,
214 updated_at: self.updated_at,
215 closed_at: self.closed_at,
216 pull_count: self.pull_count,
217 comment_count: self.comment_count,
218 assignees: serde_json::from_str(&self.assignees).unwrap_or_default(),
219 blocked_by: self.blocked_numbers.as_deref().and_then(|json| serde_json::from_str(json).ok()).unwrap_or_default(),
220 agent: self.agent,
221 milestone: milestone_ref(self.milestone, self.milestone_title),
222 repos,
223 },
224 origin,
225 })
226 }
227}
228
229#[cfg(test)]
230mod tests {
231 use super::*;
232
233 #[test]
234 fn a_workspace_is_the_one_given_or_its_repositorys() {
235 assert_eq!(workspace_of(" ", Some(&RepoPath { namespace: "Acme".into(), name: "web".into() })), "acme");
236 assert_eq!(workspace_of("Globex", None), "globex");
237 }
238}