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: work_item and work_item_comment triggers and the work permission

Work item events start work_item workflows with GitHub-style activity types (opened, edited, closed, reopened, assigned, labeled, unlabeled, milestoned, demilestoned, linked, unlinked, landed), and comments start work_item_comment ones. A job's work permission grants work:read and work:write. GitHub's issues and issue_comment triggers and issues permission still read, start and grant nothing, and say what to write instead.

G1T-Session:
ses_01m566c3bte72apbwhjf41ye53
syntaqxcommitted Parent099cfeaBrowse files
6 files+145−440/6 viewed
+25−18
2828 "pull.milestoned" => pull("milestoned"),
2929 "pull.demilestoned" => pull("demilestoned"),
3030 "pull.base_changed" => pull("edited"),
31− "issue.opened" => vec![("issues", Some("opened"))],
32− "issue.updated" => vec![("issues", Some("edited"))],
33− "issue.closed" => vec![("issues", Some("closed"))],
34− "issue.reopened" => vec![("issues", Some("reopened"))],
35− "issue.assigned" => vec![("issues", Some("assigned"))],
36− "issue.labeled" => vec![("issues", Some("labeled"))],
37− "issue.unlabeled" => vec![("issues", Some("unlabeled"))],
38− "issue.milestoned" => vec![("issues", Some("milestoned"))],
39− "issue.demilestoned" => vec![("issues", Some("demilestoned"))],
40− "comment.created" => vec![("issue_comment", Some("created"))],
41− "comment.edited" => vec![("issue_comment", Some("edited"))],
42− "comment.deleted" => vec![("issue_comment", Some("deleted"))],
31+ "work_item.opened" => vec![("work_item", Some("opened"))],
32+ "work_item.updated" => vec![("work_item", Some("edited"))],
33+ "work_item.closed" => vec![("work_item", Some("closed"))],
34+ "work_item.reopened" => vec![("work_item", Some("reopened"))],
35+ "work_item.assigned" => vec![("work_item", Some("assigned"))],
36+ "work_item.labeled" => vec![("work_item", Some("labeled"))],
37+ "work_item.unlabeled" => vec![("work_item", Some("unlabeled"))],
38+ "work_item.milestoned" => vec![("work_item", Some("milestoned"))],
39+ "work_item.demilestoned" => vec![("work_item", Some("demilestoned"))],
40+ "work_item.linked" => vec![("work_item", Some("linked"))],
41+ "work_item.unlinked" => vec![("work_item", Some("unlinked"))],
42+ "work_item.landed" => vec![("work_item", Some("landed"))],
43+ "comment.created" => vec![("work_item_comment", Some("created"))],
44+ "comment.edited" => vec![("work_item_comment", Some("edited"))],
45+ "comment.deleted" => vec![("work_item_comment", Some("deleted"))],
4346 "release.created" => vec![("release", Some("created"))],
4447 "release.published" => vec![("release", Some("published"))],
4548 "release.released" => vec![("release", Some("released"))],
308311 assert_eq!(github_events("git.deleted"), [("delete", None)]);
309312 assert_eq!(github_events("pull.labeled")[1], ("pull_request_target", Some("labeled")));
310313 assert_eq!(github_events("pull.assigned")[0], ("pull_request", Some("assigned")));
311− assert_eq!(github_events("issue.unlabeled"), [("issues", Some("unlabeled"))]);
312− assert_eq!(github_events("issue.assigned"), [("issues", Some("assigned"))]);
314+ assert_eq!(github_events("work_item.unlabeled"), [("work_item", Some("unlabeled"))]);
315+ assert_eq!(github_events("work_item.assigned"), [("work_item", Some("assigned"))]);
316+ assert_eq!(github_events("work_item.updated"), [("work_item", Some("edited"))]);
317+ assert_eq!(github_events("work_item.linked"), [("work_item", Some("linked"))]);
318+ assert_eq!(github_events("work_item.landed"), [("work_item", Some("landed"))]);
319+ assert!(github_events("issue.opened").is_empty());
313320 assert_eq!(github_events("pull.updated")[0], ("pull_request", Some("synchronize")));
314321 assert_eq!(github_events("pull.merged")[1], ("pull_request_target", Some("closed")));
315− assert_eq!(github_events("comment.created"), [("issue_comment", Some("created"))]);
316− assert_eq!(github_events("issue.labeled"), [("issues", Some("labeled"))]);
322+ assert_eq!(github_events("comment.created"), [("work_item_comment", Some("created"))]);
323+ assert_eq!(github_events("work_item.labeled"), [("work_item", Some("labeled"))]);
317324 assert_eq!(github_events("pull.unlabeled")[0], ("pull_request", Some("unlabeled")));
318325 assert_eq!(github_events("pull.milestoned")[1], ("pull_request_target", Some("milestoned")));
319326 assert_eq!(github_events("pull.base_changed")[0], ("pull_request", Some("edited")));
320327 assert_eq!(github_events("pull.reopened")[0], ("pull_request", Some("reopened")));
321328 assert_eq!(github_events("pull.converted_to_draft")[1], ("pull_request_target", Some("converted_to_draft")));
322− assert_eq!(github_events("comment.edited"), [("issue_comment", Some("edited"))]);
323− assert_eq!(github_events("comment.deleted"), [("issue_comment", Some("deleted"))]);
329+ assert_eq!(github_events("comment.edited"), [("work_item_comment", Some("edited"))]);
330+ assert_eq!(github_events("comment.deleted"), [("work_item_comment", Some("deleted"))]);
324331 assert_eq!(github_events("release.published"), [("release", Some("published"))]);
325332 assert_eq!(github_events("release.prereleased"), [("release", Some("prereleased"))]);
326333 assert_eq!(github_events("deployment_status.created"), [("deployment_status", Some("created"))]);
+2−2
11791179 "head": { "ref": "feature/x" },
11801180 "labels": [{ "name": "bug" }, { "name": "Enhancement" }]
11811181 },
1182− "issues": [
1182+ "items": [
11831183 { "labels": [{ "name": "a" }, { "name": "b" }] },
11841184 { "labels": [{ "name": "c" }] }
11851185 ],
12911291 #[test]
12921292 fn nested_object_filters_flatten() {
12931293 assert_eq!(
1294− ev("github.event.issues.*.labels.*.name"),
1294+ ev("github.event.items.*.labels.*.name"),
12951295 json!(["a", "b", "c"])
12961296 );
12971297 assert_eq!(
+16−9
1313
1414 use serde_json::Value;
1515
16−/// Every permission GitHub's token has, as workflows name them.
16+/// Every permission a job's token has, as workflows name them: GitHub's,
17+/// with `work` (work items) in place of its `issues`.
1718 pub const NAMES: [&str; 16] = [
1819 "actions",
1920 "attestations",
2223 "deployments",
2324 "discussions",
2425 "id-token",
25− "issues",
2626 "metadata",
2727 "models",
2828 "packages",
3131 "repository-projects",
3232 "security-events",
3333 "statuses",
34+ "work",
3435 ];
3536
3637 /// Permissions that grant no scope of the token: g1t has nothing behind
163164 };
164165 add("contents", &["code:read"], &["code:write", "repo:write"]);
165166 add("pull-requests", &["pull_requests:read"], &["pull_requests:write"]);
166− add("issues", &["issues:read"], &["issues:write"]);
167+ add("work", &["work:read"], &["work:write"]);
167168 add("actions", &["workflows:read"], &["workflows:write"]);
168169 add("checks", &["checks:read"], &["checks:write"]);
169170 add("statuses", &["checks:read"], &["checks:write"]);
220221 let restricted = Permissions::default_for(TokenDefault::Restricted);
221222 assert_eq!(restricted.get("contents"), Access::Read);
222223 assert_eq!(restricted.get("packages"), Access::Read);
223− assert_eq!(restricted.get("issues"), Access::None);
224+ assert_eq!(restricted.get("work"), Access::None);
224225 assert_eq!(restricted.get("metadata"), Access::Read);
225226 assert_eq!(restricted.scopes(), ["repo:read", "code:read", "packages:read"]);
226227 let permissive = Permissions::default_for(TokenDefault::Permissive);
232233
233234 #[test]
234235 fn written_permissions_leave_the_rest_at_none() {
235− let (permissions, unknown) = parse(&json!({ "contents": "write", "pull-requests": "write", "issues": "read" })).unwrap();
236+ let (permissions, unknown) = parse(&json!({ "contents": "write", "pull-requests": "write", "work": "read" })).unwrap();
236237 assert!(unknown.is_empty());
237238 assert_eq!(
238239 permissions.scopes(),
239− ["repo:read", "code:read", "code:write", "repo:write", "pull_requests:read", "pull_requests:write", "issues:read"]
240+ ["repo:read", "code:read", "code:write", "repo:write", "pull_requests:read", "pull_requests:write", "work:read"]
240241 );
241242 assert_eq!(permissions.get("packages"), Access::None);
242243 // `{}` is nothing but metadata.
249250 let (all, _) = parse(&json!("write-all")).unwrap();
250251 let scopes = all.scopes();
251252 for scope in [
252− "workflows:write", "checks:write", "deployments:write", "packages:write", "security:write", "issues:write",
253+ "workflows:write", "checks:write", "deployments:write", "packages:write", "security:write", "work:write",
253254 ] {
254255 assert!(scopes.contains(&scope), "{scope}");
255256 }
278279 #[test]
279280 fn a_called_workflow_gets_no_more_than_its_caller() {
280281 let (callee, _) = parse(&json!("write-all")).unwrap();
281− let (caller, _) = parse(&json!({ "contents": "write", "issues": "read" })).unwrap();
282+ let (caller, _) = parse(&json!({ "contents": "write", "work": "read" })).unwrap();
282283 let capped = callee.capped_by(&caller);
283284 assert_eq!(capped.get("contents"), Access::Write);
284− assert_eq!(capped.get("issues"), Access::Read);
285+ assert_eq!(capped.get("work"), Access::Read);
285286 assert_eq!(capped.get("pull-requests"), Access::None);
286287 }
287288
292293 assert!(parse(&json!(["contents"])).is_err());
293294 let (_, unknown) = parse(&json!({ "contents": "read", "wiki": "write" })).unwrap();
294295 assert_eq!(unknown, ["wiki"]);
296+ // GitHub's `issues` is `work` on g1t: alone it grants nothing.
297+ let (issues, unknown) = parse(&json!({ "issues": "write" })).unwrap();
298+ assert_eq!(unknown, ["issues"]);
299+ assert_eq!(issues.scopes(), ["repo:read"]);
300+ let (work, _) = parse(&json!({ "work": "write" })).unwrap();
301+ assert_eq!(work.scopes(), ["repo:read", "work:read", "work:write"]);
295302 }
296303 }
+33−6
11 //! GitHub's workflow syntax: the keys each mapping in a workflow may have,
22 //! as GitHub's own schema lists them (`schema/workflow-v1.0.json`, from
33 //! actions/languageservices; the tests below check these lists against
4−//! it). [`crate::workflow::parse`] refuses any other key, as GitHub does,
5−//! except `g1t`, g1t's own settings, on the workflow, a job and a step.
4+//! it), with g1t's own beside them: the `work_item` and
5+//! `work_item_comment` events ([`OWN_EVENTS`]) and the `work` permission
6+//! ([`OWN_PERMISSIONS`]). [`crate::workflow::parse`] refuses any other
7+//! key, as GitHub does, except `g1t`, g1t's own settings, on the workflow,
8+//! a job and a step.
69
710 /// g1t's own key, allowed on the workflow, each job and each step.
811 pub const EXTENSION: &str = "g1t";
7477 /// `defaults.run` (`workflow-defaults-run`, `job-defaults-run`).
7578 pub const DEFAULTS_RUN: &[&str] = &["shell", "working-directory"];
7679
77−/// `permissions` as a mapping (`permissions-mapping`).
80+/// `permissions` as a mapping (`permissions-mapping`), and g1t's own.
7881 pub const PERMISSIONS: &[&str] = &[
7982 "actions",
8083 "artifact-metadata",
9699 "security-events",
97100 "statuses",
98101 "vulnerability-alerts",
102+ "work",
99103 ];
100104
105+/// Permissions g1t has that GitHub does not: `work`, for work items.
106+pub const OWN_PERMISSIONS: &[&str] = &["work"];
107+
101108 /// Each entry of `on.schedule` (`cron-mapping`).
102109 pub const CRON: &[&str] = &["cron", "timezone"];
103110
133140 const TYPES: EventSpec = EventSpec::Filters(&["types"]);
134141 const PULL_REQUEST: EventSpec = EventSpec::Filters(&["types", "branches", "branches-ignore", "paths", "paths-ignore"]);
135142
136−/// Every event GitHub has, and what each takes (`on-mapping-strict`).
143+/// Every event GitHub has, and what each takes (`on-mapping-strict`),
144+/// and g1t's own.
137145 pub const EVENTS: &[(&str, EventSpec)] = &[
138146 ("branch_protection_rule", TYPES),
139147 ("check_run", TYPES),
172180 ("workflow_call", EventSpec::Filters(WORKFLOW_CALL)),
173181 ("workflow_dispatch", EventSpec::Filters(WORKFLOW_DISPATCH)),
174182 ("workflow_run", EventSpec::Filters(&["types", "branches", "branches-ignore", "workflows"])),
183+ ("work_item", TYPES),
184+ ("work_item_comment", TYPES),
175185 ];
176186
187+/// Events g1t has that GitHub does not: a work item changed, and a comment
188+/// on a work item or a pull request.
189+pub const OWN_EVENTS: &[&str] = &["work_item", "work_item_comment"];
190+
177191 /// What `event` takes, if GitHub has it.
178192 pub fn event(event: &str) -> Option<EventSpec> {
179193 EVENTS.iter().find(|(name, _)| *name == event).map(|(_, spec)| *spec)
212226 list.iter().map(|key| (*key).to_owned()).collect()
213227 }
214228
229+ /// GitHub's part of a list: without g1t's own.
230+ fn upstream(list: &[&str], own: &[&str]) -> BTreeSet<String> {
231+ list.iter().filter(|key| !own.contains(key)).map(|key| (*key).to_owned()).collect()
232+ }
233+
215234 #[test]
216235 fn the_lists_are_githubs() {
217236 let schema = schema();
241260 (CALL_OUTPUT, "workflow-call-output-definition"),
242261 (CALL_SECRET, "workflow-call-secret-mapping-definition"),
243262 ] {
244− assert_eq!(set(list), keys(&schema, name), "{name}");
263+ assert_eq!(upstream(list, OWN_PERMISSIONS), keys(&schema, name), "{name}");
245264 }
265+ for own in OWN_PERMISSIONS {
266+ assert!(PERMISSIONS.contains(own) && !keys(&schema, "permissions-mapping").contains(*own), "{own}");
267+ }
246268 // The background steps' own keys.
247269 let mut background = BTreeSet::from(["background".to_owned()]);
248270 for name in ["wait-step", "wait-all-step", "cancel-step", "parallel-step"] {
261283 let schema = schema();
262284 let definitions = &schema["definitions"];
263285 let events = definitions["on-mapping-strict"]["mapping"]["properties"].as_object().unwrap();
264− assert_eq!(events.keys().cloned().collect::<BTreeSet<_>>(), EVENTS.iter().map(|(name, _)| (*name).to_owned()).collect());
286+ let names: Vec<&str> = EVENTS.iter().map(|(name, _)| *name).collect();
287+ assert_eq!(events.keys().cloned().collect::<BTreeSet<_>>(), upstream(&names, OWN_EVENTS));
288+ for own in OWN_EVENTS {
289+ assert!(!events.contains_key(*own), "{own}");
290+ assert_eq!(event(own), Some(TYPES), "{own}");
291+ }
265292 for (name, kind) in events {
266293 let kind = kind.as_str().or_else(|| kind["type"].as_str()).unwrap();
267294 let definition = &definitions[kind];
+55−9
2020 "pull_request",
2121 "pull_request_target",
2222 "pull_request_review",
23− "issues",
24− "issue_comment",
23+ "work_item",
24+ "work_item_comment",
2525 "schedule",
2626 "workflow_dispatch",
2727 "repository_dispatch",
3737
3838 /// Events GitHub has that g1t knows of but never sends: a workflow on one
3939 /// of them is told so, rather than waiting for a run that never comes.
40−pub const UNSENT_EVENTS: &[(&str, &str)] = &[(
41− "label",
42− "g1t does not start runs when a label is created, edited or deleted yet, so the `label` trigger never starts it. Putting a label on an issue or a pull request starts `issues` and `pull_request` workflows with the `labeled` type.",
43−)];
40+pub const UNSENT_EVENTS: &[(&str, &str)] = &[
41+ (
42+ "label",
43+ "g1t does not start runs when a label is created, edited or deleted yet, so the `label` trigger never starts it. Putting a label on a work item or a pull request starts `work_item` and `pull_request` workflows with the `labeled` type.",
44+ ),
45+ (
46+ "issues",
47+ "g1t has work items, not issues, so the `issues` trigger never starts it. Write `work_item`: it has the same activity types, and `github.event.work_item` in place of `github.event.issue`.",
48+ ),
49+ (
50+ "issue_comment",
51+ "g1t has work items, not issues, so the `issue_comment` trigger never starts it. Write `work_item_comment`: it runs for comments on work items and pull requests, with `github.event.work_item` in place of `github.event.issue`.",
52+ ),
53+];
4454
4555 /// The `types` each event has when a workflow gives none, as on GitHub.
4656 pub fn default_types(event: &str) -> &'static [&'static str] {
864874 /// nothing behind.
865875 fn permission_notes(unknown: &[String], job: Option<&str>, note: &mut impl FnMut(Severity, Option<&str>, String)) {
866876 for name in unknown {
867− note(Severity::Warning, job, format!("`permissions.{name}`: g1t's token has nothing behind that permission yet, so it grants nothing."));
877+ let message = if name == "issues" {
878+ "`permissions.issues`: g1t has work items, not issues, so it grants nothing. Write `work` for the token to read or change work items.".to_owned()
879+ } else {
880+ format!("`permissions.{name}`: g1t's token has nothing behind that permission yet, so it grants nothing.")
881+ };
882+ note(Severity::Warning, job, message);
868883 }
869884 }
870885
10691084 // Without any, the repository's default.
10701085 let bare = parse("on: push\njobs:\n a:\n runs-on: ubuntu-latest\n steps: [{ run: 'true' }]").unwrap();
10711086 assert_eq!(bare.jobs[0].permissions(&bare, TokenDefault::Restricted).get("contents"), Access::Read);
1072− assert_eq!(bare.jobs[0].permissions(&bare, TokenDefault::Restricted).get("issues"), Access::None);
1073− assert_eq!(bare.jobs[0].permissions(&bare, TokenDefault::Permissive).get("issues"), Access::Write);
1087+ assert_eq!(bare.jobs[0].permissions(&bare, TokenDefault::Restricted).get("work"), Access::None);
1088+ assert_eq!(bare.jobs[0].permissions(&bare, TokenDefault::Permissive).get("work"), Access::Write);
10741089 // What reads but grants nothing is said.
10751090 let odd = parse("on: push\npermissions: { id-token: write, artifact-metadata: read }\njobs:\n a:\n runs-on: x\n steps: [{ run: 'true' }]").unwrap();
10761091 assert!(!odd.notes.iter().any(|n| n.message.contains("id-token")), "OIDC tokens are issued");
11781193 }
11791194
11801195 #[test]
1196+ fn work_items_start_workflows_and_issues_do_not() {
1197+ use crate::permissions::{Access, TokenDefault};
1198+ let workflow = parse(
1199+ "on:
1200+ work_item: { types: [opened, landed] }
1201+ work_item_comment:
1202+permissions: { work: write }
1203+jobs:
1204+ a:
1205+ runs-on: x
1206+ steps: [{ run: 'true' }]",
1207+ )
1208+ .unwrap();
1209+ assert!(workflow.notes.is_empty(), "{:?}", workflow.notes);
1210+ let item = workflow.trigger("work_item").unwrap();
1211+ assert!(item.wants_type(Some("landed")));
1212+ assert!(!item.wants_type(Some("closed")));
1213+ assert!(workflow.trigger("work_item_comment").unwrap().wants_type(Some("created")));
1214+ assert_eq!(workflow.jobs[0].permissions(&workflow, TokenDefault::Restricted).get("work"), Access::Write);
1215+ // GitHub's names still read, as GitHub workflows do, but start
1216+ // nothing and grant nothing, and say what to write instead.
1217+ let github = parse("on: [issues, issue_comment]\npermissions: { issues: write }\njobs:\n a:\n runs-on: x\n steps: [{ run: 'true' }]").unwrap();
1218+ assert!(github.trigger("work_item").is_none());
1219+ let said: Vec<&str> = github.notes.iter().map(|note| note.message.as_str()).collect();
1220+ assert!(said.iter().any(|note| note.contains("Write `work_item`")), "{said:?}");
1221+ assert!(said.iter().any(|note| note.contains("Write `work_item_comment`")), "{said:?}");
1222+ assert!(said.iter().any(|note| note.contains("Write `work`")), "{said:?}");
1223+ assert_eq!(github.jobs[0].permissions(&github, TokenDefault::Restricted).scopes(), ["repo:read"]);
1224+ }
1225+
1226+ #[test]
11811227 fn aliases_read_and_merge_keys_do_not() {
11821228 let aliased = parse("on: push\njobs:\n a:\n runs-on: x\n steps: &steps\n - run: 'true'\n b:\n runs-on: x\n steps: *steps").unwrap();
11831229 assert_eq!(aliased.jobs[1].steps.len(), 1);
+14−0
1+name: Triage
2+on:
3+ work_item:
4+ types: [opened, labeled, landed]
5+ work_item_comment:
6+ types: [created]
7+permissions:
8+ work: write
9+jobs:
10+ triage:
11+ if: ${{ contains(github.event.work_item.labels.*.name, 'bug') && !github.event.work_item.pull_request }}
12+ runs-on: ubuntu-latest
13+ steps:
14+ - run: echo "${{ github.event.work_item.html_url }} in ${{ github.event.work_item.workspace }}"

Comments

Sign in to comment.