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.

1,130 linesCodeBlameRaw
1//! Work items (`g1t_contracts::work::items`): any tracked work of a
2//! workspace, linked to the repositories it concerns
3//! (migrations/0049_work_items.sql).
4//!
5//! A work item is numbered in its workspace's sequence (`work_counters`),
6//! which new pull requests share, and links repositories in
7//! `work_item_repos`: its origin, its targets and those it touched. Who may
8//! see one is `access::item_role`: the workspace's members, and anyone who
9//! can read its origin. What closes one is closing.rs.
10
11use g1t_contracts::access::{self, Capability, Denied, RepoRef};
12use g1t_contracts::events::{EventLabel, WorkItemEvent};
13use g1t_contracts::repos::{ReadableArgs, Repo, RepoPath};
14use g1t_contracts::time::rfc3339;
15use g1t_contracts::work::refs::{Lookup, first_found};
16use g1t_contracts::work::*;
17use g1t_contracts::{FailureCode, Outcome, User, Viewer, new_id};
18use g1t_kit::now_ms;
19use serde::{Deserialize, Serialize};
20use worker::Result;
21use worker::wasm_bindgen::JsValue;
22
23use crate::rows::{CommentRow, NumberRow, PULL_COLUMNS, PullRow, ValueRow, milestone_ref, requester, user};
24use crate::{LIST_PAGE, UNVERIFIED, Work, optional, optional_number, state_name, valid_title};
25
26/// Unwraps an `Outcome`, returning its failure from the enclosing method.
27macro_rules! check {
28 ($outcome:expr) => {
29 match $outcome {
30 Outcome::Ok(value) => value,
31 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
32 }
33 };
34}
35
36/// A work item's columns, with what it is read with: its counts, agent,
37/// milestone's title, blockers by number and links.
38pub(crate) const ITEM_COLUMNS: &str = "work_items.*,
39 (SELECT count(*) FROM pulls WHERE pulls.item_id = work_items.id) AS pull_count,
40 (SELECT agent FROM pulls
41 WHERE pulls.item_id = work_items.id AND pulls.status IN ('draft', 'open')
42 AND pulls.fork_repo_id IS NOT NULL
43 ORDER BY pulls.created_at DESC LIMIT 1) AS agent,
44 (SELECT count(*) FROM comments
45 WHERE comments.item_id = work_items.id AND comments.kind = 'comment') AS comment_count,
46 (SELECT title FROM milestones
47 WHERE milestones.workspace = work_items.workspace AND milestones.number = work_items.milestone) AS milestone_title,
48 (SELECT json_group_array(number) FROM
49 (SELECT b.number AS number FROM json_each(work_items.blocked_by) j JOIN work_items b ON b.id = j.value
50 WHERE b.number IS NOT NULL ORDER BY j.key)) AS blocked_numbers,
51 (SELECT json_group_array(json_object('repo_id', l.repo_id, 'path', COALESCE(rw.path, l.path, ''), 'role', l.role,
52 'legacy_number', l.legacy_number, 'landed', l.landed_at IS NOT NULL, 'workspace', rw.workspace,
53 'private', rw.private))
54 FROM work_item_repos l LEFT JOIN repo_workspaces rw ON rw.repo_id = l.repo_id
55 WHERE l.item_id = work_items.id) AS links";
56
57/// The next number of workspace `?1`'s sequence, taken in one statement so
58/// two openings never get the same one. A pull request of repository `?2`
59/// never takes a number one of that repository's has (a repository moved
60/// in from another workspace brings its own), and a workspace's first
61/// number comes after every number its repositories used before work
62/// items moved to the workspace.
63pub(crate) const NEXT_NUMBER: &str = "INSERT INTO work_counters (workspace, last)
64 SELECT ?1, MAX(COALESCE(MAX(c.last), 0), COALESCE((SELECT MAX(number) FROM pulls WHERE repo_id = ?2), 0)) + 1
65 FROM counters c
66 WHERE c.repo_id = ?2 OR c.repo_id IN (SELECT repo_id FROM repo_workspaces WHERE workspace = ?1)
67 ON CONFLICT (workspace) DO UPDATE SET last = MAX(work_counters.last + 1, excluded.last)
68 RETURNING last AS n";
69
70/// What `#N` could name, read in repository `?1` (NULL for none) of
71/// workspace `?2`: a pull request of the repository, a work item filed
72/// there under that old number, the workspace's work item, and the newest
73/// pull request of that number in the workspace's other repositories.
74pub(crate) const REF_SQL: &str = "SELECT
75 (SELECT 1 FROM pulls WHERE repo_id = ?1 AND number = ?3) AS pull_here,
76 (SELECT item_id FROM work_item_repos WHERE repo_id = ?1 AND legacy_number = ?3) AS legacy,
77 (SELECT id FROM work_items WHERE workspace = ?2 AND number = ?3) AS item,
78 (SELECT repo_id FROM pulls WHERE number = ?3 AND repo_id IS NOT ?1
79 AND repo_id IN (SELECT repo_id FROM repo_workspaces WHERE workspace = ?2)
80 ORDER BY created_at DESC LIMIT 1) AS pull_there";
81
82/// Records where repository `?1` is: workspace `?2`, path `?3`, and
83/// whether it is private (`?4`).
84pub(crate) const REMEMBER_REPO: &str = "INSERT INTO repo_workspaces (repo_id, workspace, path, private)
85 VALUES (?1, ?2, ?3, ?4)
86 ON CONFLICT (repo_id) DO UPDATE SET workspace = excluded.workspace, path = excluded.path, private = excluded.private";
87
88/// What [`REF_SQL`] found.
89#[derive(Clone, Debug, Default, Deserialize)]
90pub(crate) struct RefRow {
91 #[serde(default)]
92 pub pull_here: Option<u8>,
93 #[serde(default)]
94 pub legacy: Option<String>,
95 #[serde(default)]
96 pub item: Option<String>,
97 #[serde(default)]
98 pub pull_there: Option<String>,
99}
100
101/// What a reference names, by the order of `refs::lookup_order`.
102#[derive(Clone, Debug, PartialEq, Eq)]
103pub(crate) enum Named {
104 /// A pull request of the repository it was read in.
105 PullHere,
106 /// A work item, by id.
107 Item(String),
108 /// A pull request of another repository of the workspace, by id.
109 PullThere(String),
110}
111
112impl RefRow {
113 /// The first thing found, in the order references are read in: in a
114 /// repository (`in_repo`), or a workspace alone.
115 pub(crate) fn named(self, in_repo: bool) -> Option<Named> {
116 first_found(in_repo, |lookup| match lookup {
117 Lookup::PullInRepo => self.pull_here.map(|_| Named::PullHere),
118 Lookup::LegacyItem => self.legacy.clone().map(Named::Item),
119 Lookup::Item => self.item.clone().map(Named::Item),
120 Lookup::PullInWorkspace => self.pull_there.clone().map(Named::PullThere),
121 })
122 }
123}
124
125#[derive(Deserialize)]
126pub(crate) struct ItemRow {
127 pub id: String,
128 pub workspace: Option<String>,
129 pub number: Option<u32>,
130 pub title: String,
131 pub body: String,
132 /// JSON array of label names.
133 pub labels: String,
134 pub state: State,
135 pub reason: Option<CloseReason>,
136 /// JSON array of `{ repo_id, pull, commit, pushed_by }`.
137 #[serde(default)]
138 pub resolutions: Option<String>,
139 pub author_id: String,
140 pub author_name: String,
141 #[serde(default)]
142 pub requested_by_id: Option<String>,
143 #[serde(default)]
144 pub requested_by_name: Option<String>,
145 pub created_at: String,
146 pub updated_at: String,
147 pub closed_at: Option<String>,
148 /// JSON array of usernames.
149 pub assignees: String,
150 #[serde(default)]
151 pub milestone: Option<u32>,
152 #[serde(default)]
153 pub milestone_title: Option<String>,
154 #[serde(default)]
155 pub pull_count: u32,
156 #[serde(default)]
157 pub comment_count: u32,
158 #[serde(default)]
159 pub agent: Option<String>,
160 /// JSON array of the numbers of the work items it is blocked by.
161 #[serde(default)]
162 pub blocked_numbers: Option<String>,
163 /// JSON array of [`LinkRow`].
164 #[serde(default)]
165 pub links: Option<String>,
166}
167
168/// A link as [`ITEM_COLUMNS`] reads it.
169#[derive(Deserialize)]
170struct LinkRow {
171 repo_id: String,
172 path: String,
173 role: LinkRole,
174 #[serde(default)]
175 legacy_number: Option<u32>,
176 #[serde(default)]
177 landed: u8,
178 /// Where the repository is, when known.
179 #[serde(default)]
180 workspace: Option<String>,
181 #[serde(default)]
182 private: Option<u8>,
183}
184
185/// A resolution as stored.
186#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
187pub(crate) struct StoredResolution {
188 pub repo_id: String,
189 #[serde(default)]
190 pub pull: Option<u32>,
191 #[serde(default)]
192 pub commit: Option<String>,
193 #[serde(default)]
194 pub pushed_by: Option<String>,
195}
196
197/// A work item, with what deciding who may see it needs: its origin's
198/// workspace and whether it is private.
199#[derive(Clone, Debug)]
200pub(crate) struct Held {
201 pub item: WorkItem,
202 /// The origin's id, workspace and privacy, while the repository is
203 /// known: one deleted for good no longer makes it public.
204 origin: Option<(String, String, bool)>,
205}
206
207impl Held {
208 pub(crate) fn origin(&self) -> Option<RepoRef<'_>> {
209 self.origin.as_ref().map(|(id, namespace, private)| RepoRef { id, namespace, private: *private })
210 }
211
212 /// Every repository it is linked to, its origin first.
213 pub(crate) fn repo_ids(&self) -> Vec<String> {
214 self.item.repos.iter().map(|repo| repo.repo_id.clone()).collect()
215 }
216
217 /// The repository its events are filed under: its origin, else the
218 /// first it is linked to.
219 pub(crate) fn event_repo(&self) -> Option<&str> {
220 self.item.repos.first().map(|repo| repo.repo_id.as_str())
221 }
222
223 pub(crate) fn may(&self, viewer: Option<&User>, capability: Capability) -> bool {
224 access::can_item(viewer, &self.item.workspace, self.origin(), capability)
225 }
226
227 /// `Ok` when `viewer` may do `capability` on it; not found when they
228 /// may not see it.
229 pub(crate) fn allowed(&self, viewer: Option<&User>, capability: Capability) -> Outcome<()> {
230 match access::check_item(viewer, &self.item.workspace, self.origin(), capability) {
231 Ok(()) => Outcome::Ok(()),
232 Err(Denied::NotFound) => no_item(),
233 Err(Denied::Forbidden) => Outcome::fail(FailureCode::Forbidden, access::needs_on_item(capability, &self.item.workspace)),
234 }
235 }
236}
237
238pub(crate) fn no_item<T>() -> Outcome<T> {
239 Outcome::fail(FailureCode::NotFound, "Work item not found.")
240}
241
242/// A workspace's slug as given, or `repo`'s, lowercase.
243pub(crate) fn workspace_of(workspace: &str, repo: Option<&RepoPath>) -> String {
244 let given = workspace.trim();
245 match (given.is_empty(), repo) {
246 (true, Some(repo)) => repo.namespace.to_lowercase(),
247 _ => given.to_lowercase(),
248 }
249}
250
251/// `owner/name` of a repository.
252pub(crate) fn path_of(repo: &Repo) -> String {
253 format!("{}/{}", repo.namespace, repo.name)
254}
255
256impl ItemRow {
257 /// The work item, once it has a workspace and a number: one moved from
258 /// a repository's issues has neither until the migration job runs.
259 pub(crate) fn held(self) -> Option<Held> {
260 let workspace = self.workspace?;
261 let number = self.number?;
262 let links: Vec<LinkRow> = self.links.as_deref().and_then(|json| serde_json::from_str(json).ok()).unwrap_or_default();
263 let origin = links.iter().find(|link| link.role == LinkRole::Origin).and_then(|link| {
264 let namespace = link.workspace.clone()?;
265 Some((link.repo_id.clone(), namespace, link.private != Some(0)))
266 });
267 let mut repos: Vec<ItemRepo> = links
268 .into_iter()
269 .map(|link| ItemRepo {
270 repo_id: link.repo_id,
271 path: link.path,
272 role: link.role,
273 legacy_number: link.legacy_number,
274 landed: link.landed != 0,
275 })
276 .collect();
277 sort_repos(&mut repos);
278 let stored: Vec<StoredResolution> =
279 self.resolutions.as_deref().and_then(|json| serde_json::from_str(json).ok()).unwrap_or_default();
280 let resolutions = stored
281 .into_iter()
282 .map(|resolution| Resolution {
283 repo: repos
284 .iter()
285 .find(|repo| repo.repo_id == resolution.repo_id)
286 .map_or_else(|| resolution.repo_id.clone(), |repo| repo.path.clone()),
287 pull: resolution.pull,
288 commit: resolution.commit,
289 pushed_by: resolution.pushed_by,
290 })
291 .collect();
292 Some(Held {
293 item: WorkItem {
294 id: self.id,
295 workspace,
296 number,
297 title: self.title,
298 body: self.body,
299 labels: serde_json::from_str(&self.labels).unwrap_or_default(),
300 state: self.state,
301 reason: self.reason,
302 resolutions,
303 author: user(self.author_id, self.author_name),
304 requested_by: requester(self.requested_by_id, self.requested_by_name),
305 created_at: self.created_at,
306 updated_at: self.updated_at,
307 closed_at: self.closed_at,
308 pull_count: self.pull_count,
309 comment_count: self.comment_count,
310 assignees: serde_json::from_str(&self.assignees).unwrap_or_default(),
311 blocked_by: self.blocked_numbers.as_deref().and_then(|json| serde_json::from_str(json).ok()).unwrap_or_default(),
312 agent: self.agent,
313 milestone: milestone_ref(self.milestone, self.milestone_title),
314 repos,
315 },
316 origin,
317 })
318 }
319}
320
321/// What `list_work_items` reads: `?1` the workspace, then the filters, and
322/// `?8` whether the viewer is a member (else only work items filed in a
323/// public repository or one in `?9`, the repositories they were given a
324/// role on).
325const LIST_SQL: &str = "SELECT {ITEM_COLUMNS} FROM work_items
326 WHERE workspace = ?1 AND (?2 IS NULL OR state = ?2)
327 AND (?3 IS NULL OR EXISTS (SELECT 1 FROM json_each(work_items.labels) WHERE json_each.value = ?3))
328 AND (?4 IS NULL OR milestone = ?4)
329 AND (?5 IS NULL OR EXISTS (SELECT 1 FROM json_each(work_items.assignees) WHERE json_each.value = ?5))
330 AND (?6 IS NULL OR EXISTS (SELECT 1 FROM work_item_repos r
331 WHERE r.item_id = work_items.id AND r.repo_id = ?6 AND (?7 IS NULL OR r.role = ?7)))
332 AND (?8 = 1 OR EXISTS (SELECT 1 FROM work_item_repos o JOIN repo_workspaces rw ON rw.repo_id = o.repo_id
333 WHERE o.item_id = work_items.id AND o.role = 'origin'
334 AND (rw.private = 0 OR o.repo_id IN (SELECT value FROM json_each(?9)))))
335 ORDER BY number DESC LIMIT ?10";
336
337/// A note of a landing: where, and by what.
338pub(crate) fn landing_note(path: &str, pull: Option<u32>, commit: Option<&str>) -> String {
339 match (pull, commit) {
340 (Some(pull), _) => format!("{path}#{pull}"),
341 (None, Some(commit)) => format!("{path}@{}", &commit[..7.min(commit.len())]),
342 (None, None) => path.to_owned(),
343 }
344}
345
346impl Work {
347 /// A work item by workspace and number, whoever is asking.
348 pub(crate) async fn item(&self, workspace: &str, number: u32) -> Result<Option<Held>> {
349 Ok(self
350 .db
351 .prepare(format!("SELECT {ITEM_COLUMNS} FROM work_items WHERE workspace = ? AND number = ?"))
352 .bind(&[workspace.to_lowercase().into(), number.into()])?
353 .first::<ItemRow>(None)
354 .await?
355 .and_then(ItemRow::held))
356 }
357
358 /// A work item by id, whoever is asking.
359 pub(crate) async fn item_by_id(&self, id: &str) -> Result<Option<Held>> {
360 Ok(self
361 .db
362 .prepare(format!("SELECT {ITEM_COLUMNS} FROM work_items WHERE id = ?"))
363 .bind(&[id.into()])?
364 .first::<ItemRow>(None)
365 .await?
366 .and_then(ItemRow::held))
367 }
368
369 /// The work items of these ids, in no particular order.
370 pub(crate) async fn items_by_ids(&self, ids: &[String]) -> Result<Vec<Held>> {
371 if ids.is_empty() {
372 return Ok(Vec::new());
373 }
374 Ok(self
375 .db
376 .prepare(format!("SELECT {ITEM_COLUMNS} FROM work_items WHERE id IN (SELECT value FROM json_each(?))"))
377 .bind(&[serde_json::to_string(ids)?.into()])?
378 .all()
379 .await?
380 .results::<ItemRow>()?
381 .into_iter()
382 .filter_map(ItemRow::held)
383 .collect())
384 }
385
386 /// A work item, if `viewer` may do `capability` on it.
387 pub(crate) async fn item_for(&self, viewer: &Viewer, workspace: &str, number: u32, capability: Capability) -> Result<Outcome<Held>> {
388 let Some(held) = self.item(workspace, number).await? else {
389 return Ok(no_item());
390 };
391 check!(held.allowed(viewer.as_ref(), capability));
392 Ok(Outcome::Ok(held))
393 }
394
395 /// A work item `actor` may change: theirs (`WorkItem::owner`), or one
396 /// they may triage.
397 pub(crate) async fn manageable_item(&self, actor: &User, workspace: &str, number: u32) -> Result<Outcome<Held>> {
398 let held = check!(self.item_for(&Some(actor.clone()), workspace, number, Capability::Read).await?);
399 if held.item.owner().id != actor.id {
400 check!(held.allowed(Some(actor), Capability::Triage));
401 }
402 Ok(Outcome::Ok(held))
403 }
404
405 /// The next number of `workspace`'s sequence, for a work item, or for
406 /// a pull request of `repo_id` ([`NEXT_NUMBER`]).
407 pub(crate) async fn next_number(&self, workspace: &str, repo_id: Option<&str>) -> Result<u32> {
408 let row = self
409 .db
410 .prepare(NEXT_NUMBER)
411 .bind(&[workspace.to_lowercase().into(), repo_id.map_or(JsValue::NULL, JsValue::from)])?
412 .first::<NumberRow>(None)
413 .await?;
414 row.map(|row| row.n).ok_or_else(|| worker::Error::RustError("no number was assigned".into()))
415 }
416
417 /// Records which workspace `repo` is in, its path and whether it is
418 /// private, as repos just said.
419 pub(crate) async fn remember_repo(&self, repo: &Repo) -> Result<()> {
420 self.db
421 .prepare(REMEMBER_REPO)
422 .bind(&[
423 repo.id.as_str().into(),
424 repo.namespace.to_lowercase().into(),
425 path_of(repo).into(),
426 u32::from(repo.is_private).into(),
427 ])?
428 .run()
429 .await?;
430 Ok(())
431 }
432
433 /// The workspace repository `repo_id` is in: as recorded, or as repos
434 /// says when it is not yet.
435 pub(crate) async fn repo_workspace(&self, repo_id: &str) -> Result<Option<String>> {
436 let recorded = self
437 .db
438 .prepare("SELECT workspace AS value FROM repo_workspaces WHERE repo_id = ?")
439 .bind(&[repo_id.into()])?
440 .first::<ValueRow>(None)
441 .await?;
442 if let Some(row) = recorded {
443 return Ok(Some(row.value));
444 }
445 let path: Option<RepoPath> =
446 g1t_kit::call(&self.repos, "path_by_id", &g1t_contracts::repos::PathByIdArgs { id: repo_id.to_owned() }).await?;
447 Ok(path.map(|path| path.namespace.to_lowercase()))
448 }
449
450 /// Whether the job that moved issues into work items is done.
451 pub(crate) async fn items_moved(&self) -> Result<bool> {
452 Ok(self
453 .db
454 .prepare("SELECT done_at AS value FROM work_jobs WHERE name = 'work_items' AND done_at IS NOT NULL")
455 .first::<ValueRow>(None)
456 .await?
457 .is_some())
458 }
459
460 pub(crate) fn item_event(held: &Held) -> WorkItemEvent {
461 WorkItemEvent {
462 item_id: held.item.id.clone(),
463 workspace: held.item.workspace.clone(),
464 number: held.item.number,
465 repo_ids: held.repo_ids(),
466 author: Some((&held.item.author).into()),
467 requested_by: held.item.requested_by.as_ref().map(Into::into),
468 ..WorkItemEvent::default()
469 }
470 }
471
472 /// Publishes a `work_item.*` event about `held`, filed under its origin.
473 pub(crate) async fn publish_item(&self, kind: &'static str, held: &Held, actor: Option<&User>, data: WorkItemEvent) -> Result<()> {
474 let data = g1t_contracts::events::marked(data, actor);
475 self.publish_on(kind, held.event_repo(), actor.map(|actor| actor.id.clone()), data).await
476 }
477
478 /// Notes something that happened to a work item, in its conversation.
479 pub(crate) async fn note_item<'a>(&self, item_id: &str, author: impl Into<crate::rows::By<'a>>, text: &str) -> Result<()> {
480 self.note_on(crate::On::Item(item_id), author.into(), text).await
481 }
482
483 // --- RPC methods ----------------------------------------------------------
484
485 pub(crate) async fn open_work_item(&self, a: OpenWorkItemArgs) -> Result<Outcome<WorkItem>> {
486 if !a.actor.verified {
487 return Ok(Outcome::fail(FailureCode::Forbidden, UNVERIFIED));
488 }
489 let title = match valid_title(&a.title) {
490 Ok(title) => title.to_owned(),
491 Err(message) => return Ok(Outcome::fail(FailureCode::Invalid, message)),
492 };
493 let Some(labels) = normalize_labels(&a.labels) else {
494 return Ok(Outcome::fail(
495 FailureCode::Invalid,
496 format!("A work item can have up to {MAX_LABELS} labels of up to {MAX_LABEL_CHARS} characters each."),
497 ));
498 };
499 let workspace = workspace_of(&a.workspace, a.repo.as_ref());
500 if workspace.is_empty() {
501 return Ok(Outcome::fail(FailureCode::Invalid, "Say which workspace, or which repository it is filed from."));
502 }
503 // Numbered only once the issues before it are, so every new number
504 // comes after the old ones.
505 if !self.items_moved().await? {
506 return Ok(crate::labels::still_moving());
507 }
508 let actor = Some(a.actor.clone());
509 // Where it is filed from: someone who may take part there may file
510 // it; without one, only a member of the workspace.
511 let origin = match &a.repo {
512 Some(path) => {
513 let repo = check!(self.repo(path, &actor).await?);
514 if !repo.namespace.eq_ignore_ascii_case(&workspace) {
515 return Ok(Outcome::fail(FailureCode::Invalid, format!("{} is not a repository of {workspace}.", path_of(&repo))));
516 }
517 check!(crate::retired::writable(&repo));
518 check!(crate::allowed(Some(&a.actor), &repo, Capability::Participate));
519 if !self.work_items_enabled(&repo).await? {
520 return Ok(Outcome::fail(FailureCode::Forbidden, crate::settings::work_items_off(&repo)));
521 }
522 self.remember_repo(&repo).await?;
523 Some(repo)
524 }
525 None => {
526 if access::workspace_role(&a.actor, &workspace).is_none() {
527 return Ok(Outcome::fail(
528 FailureCode::Forbidden,
529 format!("Only members of {workspace} can file a work item there without a repository to file it from."),
530 ));
531 }
532 None
533 }
534 };
535 let origin_ref = origin.as_ref().map(RepoRef::from);
536 let may = |capability| access::can_item(Some(&a.actor), &workspace, origin_ref, capability);
537 // Where it should land: repositories of the workspace the actor can see.
538 let mut targets: Vec<Repo> = Vec::new();
539 for path in &a.targets {
540 let repo = check!(self.repo(path, &actor).await?);
541 if !repo.namespace.eq_ignore_ascii_case(&workspace) {
542 return Ok(Outcome::fail(FailureCode::Invalid, format!("{} is not a repository of {workspace}.", path_of(&repo))));
543 }
544 if origin.as_ref().is_some_and(|origin| origin.id == repo.id) || targets.iter().any(|known| known.id == repo.id) {
545 continue;
546 }
547 self.remember_repo(&repo).await?;
548 targets.push(repo);
549 }
550 if !targets.is_empty() && origin.is_some() && !may(Capability::Triage) {
551 return Ok(Outcome::fail(FailureCode::Forbidden, access::needs_on_item(Capability::Triage, &workspace)));
552 }
553 let colors = check!(self.ensure_labels(&a.actor, &workspace, origin_ref, &labels).await?);
554 let milestone = match a.milestone.filter(|number| *number > 0) {
555 Some(number) => {
556 if !may(Capability::Triage) {
557 return Ok(Outcome::fail(FailureCode::Forbidden, access::needs_on_item(Capability::Triage, &workspace)));
558 }
559 check!(self.milestone_ref(&workspace, number).await?)
560 }
561 None => None,
562 };
563 let now = now_ms();
564 let id = new_id("wki", now);
565 let number = self.next_number(&workspace, origin.as_ref().map(|repo| repo.id.as_str())).await?;
566 let timestamp = rfc3339(now);
567 let (author, requested_by) = authorship(&a.actor);
568 let mut statements = vec![self
569 .db
570 .prepare(
571 "INSERT INTO work_items
572 (id, workspace, number, title, body, labels, author_id, author_name, requested_by_id,
573 requested_by_name, created_at, updated_at, milestone)
574 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
575 )
576 .bind(&[
577 id.as_str().into(),
578 workspace.as_str().into(),
579 number.into(),
580 title.as_str().into(),
581 a.body.trim().into(),
582 serde_json::to_string(&labels)?.into(),
583 author.id.as_str().into(),
584 author.username.as_str().into(),
585 optional(&requested_by.as_ref().map(|user| user.id.clone())),
586 optional(&requested_by.as_ref().map(|user| user.username.clone())),
587 timestamp.as_str().into(),
588 timestamp.as_str().into(),
589 optional_number(milestone.as_ref().map(|milestone| milestone.number)),
590 ])?];
591 let links = origin.iter().map(|repo| (repo, LinkRole::Origin)).chain(targets.iter().map(|repo| (repo, LinkRole::Target)));
592 for (repo, role) in links {
593 statements.push(
594 self.db
595 .prepare(
596 "INSERT INTO work_item_repos (item_id, repo_id, role, path, added_at, added_by)
597 VALUES (?, ?, ?, ?, ?, ?)",
598 )
599 .bind(&[
600 id.as_str().into(),
601 repo.id.as_str().into(),
602 role.as_str().into(),
603 path_of(repo).into(),
604 timestamp.as_str().into(),
605 a.actor.id.as_str().into(),
606 ])?,
607 );
608 }
609 self.db.batch(statements).await?;
610 let Some(held) = self.item_by_id(&id).await? else {
611 return Ok(no_item());
612 };
613 self.publish_item("work_item.opened", &held, Some(&a.actor), WorkItemEvent { title: Some(held.item.title.clone()), ..Self::item_event(&held) })
614 .await?;
615 // Opened with labels and a milestone: each is said, as it would be
616 // if they were added afterwards, without notes in the conversation.
617 for label in &held.item.labels {
618 let color = colors.iter().find(|(name, _)| name == label).map_or_else(|| label_color_for(label), |(_, c)| c.clone());
619 let label = Some(EventLabel { name: label.clone(), color });
620 self.publish_item("work_item.labeled", &held, Some(&a.actor), WorkItemEvent { label, ..Self::item_event(&held) }).await?;
621 }
622 if let Some(milestone) = milestone {
623 self.publish_item(
624 "work_item.milestoned",
625 &held,
626 Some(&a.actor),
627 WorkItemEvent { milestone: Some(milestone), ..Self::item_event(&held) },
628 )
629 .await?;
630 }
631 Ok(Outcome::Ok(held.item))
632 }
633
634 pub(crate) async fn list_work_items(&self, a: ListWorkItemsArgs) -> Result<Outcome<Vec<WorkItem>>> {
635 let workspace = workspace_of(&a.workspace, a.repo.as_ref());
636 let repo = match &a.repo {
637 Some(path) => {
638 let repo = check!(self.repo(path, &a.viewer).await?);
639 if !repo.namespace.eq_ignore_ascii_case(&workspace) {
640 return Ok(Outcome::Ok(Vec::new()));
641 }
642 Some(repo)
643 }
644 None => None,
645 };
646 let viewer = a.viewer.as_ref();
647 let member = viewer.and_then(|user| access::workspace_role(user, &workspace)).is_some();
648 let granted: Vec<&str> = viewer
649 .map(|user| user.grants.iter().filter(|grant| grant.workspace.eq_ignore_ascii_case(&workspace)).map(|grant| grant.repo_id.as_str()).collect())
650 .unwrap_or_default();
651 let label = a
652 .label
653 .map(|label| label.split_whitespace().collect::<Vec<_>>().join(" ").to_lowercase())
654 .filter(|label| !label.is_empty());
655 let assignee = a.assignee.map(|name| name.trim().trim_start_matches('@').to_lowercase()).filter(|name| !name.is_empty());
656 let state = state_name(a.state).map_or(JsValue::NULL, JsValue::from);
657 let query = self
658 .db
659 .prepare(LIST_SQL.replace("{ITEM_COLUMNS}", ITEM_COLUMNS))
660 .bind(&[
661 workspace.as_str().into(),
662 state,
663 optional(&label),
664 optional_number(a.milestone),
665 optional(&assignee),
666 optional(&repo.as_ref().map(|repo| repo.id.clone())),
667 a.role.map_or(JsValue::NULL, |role| role.as_str().into()),
668 u32::from(member).into(),
669 serde_json::to_string(&granted)?.into(),
670 LIST_PAGE.into(),
671 ])?;
672 let rows = self.timing.db(1, query.all()).await?.results::<ItemRow>()?;
673 Ok(Outcome::Ok(
674 rows.into_iter()
675 .filter_map(ItemRow::held)
676 .filter(|held| held.may(viewer, Capability::Read))
677 .map(|held| held.item)
678 .collect(),
679 ))
680 }
681
682 /// A work item, the pull requests for it the viewer may see, and its
683 /// comments and reactions.
684 pub(crate) async fn get_work_item(&self, a: WorkItemViewArgs) -> Result<Outcome<WorkItemDetail>> {
685 let held = check!(self.item_for(&a.viewer, &a.workspace, a.number, Capability::Read).await?);
686 let key = [JsValue::from(held.item.id.as_str())];
687 let statements = vec![
688 self.db.prepare(format!("SELECT {PULL_COLUMNS} FROM pulls WHERE item_id = ?1 ORDER BY created_at")).bind(&key)?,
689 self.db.prepare("SELECT * FROM comments WHERE item_id = ?1 ORDER BY id LIMIT 500").bind(&key)?,
690 self.db.prepare(crate::reactions::ITEM_THREAD_SQL).bind(&key)?,
691 ];
692 let results = self.timing.db(3, self.db.batch(statements)).await?;
693 let rows = |index: usize| results.get(index).ok_or_else(|| worker::Error::RustError("short batch".into()));
694 let pulls: Vec<Pull> = rows(0)?.results::<PullRow>()?.into_iter().map(Pull::from).collect();
695 let mut comments: Vec<Comment> = rows(1)?.results::<CommentRow>()?.into_iter().map(Comment::from).collect();
696 let reacted = rows(2)?.results::<crate::reactions::ReactionRow>()?;
697 let reactions = crate::reactions::attach(&reacted, &held.item.id, &mut comments);
698 let pulls = self.readable_pulls(&a.viewer, pulls).await?;
699 Ok(Outcome::Ok(WorkItemDetail { item: held.item, pulls, comments, reactions }))
700 }
701
702 /// Those of `pulls` whose repositories `viewer` may read, each naming
703 /// the branch it merges into.
704 pub(crate) async fn readable_pulls(&self, viewer: &Viewer, pulls: Vec<Pull>) -> Result<Vec<Pull>> {
705 if pulls.is_empty() {
706 return Ok(pulls);
707 }
708 let mut ids: Vec<String> = pulls.iter().map(|pull| pull.repo_id.clone()).collect();
709 ids.sort();
710 ids.dedup();
711 let readable: Vec<Repo> = g1t_kit::call(&self.repos, "readable", &ReadableArgs { ids, viewer: viewer.clone() }).await?;
712 Ok(pulls
713 .into_iter()
714 .filter_map(|mut pull| {
715 let repo = readable.iter().find(|repo| repo.id == pull.repo_id)?;
716 crate::fill_base(&mut pull, repo);
717 Some(pull)
718 })
719 .collect())
720 }
721
722 pub(crate) async fn update_work_item(&self, a: UpdateWorkItemArgs) -> Result<Outcome<WorkItem>> {
723 let held = check!(self.manageable_item(&a.actor, &a.workspace, a.number).await?);
724 let title = match a.title.as_deref().map(valid_title) {
725 Some(Err(message)) => return Ok(Outcome::fail(FailureCode::Invalid, message)),
726 Some(Ok(title)) => Some(title.to_owned()),
727 None => None,
728 };
729 if a.labels.as_deref().is_some_and(|labels| normalize_labels(labels).is_none()) {
730 return Ok(Outcome::fail(
731 FailureCode::Invalid,
732 format!("A work item can have up to {MAX_LABELS} labels of up to {MAX_LABEL_CHARS} characters each."),
733 ));
734 }
735 let assignees = match a.assignees {
736 Some(names) => Some(check!(self.valid_assignees(names).await?)),
737 None => None,
738 };
739 // What it is blocked by: checked before anything changes.
740 let blocked_by = match &a.blocked_by {
741 Some(asked) => Some(check!(self.checked_blocked_by(&held.item.workspace, held.item.number, asked).await?)),
742 None => None,
743 };
744 // Its labels and milestone first: either can be refused, and then
745 // nothing else changes.
746 if let Some(number) = a.milestone {
747 check!(self.set_milestone(&a.actor, &crate::labels::Labeled::Item(held.clone()), number).await?);
748 }
749 if let Some(labels) = &a.labels {
750 check!(self.relabel(&a.actor, &crate::labels::Labeled::Item(held.clone()), labels).await?);
751 }
752 let assigned = assignees.as_ref().map(serde_json::to_string).transpose()?;
753 let blocked_ids = match &blocked_by {
754 Some(numbers) => Some(serde_json::to_string(&self.item_ids(&held.item.workspace, numbers).await?)?),
755 None => None,
756 };
757 let body = a.body.map(|body| body.trim().to_owned());
758 let updated_at = rfc3339(now_ms());
759 let dependency = match &blocked_by {
760 Some(after) => self.dependency_change(&held, after, &a.actor).await?,
761 None => None,
762 };
763 let mut statements = vec![self
764 .db
765 .prepare(
766 "UPDATE work_items
767 SET title = COALESCE(?, title), body = COALESCE(?, body),
768 assignees = COALESCE(?, assignees), blocked_by = COALESCE(?, blocked_by),
769 updated_at = ?
770 WHERE id = ?",
771 )
772 .bind(&[
773 optional(&title),
774 optional(&body),
775 optional(&assigned),
776 optional(&blocked_ids),
777 updated_at.as_str().into(),
778 held.item.id.as_str().into(),
779 ])?];
780 // A change to what it is blocked by reaches the sessions it concerns
781 // with the change itself.
782 let dependency_sent = match (&dependency, &blocked_ids) {
783 (Some(data), Some(blocked)) => crate::coordination::updates::push_delivery(
784 &mut statements,
785 self.delivery_to(
786 "dependency.changed",
787 &held.repo_ids(),
788 data.clone(),
789 &format!("{}:blocked_by:{updated_at}", held.item.id),
790 "EXISTS (SELECT 1 FROM work_items WHERE id = ?7 AND blocked_by = ?8 AND updated_at = ?9)",
791 vec![held.item.id.as_str().into(), blocked.as_str().into(), updated_at.as_str().into()],
792 )?,
793 ),
794 _ => None,
795 };
796 self.db.batch(statements).await?;
797 let before = held.item.assignees.clone();
798 let blocked_before = held.item.blocked_by.clone();
799 let Some(held) = self.item_by_id(&held.item.id).await? else {
800 return Ok(no_item());
801 };
802 self.publish_item("work_item.updated", &held, Some(&a.actor), Self::item_event(&held)).await?;
803 if let Some(data) = dependency {
804 self.delivered(dependency_sent, "dependency.changed", held.event_repo().unwrap_or_default(), Some(a.actor.id.clone()), data)
805 .await?;
806 self.blocked_by_changed(&held, &blocked_before, &a.actor).await?;
807 }
808 if let Some(assignees) = assignees {
809 self.note_changes(crate::On::Item(&held.item.id), &a.actor, &before, &assignees, ("assigned", "unassigned")).await?;
810 let added: Vec<String> = assignees.iter().filter(|name| !before.contains(name)).cloned().collect();
811 self.publish_item(
812 "work_item.assigned",
813 &held,
814 Some(&a.actor),
815 WorkItemEvent { assignees: Some(assignees), added: Some(added), ..Self::item_event(&held) },
816 )
817 .await?;
818 }
819 Ok(Outcome::Ok(held.item))
820 }
821
822 /// The ids of the work items of `workspace` with these numbers, in the
823 /// order given.
824 pub(crate) async fn item_ids(&self, workspace: &str, numbers: &[u32]) -> Result<Vec<String>> {
825 #[derive(Deserialize)]
826 struct IdNumber {
827 id: String,
828 number: u32,
829 }
830 if numbers.is_empty() {
831 return Ok(Vec::new());
832 }
833 let rows = self
834 .db
835 .prepare("SELECT id, number FROM work_items WHERE workspace = ? AND number IN (SELECT value FROM json_each(?))")
836 .bind(&[workspace.into(), serde_json::to_string(numbers)?.into()])?
837 .all()
838 .await?
839 .results::<IdNumber>()?;
840 Ok(numbers.iter().filter_map(|number| rows.iter().find(|row| row.number == *number).map(|row| row.id.clone())).collect())
841 }
842
843 pub(crate) async fn close_work_item(&self, args: CloseWorkItemArgs) -> Result<Outcome<WorkItem>> {
844 let CloseWorkItemArgs { item: a, repo, commit } = args;
845 let held = check!(self.manageable_item(&a.actor, &a.workspace, a.number).await?);
846 if held.item.state == State::Closed {
847 return Ok(Outcome::fail(FailureCode::Conflict, "This work item is already closed."));
848 }
849 // Closed by a commit on a repository's default branch: as its push
850 // would have.
851 if let Some(commit) = commit.as_deref() {
852 if a.reason == Some(CloseReason::NotPlanned) {
853 return Ok(Outcome::fail(FailureCode::Invalid, "A commit closes a work item as completed, not as not planned."));
854 }
855 let Some(path) = repo.as_ref() else {
856 return Ok(Outcome::fail(FailureCode::Invalid, "Say which repository the commit is in (repo)."));
857 };
858 let found = check!(self.repo(path, &Some(a.actor.clone())).await?);
859 let (commit, named) = check!(self.closing_commit(&found, &held, commit, &a.actor).await?);
860 let session = match named {
861 Some(session) => Some(session),
862 None => self.caller_session(&held, &a.actor).await?,
863 };
864 let landing = crate::closing::Landing { pull: None, commit: Some(commit), pushed_by: Some(a.actor.username.clone()) };
865 self.land(&found, &landing, &a.actor, std::slice::from_ref(&held.item.id), &[], session.as_deref(), false).await?;
866 return Ok(match self.item_by_id(&held.item.id).await? {
867 Some(held) => Outcome::Ok(held.item),
868 None => no_item(),
869 });
870 }
871 let session = self.caller_session(&held, &a.actor).await?;
872 let reason = a.reason.unwrap_or(CloseReason::Completed);
873 let now = rfc3339(now_ms());
874 self.db
875 .prepare("UPDATE work_items SET state = 'closed', reason = ?, closed_at = ?, updated_at = ? WHERE id = ?")
876 .bind(&[reason.as_str().into(), now.as_str().into(), now.as_str().into(), held.item.id.as_str().into()])?
877 .run()
878 .await?;
879 self.publish_item(
880 "work_item.closed",
881 &held,
882 Some(&a.actor),
883 WorkItemEvent { reason: Some(reason.as_str()), session: session.clone(), ..Self::item_event(&held) },
884 )
885 .await?;
886 let note = match reason {
887 CloseReason::Completed => "closed this as completed",
888 CloseReason::NotPlanned => "closed this as not planned",
889 };
890 self.note_item(&held.item.id, &a.actor, &crate::pushed::closed_note(note, session.as_deref())).await?;
891 // Its claims end, and the work items it blocks hear (coordination/).
892 self.item_resolved(&held, &a.actor, true, None).await;
893 let mut item = held.item;
894 item.state = State::Closed;
895 item.reason = Some(reason);
896 item.closed_at = Some(now.clone());
897 item.updated_at = now;
898 Ok(Outcome::Ok(item))
899 }
900
901 pub(crate) async fn reopen_work_item(&self, a: WorkItemActionArgs) -> Result<Outcome<WorkItem>> {
902 let held = check!(self.manageable_item(&a.actor, &a.workspace, a.number).await?);
903 if held.item.state == State::Open {
904 return Ok(Outcome::fail(FailureCode::Conflict, "This work item is already open."));
905 }
906 let now = rfc3339(now_ms());
907 // Reopened, nothing has landed for it any more.
908 self.db
909 .batch(vec![
910 self.db
911 .prepare(
912 "UPDATE work_items
913 SET state = 'open', reason = NULL, resolutions = '[]', closed_at = NULL, updated_at = ?
914 WHERE id = ?",
915 )
916 .bind(&[now.as_str().into(), held.item.id.as_str().into()])?,
917 self.db.prepare("UPDATE work_item_repos SET landed_at = NULL WHERE item_id = ?").bind(&[held.item.id.as_str().into()])?,
918 ])
919 .await?;
920 self.publish_item("work_item.reopened", &held, Some(&a.actor), Self::item_event(&held)).await?;
921 self.note_item(&held.item.id, &a.actor, "reopened this").await?;
922 Ok(match self.item_by_id(&held.item.id).await? {
923 Some(held) => Outcome::Ok(held.item),
924 None => no_item(),
925 })
926 }
927
928 pub(crate) async fn link_work_item(&self, a: LinkWorkItemArgs) -> Result<Outcome<WorkItem>> {
929 self.relink(a, false).await
930 }
931
932 pub(crate) async fn unlink_work_item(&self, a: LinkWorkItemArgs) -> Result<Outcome<WorkItem>> {
933 self.relink(a, true).await
934 }
935
936 async fn relink(&self, a: LinkWorkItemArgs, unlink: bool) -> Result<Outcome<WorkItem>> {
937 if !a.actor.verified {
938 return Ok(Outcome::fail(FailureCode::Forbidden, UNVERIFIED));
939 }
940 let held = check!(self.item_for(&Some(a.actor.clone()), &a.workspace, a.number, Capability::Triage).await?);
941 let repo = check!(self.repo(&a.repo, &Some(a.actor.clone())).await?);
942 let path = path_of(&repo);
943 if !repo.namespace.eq_ignore_ascii_case(&held.item.workspace) {
944 return Ok(Outcome::fail(FailureCode::Invalid, format!("{path} is not a repository of {}.", held.item.workspace)));
945 }
946 self.remember_repo(&repo).await?;
947 let current = held.item.link(&repo.id).cloned();
948 if current.as_ref().is_some_and(|link| link.role == LinkRole::Origin) {
949 return Ok(Outcome::fail(
950 FailureCode::Conflict,
951 format!("{path} is where this work item was filed; that link stays as it is."),
952 ));
953 }
954 let now = rfc3339(now_ms());
955 let (kind, role, text) = if unlink {
956 let Some(link) = current else {
957 return Ok(Outcome::fail(FailureCode::NotFound, format!("This work item is not linked to {path}.")));
958 };
959 self.db
960 .prepare("DELETE FROM work_item_repos WHERE item_id = ? AND repo_id = ? AND role <> 'origin'")
961 .bind(&[held.item.id.as_str().into(), repo.id.as_str().into()])?
962 .run()
963 .await?;
964 ("work_item.unlinked", link.role, format!("unlinked {path}"))
965 } else {
966 if a.role == LinkRole::Origin {
967 return Ok(Outcome::fail(FailureCode::Invalid, "A work item's origin is where it was filed; link a target instead."));
968 }
969 if current.as_ref().is_some_and(|link| link.role == a.role) {
970 return Ok(Outcome::Ok(held.item));
971 }
972 self.db
973 .prepare(
974 "INSERT INTO work_item_repos (item_id, repo_id, role, path, added_at, added_by)
975 VALUES (?1, ?2, ?3, ?4, ?5, ?6)
976 ON CONFLICT (item_id, repo_id) DO UPDATE SET role = excluded.role WHERE work_item_repos.role <> 'origin'",
977 )
978 .bind(&[
979 held.item.id.as_str().into(),
980 repo.id.as_str().into(),
981 a.role.as_str().into(),
982 path.as_str().into(),
983 now.as_str().into(),
984 a.actor.id.as_str().into(),
985 ])?
986 .run()
987 .await?;
988 let text = match a.role {
989 LinkRole::Target => format!("added {path} as a target"),
990 _ => format!("linked {path}"),
991 };
992 ("work_item.linked", a.role, text)
993 };
994 self.db
995 .prepare("UPDATE work_items SET updated_at = ? WHERE id = ?")
996 .bind(&[now.as_str().into(), held.item.id.as_str().into()])?
997 .run()
998 .await?;
999 let Some(after) = self.item_by_id(&held.item.id).await? else {
1000 return Ok(no_item());
1001 };
1002 // An unlinked repository still hears that it was.
1003 let mut event = Self::item_event(&after);
1004 if unlink {
1005 event.repo_ids.push(repo.id.clone());
1006 }
1007 self.publish_item(kind, &after, Some(&a.actor), WorkItemEvent { repo: Some(path), role: Some(role), ..event }).await?;
1008 self.note_item(&after.item.id, &a.actor, &text).await?;
1009 Ok(Outcome::Ok(after.item))
1010 }
1011
1012 /// What [`REF_SQL`] finds for `#number` in `repo_id` (or none) of
1013 /// `workspace`.
1014 pub(crate) async fn lookup_ref(&self, repo_id: Option<&str>, workspace: &str, number: u32) -> Result<RefRow> {
1015 Ok(self
1016 .db
1017 .prepare(REF_SQL)
1018 .bind(&[repo_id.map_or(JsValue::NULL, JsValue::from), workspace.to_lowercase().into(), number.into()])?
1019 .first::<RefRow>(None)
1020 .await?
1021 .unwrap_or_default())
1022 }
1023
1024 /// `resolve_ref`: what `#number` names for the viewer, read in `repo`
1025 /// or in `workspace` (`refs`).
1026 pub(crate) async fn resolve_ref(&self, a: ResolveRefArgs) -> Result<RefTarget> {
1027 let (repo, workspace) = match &a.repo {
1028 Some(path) => match self.repo(path, &a.viewer).await? {
1029 Outcome::Ok(repo) => {
1030 let workspace = repo.namespace.to_lowercase();
1031 (Some(repo), workspace)
1032 }
1033 Outcome::Fail(_) => return Ok(RefTarget::None),
1034 },
1035 None => (None, a.workspace.trim().to_lowercase()),
1036 };
1037 if a.number == 0 || workspace.is_empty() {
1038 return Ok(RefTarget::None);
1039 }
1040 // What the viewer may not see is passed over for what comes next.
1041 let mut row = self.lookup_ref(repo.as_ref().map(|repo| repo.id.as_str()), &workspace, a.number).await?;
1042 for _ in 0..4 {
1043 let Some(named) = row.clone().named(repo.is_some()) else {
1044 return Ok(RefTarget::None);
1045 };
1046 match named {
1047 Named::PullHere => {
1048 let repo = repo.as_ref().map(path_of).unwrap_or_default();
1049 return Ok(RefTarget::Pull { repo, number: a.number });
1050 }
1051 Named::Item(id) => {
1052 if let Some(held) = self.item_by_id(&id).await?
1053 && held.may(a.viewer.as_ref(), Capability::Read)
1054 {
1055 return Ok(RefTarget::WorkItem { workspace: held.item.workspace, number: held.item.number, item_id: held.item.id });
1056 }
1057 if row.legacy.as_deref() == Some(id.as_str()) {
1058 row.legacy = None;
1059 } else {
1060 row.item = None;
1061 }
1062 }
1063 Named::PullThere(repo_id) => {
1064 let readable: Vec<Repo> = g1t_kit::call(
1065 &self.repos,
1066 "readable",
1067 &ReadableArgs { ids: vec![repo_id.clone()], viewer: a.viewer.clone() },
1068 )
1069 .await?;
1070 return Ok(match readable.first() {
1071 Some(other) => RefTarget::Pull { repo: path_of(other), number: a.number },
1072 None => RefTarget::None,
1073 });
1074 }
1075 }
1076 }
1077 Ok(RefTarget::None)
1078 }
1079
1080 /// `legacy_item_map`: a page of where repositories' old issues went.
1081 pub(crate) async fn legacy_item_map(&self, a: LegacyItemMapArgs) -> Result<LegacyItemPage> {
1082 let limit = a.limit.clamp(1, 500);
1083 let items = self
1084 .db
1085 .prepare(
1086 "SELECT r.repo_id, r.legacy_number, i.workspace, i.number, i.id AS item_id
1087 FROM work_item_repos r JOIN work_items i ON i.id = r.item_id
1088 WHERE r.legacy_number IS NOT NULL AND i.number IS NOT NULL AND (?1 IS NULL OR i.id > ?1)
1089 ORDER BY i.id LIMIT ?2",
1090 )
1091 .bind(&[optional(&a.after), limit.into()])?
1092 .all()
1093 .await?
1094 .results::<MigratedItem>()?;
1095 let next = (items.len() as u32 == limit).then(|| items.last().map(|item| item.item_id.clone())).flatten();
1096 Ok(LegacyItemPage { items, next })
1097 }
1098}
1099
1100#[cfg(test)]
1101pub(crate) mod tests {
1102 use super::*;
1103
1104 #[test]
1105 fn a_landing_is_named_by_its_pull_request_or_commit() {
1106 assert_eq!(landing_note("acme/web", Some(12), None), "acme/web#12");
1107 assert_eq!(landing_note("acme/web", None, Some("abcdef1234")), "acme/web@abcdef1");
1108 assert_eq!(workspace_of(" ", Some(&RepoPath { namespace: "Acme".into(), name: "web".into() })), "acme");
1109 assert_eq!(workspace_of("Globex", None), "globex");
1110 }
1111
1112 #[test]
1113 fn a_reference_names_the_first_thing_found_in_order() {
1114 let row = |pull_here: bool, legacy: Option<&str>, item: Option<&str>, there: Option<&str>| RefRow {
1115 pull_here: pull_here.then_some(1),
1116 legacy: legacy.map(str::to_owned),
1117 item: item.map(str::to_owned),
1118 pull_there: there.map(str::to_owned),
1119 };
1120 // In a repository: its own pull request, then its old issue, then
1121 // the workspace's item, then another repository's pull request.
1122 assert_eq!(row(true, Some("iss_1"), Some("wki_1"), None).named(true), Some(Named::PullHere));
1123 assert_eq!(row(false, Some("iss_1"), Some("wki_1"), None).named(true), Some(Named::Item("iss_1".into())));
1124 assert_eq!(row(false, None, Some("wki_1"), Some("rep_2")).named(true), Some(Named::Item("wki_1".into())));
1125 assert_eq!(row(false, None, None, Some("rep_2")).named(true), Some(Named::PullThere("rep_2".into())));
1126 // In a workspace alone: its item, then a pull request.
1127 assert_eq!(row(false, None, Some("wki_1"), Some("rep_2")).named(false), Some(Named::Item("wki_1".into())));
1128 assert_eq!(row(false, None, None, None).named(false), None);
1129 }
1130}