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.

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