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.

Commit

Actions service: runs for work items in every repository they link

work_item.* events start work_item workflows, and comments start work_item_comment ones, in each repository the item is linked to (repoIds, origin first), on that repository's default branch. github.event.work_item is the item, shaped as GitHub's issue, with its workspace, linked repositories and roles, resolutions and /{workspace}/-/work/{number} URLs; on a pull request's comment it is the pull request with pull_request set. A pull request's payload names its item, and labels and milestones are the workspace's. Migration 0024 renames past runs' issues and issue_comment events and moves their payload's issue to work_item.

G1T-Session:
ses_01m566c3bte72apbwhjf41ye53
syntaqxcommitted Parentc7d039cBrowse files
5 files+412−1140/5 viewed
+13−0
1+-- Work items: runs that issues and their comments started name the events
2+-- that start them now, `work_item` and `work_item_comment`, and their
3+-- payload (`info.event`) has the item in `work_item`, where `issue` was.
4+-- A workflow's listed events stay as its file names them: a file that
5+-- still says `issues` starts nothing until it says `work_item`.
6+UPDATE runs
7+SET info = json_remove(json_set(info, '$.event.work_item', json(json_extract(info, '$.event.issue'))), '$.event.issue')
8+WHERE event IN ('issues', 'issue_comment') AND json_type(info, '$.event.issue') IS NOT NULL;
9+
10+UPDATE runs
11+SET event = CASE event WHEN 'issues' THEN 'work_item' ELSE 'work_item_comment' END,
12+ info = json_set(info, '$.eventName', CASE event WHEN 'issues' THEN 'work_item' ELSE 'work_item_comment' END)
13+WHERE event IN ('issues', 'issue_comment');
+233−83
11 //! `github.event`: the webhook-shaped payload GitHub gives a workflow,
22 //! built from g1t's own records so `github.event.pull_request.number`,
3−//! `github.event.issue.labels.*.name` and the like read as they do there.
3+//! `github.event.pull_request.labels.*.name` and the like read as they do
4+//! there. g1t's `work_item` and `work_item_comment` events give
5+//! `github.event.work_item`, shaped as GitHub's `issue` is, with the
6+//! workspace and the repositories it is linked to.
47 //!
58 //! Ids are as GitHub's are: `id` a whole number, the same for an object
69 //! wherever it appears ([`github_id`] of its g1t id, [`account_id`] of a
710 //! person's or workspace's name), and `node_id` g1t's own id. What g1t's
811 //! API addresses by its own id keeps that id as `id`, so a payload's id
912 //! can be passed straight back to the API: a comment (`PATCH
10−//! /repos/{owner}/{repo}/issues/comments/{comment_id}`), a release, a
13+//! /workspaces/{workspace}/work/comments/{comment_id}`), a release, a
1114 //! deployment and its statuses, and a workflow run.
1215
1316 use g1t_actions::events::{account_id, github_id};
1417 use g1t_contracts::{PrincipalKind, User};
1518 use g1t_contracts::repos::{Commit, Repo, RepoPath};
1619 use g1t_contracts::repos::FileStatus;
17−use g1t_contracts::work::{Comment, DEFAULT_LABELS, Issue, Label, MilestoneRef, Pull, PullStatus, State, label_color_for};
20+use g1t_contracts::work::{Comment, DEFAULT_LABELS, Label, MilestoneRef, Pull, PullStatus, State, WorkItem, label_color_for};
1821 use serde_json::{Value, json};
1922
2023 use crate::{API, SITE};
139142 Value::Array(names.iter().map(|name| label(repo, name, None)).collect())
140143 }
141144
142−/// A label, by its name in `repo`. Its `color` is the one g1t gives a
143−/// label of that name until [`color_labels`] sets the repository's own;
144−/// `default` is whether it is one of the labels a repository starts with.
145+/// A label, by its name in `repo`'s workspace, which keeps the labels of
146+/// all its repositories. Its `color` is the one g1t gives a label of that
147+/// name until [`color_labels`] sets the workspace's own; `default` is
148+/// whether it is one of the labels a workspace starts with.
145149 fn label(repo: &Repo, name: &str, color: Option<&Value>) -> Value {
146150 json!({
147− "id": github_id(&format!("{}:label:{name}", repo.id)),
148− "node_id": format!("{}:label:{name}", repo.id),
151+ "id": github_id(&format!("{}:label:{name}", repo.owner_id)),
152+ "node_id": format!("{}:label:{name}", repo.owner_id),
149153 "name": name,
150154 "color": color.filter(|c| c.is_string()).cloned().unwrap_or_else(|| json!(label_color_for(name))),
151155 "description": DEFAULT_LABELS.iter().find(|(known, _, _)| *known == name).map(|(_, _, about)| *about),
154158 }
155159
156160 /// Gives every label in a payload (`labels[]` and `label`) the color and
157−/// description the repository has for it (work's `list_labels`).
161+/// description the workspace has for it (work's `list_labels`).
158162 pub fn color_labels(payload: &mut Value, known: &[Label]) {
159163 fn set(label: &mut Value, known: &[Label]) {
160164 let Some(found) = label["name"].as_str().and_then(|name| known.iter().find(|k| k.name == name)) else { return };
184188 }
185189 }
186190
187−/// A milestone as an issue or a pull request names it.
191+/// A milestone as a work item or a pull request names it: one of the
192+/// workspace's.
188193 fn milestone(repo: &Repo, milestone: Option<&MilestoneRef>) -> Value {
189194 milestone.map_or(Value::Null, |milestone| milestone_of(repo, milestone.number, &json!(milestone.title)))
190195 }
191196
192197 fn milestone_of(repo: &Repo, number: u32, title: &Value) -> Value {
193198 json!({
194− "id": github_id(&format!("{}:milestone:{number}", repo.id)),
199+ "id": github_id(&format!("{}:milestone:{number}", repo.owner_id)),
195200 "number": number,
196201 "title": title,
197− "html_url": format!("{SITE}/{}/{}/milestones/{number}", repo.namespace, repo.name),
202+ "html_url": format!("{SITE}/{}/-/work/milestones/{number}", repo.namespace),
198203 })
199204 }
200205
276281 })
277282 }
278283
279−pub fn issue(repo: &Repo, issue: &Issue) -> Value {
280− let full_name = format!("{}/{}", repo.namespace, repo.name);
284+/// Where a work item is on g1t.sh, and in the API.
285+fn work_item_urls(workspace: &str, number: u32) -> (String, String) {
286+ (format!("{SITE}/{workspace}/-/work/{number}"), format!("{API}/workspaces/{workspace}/work/{number}"))
287+}
288+
289+/// A work item, as `github.event.work_item`: GitHub's `issue`, with g1t's
290+/// own fields beside it: its `workspace`, the `repos` it is linked to (each
291+/// with its role and whether the work landed there), what resolved it,
292+/// and what it is blocked by. `repo` is the run's repository, which is one
293+/// of them.
294+pub fn work_item(repo: &Repo, item: &WorkItem) -> Value {
295+ let (html_url, url) = work_item_urls(&item.workspace, item.number);
296+ let repos: Vec<Value> = item
297+ .repos
298+ .iter()
299+ .map(|linked| {
300+ json!({
301+ "id": github_id(&linked.repo_id),
302+ "node_id": linked.repo_id,
303+ "full_name": linked.path,
304+ "role": linked.role.as_str(),
305+ "legacy_number": linked.legacy_number,
306+ "landed": linked.landed,
307+ })
308+ })
309+ .collect();
310+ let resolutions: Vec<Value> = item.resolutions.iter().map(resolution).collect();
281311 json!({
282− "id": github_id(&issue.id),
283− "node_id": issue.id,
284− "number": issue.number,
285− "title": issue.title,
286− "body": issue.body,
287− "state": if issue.state == State::Open { "open" } else { "closed" },
288− "state_reason": issue.reason,
312+ "id": github_id(&item.id),
313+ "node_id": item.id,
314+ "number": item.number,
315+ "title": item.title,
316+ "body": item.body,
317+ "state": if item.state == State::Open { "open" } else { "closed" },
318+ "state_reason": item.reason,
289319 "locked": false,
290− "labels": labels(repo, &issue.labels),
291− "milestone": milestone(repo, issue.milestone.as_ref()),
292− "user": person(&issue.author),
293− // g1t's own field: for an issue its agent filed, who it worked for.
294− "requested_by": issue.requested_by.as_ref().map(person),
295− "assignee": issue.assignees.first().map(|a| user(a)),
296− "assignees": people(&issue.assignees),
297− "comments": issue.comment_count,
298− "created_at": issue.created_at,
299− "updated_at": issue.updated_at,
300− "closed_at": issue.closed_at,
301− "html_url": format!("{SITE}/{full_name}/issues/{}", issue.number),
302− "url": format!("{API}/repos/{full_name}/issues/{}", issue.number),
303− "repository_url": format!("{API}/repos/{full_name}"),
320+ "labels": labels(repo, &item.labels),
321+ "milestone": milestone(repo, item.milestone.as_ref()),
322+ "user": person(&item.author),
323+ // g1t's own field: for one its agent filed, who it worked for.
324+ "requested_by": item.requested_by.as_ref().map(person),
325+ "assignee": item.assignees.first().map(|a| user(a)),
326+ "assignees": people(&item.assignees),
327+ "comments": item.comment_count,
328+ "created_at": item.created_at,
329+ "updated_at": item.updated_at,
330+ "closed_at": item.closed_at,
331+ "html_url": html_url,
332+ "url": url,
333+ "workspace": item.workspace,
334+ "workspace_url": format!("{API}/workspaces/{}", item.workspace),
335+ "repos": repos,
336+ "resolutions": resolutions,
337+ "blocked_by": item.blocked_by,
338+ "pull_count": item.pull_count,
339+ })
340+}
341+
342+/// What resolved a work item in one repository, as the payload names it.
343+fn resolution(resolution: &g1t_contracts::work::Resolution) -> Value {
344+ json!({
345+ "repository": resolution.repo,
346+ "pull": resolution.pull,
347+ "commit": resolution.commit,
348+ "pushed_by": resolution.pushed_by.as_deref().map(user),
304349 })
305350 }
306351
352+/// What a `work_item` event says besides the item: on `linked` and
353+/// `unlinked`, the repository and its role (`link`); on `landed` and on a
354+/// close a landing caused, what landed (`resolution`) and the targets it
355+/// has still to land in (`missing`).
356+pub fn work_item_changed(payload: &mut Value, data: &Value) {
357+ if let Some(path) = data["repo"].as_str() {
358+ payload["link"] = json!({ "repository": path, "role": data["role"] });
359+ }
360+ if let Ok(landed) = serde_json::from_value::<g1t_contracts::work::Resolution>(data["resolution"].clone()) {
361+ payload["resolution"] = resolution(&landed);
362+ }
363+ if let Some(missing) = data.get("missing").filter(|missing| missing.is_array()) {
364+ payload["missing"] = missing.clone();
365+ }
366+}
367+
307368 /// A pull request, with its own labels. `base.sha` is the pull request's
308369 /// merge base until the trigger sets the base branch's tip, as GitHub
309370 /// gives it (trigger.rs).
372433 "closed_at": closed_at,
373434 "html_url": format!("{SITE}/{full_name}/pull/{}", pull.number),
374435 "url": format!("{API}/repos/{full_name}/pulls/{}", pull.number),
375− "issue_url": format!("{API}/repos/{full_name}/issues/{}", pull.number),
436+ // g1t's own: the work item it is for, by its number in the
437+ // repository's workspace, and those it closes and is part of.
438+ "item": pull.item,
439+ "item_url": pull.item.map(|number| work_item_urls(&repo.namespace, number).1),
440+ "closes": pull.closes,
441+ "parts": pull.parts,
376442 })
377443 }
378444
402468 pull.branch.clone().unwrap_or_else(|| format!("pull/{}", pull.number))
403469 }
404470
471+/// What a comment is on: a work item of a workspace, or a pull request of
472+/// the run's repository.
473+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
474+pub enum Thread<'a> {
475+ Item { workspace: &'a str },
476+ Pull,
477+}
478+
479+impl Thread<'_> {
480+ /// Where the thread numbered `number` is on g1t.sh, and in the API.
481+ fn urls(self, repo: &Repo, number: u32) -> (String, String) {
482+ match self {
483+ Thread::Item { workspace } => work_item_urls(workspace, number),
484+ Thread::Pull => {
485+ let full_name = format!("{}/{}", repo.namespace, repo.name);
486+ (format!("{SITE}/{full_name}/pull/{number}"), format!("{API}/repos/{full_name}/pulls/{number}"))
487+ }
488+ }
489+ }
490+
491+ /// Where the API edits and deletes a comment on it, by its id.
492+ fn comment_url(self, repo: &Repo, id: &str) -> String {
493+ let workspace = match self {
494+ Thread::Item { workspace } => workspace,
495+ Thread::Pull => &repo.namespace,
496+ };
497+ format!("{API}/workspaces/{workspace}/work/comments/{id}")
498+ }
499+}
500+
405501 /// A comment. Its `id` is g1t's (`cmt_…`), as the API takes it back to
406502 /// edit or delete it.
407−pub fn comment(repo: &Repo, number: u32, comment: &Comment, on_pull: bool) -> Value {
408− let full_name = format!("{}/{}", repo.namespace, repo.name);
409− let page = if on_pull { "pull" } else { "issues" };
503+pub fn comment(repo: &Repo, number: u32, comment: &Comment, on: Thread) -> Value {
504+ let (html_url, item_url) = on.urls(repo, number);
410505 json!({
411506 "id": comment.id,
412507 "node_id": comment.id,
416511 "updated_at": comment.edited_at.as_deref().unwrap_or(&comment.created_at),
417512 "path": comment.path,
418513 "line": comment.line,
419− "html_url": format!("{SITE}/{full_name}/{page}/{number}#{}", comment.id),
420− "url": format!("{API}/repos/{full_name}/issues/comments/{}", comment.id),
421− "issue_url": format!("{API}/repos/{full_name}/issues/{number}"),
514+ "html_url": format!("{html_url}#{}", comment.id),
515+ "url": on.comment_url(repo, &comment.id),
516+ "item_url": item_url,
422517 })
423518 }
424519
442537 })
443538 }
444539
445−/// An issue as `issue_comment` gives it for a pull request: the pull
446−/// request's number and title, with `pull_request` set. Its id is not the
447−/// pull request's, as on GitHub, where the two are different objects.
448−pub fn pull_as_issue(repo: &Repo, pull: &Pull) -> Value {
540+/// A pull request as `work_item_comment` gives it in `work_item`, as
541+/// GitHub's `issue_comment` gives one in `issue`: the pull request's
542+/// number and title, with `pull_request` set. Its id is not the pull
543+/// request's, as on GitHub, where the two are different objects.
544+pub fn pull_as_item(repo: &Repo, pull: &Pull) -> Value {
449545 let full_name = format!("{}/{}", repo.namespace, repo.name);
450546 json!({
451− "id": github_id(&format!("issue:{}", pull.id)),
547+ "id": github_id(&format!("item:{}", pull.id)),
452548 "node_id": pull.id,
453549 "number": pull.number,
454550 "title": pull.title,
465561 "created_at": pull.created_at,
466562 "updated_at": pull.updated_at,
467563 "html_url": format!("{SITE}/{full_name}/pull/{}", pull.number),
468− "url": format!("{API}/repos/{full_name}/issues/{}", pull.number),
564+ "url": format!("{API}/repos/{full_name}/pulls/{}", pull.number),
469565 "repository_url": format!("{API}/repos/{full_name}"),
566+ "workspace": repo.namespace,
470567 "pull_request": {
471568 "url": format!("{API}/repos/{full_name}/pulls/{}", pull.number),
472569 "html_url": format!("{SITE}/{full_name}/pull/{}", pull.number),
477574
478575 /// A comment as the event that deleted it kept it (`comment.deleted`'s
479576 /// `comment`), since it can no longer be read.
480−pub fn deleted_comment(repo: &Repo, number: u32, kept: &Value, on_pull: bool) -> Value {
481− let full_name = format!("{}/{}", repo.namespace, repo.name);
482− let page = if on_pull { "pull" } else { "issues" };
577+pub fn deleted_comment(repo: &Repo, number: u32, kept: &Value, on: Thread) -> Value {
578+ let (html_url, item_url) = on.urls(repo, number);
483579 let id = kept["id"].as_str().unwrap_or_default();
484580 let login = kept["author"]["username"].as_str().unwrap_or_default();
485581 json!({
491587 "updated_at": kept["editedAt"].as_str().map_or(kept["createdAt"].clone(), |at| json!(at)),
492588 "path": kept["path"],
493589 "line": kept["line"],
494− "html_url": format!("{SITE}/{full_name}/{page}/{number}#{id}"),
495− "url": format!("{API}/repos/{full_name}/issues/comments/{id}"),
496− "issue_url": format!("{API}/repos/{full_name}/issues/{number}"),
590+ "html_url": format!("{html_url}#{id}"),
591+ "url": on.comment_url(repo, id),
592+ "item_url": item_url,
497593 })
498594 }
499595
702798 const KNOWN: &[(&str, &str, &str)] = &[
703799 // The API addresses these by g1t's own id, so the payload gives
704800 // that id, to be passed straight back.
705− ("issue_comment", "comment.id", "string"),
801+ ("work_item_comment", "comment.id", "string"),
802+ // A work item belongs to a workspace, not to one repository: it
803+ // has `workspace_url` and `repos`, and its comments `item_url`.
804+ // A pull request has `item_url` for its work item.
805+ ("work_item", "work_item.repository_url", "absent"),
806+ ("work_item_comment", "comment.issue_url", "absent"),
807+ ("pull_request", "pull_request.issue_url", "absent"),
706808 ("release", "release.id", "string"),
707809 ("workflow_run", "workflow_run.id", "string"),
708810 ("workflow_run", "workflow_run.workflow_id", "string"),
774876
775877 fn a_pull() -> Pull {
776878 serde_json::from_value(json!({
777− "id": "pr_01kq000000000000000000000a", "repoId": "rep_1", "number": 2, "issue": null,
879+ "id": "pr_01kq000000000000000000000a", "repoId": "rep_1", "number": 2, "item": null,
778880 "title": "Update the README with new information.", "body": "A simple change.",
779881 "agent": "", "runtime": "external", "status": "open",
780882 "fork": null, "forkRepoId": null, "branch": "changes",
788890 .unwrap()
789891 }
790892
791− fn an_issue() -> Issue {
893+ fn an_item() -> WorkItem {
792894 serde_json::from_value(json!({
793− "id": "iss_01kq000000000000000000000a", "repoId": "rep_1", "number": 1,
895+ "id": "wki_01kq000000000000000000000a", "workspace": "acme", "number": 1,
794896 "title": "Spelling error in the README file", "body": "Two t's.", "labels": ["bug"], "state": "open",
795− "reason": null, "resolvedBy": null, "author": someone("usr_1", "ada"), "requestedBy": null,
897+ "reason": null, "author": someone("usr_1", "ada"), "requestedBy": null,
796898 "createdAt": "2026-05-15T15:20:18Z", "updatedAt": "2026-05-15T15:20:18Z", "closedAt": null,
797− "pullCount": 0, "commentCount": 0, "assignees": ["bo"], "milestone": { "number": 1, "title": "v1.0" }
899+ "pullCount": 0, "commentCount": 0, "assignees": ["bo"], "milestone": { "number": 1, "title": "v1.0" },
900+ "repos": [
901+ { "repoId": "rep_01kpw0aaaaaaaaaaaaaaaaaaaa", "path": "acme/web", "role": "origin", "legacyNumber": 7 },
902+ { "repoId": "rep_2", "path": "acme/api", "role": "target", "landed": true }
903+ ],
904+ "resolutions": [{ "repo": "acme/api", "pull": 3, "pushedBy": "bo" }]
798905 }))
799906 .unwrap()
800907 }
822929 let pull = a_pull();
823930 let mut push = push(&repo, "refs/heads/main", Some("6113728f27ae82c7b1a177c8d03f9e96e0adf246"), &a_commit().hash, &[a_commit()], "ada", Some("0d0efg+ada@users.noreply.g1t.sh"), false);
824931 push_changes(&mut push, &[("README.md".into(), FileStatus::Modified)]);
825− let known = [Label { name: "bug".into(), color: "d73a4a".into(), description: "Something isn't working".into(), issues: 1, pulls: 1 }];
932+ let known = [Label { name: "bug".into(), color: "d73a4a".into(), description: "Something isn't working".into(), items: 1, pulls: 1 }];
826933 let mut pull_request = json!({
827934 "action": "opened", "number": 2, "pull_request": self::pull(&repo, &pull),
828935 "repository": repository(&repo), "sender": user("ada"),
832939 "action": "submitted", "review": self::review(&repo, &pull, Some(&a_comment(Some(Verdict::Approve)))),
833940 "pull_request": self::pull(&repo, &pull), "repository": repository(&repo), "sender": user("cy"),
834941 });
835− let mut issues = json!({ "action": "labeled", "issue": issue(&repo, &an_issue()), "repository": repository(&repo), "sender": user("ada") });
836− changed(&repo, &mut issues, &json!({ "label": { "name": "bug", "color": "d73a4a" } }));
837− color_labels(&mut issues, &known);
942+ let mut items = json!({ "action": "labeled", "work_item": work_item(&repo, &an_item()), "repository": repository(&repo), "sender": user("ada") });
943+ changed(&repo, &mut items, &json!({ "label": { "name": "bug", "color": "d73a4a" } }));
944+ color_labels(&mut items, &known);
838945 let mut on_pull = pull.clone();
839946 on_pull.labels.clear();
840947 on_pull.assignees.clear();
841948 on_pull.milestone = None;
842949 let mut comment = a_comment(None);
843950 comment.author = someone("usr_1", "ada");
844− let issue_comment = json!({
845− "action": "created", "issue": pull_as_issue(&repo, &on_pull), "comment": self::comment(&repo, 2, &comment, true),
951+ let item_comment = json!({
952+ "action": "created", "work_item": pull_as_item(&repo, &on_pull), "comment": self::comment(&repo, 2, &comment, Thread::Pull),
846953 "repository": repository(&repo), "sender": user("ada"),
847954 });
848955 let data = json!({
883990 ("push", push),
884991 ("pull_request", pull_request),
885992 ("pull_request_review", review),
886− ("issues", issues),
887− ("issue_comment", issue_comment),
993+ ("work_item", items),
994+ ("work_item_comment", item_comment),
888995 ("workflow_run", workflow_run),
889996 ("release", release),
890997 ]
891998 }
892999
1000+ /// GitHub's payload for an event, as g1t names it: `work_item` is
1001+ /// shaped as GitHub's `issues`, with `work_item` in place of `issue`,
1002+ /// and `work_item_comment` as its `issue_comment`.
8931003 fn fixture(event: &str) -> Value {
8941004 let text = match event {
8951005 "push" => include_str!("../tests/fixtures/push.json"),
8961006 "pull_request" => include_str!("../tests/fixtures/pull_request.json"),
8971007 "pull_request_review" => include_str!("../tests/fixtures/pull_request_review.json"),
898− "issues" => include_str!("../tests/fixtures/issues.json"),
899− "issue_comment" => include_str!("../tests/fixtures/issue_comment.json"),
1008+ "work_item" => include_str!("../tests/fixtures/issues.json"),
1009+ "work_item_comment" => include_str!("../tests/fixtures/issue_comment.json"),
9001010 "workflow_run" => include_str!("../tests/fixtures/workflow_run.json"),
9011011 "release" => include_str!("../tests/fixtures/release.json"),
9021012 other => panic!("no fixture for {other}"),
9031013 };
904− serde_json::from_str(text).unwrap()
1014+ let mut payload: Value = serde_json::from_str(text).unwrap();
1015+ if let Some(issue) = payload.as_object_mut().and_then(|map| map.remove("issue")) {
1016+ payload["work_item"] = issue;
1017+ }
1018+ payload
9051019 }
9061020
9071021 #[test]
9301044 fn the_same_object_has_the_same_number_everywhere() {
9311045 let all = payloads();
9321046 let by = |event: &str| all.iter().find(|(e, _)| *e == event).map(|(_, v)| v.clone()).unwrap();
933− let (pr, issues, run) = (by("pull_request"), by("issues"), by("workflow_run"));
1047+ let (pr, items, run) = (by("pull_request"), by("work_item"), by("workflow_run"));
9341048 let repo_id = &pr["repository"]["id"];
9351049 assert_eq!(repo_id, &json!(github_id(&repo().id)), "github.repository_id is the same number");
9361050 assert_eq!(&pr["pull_request"]["base"]["repo"]["id"], repo_id);
9411055 assert_eq!(pr["sender"]["id"], json!(account_id("ada")), "github.actor_id is the same number");
9421056 assert_eq!(pr["repository"]["owner"]["id"], pr["pull_request"]["head"]["user"]["id"]);
9431057 assert_eq!(pr["repository"]["owner"]["id"], json!(account_id("acme")));
944− assert_eq!(pr["pull_request"]["labels"][0]["id"], issues["label"]["id"]);
945− // A pull request's own id and its issue's are different objects.
946− assert_ne!(pull_as_issue(&repo(), &a_pull())["id"], pr["pull_request"]["id"]);
1058+ assert_eq!(pr["pull_request"]["labels"][0]["id"], items["label"]["id"]);
1059+ // A pull request's own id and its id as a thread are different objects.
1060+ assert_ne!(pull_as_item(&repo(), &a_pull())["id"], pr["pull_request"]["id"]);
9471061 assert_eq!(pr["pull_request"]["node_id"], a_pull().id);
9481062 }
9491063
9991113 }
10001114
10011115 #[test]
1002− fn labels_have_the_repositorys_colors() {
1116+ fn a_work_item_names_its_workspace_and_repositories() {
10031117 let repo = repo();
1004− let mut event = json!({ "issue": issue(&repo, &an_issue()), "label": label(&repo, "bug", None) });
1118+ let item = work_item(&repo, &an_item());
1119+ assert_eq!(item["html_url"], "https://g1t.sh/acme/-/work/1");
1120+ assert_eq!(item["url"], format!("{API}/workspaces/acme/work/1"));
1121+ assert_eq!(item["workspace"], "acme");
1122+ assert_eq!(item["number"], 1);
1123+ assert_eq!(item["node_id"], "wki_01kq000000000000000000000a");
1124+ assert_eq!(item["repos"][0]["full_name"], "acme/web");
1125+ assert_eq!(item["repos"][0]["role"], "origin");
1126+ assert_eq!(item["repos"][0]["legacy_number"], 7);
1127+ assert_eq!(item["repos"][0]["id"], json!(github_id(&repo.id)), "the same number as the repository's");
1128+ assert_eq!(item["repos"][1]["role"], "target");
1129+ assert_eq!(item["repos"][1]["landed"], true);
1130+ assert_eq!(item["resolutions"][0]["repository"], "acme/api");
1131+ assert_eq!(item["resolutions"][0]["pull"], 3);
1132+ assert_eq!(item["resolutions"][0]["pushed_by"]["login"], "bo");
1133+ assert_eq!(item["milestone"]["html_url"], "https://g1t.sh/acme/-/work/milestones/1");
1134+ // A label is the workspace's: the same object on a pull request.
1135+ assert_eq!(item["labels"][0]["id"], pull(&repo, &a_pull())["labels"][0]["id"]);
1136+ // What a link, a landing and a close say.
1137+ let mut linked = json!({ "action": "linked" });
1138+ work_item_changed(&mut linked, &json!({ "repo": "acme/api", "role": "target" }));
1139+ assert_eq!(linked["link"], json!({ "repository": "acme/api", "role": "target" }));
1140+ let mut landed = json!({ "action": "landed" });
1141+ work_item_changed(&mut landed, &json!({ "resolution": { "repo": "acme/api", "commit": "abc" }, "missing": ["acme/web"] }));
1142+ assert_eq!(landed["resolution"]["commit"], "abc");
1143+ assert_eq!(landed["missing"], json!(["acme/web"]));
1144+ assert!(landed.get("link").is_none());
1145+ // A comment on it is addressed in its workspace.
1146+ let on_item = comment(&repo, 1, &a_comment(None), Thread::Item { workspace: "acme" });
1147+ assert_eq!(on_item["html_url"], "https://g1t.sh/acme/-/work/1#cmt_01kq000000000000000000000a");
1148+ assert_eq!(on_item["item_url"], format!("{API}/workspaces/acme/work/1"));
1149+ }
1150+
1151+ #[test]
1152+ fn labels_have_the_workspaces_colors() {
1153+ let repo = repo();
1154+ let mut event = json!({ "work_item": work_item(&repo, &an_item()), "label": label(&repo, "bug", None) });
10051155 // Before the repository's are known, g1t's default for the name.
1006− assert_eq!(event["issue"]["labels"][0]["color"], "d73a4a");
1007− assert_eq!(event["issue"]["labels"][0]["default"], true);
1156+ assert_eq!(event["work_item"]["labels"][0]["color"], "d73a4a");
1157+ assert_eq!(event["work_item"]["labels"][0]["default"], true);
10081158 assert!(has_labels(&event));
1009− let known = [Label { name: "bug".into(), color: "123456".into(), description: String::new(), issues: 1, pulls: 0 }];
1159+ let known = [Label { name: "bug".into(), color: "123456".into(), description: String::new(), items: 1, pulls: 0 }];
10101160 color_labels(&mut event, &known);
1011− assert_eq!(event["issue"]["labels"][0]["color"], "123456");
1012− assert_eq!(event["issue"]["labels"][0]["description"], Value::Null);
1161+ assert_eq!(event["work_item"]["labels"][0]["color"], "123456");
1162+ assert_eq!(event["work_item"]["labels"][0]["description"], Value::Null);
10131163 assert_eq!(event["label"]["color"], "123456");
1014− assert!(!has_labels(&json!({ "issue": { "labels": [] } })));
1164+ assert!(!has_labels(&json!({ "work_item": { "labels": [] } })));
10151165 }
10161166
10171167 #[test]
+1−1
14131413 let restricted = token(&Permissions::default_for(TokenDefault::Restricted));
14141414 assert!(restricted.allows(Scope::CodeRead));
14151415 assert!(!restricted.allows(Scope::CodeWrite));
1416− assert!(!restricted.allows(Scope::IssuesWrite));
1416+ assert!(!restricted.allows(Scope::PullRequestsWrite));
14171417 // A pull request from outside reads, whatever it asks for.
14181418 let outside = token(&Permissions::all(Access::Write).read_only());
14191419 assert!(Scope::ALL.iter().filter(|scope| outside.allows(**scope)).all(|scope| scope.level() == Level::Read));
+5−4
285285
286286 #[test]
287287 fn a_synced_workflow_starts_on_the_events_it_lists_or_when_broken() {
288− assert!(could_start(r#"["push","issues"]"#, None, "issues"));
289− assert!(!could_start(r#"["push","pull_request"]"#, None, "issue_comment"));
288+ assert!(could_start(r#"["push","work_item"]"#, None, "work_item"));
289+ assert!(!could_start(r#"["push","pull_request"]"#, None, "work_item_comment"));
290+ assert!(!could_start(r#"["push","issues"]"#, None, "work_item"));
290291 // A file that does not parse is read again, so its error shows.
291− assert!(could_start("[]", Some("bad yaml"), "issues"));
292+ assert!(could_start("[]", Some("bad yaml"), "work_item"));
292293 // A file gone from the branch starts nothing.
293− assert!(!could_start("[]", Some(GONE), "issues"));
294+ assert!(!could_start("[]", Some(GONE), "work_item"));
294295 }
295296 }
+160−26
99 use g1t_contracts::events::{Event, caused_by_job};
1010 use g1t_contracts::identity::{AGENT_ID, AGENT_NAME, UsernamesArgs};
1111 use g1t_contracts::repos::{Commit, CompareArgs, Comparison, FileStatus, LogArgs, PullMergeRef, PullMergeRefArgs, Repo, RepoPath};
12−use g1t_contracts::work::{IssueDetail, Label, PullDetail, ViewArgs};
12+use g1t_contracts::work::{Label, PullDetail, ViewArgs, WorkItemDetail, WorkItemViewArgs, WorkspaceViewArgs};
1313 use g1t_contracts::{FailureCode, Outcome, User, new_id};
1414 use g1t_contracts::time::rfc3339;
1515 use g1t_kit::now_ms;
130130 data["added"].as_array().is_some_and(Vec::is_empty)
131131 }
132132
133+/// The repositories whose workflows an event may start: every repository a
134+/// work item is linked to (`repoIds`, on its events and on comments on it),
135+/// else the one the event is filed under.
136+fn event_repos(event: &Event) -> Vec<String> {
137+ let mut repos: Vec<String> = Vec::new();
138+ for id in event.data["repoIds"].as_array().into_iter().flatten().filter_map(Value::as_str) {
139+ if !id.is_empty() && !repos.iter().any(|known| known == id) {
140+ repos.push(id.to_owned());
141+ }
142+ }
143+ if repos.is_empty()
144+ && let Some(id) = event.repo_id.as_deref().filter(|id| !id.is_empty())
145+ {
146+ repos.push(id.to_owned());
147+ }
148+ repos
149+}
150+
133151 /// Whether whoever a pull request is for is trusted without asking
134152 /// identity: someone whose role here is known to allow pushing.
135153 fn trusted_outright(owner: &User, repo: &Repo) -> bool {
247265 Ok(compared.into_result().map(|c| c.files.into_iter().map(|f| (f.path, f.status)).collect()).unwrap_or_default())
248266 }
249267
250− /// The repository's labels, for their colors, when `payload` has any.
268+ /// The workspace's labels, for their colors, when `payload` has any.
251269 async fn label_colors(&self, repo: &Repo, ws: &User, payload: &mut Value) -> Result<()> {
252270 if !payload::has_labels(payload) {
253271 return Ok(());
254272 }
255− let args = ViewArgs { repo: Self::repo_path(repo), number: 0, viewer: Some(ws.clone()), after_seq: 0 };
273+ let args = WorkspaceViewArgs { workspace: repo.namespace.clone(), viewer: Some(ws.clone()) };
256274 let labels: Outcome<Vec<Label>> = g1t_kit::call(&self.work, "list_labels", &args).await?;
257275 if let Outcome::Ok(labels) = labels {
258276 payload::color_labels(payload, &labels);
609627 subject.filter_ref = format!("refs/heads/{head_branch}");
610628 Some(subject)
611629 }
612− "issues" | "issue_comment" => {
630+ // A work item, or a comment on one or on a pull request: on the
631+ // default branch, as GitHub's `issues` and `issue_comment` are.
632+ "work_item" | "work_item_comment" => {
613633 let Some(number) = data["number"].as_u64().map(|n| n as u32) else { return Ok(None) };
614634 if action == Some("assigned") && nobody_added(data) {
615635 return Ok(None);
616636 }
637+ let on_pull = event_name == "work_item_comment" && data["pullId"].is_string();
638+ let workspace = data["workspace"].as_str().unwrap_or(&repo.namespace).to_owned();
617639 let Some(sha) = self.default_head(repo).await? else { return Ok(None) };
618− let issue: Outcome<IssueDetail> = g1t_kit::call(&self.work, "get_issue", &view(number)).await?;
619− let (issue_json, comments, title, on_pull) = match issue {
620− Outcome::Ok(detail) => (payload::issue(repo, &detail.issue), detail.comments, detail.issue.title.clone(), false),
621− Outcome::Fail(_) => {
622− let pull: Outcome<PullDetail> = g1t_kit::call(&self.work, "get_pull", &view(number)).await?;
623− let Outcome::Ok(detail) = pull else { return Ok(None) };
624− (payload::pull_as_issue(repo, &detail.pull), detail.comments, detail.pull.title.clone(), true)
625− }
640+ let (item_json, comments, title, thread) = if on_pull {
641+ let pull: Outcome<PullDetail> = g1t_kit::call(&self.work, "get_pull", &view(number)).await?;
642+ let Outcome::Ok(detail) = pull else { return Ok(None) };
643+ (payload::pull_as_item(repo, &detail.pull), detail.comments, detail.pull.title.clone(), payload::Thread::Pull)
644+ } else {
645+ let args = WorkItemViewArgs { workspace: workspace.clone(), number, viewer: Some(ws.clone()) };
646+ let item: Outcome<WorkItemDetail> = g1t_kit::call(&self.work, "get_work_item", &args).await?;
647+ let Outcome::Ok(detail) = item else { return Ok(None) };
648+ let thread = payload::Thread::Item { workspace: &workspace };
649+ (payload::work_item(repo, &detail.item), detail.comments, detail.item.title.clone(), thread)
626650 };
627651 let mut payload = json!({
628652 "action": action,
629− "issue": issue_json,
653+ "work_item": item_json,
630654 "repository": payload::repository(repo),
631655 "sender": payload::user(sender),
632656 });
633657 payload::changed(repo, &mut payload, data);
658+ if event_name == "work_item" {
659+ payload::work_item_changed(&mut payload, data);
660+ }
634661 self.label_colors(repo, ws, &mut payload).await?;
635− if event_name == "issue_comment" {
662+ if event_name == "work_item_comment" {
636663 let comment_id = data["commentId"].as_str();
637664 if action == Some("deleted") {
638665 // Gone by now: as the event kept it.
639666 match data.get("comment").filter(|kept| kept.is_object()) {
640− Some(kept) => payload["comment"] = payload::deleted_comment(repo, number, kept, on_pull),
667+ Some(kept) => payload["comment"] = payload::deleted_comment(repo, number, kept, thread),
641668 None => return Ok(None),
642669 }
643670 } else {
645672 // A new comment is the newest; an edited one must be found.
646673 let comment = if action == Some("created") { comment.or(comments.last()) } else { comment };
647674 match comment {
648− Some(comment) => payload["comment"] = payload::comment(repo, number, comment, on_pull),
675+ Some(comment) => payload["comment"] = payload::comment(repo, number, comment, thread),
649676 None => return Ok(None),
650677 }
651678 }
762789 pub async fn on_event(&self, event: &Event) -> Result<()> {
763790 // Each queued event reads repositories' settings afresh.
764791 self.actions_on.clear();
765− let Some(repo_id) = event.repo_id.as_deref() else { return Ok(()) };
792+ for repo_id in event_repos(event) {
793+ self.on_event_in(event, &repo_id).await?;
794+ }
795+ Ok(())
796+ }
797+
798+ /// What `event` starts in one of the repositories it is about.
799+ async fn on_event_in(&self, event: &Event, repo_id: &str) -> Result<()> {
766800 let mut mapped = github_events(&event.kind);
767801 // A new branch or tag is also `create`.
768802 if event.kind == "git.push" && event.data["before"].is_null() {
796830 }
797831 let sender = self.username(event.actor.as_deref()).await?.unwrap_or_else(|| repo.namespace.clone());
798832 for (event_name, action) in mapped {
799− // Issues and comments start the default branch's workflows,
800− // which the synced table lists: when none listens, nothing is
801− // read from git. Agents make many of these events.
833+ // Work items and comments start the default branch's
834+ // workflows, which the synced table lists: when none listens,
835+ // nothing is read from git. Agents make many of these events.
802836 // Deployments' statuses are as frequent (every g1t.page build
803837 // reports several), so they look there first too.
804− if matches!(event_name, "issues" | "issue_comment" | "deployment" | "deployment_status" | "delete")
838+ if matches!(event_name, "work_item" | "work_item_comment" | "deployment" | "deployment_status" | "delete")
805839 && self.listens(repo_id, event_name).await? == Some(false)
806840 {
807841 continue;
15871621
15881622 fn made_for(asker: User) -> Pull {
15891623 serde_json::from_value(json!({
1590− "id": "pr_1", "repoId": "rep_1", "number": 14, "issue": 12, "title": "Fix it", "body": null,
1624+ "id": "pr_1", "repoId": "rep_1", "number": 14, "item": 12, "title": "Fix it", "body": null,
15911625 "agent": "g1t", "runtime": "hosted", "status": "open",
15921626 "fork": { "namespace": "pulls", "name": "pr_1" }, "forkRepoId": "rep_f",
15931627 "branch": null, "headCommit": "abc", "mergeBase": null, "mergedBy": null, "mergedAt": null,
16191653 assert_eq!(event["user"]["type"], "Bot");
16201654 assert_eq!(event["requested_by"]["login"], "syntaqx");
16211655 assert_eq!(event["requested_by"]["type"], "User");
1622− let as_issue = payload::pull_as_issue(&repo(), &pull);
1623− assert_eq!(as_issue["user"]["login"], "g1t");
1624− assert_eq!(as_issue["requested_by"]["login"], "syntaqx");
1656+ assert_eq!(event["item"], 12);
1657+ assert_eq!(event["item_url"], format!("{API}/workspaces/acme/work/12"));
1658+ let as_item = payload::pull_as_item(&repo(), &pull);
1659+ assert_eq!(as_item["user"]["login"], "g1t");
1660+ assert_eq!(as_item["requested_by"]["login"], "syntaqx");
1661+ assert!(as_item["pull_request"].is_object());
16251662 }
16261663
16271664 #[test]
16431680 assert_eq!(status["environment"], "staging");
16441681 assert_eq!(status["environment_url"], "https://s.example");
16451682 assert_eq!(payload::deployment(&repo(), &deployment)["payload"], json!({}));
1646− let gone = payload::deleted_comment(&repo(), 7, &json!({ "id": "cmt_1", "body": "hi", "author": { "id": "usr_1", "username": "bo" }, "createdAt": "t" }), true);
1683+ let gone = payload::deleted_comment(&repo(), 7, &json!({ "id": "cmt_1", "body": "hi", "author": { "id": "usr_1", "username": "bo" }, "createdAt": "t" }), payload::Thread::Pull);
16471684 assert_eq!(gone["user"]["login"], "bo");
16481685 assert_eq!(gone["html_url"], "https://g1t.sh/acme/web/pull/7#cmt_1");
1686+ let on_item = payload::deleted_comment(&repo(), 9, &json!({ "id": "cmt_2", "body": "hi", "author": { "username": "bo" } }), payload::Thread::Item { workspace: "acme" });
1687+ assert_eq!(on_item["html_url"], "https://g1t.sh/acme/-/work/9#cmt_2");
1688+ assert_eq!(on_item["item_url"], format!("{API}/workspaces/acme/work/9"));
1689+ assert_eq!(on_item["url"], format!("{API}/workspaces/acme/work/comments/cmt_2"));
16491690 assert!(is_commit("0123456789abcdef0123456789abcdef01234567"));
16501691 assert!(!is_commit("main"));
16511692 }
16731714 assert!(!nobody_added(&serde_json::json!({ "assignees": ["ana"], "added": ["ana"] })));
16741715 }
16751716
1717+ fn event(repo_id: Option<&str>, data: Value) -> Event {
1718+ Event {
1719+ id: "evt_1".into(),
1720+ kind: "work_item.opened".into(),
1721+ source: "work".into(),
1722+ time: "t".into(),
1723+ repo_id: repo_id.map(str::to_owned),
1724+ actor: None,
1725+ data,
1726+ }
1727+ }
1728+
1729+ #[test]
1730+ fn a_work_item_starts_runs_in_every_repository_it_is_linked_to() {
1731+ // Its origin first, each once.
1732+ let linked = event(Some("rep_1"), json!({ "itemId": "wki_1", "repoIds": ["rep_1", "rep_2", "rep_1"] }));
1733+ assert_eq!(event_repos(&linked), ["rep_1", "rep_2"]);
1734+ // One without an origin is filed under the first it is linked to.
1735+ assert_eq!(event_repos(&event(Some("rep_2"), json!({ "repoIds": ["rep_2", "rep_3"] }))), ["rep_2", "rep_3"]);
1736+ // Linked nowhere: no repository's workflows.
1737+ assert!(event_repos(&event(None, json!({ "repoIds": [] }))).is_empty());
1738+ // Anything else: the repository it is filed under.
1739+ assert_eq!(event_repos(&event(Some("rep_1"), json!({ "ref": "refs/heads/main" }))), ["rep_1"]);
1740+ assert!(event_repos(&event(None, json!({}))).is_empty());
1741+ }
1742+
16761743 fn dispatchable(yaml: &str) -> Trigger {
16771744 workflow::parse(yaml).unwrap().trigger("workflow_dispatch").unwrap().clone()
16781745 }
17551822 assert!(!workflow.notes.iter().any(|note| note.severity == workflow::Severity::Unsupported));
17561823 }
17571824 }
1825+
1826+#[cfg(test)]
1827+mod work_item_runs {
1828+ use rusqlite::Connection;
1829+ use serde_json::{Value, json};
1830+
1831+ const MIGRATION: &str = "0024_work_items.sql";
1832+
1833+ /// The migrations before [`MIGRATION`], and [`MIGRATION`] itself.
1834+ fn migrations() -> (Vec<String>, String) {
1835+ let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("migrations");
1836+ let mut names: Vec<String> = std::fs::read_dir(&dir).unwrap().map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned()).collect();
1837+ names.sort();
1838+ let read = |name: &str| std::fs::read_to_string(dir.join(name)).unwrap();
1839+ let before = names.iter().filter(|name| name.as_str() < MIGRATION).map(|name| read(name)).collect();
1840+ (before, read(MIGRATION))
1841+ }
1842+
1843+ fn run(db: &Connection, id: &str, event: &str, info: &Value) {
1844+ db.execute(
1845+ "INSERT INTO runs (id, workflow_id, repo_id, repo, path, name, title, number, event, action, git_ref, sha, status, source, info, event_key, created_at)
1846+ VALUES (?1, 'wfl_1', 'rep_1', 'acme/web', '.g1t/workflows/t.yml', 'T', 'T', 1, ?2, 'opened', 'refs/heads/main', 'abc', 'completed', '', ?3, ?1, 't')",
1847+ rusqlite::params![id, event, info.to_string()],
1848+ )
1849+ .unwrap();
1850+ }
1851+
1852+ fn row(db: &Connection, id: &str) -> (String, Value) {
1853+ db.query_row("SELECT event, info FROM runs WHERE id = ?1", [id], |row| {
1854+ Ok((row.get::<_, String>(0)?, serde_json::from_str(&row.get::<_, String>(1)?).unwrap()))
1855+ })
1856+ .unwrap()
1857+ }
1858+
1859+ #[test]
1860+ fn runs_issues_started_name_work_items() {
1861+ let db = Connection::open_in_memory().unwrap();
1862+ let (before, migration) = migrations();
1863+ for sql in before {
1864+ db.execute_batch(&sql).unwrap();
1865+ }
1866+ let issue = json!({ "number": 4, "labels": [{ "name": "bug" }] });
1867+ run(&db, "run_1", "issues", &json!({ "eventName": "issues", "event": { "action": "opened", "issue": issue } }));
1868+ run(&db, "run_2", "issue_comment", &json!({ "eventName": "issue_comment", "event": { "issue": issue, "comment": { "id": "cmt_1" } } }));
1869+ run(&db, "run_3", "push", &json!({ "eventName": "push", "event": { "ref": "refs/heads/main" } }));
1870+ run(&db, "run_4", "issues", &json!({ "eventName": "issues", "event": {} }));
1871+ db.execute_batch(&migration).unwrap();
1872+ let (event, info) = row(&db, "run_1");
1873+ assert_eq!(event, "work_item");
1874+ assert_eq!(info["eventName"], "work_item");
1875+ assert_eq!(info["event"]["work_item"], issue, "the item as an object, not text");
1876+ assert!(info["event"].get("issue").is_none());
1877+ assert_eq!(info["event"]["action"], "opened");
1878+ let (event, info) = row(&db, "run_2");
1879+ assert_eq!(event, "work_item_comment");
1880+ assert_eq!(info["eventName"], "work_item_comment");
1881+ assert_eq!(info["event"]["work_item"]["number"], 4);
1882+ assert_eq!(info["event"]["comment"]["id"], "cmt_1");
1883+ assert_eq!(row(&db, "run_3"), ("push".into(), json!({ "eventName": "push", "event": { "ref": "refs/heads/main" } })));
1884+ let (event, info) = row(&db, "run_4");
1885+ assert_eq!(event, "work_item");
1886+ assert!(info["event"].get("work_item").is_none(), "no item where there was no issue");
1887+ // Applied again, it changes nothing.
1888+ db.execute_batch(&migration).unwrap();
1889+ assert_eq!(row(&db, "run_1").0, "work_item");
1890+ }
1891+}

Comments

Sign in to comment.