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.

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