| 1 | 1 | | //! `github.event`: the webhook-shaped payload GitHub gives a workflow, |
| 2 | 2 | | //! 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. |
| 4 | 7 | | //! |
| 5 | 8 | | //! Ids are as GitHub's are: `id` a whole number, the same for an object |
| 6 | 9 | | //! wherever it appears ([`github_id`] of its g1t id, [`account_id`] of a |
| 7 | 10 | | //! person's or workspace's name), and `node_id` g1t's own id. What g1t's |
| 8 | 11 | | //! API addresses by its own id keeps that id as `id`, so a payload's id |
| 9 | 12 | | //! 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 |
| 11 | 14 | | //! deployment and its statuses, and a workflow run. |
| 12 | 15 | | |
| 13 | 16 | | use g1t_actions::events::{account_id, github_id}; |
| 14 | 17 | | use g1t_contracts::{PrincipalKind, User}; |
| 15 | 18 | | use g1t_contracts::repos::{Commit, Repo, RepoPath}; |
| 16 | 19 | | 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}; |
| 18 | 21 | | use serde_json::{Value, json}; |
| 19 | 22 | | |
| 20 | 23 | | use crate::{API, SITE}; |
| ⋯ |
| 139 | 142 | | Value::Array(names.iter().map(|name| label(repo, name, None)).collect()) |
| 140 | 143 | | } |
| 141 | 144 | | |
| 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. |
| 145 | 149 | | fn label(repo: &Repo, name: &str, color: Option<&Value>) -> Value { |
| 146 | 150 | | 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), |
| 149 | 153 | | "name": name, |
| 150 | 154 | | "color": color.filter(|c| c.is_string()).cloned().unwrap_or_else(|| json!(label_color_for(name))), |
| 151 | 155 | | "description": DEFAULT_LABELS.iter().find(|(known, _, _)| *known == name).map(|(_, _, about)| *about), |
| ⋯ |
| 154 | 158 | | } |
| 155 | 159 | | |
| 156 | 160 | | /// 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`). |
| 158 | 162 | | pub fn color_labels(payload: &mut Value, known: &[Label]) { |
| 159 | 163 | | fn set(label: &mut Value, known: &[Label]) { |
| 160 | 164 | | let Some(found) = label["name"].as_str().and_then(|name| known.iter().find(|k| k.name == name)) else { return }; |
| ⋯ |
| 184 | 188 | | } |
| 185 | 189 | | } |
| 186 | 190 | | |
| 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. |
| 188 | 193 | | fn milestone(repo: &Repo, milestone: Option<&MilestoneRef>) -> Value { |
| 189 | 194 | | milestone.map_or(Value::Null, |milestone| milestone_of(repo, milestone.number, &json!(milestone.title))) |
| 190 | 195 | | } |
| 191 | 196 | | |
| 192 | 197 | | fn milestone_of(repo: &Repo, number: u32, title: &Value) -> Value { |
| 193 | 198 | | json!({ |
| 194 | | − | "id": github_id(&format!("{}:milestone:{number}", repo.id)), |
| 199 | + | "id": github_id(&format!("{}:milestone:{number}", repo.owner_id)), |
| 195 | 200 | | "number": number, |
| 196 | 201 | | "title": title, |
| 197 | | − | "html_url": format!("{SITE}/{}/{}/milestones/{number}", repo.namespace, repo.name), |
| 202 | + | "html_url": format!("{SITE}/{}/-/work/milestones/{number}", repo.namespace), |
| 198 | 203 | | }) |
| 199 | 204 | | } |
| 200 | 205 | | |
| ⋯ |
| 276 | 281 | | }) |
| 277 | 282 | | } |
| 278 | 283 | | |
| 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(); |
| 281 | 311 | | 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, |
| 289 | 319 | | "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), |
| 304 | 349 | | }) |
| 305 | 350 | | } |
| 306 | 351 | | |
| 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 | + | |
| 307 | 368 | | /// A pull request, with its own labels. `base.sha` is the pull request's |
| 308 | 369 | | /// merge base until the trigger sets the base branch's tip, as GitHub |
| 309 | 370 | | /// gives it (trigger.rs). |
| ⋯ |
| 372 | 433 | | "closed_at": closed_at, |
| 373 | 434 | | "html_url": format!("{SITE}/{full_name}/pull/{}", pull.number), |
| 374 | 435 | | "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, |
| 376 | 442 | | }) |
| 377 | 443 | | } |
| 378 | 444 | | |
| ⋯ |
| 402 | 468 | | pull.branch.clone().unwrap_or_else(|| format!("pull/{}", pull.number)) |
| 403 | 469 | | } |
| 404 | 470 | | |
| 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 | + | |
| 405 | 501 | | /// A comment. Its `id` is g1t's (`cmt_…`), as the API takes it back to |
| 406 | 502 | | /// 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); |
| 410 | 505 | | json!({ |
| 411 | 506 | | "id": comment.id, |
| 412 | 507 | | "node_id": comment.id, |
| ⋯ |
| 416 | 511 | | "updated_at": comment.edited_at.as_deref().unwrap_or(&comment.created_at), |
| 417 | 512 | | "path": comment.path, |
| 418 | 513 | | "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, |
| 422 | 517 | | }) |
| 423 | 518 | | } |
| 424 | 519 | | |
| ⋯ |
| 442 | 537 | | }) |
| 443 | 538 | | } |
| 444 | 539 | | |
| 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 { |
| 449 | 545 | | let full_name = format!("{}/{}", repo.namespace, repo.name); |
| 450 | 546 | | json!({ |
| 451 | | − | "id": github_id(&format!("issue:{}", pull.id)), |
| 547 | + | "id": github_id(&format!("item:{}", pull.id)), |
| 452 | 548 | | "node_id": pull.id, |
| 453 | 549 | | "number": pull.number, |
| 454 | 550 | | "title": pull.title, |
| ⋯ |
| 465 | 561 | | "created_at": pull.created_at, |
| 466 | 562 | | "updated_at": pull.updated_at, |
| 467 | 563 | | "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), |
| 469 | 565 | | "repository_url": format!("{API}/repos/{full_name}"), |
| 566 | + | "workspace": repo.namespace, |
| 470 | 567 | | "pull_request": { |
| 471 | 568 | | "url": format!("{API}/repos/{full_name}/pulls/{}", pull.number), |
| 472 | 569 | | "html_url": format!("{SITE}/{full_name}/pull/{}", pull.number), |
| ⋯ |
| 477 | 574 | | |
| 478 | 575 | | /// A comment as the event that deleted it kept it (`comment.deleted`'s |
| 479 | 576 | | /// `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); |
| 483 | 579 | | let id = kept["id"].as_str().unwrap_or_default(); |
| 484 | 580 | | let login = kept["author"]["username"].as_str().unwrap_or_default(); |
| 485 | 581 | | json!({ |
| ⋯ |
| 491 | 587 | | "updated_at": kept["editedAt"].as_str().map_or(kept["createdAt"].clone(), |at| json!(at)), |
| 492 | 588 | | "path": kept["path"], |
| 493 | 589 | | "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, |
| 497 | 593 | | }) |
| 498 | 594 | | } |
| 499 | 595 | | |
| ⋯ |
| 702 | 798 | | const KNOWN: &[(&str, &str, &str)] = &[ |
| 703 | 799 | | // The API addresses these by g1t's own id, so the payload gives |
| 704 | 800 | | // 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"), |
| 706 | 808 | | ("release", "release.id", "string"), |
| 707 | 809 | | ("workflow_run", "workflow_run.id", "string"), |
| 708 | 810 | | ("workflow_run", "workflow_run.workflow_id", "string"), |
| ⋯ |
| 774 | 876 | | |
| 775 | 877 | | fn a_pull() -> Pull { |
| 776 | 878 | | 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, |
| 778 | 880 | | "title": "Update the README with new information.", "body": "A simple change.", |
| 779 | 881 | | "agent": "", "runtime": "external", "status": "open", |
| 780 | 882 | | "fork": null, "forkRepoId": null, "branch": "changes", |
| ⋯ |
| 788 | 890 | | .unwrap() |
| 789 | 891 | | } |
| 790 | 892 | | |
| 791 | | − | fn an_issue() -> Issue { |
| 893 | + | fn an_item() -> WorkItem { |
| 792 | 894 | | serde_json::from_value(json!({ |
| 793 | | − | "id": "iss_01kq000000000000000000000a", "repoId": "rep_1", "number": 1, |
| 895 | + | "id": "wki_01kq000000000000000000000a", "workspace": "acme", "number": 1, |
| 794 | 896 | | "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, |
| 796 | 898 | | "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" }] |
| 798 | 905 | | })) |
| 799 | 906 | | .unwrap() |
| 800 | 907 | | } |
| ⋯ |
| 822 | 929 | | let pull = a_pull(); |
| 823 | 930 | | let mut push = push(&repo, "refs/heads/main", Some("6113728f27ae82c7b1a177c8d03f9e96e0adf246"), &a_commit().hash, &[a_commit()], "ada", Some("0d0efg+ada@users.noreply.g1t.sh"), false); |
| 824 | 931 | | 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 }]; |
| 826 | 933 | | let mut pull_request = json!({ |
| 827 | 934 | | "action": "opened", "number": 2, "pull_request": self::pull(&repo, &pull), |
| 828 | 935 | | "repository": repository(&repo), "sender": user("ada"), |
| ⋯ |
| 832 | 939 | | "action": "submitted", "review": self::review(&repo, &pull, Some(&a_comment(Some(Verdict::Approve)))), |
| 833 | 940 | | "pull_request": self::pull(&repo, &pull), "repository": repository(&repo), "sender": user("cy"), |
| 834 | 941 | | }); |
| 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); |
| 838 | 945 | | let mut on_pull = pull.clone(); |
| 839 | 946 | | on_pull.labels.clear(); |
| 840 | 947 | | on_pull.assignees.clear(); |
| 841 | 948 | | on_pull.milestone = None; |
| 842 | 949 | | let mut comment = a_comment(None); |
| 843 | 950 | | 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), |
| 846 | 953 | | "repository": repository(&repo), "sender": user("ada"), |
| 847 | 954 | | }); |
| 848 | 955 | | let data = json!({ |
| ⋯ |
| 883 | 990 | | ("push", push), |
| 884 | 991 | | ("pull_request", pull_request), |
| 885 | 992 | | ("pull_request_review", review), |
| 886 | | − | ("issues", issues), |
| 887 | | − | ("issue_comment", issue_comment), |
| 993 | + | ("work_item", items), |
| 994 | + | ("work_item_comment", item_comment), |
| 888 | 995 | | ("workflow_run", workflow_run), |
| 889 | 996 | | ("release", release), |
| 890 | 997 | | ] |
| 891 | 998 | | } |
| 892 | 999 | | |
| 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`. |
| 893 | 1003 | | fn fixture(event: &str) -> Value { |
| 894 | 1004 | | let text = match event { |
| 895 | 1005 | | "push" => include_str!("../tests/fixtures/push.json"), |
| 896 | 1006 | | "pull_request" => include_str!("../tests/fixtures/pull_request.json"), |
| 897 | 1007 | | "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"), |
| 900 | 1010 | | "workflow_run" => include_str!("../tests/fixtures/workflow_run.json"), |
| 901 | 1011 | | "release" => include_str!("../tests/fixtures/release.json"), |
| 902 | 1012 | | other => panic!("no fixture for {other}"), |
| 903 | 1013 | | }; |
| 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 |
| 905 | 1019 | | } |
| 906 | 1020 | | |
| 907 | 1021 | | #[test] |
| ⋯ |
| 930 | 1044 | | fn the_same_object_has_the_same_number_everywhere() { |
| 931 | 1045 | | let all = payloads(); |
| 932 | 1046 | | 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")); |
| 934 | 1048 | | let repo_id = &pr["repository"]["id"]; |
| 935 | 1049 | | assert_eq!(repo_id, &json!(github_id(&repo().id)), "github.repository_id is the same number"); |
| 936 | 1050 | | assert_eq!(&pr["pull_request"]["base"]["repo"]["id"], repo_id); |
| ⋯ |
| 941 | 1055 | | assert_eq!(pr["sender"]["id"], json!(account_id("ada")), "github.actor_id is the same number"); |
| 942 | 1056 | | assert_eq!(pr["repository"]["owner"]["id"], pr["pull_request"]["head"]["user"]["id"]); |
| 943 | 1057 | | 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"]); |
| 947 | 1061 | | assert_eq!(pr["pull_request"]["node_id"], a_pull().id); |
| 948 | 1062 | | } |
| 949 | 1063 | | |
| ⋯ |
| 999 | 1113 | | } |
| 1000 | 1114 | | |
| 1001 | 1115 | | #[test] |
| 1002 | | − | fn labels_have_the_repositorys_colors() { |
| 1116 | + | fn a_work_item_names_its_workspace_and_repositories() { |
| 1003 | 1117 | | 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) }); |
| 1005 | 1155 | | // 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); |
| 1008 | 1158 | | 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 }]; |
| 1010 | 1160 | | 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); |
| 1013 | 1163 | | assert_eq!(event["label"]["color"], "123456"); |
| 1014 | | − | assert!(!has_labels(&json!({ "issue": { "labels": [] } }))); |
| 1164 | + | assert!(!has_labels(&json!({ "work_item": { "labels": [] } }))); |
| 1015 | 1165 | | } |
| 1016 | 1166 | | |
| 1017 | 1167 | | #[test] |