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.

325 linesCodeBlameRaw
1//! Reading work items: one by number or id, as a viewer may see it, and
2//! `list_work_items` and `get_work_item`.
3
4use g1t_contracts::access::{self, Capability};
5use g1t_contracts::repos::{ReadableArgs, Repo};
6use g1t_contracts::work::*;
7use g1t_contracts::{Outcome, User, Viewer};
8use serde::Deserialize;
9use worker::Result;
10use worker::wasm_bindgen::JsValue;
11
12use super::rows::{Held, ITEM_COLUMNS, ItemRow, no_item, workspace_of};
13use crate::rows::{CommentRow, PULL_COLUMNS, PullRow};
14use crate::{LIST_PAGE, Work, optional, optional_number, state_name};
15
16/// What `list_work_items` reads: `?1` the workspace, then the filters, and
17/// `?8` whether the viewer is a member (else only work items filed in a
18/// public repository or one in `?9`, the repositories they were given a
19/// role on).
20const LIST_SQL: &str = "SELECT {ITEM_COLUMNS} FROM work_items
21 WHERE workspace = ?1 AND (?2 IS NULL OR state = ?2)
22 AND (?3 IS NULL OR EXISTS (SELECT 1 FROM json_each(work_items.labels) WHERE json_each.value = ?3))
23 AND (?4 IS NULL OR milestone = ?4)
24 AND (?5 IS NULL OR EXISTS (SELECT 1 FROM json_each(work_items.assignees) WHERE json_each.value = ?5))
25 AND (?6 IS NULL OR EXISTS (SELECT 1 FROM work_item_repos r
26 WHERE r.item_id = work_items.id AND r.repo_id = ?6 AND (?7 IS NULL OR r.role = ?7)))
27 AND (?8 = 1 OR EXISTS (SELECT 1 FROM work_item_repos o JOIN repo_workspaces rw ON rw.repo_id = o.repo_id
28 WHERE o.item_id = work_items.id AND o.role = 'origin'
29 AND (rw.private = 0 OR o.repo_id IN (SELECT value FROM json_each(?9)))))
30 ORDER BY number DESC LIMIT ?10";
31
32/// The work items matching `condition` (on `work_items`), with everything
33/// they are read with.
34pub(crate) fn select(condition: &str) -> String {
35 format!("SELECT {ITEM_COLUMNS} FROM work_items WHERE {condition}")
36}
37
38/// The work items a statement made by [`select`] read.
39pub(crate) fn held_rows(result: &worker::D1Result) -> Result<Vec<Held>> {
40 Ok(result.results::<ItemRow>()?.into_iter().filter_map(ItemRow::held).collect())
41}
42
43/// What a repository the reader may not read is called instead.
44const UNNAMED: &str = "a private repository";
45
46/// `text` with each of `hidden` (`owner/name`, any case) that stands as a
47/// name of its own, not inside a longer one, called [`UNNAMED`]; what
48/// follows it (`#5`, `@abc1234`) goes too.
49pub(crate) fn redact_paths(text: &str, hidden: &[String]) -> String {
50 let part = |c: char| c.is_alphanumeric() || matches!(c, '-' | '_' | '.' | '/');
51 let hidden: Vec<String> = hidden.iter().map(|path| path.to_lowercase()).collect();
52 let mut out = String::with_capacity(text.len());
53 let mut at = 0;
54 'scan: while let Some(c) = text[at..].chars().next() {
55 if text[..at].chars().next_back().is_none_or(|before| !part(before)) {
56 for path in &hidden {
57 let end = at + path.len();
58 let here = text.get(at..end).is_some_and(|found| found.eq_ignore_ascii_case(path));
59 if here && text[end..].chars().next().is_none_or(|after| !part(after)) {
60 out.push_str(UNNAMED);
61 // Its pull request or commit goes with it.
62 let mut rest = end;
63 if let Some(mark) = text[end..].chars().next().filter(|mark| matches!(mark, '#' | '@')) {
64 rest += mark.len_utf8();
65 rest += text[rest..].chars().take_while(char::is_ascii_alphanumeric).map(char::len_utf8).sum::<usize>();
66 }
67 at = rest;
68 continue 'scan;
69 }
70 }
71 }
72 out.push(c);
73 at += c.len_utf8();
74 }
75 out
76}
77
78impl Work {
79 /// A work item by workspace and number, whoever is asking.
80 pub(super) async fn item(&self, workspace: &str, number: u32) -> Result<Option<Held>> {
81 Ok(self
82 .db
83 .prepare(select("workspace = ? AND number = ?"))
84 .bind(&[workspace.to_lowercase().into(), number.into()])?
85 .first::<ItemRow>(None)
86 .await?
87 .and_then(ItemRow::held))
88 }
89
90 /// A work item by id, whoever is asking.
91 pub(super) async fn item_by_id(&self, id: &str) -> Result<Option<Held>> {
92 Ok(self
93 .db
94 .prepare(select("id = ?"))
95 .bind(&[id.into()])?
96 .first::<ItemRow>(None)
97 .await?
98 .and_then(ItemRow::held))
99 }
100
101 /// The work items of these ids, in no particular order.
102 pub(super) async fn items_by_ids(&self, ids: &[String]) -> Result<Vec<Held>> {
103 if ids.is_empty() {
104 return Ok(Vec::new());
105 }
106 held_rows(
107 &self
108 .db
109 .prepare(select("id IN (SELECT value FROM json_each(?))"))
110 .bind(&[serde_json::to_string(ids)?.into()])?
111 .all()
112 .await?,
113 )
114 }
115
116 /// Those of `ids` (repositories) that `viewer` may read, as
117 /// `repo_workspaces` records them: one not recorded is not.
118 pub(super) async fn readable_repo_ids(&self, viewer: Option<&User>, ids: &[String]) -> Result<std::collections::HashSet<String>> {
119 #[derive(Deserialize)]
120 struct Recorded {
121 repo_id: String,
122 workspace: String,
123 private: u8,
124 }
125 if ids.is_empty() {
126 return Ok(std::collections::HashSet::new());
127 }
128 let recorded = self
129 .db
130 .prepare("SELECT repo_id, workspace, private FROM repo_workspaces WHERE repo_id IN (SELECT value FROM json_each(?))")
131 .bind(&[serde_json::to_string(ids)?.into()])?
132 .all()
133 .await?
134 .results::<Recorded>()?;
135 Ok(recorded
136 .into_iter()
137 .filter(|row| super::rows::repo_readable(viewer, &row.repo_id, Some(&row.workspace), Some(row.private)))
138 .map(|row| row.repo_id)
139 .collect())
140 }
141
142 /// A work item, if `viewer` may do `capability` on it.
143 pub(super) async fn item_for(&self, viewer: &Viewer, workspace: &str, number: u32, capability: Capability) -> Result<Outcome<Held>> {
144 let Some(held) = self.item(workspace, number).await? else {
145 return Ok(no_item());
146 };
147 check!(held.allowed(viewer.as_ref(), capability));
148 Ok(Outcome::Ok(held))
149 }
150
151 /// A work item `actor` may change: theirs (`WorkItem::owner`), or one
152 /// they may triage.
153 pub(super) async fn manageable_item(&self, actor: &User, workspace: &str, number: u32) -> Result<Outcome<Held>> {
154 let held = check!(self.item_for(&Some(actor.clone()), workspace, number, Capability::Read).await?);
155 if held.item.owner().id != actor.id {
156 check!(held.allowed(Some(actor), Capability::Triage));
157 }
158 Ok(Outcome::Ok(held))
159 }
160
161 /// The ids of the work items of `workspace` with these numbers, in the
162 /// order given.
163 pub(super) async fn item_ids(&self, workspace: &str, numbers: &[u32]) -> Result<Vec<String>> {
164 #[derive(Deserialize)]
165 struct IdNumber {
166 id: String,
167 number: u32,
168 }
169 if numbers.is_empty() {
170 return Ok(Vec::new());
171 }
172 let rows = self
173 .db
174 .prepare("SELECT id, number FROM work_items WHERE workspace = ? AND number IN (SELECT value FROM json_each(?))")
175 .bind(&[workspace.into(), serde_json::to_string(numbers)?.into()])?
176 .all()
177 .await?
178 .results::<IdNumber>()?;
179 Ok(numbers.iter().filter_map(|number| rows.iter().find(|row| row.number == *number).map(|row| row.id.clone())).collect())
180 }
181
182 pub(crate) async fn list_work_items(&self, a: ListWorkItemsArgs) -> Result<Outcome<Vec<WorkItem>>> {
183 let workspace = workspace_of(&a.workspace, a.repo.as_ref());
184 let repo = match &a.repo {
185 Some(path) => {
186 let repo = check!(self.repo(path, &a.viewer).await?);
187 if !repo.namespace.eq_ignore_ascii_case(&workspace) {
188 return Ok(Outcome::Ok(Vec::new()));
189 }
190 Some(repo)
191 }
192 None => None,
193 };
194 let viewer = a.viewer.as_ref();
195 let member = viewer.and_then(|user| access::workspace_role(user, &workspace)).is_some();
196 let granted: Vec<&str> = viewer
197 .map(|user| user.grants.iter().filter(|grant| grant.workspace.eq_ignore_ascii_case(&workspace)).map(|grant| grant.repo_id.as_str()).collect())
198 .unwrap_or_default();
199 let label = a
200 .label
201 .map(|label| label.split_whitespace().collect::<Vec<_>>().join(" ").to_lowercase())
202 .filter(|label| !label.is_empty());
203 let assignee = a.assignee.map(|name| name.trim().trim_start_matches('@').to_lowercase()).filter(|name| !name.is_empty());
204 let state = state_name(a.state).map_or(JsValue::NULL, JsValue::from);
205 let query = self
206 .db
207 .prepare(LIST_SQL.replace("{ITEM_COLUMNS}", ITEM_COLUMNS))
208 .bind(&[
209 workspace.as_str().into(),
210 state,
211 optional(&label),
212 optional_number(a.milestone),
213 optional(&assignee),
214 optional(&repo.as_ref().map(|repo| repo.id.clone())),
215 a.role.map_or(JsValue::NULL, |role| role.as_str().into()),
216 u32::from(member).into(),
217 serde_json::to_string(&granted)?.into(),
218 LIST_PAGE.into(),
219 ])?;
220 let rows = self.timing.db(1, query.all()).await?.results::<ItemRow>()?;
221 Ok(Outcome::Ok(
222 rows.into_iter()
223 .filter_map(ItemRow::held)
224 .filter(|held| held.may(viewer, Capability::Read))
225 .map(|held| held.shown(viewer))
226 .collect(),
227 ))
228 }
229
230 /// A work item, the pull requests for it the viewer may see, and its
231 /// comments and reactions.
232 pub(crate) async fn get_work_item(&self, a: WorkItemViewArgs) -> Result<Outcome<WorkItemDetail>> {
233 let held = check!(self.item_for(&a.viewer, &a.workspace, a.number, Capability::Read).await?);
234 let key = [JsValue::from(held.item.id.as_str())];
235 let statements = vec![
236 self.db.prepare(format!("SELECT {PULL_COLUMNS} FROM pulls WHERE item_id = ?1 ORDER BY created_at")).bind(&key)?,
237 self.db.prepare("SELECT * FROM comments WHERE item_id = ?1 ORDER BY id LIMIT 500").bind(&key)?,
238 self.db.prepare(crate::reactions::ITEM_THREAD_SQL).bind(&key)?,
239 ];
240 let results = self.timing.db(3, self.db.batch(statements)).await?;
241 let rows = |index: usize| results.get(index).ok_or_else(|| worker::Error::RustError("short batch".into()));
242 let pulls: Vec<Pull> = rows(0)?.results::<PullRow>()?.into_iter().map(Pull::from).collect();
243 let mut comments: Vec<Comment> = rows(1)?.results::<CommentRow>()?.into_iter().map(Comment::from).collect();
244 let reacted = rows(2)?.results::<crate::reactions::ReactionRow>()?;
245 let reactions = crate::reactions::attach(&reacted, &held.item.id, &mut comments);
246 let pulls = self.readable_pulls(&a.viewer, pulls).await?;
247 // What g1t noted names repositories ("added acme/api as a target",
248 // "closed this by merging acme/api#5"): those the viewer may not
249 // read are not named.
250 let hidden = self.hidden_paths(&held, a.viewer.as_ref()).await?;
251 if !hidden.is_empty() {
252 for comment in comments.iter_mut().filter(|comment| comment.kind == CommentKind::Event) {
253 comment.body = redact_paths(&comment.body, &hidden);
254 }
255 }
256 Ok(Outcome::Ok(WorkItemDetail { item: held.shown(a.viewer.as_ref()), pulls, comments, reactions }))
257 }
258
259 /// The paths (`owner/name`) of the repositories of `held`'s workspace,
260 /// and of those it is linked to, that `viewer` may not read.
261 async fn hidden_paths(&self, held: &Held, viewer: Option<&User>) -> Result<Vec<String>> {
262 #[derive(Deserialize)]
263 struct Recorded {
264 repo_id: String,
265 workspace: String,
266 path: String,
267 private: u8,
268 }
269 let recorded = self
270 .db
271 .prepare(
272 "SELECT repo_id, workspace, path, private FROM repo_workspaces
273 WHERE workspace = ?1 OR repo_id IN (SELECT repo_id FROM work_item_repos WHERE item_id = ?2)",
274 )
275 .bind(&[held.item.workspace.as_str().into(), held.item.id.as_str().into()])?
276 .all()
277 .await?
278 .results::<Recorded>()?;
279 Ok(recorded
280 .into_iter()
281 .filter(|row| !super::rows::repo_readable(viewer, &row.repo_id, Some(&row.workspace), Some(row.private)))
282 .map(|row| row.path)
283 .filter(|path| !path.is_empty())
284 .collect())
285 }
286
287 /// Those of `pulls` whose repositories `viewer` may read, each naming
288 /// the branch it merges into.
289 pub(super) async fn readable_pulls(&self, viewer: &Viewer, pulls: Vec<Pull>) -> Result<Vec<Pull>> {
290 if pulls.is_empty() {
291 return Ok(pulls);
292 }
293 let mut ids: Vec<String> = pulls.iter().map(|pull| pull.repo_id.clone()).collect();
294 ids.sort();
295 ids.dedup();
296 let readable: Vec<Repo> = g1t_kit::call(&self.repos, "readable", &ReadableArgs { ids, viewer: viewer.clone() }).await?;
297 Ok(pulls
298 .into_iter()
299 .filter_map(|mut pull| {
300 let repo = readable.iter().find(|repo| repo.id == pull.repo_id)?;
301 crate::fill_base(&mut pull, repo);
302 Some(pull)
303 })
304 .collect())
305 }
306}
307
308#[cfg(test)]
309mod tests {
310 use super::redact_paths;
311
312 #[test]
313 fn notes_do_not_name_repositories_the_reader_may_not_read() {
314 let hidden = vec!["acme/secret".to_owned()];
315 assert_eq!(redact_paths("added acme/secret as a target", &hidden), "added a private repository as a target");
316 assert_eq!(redact_paths("closed this by merging Acme/Secret#5", &hidden), "closed this by merging a private repository");
317 assert_eq!(
318 redact_paths("landed acme/secret@abc1234; still to land in acme/web", &hidden),
319 "landed a private repository; still to land in acme/web"
320 );
321 // Only the name itself, never a longer one it starts or ends.
322 assert_eq!(redact_paths("added acme/secret-tools as a target", &hidden), "added acme/secret-tools as a target");
323 assert_eq!(redact_paths("added xacme/secret", &hidden), "added xacme/secret");
324 }
325}