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.

503 linesCodeBlameRaw
1//! Work items: any tracked work (a feature, a bug, a chore) of a workspace,
2//! linked to the repositories it concerns.
3//!
4//! A work item belongs to a workspace and is numbered in the workspace's
5//! sequence, which new pull requests share. It links repositories, each
6//! with a [`LinkRole`]: where it was filed (`origin`), where the work is
7//! meant to land (`target`), and where a pull request or commit for it
8//! landed or is under way (`touched`). It closes once it has landed in
9//! every target, or, with no targets, once anything that closes it lands.
10
11use serde::{Deserialize, Serialize};
12
13use super::{Comment, MilestoneRef, Pull, ReactionGroup, State};
14use crate::repos::RepoPath;
15use crate::{User, Viewer};
16
17/// How a work item is linked to a repository.
18#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
19#[serde(rename_all = "snake_case")]
20pub enum LinkRole {
21 /// Where it was filed. A work item moved from a repository's issues
22 /// has one, which keeps the issue's old number.
23 Origin,
24 /// Where the work is meant to land.
25 Target,
26 /// Where a pull request or commit for it landed or is in progress,
27 /// linked by itself.
28 Touched,
29}
30
31impl LinkRole {
32 pub const ALL: [LinkRole; 3] = [LinkRole::Origin, LinkRole::Target, LinkRole::Touched];
33
34 pub fn as_str(self) -> &'static str {
35 match self {
36 LinkRole::Origin => "origin",
37 LinkRole::Target => "target",
38 LinkRole::Touched => "touched",
39 }
40 }
41
42 pub fn parse(name: &str) -> Option<LinkRole> {
43 LinkRole::ALL.into_iter().find(|role| role.as_str() == name.trim().to_ascii_lowercase())
44 }
45}
46
47/// A repository a work item is linked to.
48#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
49#[serde(rename_all = "camelCase")]
50pub struct ItemRepo {
51 pub repo_id: String,
52 /// `owner/name`, as it is now, or as it was when the repository was
53 /// deleted.
54 pub path: String,
55 pub role: LinkRole,
56 /// For the origin of a work item moved from a repository's issues: the
57 /// number the issue had there, which `#N` in that repository's old
58 /// text still names.
59 #[serde(default)]
60 pub legacy_number: Option<u32>,
61 /// A pull request or commit that closes it landed in this repository.
62 #[serde(default)]
63 pub landed: bool,
64}
65
66/// What resolved a work item in one repository: a pull request merged, or
67/// a commit pushed to its default branch.
68#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
69#[serde(rename_all = "camelCase")]
70pub struct Resolution {
71 /// The repository, `owner/name`.
72 pub repo: String,
73 /// The pull request whose merge did it.
74 #[serde(default)]
75 pub pull: Option<u32>,
76 /// The commit pushed to the default branch whose message did it
77 /// (`Closes #12`).
78 #[serde(default)]
79 pub commit: Option<String>,
80 /// Who pushed that commit, by username.
81 #[serde(default)]
82 pub pushed_by: Option<String>,
83}
84
85/// Why a work item was closed.
86#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
87#[serde(rename_all = "snake_case")]
88pub enum CloseReason {
89 /// The work was done. What did it is in `resolutions`.
90 Completed,
91 NotPlanned,
92}
93
94impl CloseReason {
95 pub fn as_str(self) -> &'static str {
96 match self {
97 CloseReason::Completed => "completed",
98 CloseReason::NotPlanned => "not_planned",
99 }
100 }
101}
102
103/// Any tracked work of a workspace: a feature, a bug, a chore. Opened by a
104/// person, an agent or an integration. Pull requests in its repositories
105/// are made for it; it closes once what closes it has landed everywhere it
106/// should.
107#[derive(Clone, Debug, Serialize, Deserialize)]
108#[serde(rename_all = "camelCase")]
109pub struct WorkItem {
110 /// `wki_…`; one moved from a repository's issues keeps its `iss_…`.
111 pub id: String,
112 /// The workspace's slug.
113 pub workspace: String,
114 /// Shown as `#12`, numbered in the workspace with its pull requests.
115 pub number: u32,
116 pub title: String,
117 /// Markdown. Also what an agent is given to work from.
118 pub body: String,
119 pub labels: Vec<String>,
120 pub state: State,
121 /// Set when closed.
122 pub reason: Option<CloseReason>,
123 /// What resolved it, in each repository it landed in, oldest first.
124 #[serde(default)]
125 pub resolutions: Vec<Resolution>,
126 /// Who opened it: a person, an integration, or g1t (`kind` `agent`)
127 /// for one its agent filed while at work.
128 pub author: User,
129 /// For one g1t's agent filed: the person it was working for. They may
130 /// manage it as its author could. See [`owner`](WorkItem::owner).
131 #[serde(default)]
132 pub requested_by: Option<User>,
133 /// RFC 3339.
134 pub created_at: String,
135 /// RFC 3339.
136 pub updated_at: String,
137 /// RFC 3339.
138 pub closed_at: Option<String>,
139 /// Pull requests made for it, in any state and repository.
140 pub pull_count: u32,
141 pub comment_count: u32,
142 /// Usernames of the people it is assigned to.
143 #[serde(default)]
144 pub assignees: Vec<String>,
145 /// The numbers of the work items of the workspace that have to land
146 /// before this one is worked on.
147 #[serde(default)]
148 pub blocked_by: Vec<u32>,
149 /// The agent working on it now: the one behind its newest pull request
150 /// that is still in progress in a fork, such as `g1t`.
151 #[serde(default)]
152 pub agent: Option<String>,
153 /// The workspace milestone it is in, if any.
154 #[serde(default)]
155 pub milestone: Option<MilestoneRef>,
156 /// The repositories it is linked to: its origin first, then targets,
157 /// then those it touched.
158 #[serde(default)]
159 pub repos: Vec<ItemRepo>,
160}
161
162impl WorkItem {
163 /// Who it is theirs to manage: the person g1t's agent filed it for, or
164 /// its author. They may edit, close and reopen it without the Triage
165 /// role.
166 pub fn owner(&self) -> &User {
167 self.requested_by.as_ref().unwrap_or(&self.author)
168 }
169
170 /// The repository it was filed in, if it was filed in one.
171 pub fn origin(&self) -> Option<&ItemRepo> {
172 self.repos.iter().find(|repo| repo.role == LinkRole::Origin)
173 }
174
175 /// Its link to the repository with this id, if it has one.
176 pub fn link(&self, repo_id: &str) -> Option<&ItemRepo> {
177 self.repos.iter().find(|repo| repo.repo_id == repo_id)
178 }
179
180 /// The targets it has not landed in yet, by path.
181 pub fn missing(&self) -> Vec<String> {
182 missing_targets(&self.repos)
183 }
184}
185
186/// The targets among `repos` that have not landed, by path.
187pub fn missing_targets(repos: &[ItemRepo]) -> Vec<String> {
188 repos
189 .iter()
190 .filter(|repo| repo.role == LinkRole::Target && !repo.landed)
191 .map(|repo| repo.path.clone())
192 .collect()
193}
194
195/// Whether a work item linked to `repos` is done landing: it has no
196/// targets, or every target has landed. What closes it closes it then.
197pub fn landed_everywhere(repos: &[ItemRepo]) -> bool {
198 repos.iter().filter(|repo| repo.role == LinkRole::Target).all(|repo| repo.landed)
199}
200
201/// The order a work item's repositories are listed in: origin, targets,
202/// touched, each by path.
203pub fn sort_repos(repos: &mut [ItemRepo]) {
204 repos.sort_by(|a, b| (a.role, &a.path).cmp(&(b.role, &b.path)));
205}
206
207/// A work item and everything on it.
208#[derive(Clone, Debug, Serialize, Deserialize)]
209pub struct WorkItemDetail {
210 pub item: WorkItem,
211 /// Every pull request made for it, in any repository, oldest first.
212 pub pulls: Vec<Pull>,
213 pub comments: Vec<Comment>,
214 /// How people (and g1t) reacted to its description.
215 #[serde(default)]
216 pub reactions: Vec<ReactionGroup>,
217}
218
219// --- Arguments --------------------------------------------------------------
220
221/// `open_work_item`: files a work item in a workspace. With `repo`, it is
222/// filed from that repository, its origin: anyone who may participate in
223/// it may then file one (on a public repository, anyone signed in), and
224/// `workspace` may be left empty to mean the repository's. Without, only a
225/// member of the workspace may. `targets` are linked as where the work is
226/// meant to land, each a repository of the workspace. Publishes
227/// `work_item.opened`. Returns `Outcome<WorkItem>`.
228#[derive(Debug, Serialize, Deserialize)]
229pub struct OpenWorkItemArgs {
230 pub actor: User,
231 #[serde(default)]
232 pub workspace: String,
233 /// The repository it is filed from.
234 #[serde(default)]
235 pub repo: Option<RepoPath>,
236 /// Repositories of the workspace where the work is meant to land.
237 #[serde(default)]
238 pub targets: Vec<RepoPath>,
239 pub title: String,
240 #[serde(default)]
241 pub body: String,
242 #[serde(default)]
243 pub labels: Vec<String>,
244 /// The number of the workspace milestone to put it in. Needs the
245 /// Triage role.
246 #[serde(default)]
247 pub milestone: Option<u32>,
248}
249
250/// `list_work_items`, newest first: a workspace's work items the viewer
251/// may see. With `repo` (then `workspace` may be empty, meaning the
252/// repository's), only those linked to it, in any role or in `role`.
253/// Returns `Outcome<Vec<WorkItem>>`.
254#[derive(Debug, Serialize, Deserialize)]
255pub struct ListWorkItemsArgs {
256 #[serde(default)]
257 pub workspace: String,
258 pub viewer: Viewer,
259 #[serde(default)]
260 pub repo: Option<RepoPath>,
261 /// With `repo`: only those linked to it this way.
262 #[serde(default)]
263 pub role: Option<LinkRole>,
264 #[serde(default)]
265 pub state: Option<State>,
266 /// Only those carrying this label.
267 #[serde(default)]
268 pub label: Option<String>,
269 /// Only those in the workspace milestone of this number.
270 #[serde(default)]
271 pub milestone: Option<u32>,
272 /// Only those assigned to this username.
273 #[serde(default)]
274 pub assignee: Option<String>,
275}
276
277/// `get_work_item` (`Outcome<WorkItemDetail>`), `get_work`
278/// (`Outcome<WorkContext>`), and `get_milestone`, whose `number` is the
279/// milestone's (`Outcome<MilestoneDetail>`).
280#[derive(Debug, Serialize, Deserialize)]
281pub struct WorkItemViewArgs {
282 pub workspace: String,
283 pub number: u32,
284 pub viewer: Viewer,
285}
286
287/// `update_work_item`: changes whichever fields are given. Its owner, or
288/// someone with the Triage role on it, may. Returns `Outcome<WorkItem>`.
289#[derive(Debug, Serialize, Deserialize)]
290pub struct UpdateWorkItemArgs {
291 pub actor: User,
292 pub workspace: String,
293 pub number: u32,
294 #[serde(default)]
295 pub title: Option<String>,
296 #[serde(default)]
297 pub body: Option<String>,
298 #[serde(default)]
299 pub labels: Option<Vec<String>>,
300 /// Usernames of the people it is assigned to; replaces the whole set.
301 #[serde(default)]
302 pub assignees: Option<Vec<String>>,
303 /// The number of the workspace milestone to put it in; 0 takes it out
304 /// of its milestone. Needs the Triage role.
305 #[serde(default)]
306 pub milestone: Option<u32>,
307 /// The numbers of the work items of the workspace that have to land
308 /// first; replaces the whole set. Refused when one is not a work item,
309 /// is the item itself, or would make a cycle.
310 #[serde(default)]
311 pub blocked_by: Option<Vec<u32>>,
312}
313
314/// `close_work_item` and `reopen_work_item`. Each returns
315/// `Outcome<WorkItem>`. `close_work_item` takes [`CloseWorkItemArgs`],
316/// which these are a part of.
317#[derive(Debug, Serialize, Deserialize)]
318pub struct WorkItemActionArgs {
319 pub actor: User,
320 pub workspace: String,
321 pub number: u32,
322 /// For `close_work_item`; `completed` if left out.
323 #[serde(default)]
324 pub reason: Option<CloseReason>,
325}
326
327/// `close_work_item`: a [`WorkItemActionArgs`], and the commit that did
328/// it, if one did. With `commit` (and `repo`, the repository it is in), a
329/// commit on that repository's default branch whose message names the
330/// work item after a closing keyword (`Fixes #12`): it lands there as the
331/// push of that commit would have, and closes when nothing else is left to
332/// land; a commit not on the default branch, or one that does not name it,
333/// is refused. Returns `Outcome<WorkItem>`.
334#[derive(Debug, Serialize, Deserialize)]
335pub struct CloseWorkItemArgs {
336 #[serde(flatten)]
337 pub item: WorkItemActionArgs,
338 #[serde(default, skip_serializing_if = "Option::is_none")]
339 pub repo: Option<RepoPath>,
340 #[serde(default, skip_serializing_if = "Option::is_none")]
341 pub commit: Option<String>,
342}
343
344/// `link_work_item`: links a repository to a work item as a target (or as
345/// touched), or changes how it is linked; `unlink_work_item` removes the
346/// link (never the origin's), and ignores `role`. The repository must be
347/// one of the workspace's, and the actor needs the Triage role on the work
348/// item. Publishes `work_item.linked` or `work_item.unlinked`, and closes
349/// or reopens nothing by itself: a work item closes when what closes it
350/// lands. Returns `Outcome<WorkItem>`.
351#[derive(Debug, Serialize, Deserialize)]
352pub struct LinkWorkItemArgs {
353 pub actor: User,
354 pub workspace: String,
355 pub number: u32,
356 pub repo: RepoPath,
357 /// `target` when absent. `origin` is refused: a work item's origin is
358 /// where it was filed.
359 #[serde(default = "target")]
360 pub role: LinkRole,
361}
362
363fn target() -> LinkRole {
364 LinkRole::Target
365}
366
367/// `resolve_ref`: what `#number` names, read in the context of `repo` (in
368/// its workspace) or of `workspace` alone; see [`super::refs`] for the
369/// order. Only what the viewer may see is found. Returns `RefTarget`.
370#[derive(Debug, Serialize, Deserialize)]
371pub struct ResolveRefArgs {
372 pub viewer: Viewer,
373 #[serde(default)]
374 pub repo: Option<RepoPath>,
375 /// The workspace, when there is no `repo`; with one, ignored.
376 #[serde(default)]
377 pub workspace: String,
378 pub number: u32,
379}
380
381/// What a reference names.
382#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
383#[serde(tag = "kind", rename_all = "snake_case")]
384pub enum RefTarget {
385 /// A work item: `/<workspace>/-/work/<number>` on the site.
386 #[serde(rename_all = "camelCase")]
387 WorkItem { workspace: String, number: u32, item_id: String },
388 /// A pull request of `repo` (`owner/name`).
389 Pull { repo: String, number: u32 },
390 /// Nothing the viewer may see.
391 None,
392}
393
394/// `migrate_work_items`: moves on the job that moves repositories' issues
395/// into their workspaces as work items (the cron trigger runs it too). It
396/// is resumable and changes nothing once done. Returns `WorkMigration`.
397#[derive(Debug, Default, Serialize, Deserialize)]
398pub struct MigrateWorkItemsArgs {}
399
400/// Where the move of issues into work items stands.
401#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
402#[serde(rename_all = "camelCase")]
403pub struct WorkMigration {
404 /// Every work item is numbered in its workspace.
405 pub done: bool,
406 /// Repositories recorded with their workspaces in this call.
407 pub repos: u32,
408 /// Work items numbered in this call.
409 pub numbered: u32,
410 /// Work items left without a workspace, whose repository is gone.
411 pub unplaced: u32,
412}
413
414/// `legacy_item_map`: for services that stored issue numbers, where each
415/// repository's old issue went: a page of the mapping `work.migrated`
416/// announces, ordered by item id. Returns `LegacyItemPage`.
417#[derive(Debug, Default, Serialize, Deserialize)]
418pub struct LegacyItemMapArgs {
419 /// Item ids after this one.
420 #[serde(default)]
421 pub after: Option<String>,
422 /// At most 500.
423 pub limit: u32,
424}
425
426/// One issue moved into a work item.
427#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
428#[serde(rename_all = "camelCase")]
429pub struct MigratedItem {
430 pub repo_id: String,
431 pub legacy_number: u32,
432 pub workspace: String,
433 pub number: u32,
434 pub item_id: String,
435}
436
437#[derive(Clone, Debug, Default, Serialize, Deserialize)]
438pub struct LegacyItemPage {
439 pub items: Vec<MigratedItem>,
440 /// Where the next page starts; null on the last.
441 pub next: Option<String>,
442 /// Whether every issue has its work item's number: until then the map
443 /// is not whole, and a service catching up on it waits.
444 #[serde(default)]
445 pub moved: bool,
446}
447
448#[cfg(test)]
449mod tests {
450 use super::*;
451
452 fn link(path: &str, role: LinkRole, landed: bool) -> ItemRepo {
453 ItemRepo { repo_id: format!("rep_{path}"), path: path.into(), role, legacy_number: None, landed }
454 }
455
456 #[test]
457 fn an_item_has_landed_once_every_target_has() {
458 // No targets: whatever closes it closes it.
459 assert!(landed_everywhere(&[link("acme/web", LinkRole::Origin, false)]));
460 let both = [
461 link("acme/web", LinkRole::Origin, false),
462 link("acme/api", LinkRole::Target, true),
463 link("acme/docs", LinkRole::Target, false),
464 link("acme/cli", LinkRole::Touched, false),
465 ];
466 assert!(!landed_everywhere(&both));
467 assert_eq!(missing_targets(&both), vec!["acme/docs".to_owned()]);
468 let mut landed = both.clone();
469 landed[2].landed = true;
470 assert!(landed_everywhere(&landed));
471 }
472
473 #[test]
474 fn repos_list_origin_then_targets_then_touched() {
475 let mut repos = vec![
476 link("acme/z", LinkRole::Touched, false),
477 link("acme/b", LinkRole::Target, false),
478 link("acme/a", LinkRole::Target, false),
479 link("acme/web", LinkRole::Origin, false),
480 ];
481 sort_repos(&mut repos);
482 let paths: Vec<&str> = repos.iter().map(|repo| repo.path.as_str()).collect();
483 assert_eq!(paths, ["acme/web", "acme/a", "acme/b", "acme/z"]);
484 assert_eq!(LinkRole::parse(" Target "), Some(LinkRole::Target));
485 assert_eq!(serde_json::to_value(LinkRole::Touched).unwrap(), "touched");
486 }
487
488 #[test]
489 fn a_reference_target_says_what_it_is() {
490 let item = RefTarget::WorkItem { workspace: "acme".into(), number: 12, item_id: "wki_1".into() };
491 assert_eq!(
492 serde_json::to_value(&item).unwrap(),
493 serde_json::json!({ "kind": "work_item", "workspace": "acme", "number": 12, "itemId": "wki_1" })
494 );
495 assert_eq!(serde_json::to_value(RefTarget::None).unwrap(), serde_json::json!({ "kind": "none" }));
496 let link: LinkWorkItemArgs = serde_json::from_value(serde_json::json!({
497 "actor": { "id": "usr_1", "username": "ana" }, "workspace": "acme", "number": 3,
498 "repo": { "namespace": "acme", "name": "api" }
499 }))
500 .unwrap();
501 assert_eq!(link.role, LinkRole::Target);
502 }
503}