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.

169 linesCodeBlameRaw
1//! A work item's repositories (`work_item_repos`): its origin, its targets
2//! and those it touched, and where each repository is (`repo_workspaces`).
3
4use g1t_contracts::access::Capability;
5use g1t_contracts::events::WorkItemEvent;
6use g1t_contracts::repos::{Repo, RepoPath};
7use g1t_contracts::time::rfc3339;
8use g1t_contracts::work::*;
9use g1t_contracts::{FailureCode, Outcome, User};
10use g1t_kit::now_ms;
11use worker::Result;
12
13use super::rows::{Held, no_item, path_of};
14use crate::rows::ValueRow;
15use crate::{UNVERIFIED, Work};
16
17/// Records where repository `?1` is: workspace `?2`, path `?3`, and
18/// whether it is private (`?4`).
19pub(crate) const REMEMBER_REPO: &str = "INSERT INTO repo_workspaces (repo_id, workspace, path, private)
20 VALUES (?1, ?2, ?3, ?4)
21 ON CONFLICT (repo_id) DO UPDATE SET workspace = excluded.workspace, path = excluded.path, private = excluded.private";
22
23/// Linking a work item (`?1`) that a change in repository `?2` is part of,
24/// at `?3`, by `?4`, unless it is linked already.
25pub(crate) const PART_SQL: &str = "INSERT INTO work_item_repos (item_id, repo_id, role, added_at, added_by)
26 VALUES (?1, ?2, 'touched', ?3, ?4) ON CONFLICT (item_id, repo_id) DO NOTHING RETURNING item_id AS value";
27
28impl Work {
29 /// Records which workspace `repo` is in, its path and whether it is
30 /// private, as repos just said.
31 pub(super) async fn remember_repo(&self, repo: &Repo) -> Result<()> {
32 self.db
33 .prepare(REMEMBER_REPO)
34 .bind(&[
35 repo.id.as_str().into(),
36 repo.namespace.to_lowercase().into(),
37 path_of(repo).into(),
38 u32::from(repo.is_private).into(),
39 ])?
40 .run()
41 .await?;
42 Ok(())
43 }
44
45 /// The workspace repository `repo_id` is in: as recorded, or as repos
46 /// says when it is not yet.
47 pub(super) async fn repo_workspace(&self, repo_id: &str) -> Result<Option<String>> {
48 let recorded = self
49 .db
50 .prepare("SELECT workspace AS value FROM repo_workspaces WHERE repo_id = ?")
51 .bind(&[repo_id.into()])?
52 .first::<ValueRow>(None)
53 .await?;
54 if let Some(row) = recorded {
55 return Ok(Some(row.value));
56 }
57 let path: Option<RepoPath> =
58 g1t_kit::call(&self.repos, "path_by_id", &g1t_contracts::repos::PathByIdArgs { id: repo_id.to_owned() }).await?;
59 Ok(path.map(|path| path.namespace.to_lowercase()))
60 }
61
62 /// Links `repo` to a work item it now has a pull request for, as
63 /// touched, unless it is linked already, and says so.
64 pub(super) async fn touch_item(&self, held: &Held, repo: &Repo, actor: &User) -> Result<()> {
65 let linked = self
66 .db
67 .prepare(PART_SQL)
68 .bind(&[held.item.id.as_str().into(), repo.id.as_str().into(), rfc3339(now_ms()).into(), actor.id.as_str().into()])?
69 .first::<ValueRow>(None)
70 .await?;
71 if linked.is_none() {
72 return Ok(());
73 }
74 let Some(after) = self.item_by_id(&held.item.id).await? else {
75 return Ok(());
76 };
77 self.publish_item(
78 "work_item.linked",
79 &after,
80 Some(actor),
81 WorkItemEvent { repo: Some(path_of(repo)), role: Some(LinkRole::Touched), ..Self::item_event(&after) },
82 )
83 .await
84 }
85
86 pub(crate) async fn link_work_item(&self, a: LinkWorkItemArgs) -> Result<Outcome<WorkItem>> {
87 self.relink(a, false).await
88 }
89
90 pub(crate) async fn unlink_work_item(&self, a: LinkWorkItemArgs) -> Result<Outcome<WorkItem>> {
91 self.relink(a, true).await
92 }
93
94 async fn relink(&self, a: LinkWorkItemArgs, unlink: bool) -> Result<Outcome<WorkItem>> {
95 if !a.actor.verified {
96 return Ok(Outcome::fail(FailureCode::Forbidden, UNVERIFIED));
97 }
98 let held = check!(self.item_for(&Some(a.actor.clone()), &a.workspace, a.number, Capability::Triage).await?);
99 let repo = check!(self.repo(&a.repo, &Some(a.actor.clone())).await?);
100 let path = path_of(&repo);
101 if !repo.namespace.eq_ignore_ascii_case(&held.item.workspace) {
102 return Ok(Outcome::fail(FailureCode::Invalid, format!("{path} is not a repository of {}.", held.item.workspace)));
103 }
104 self.remember_repo(&repo).await?;
105 let current = held.item.link(&repo.id).cloned();
106 if current.as_ref().is_some_and(|link| link.role == LinkRole::Origin) {
107 return Ok(Outcome::fail(
108 FailureCode::Conflict,
109 format!("{path} is where this work item was filed; that link stays as it is."),
110 ));
111 }
112 let now = rfc3339(now_ms());
113 let (kind, role, text) = if unlink {
114 let Some(link) = current else {
115 return Ok(Outcome::fail(FailureCode::NotFound, format!("This work item is not linked to {path}.")));
116 };
117 self.db
118 .prepare("DELETE FROM work_item_repos WHERE item_id = ? AND repo_id = ? AND role <> 'origin'")
119 .bind(&[held.item.id.as_str().into(), repo.id.as_str().into()])?
120 .run()
121 .await?;
122 ("work_item.unlinked", link.role, format!("unlinked {path}"))
123 } else {
124 if a.role == LinkRole::Origin {
125 return Ok(Outcome::fail(FailureCode::Invalid, "A work item's origin is where it was filed; link a target instead."));
126 }
127 if current.as_ref().is_some_and(|link| link.role == a.role) {
128 return Ok(Outcome::Ok(held.item));
129 }
130 self.db
131 .prepare(
132 "INSERT INTO work_item_repos (item_id, repo_id, role, path, added_at, added_by)
133 VALUES (?1, ?2, ?3, ?4, ?5, ?6)
134 ON CONFLICT (item_id, repo_id) DO UPDATE SET role = excluded.role WHERE work_item_repos.role <> 'origin'",
135 )
136 .bind(&[
137 held.item.id.as_str().into(),
138 repo.id.as_str().into(),
139 a.role.as_str().into(),
140 path.as_str().into(),
141 now.as_str().into(),
142 a.actor.id.as_str().into(),
143 ])?
144 .run()
145 .await?;
146 let text = match a.role {
147 LinkRole::Target => format!("added {path} as a target"),
148 _ => format!("linked {path}"),
149 };
150 ("work_item.linked", a.role, text)
151 };
152 self.db
153 .prepare("UPDATE work_items SET updated_at = ? WHERE id = ?")
154 .bind(&[now.as_str().into(), held.item.id.as_str().into()])?
155 .run()
156 .await?;
157 let Some(after) = self.item_by_id(&held.item.id).await? else {
158 return Ok(no_item());
159 };
160 // An unlinked repository still hears that it was.
161 let mut event = Self::item_event(&after);
162 if unlink {
163 event.repo_ids.push(repo.id.clone());
164 }
165 self.publish_item(kind, &after, Some(&a.actor), WorkItemEvent { repo: Some(path), role: Some(role), ..event }).await?;
166 self.note_item(&after.item.id, &a.actor, &text).await?;
167 Ok(Outcome::Ok(after.item))
168 }
169}