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.

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