Events: work items in the inbox, the log and live pages
A work item is one inbox thread (item:<id>) in its workspace, linked at /<workspace>/-/work/<n>, read from the work service by its id. Watchers of every repository it is linked to hear of it; it is kept with its origin, shown to the workspace's members and whoever can read the origin, and pushed live only to those who may see it. Watches choose work_items, the digest counts work items, the log's numbers filter reads item, and live pages of every linked repository refresh. Migration 0013 renames what needs only names, lets subscriptions keep no repository, and records work.migrated pages, from which threads, subscriptions and the log's issue events are rewritten to the work items, idempotently.
- G1T-Session:
- ses_01m566c3bte71t8fa4g03g1c8v
10 files+987−2350/10 viewed
| 1503 | 1503 | dependencies = [ | |
| 1504 | 1504 | "g1t-contracts", | |
| 1505 | 1505 | "g1t-kit", | |
| 1506 | + | "rusqlite", | |
| 1506 | 1507 | "serde", | |
| 1507 | 1508 | "serde_json", | |
| 1508 | 1509 | "worker", |
| 15 | 15 | serde_json.workspace = true | |
| 16 | 16 | worker.workspace = true | |
| 17 | 17 | ||
| 18 | + | [dev-dependencies] | |
| 19 | + | rusqlite = { version = "0.32", features = ["bundled"] } | |
| 20 | + | ||
| 18 | 21 | # wasm-opt at -O1: about the same gzipped size as -O in a tenth of the | |
| 19 | 22 | # time. | |
| 20 | 23 | [package.metadata.wasm-pack.profile.release] |
| 1 | + | -- Issues became work items (g1t #57). What needs only names is rewritten | |
| 2 | + | -- here; what was kept by a repository's issue number (inbox threads, | |
| 3 | + | -- subscriptions, the log's events) is rewritten as the work service | |
| 4 | + | -- announces where each issue went (`work.migrated`, src/work_items.rs), | |
| 5 | + | -- recorded in `work_migrated`. | |
| 6 | + | ||
| 7 | + | -- Inbox items about an issue are about its work item. | |
| 8 | + | UPDATE inbox_items SET subject = 'item' WHERE subject = 'issue'; | |
| 9 | + | ||
| 10 | + | -- A custom watch that followed issues follows work items. | |
| 11 | + | UPDATE inbox_watching | |
| 12 | + | SET events = ( | |
| 13 | + | SELECT json_group_array(CASE value WHEN 'issues' THEN 'work_items' ELSE value END) | |
| 14 | + | FROM json_each(inbox_watching.events) | |
| 15 | + | ) | |
| 16 | + | WHERE EXISTS (SELECT 1 FROM json_each(inbox_watching.events) WHERE value = 'issues'); | |
| 17 | + | ||
| 18 | + | -- Subscriptions to a work item keep its origin as `repo_id`, and one | |
| 19 | + | -- with no origin has none. | |
| 20 | + | CREATE TABLE inbox_subscriptions_new ( | |
| 21 | + | username TEXT NOT NULL, | |
| 22 | + | -- `<repo_id>#<number>` for a pull request, `item:<item_id>` for a work item. | |
| 23 | + | thread TEXT NOT NULL, | |
| 24 | + | -- The pull request's repository, or the work item's origin. | |
| 25 | + | repo_id TEXT, | |
| 26 | + | -- subscribed | unsubscribed | ignored | |
| 27 | + | state TEXT NOT NULL, | |
| 28 | + | -- Why: comment, mention, assign, review_requested, author or manual. | |
| 29 | + | reason TEXT, | |
| 30 | + | created_at TEXT NOT NULL, | |
| 31 | + | -- When the person last chose (subscribed, unsubscribed or ignored by | |
| 32 | + | -- hand); null while it only follows what they did. | |
| 33 | + | chosen_at TEXT, | |
| 34 | + | PRIMARY KEY (thread, username) | |
| 35 | + | ); | |
| 36 | + | INSERT INTO inbox_subscriptions_new (username, thread, repo_id, state, reason, created_at, chosen_at) | |
| 37 | + | SELECT username, thread, repo_id, state, reason, created_at, chosen_at FROM inbox_subscriptions; | |
| 38 | + | DROP TABLE inbox_subscriptions; | |
| 39 | + | ALTER TABLE inbox_subscriptions_new RENAME TO inbox_subscriptions; | |
| 40 | + | CREATE INDEX inbox_subscriptions_repo ON inbox_subscriptions (repo_id); | |
| 41 | + | ||
| 42 | + | -- Where each repository's issues went, a page of `work.migrated` at a time. | |
| 43 | + | CREATE TABLE work_migrated ( | |
| 44 | + | repo_id TEXT NOT NULL, | |
| 45 | + | -- The issue's number in its repository. | |
| 46 | + | legacy_number INTEGER NOT NULL, | |
| 47 | + | -- The work item's workspace (a slug), number in it, and id. | |
| 48 | + | workspace TEXT NOT NULL, | |
| 49 | + | number INTEGER NOT NULL, | |
| 50 | + | item_id TEXT NOT NULL, | |
| 51 | + | -- The `work.migrated` event that said so. | |
| 52 | + | event_id TEXT NOT NULL, | |
| 53 | + | PRIMARY KEY (repo_id, legacy_number) | |
| 54 | + | ); | |
| 55 | + | CREATE INDEX work_migrated_event ON work_migrated (event_id); |
| 17 | 17 | ||
| 18 | 18 | use g1t_contracts::events::{ | |
| 19 | 19 | ActivityDigest, ActivityDigestArgs, ActorCount, DefaultBranchPushes, DeploymentDigest, Event, | |
| 20 | − | FolioDigest, FolioEdited, IssueDigest, MAX_DIGEST_EVENTS, MAX_DIGEST_LISTED, MAX_DIGEST_REPOS, | |
| 20 | + | FolioDigest, FolioEdited, ItemDigest, MAX_DIGEST_EVENTS, MAX_DIGEST_LISTED, MAX_DIGEST_REPOS, | |
| 21 | 21 | PackageDigest, PullDigest, PushDigest, PushesBy, ReleaseDigest, RepoDigest, | |
| 22 | 22 | }; | |
| 23 | 23 | use g1t_contracts::id_floor; | |
| ⋯ | |||
| 27 | 27 | ||
| 28 | 28 | use crate::EventRow; | |
| 29 | 29 | ||
| 30 | − | /// The repository events a digest counts. | |
| 30 | + | /// The repository events a digest counts. A work item's count in its | |
| 31 | + | /// origin's, the repository its events carry; one with no origin in none. | |
| 31 | 32 | const REPO_TYPES: [&str; 12] = [ | |
| 32 | 33 | "git.push", | |
| 33 | 34 | "pull.opened", | |
| 34 | 35 | "pull.merged", | |
| 35 | 36 | "pull.closed", | |
| 36 | − | "issue.opened", | |
| 37 | − | "issue.closed", | |
| 37 | + | "work_item.opened", | |
| 38 | + | "work_item.closed", | |
| 38 | 39 | "review.completed", | |
| 39 | 40 | "comment.created", | |
| 40 | 41 | "deployment.succeeded", | |
| ⋯ | |||
| 237 | 238 | pulls_opened: Tally, | |
| 238 | 239 | pulls_merged: Tally, | |
| 239 | 240 | pulls_closed: Tally, | |
| 240 | − | issues_opened: Tally, | |
| 241 | − | issues_closed: Tally, | |
| 241 | + | items_opened: Tally, | |
| 242 | + | items_closed: Tally, | |
| 242 | 243 | reviews: Tally, | |
| 243 | 244 | comments: Tally, | |
| 244 | 245 | deploys_succeeded: Tally, | |
| ⋯ | |||
| 279 | 280 | "pull.opened" => self.pulls_opened.add(actor), | |
| 280 | 281 | "pull.merged" => self.pulls_merged.add(actor), | |
| 281 | 282 | "pull.closed" => self.pulls_closed.add(actor), | |
| 282 | − | "issue.opened" => self.issues_opened.add(actor), | |
| 283 | − | "issue.closed" => self.issues_closed.add(actor), | |
| 283 | + | "work_item.opened" => self.items_opened.add(actor), | |
| 284 | + | "work_item.closed" => self.items_closed.add(actor), | |
| 284 | 285 | "review.completed" => self.reviews.add(actor), | |
| 285 | 286 | "comment.created" => { | |
| 286 | 287 | // One of the workspace's agents, as itself: the event's actor | |
| ⋯ | |||
| 356 | 357 | merged: self.pulls_merged.into_counts(), | |
| 357 | 358 | closed: self.pulls_closed.into_counts(), | |
| 358 | 359 | }, | |
| 359 | − | issues: IssueDigest { | |
| 360 | − | opened: self.issues_opened.into_counts(), | |
| 361 | − | closed: self.issues_closed.into_counts(), | |
| 360 | + | items: ItemDigest { | |
| 361 | + | opened: self.items_opened.into_counts(), | |
| 362 | + | closed: self.items_closed.into_counts(), | |
| 362 | 363 | }, | |
| 363 | 364 | reviews: self.reviews.into_counts(), | |
| 364 | 365 | comments: self.comments.into_counts(), | |
| ⋯ | |||
| 582 | 583 | ), | |
| 583 | 584 | event( | |
| 584 | 585 | "evt_5", | |
| 585 | − | "issue.opened", | |
| 586 | + | "work_item.opened", | |
| 586 | 587 | Some("rep_2"), | |
| 587 | 588 | Some("usr_bo"), | |
| 588 | 589 | json!({ "number": 4 }), | |
| 589 | 590 | ), | |
| 590 | 591 | event( | |
| 591 | 592 | "evt_6", | |
| 592 | − | "issue.closed", | |
| 593 | + | "work_item.closed", | |
| 593 | 594 | Some("rep_2"), | |
| 594 | 595 | g1t, | |
| 595 | 596 | json!({ "number": 4 }), | |
| ⋯ | |||
| 755 | 756 | }] | |
| 756 | 757 | ); | |
| 757 | 758 | assert_eq!( | |
| 758 | − | api.issues.opened, | |
| 759 | + | api.items.opened, | |
| 759 | 760 | vec![ActorCount { | |
| 760 | 761 | actor: "user:usr_bo".into(), | |
| 761 | 762 | count: 1 | |
| 762 | 763 | }] | |
| 763 | 764 | ); | |
| 764 | 765 | assert_eq!( | |
| 765 | − | api.issues.closed, | |
| 766 | + | api.items.closed, | |
| 766 | 767 | vec![ActorCount { | |
| 767 | 768 | actor: "user:usr_g1t_agent".into(), | |
| 768 | 769 | count: 1 | |
A large diff (1,103 lines) is not shown until you ask for it.
| 26 | 26 | mod fanout; | |
| 27 | 27 | mod inbox; | |
| 28 | 28 | mod live; | |
| 29 | + | #[cfg(test)] | |
| 30 | + | mod schema; | |
| 29 | 31 | mod security_digest; | |
| 30 | 32 | mod subscriptions; | |
| 33 | + | mod work_items; | |
| 31 | 34 | ||
| 32 | 35 | use g1t_contracts::events::{Event, ListArgs, MAX_LIST_REPOS, PublishArgs}; | |
| 33 | 36 | use g1t_contracts::new_id; | |
| ⋯ | |||
| 497 | 500 | values.push(Bind::Text(actor.clone())); | |
| 498 | 501 | } | |
| 499 | 502 | if !a.numbers.is_empty() { | |
| 500 | − | // An issue or pull request's own events name it as `number`; a | |
| 501 | − | // comment, review or link on it names it as `issue`. | |
| 503 | + | // A work item's or pull request's own events name it as `number`; a | |
| 504 | + | // comment, review or link on it names it as `item`. | |
| 502 | 505 | let marks = vec!["?"; a.numbers.len()].join(", "); | |
| 503 | 506 | conditions.push(format!( | |
| 504 | − | "(json_extract(data, '$.number') IN ({marks}) OR json_extract(data, '$.issue') IN ({marks}))" | |
| 507 | + | "(json_extract(data, '$.number') IN ({marks}) OR json_extract(data, '$.item') IN ({marks}))" | |
| 505 | 508 | )); | |
| 506 | 509 | for _ in 0..2 { | |
| 507 | 510 | values.extend(a.numbers.iter().map(|number| Bind::Number(f64::from(*number)))); | |
| ⋯ | |||
| 564 | 567 | fn numbers_match_an_item_or_what_is_said_on_it_since_a_time() { | |
| 565 | 568 | let (filter, binds) = list_filter(&ListArgs { | |
| 566 | 569 | repo_id: Some("rep_1".into()), | |
| 567 | − | types: vec!["issue.opened".into()], | |
| 570 | + | types: vec!["work_item.opened".into()], | |
| 568 | 571 | numbers: vec![4, 9], | |
| 569 | 572 | since: Some("2026-10-01T00:00:00Z".into()), | |
| 570 | 573 | actor: Some("usr_g1t_agent".into()), | |
| ⋯ | |||
| 573 | 576 | assert_eq!( | |
| 574 | 577 | filter, | |
| 575 | 578 | "WHERE repo_id = ? AND type IN (?) AND actor = ? AND \ | |
| 576 | − | (json_extract(data, '$.number') IN (?, ?) OR json_extract(data, '$.issue') IN (?, ?)) AND time >= ?" | |
| 579 | + | (json_extract(data, '$.number') IN (?, ?) OR json_extract(data, '$.item') IN (?, ?)) AND time >= ?" | |
| 577 | 580 | ); | |
| 578 | 581 | assert_eq!( | |
| 579 | 582 | binds, | |
| 580 | 583 | vec![ | |
| 581 | 584 | Bind::Text("rep_1".into()), | |
| 582 | − | Bind::Text("issue.opened".into()), | |
| 585 | + | Bind::Text("work_item.opened".into()), | |
| 583 | 586 | Bind::Text("usr_g1t_agent".into()), | |
| 584 | 587 | Bind::Number(4.0), | |
| 585 | 588 | Bind::Number(9.0), | |
| ⋯ | |||
| 604 | 607 | filter, | |
| 605 | 608 | format!( | |
| 606 | 609 | "WHERE repo_id = ? AND type IN (?, ?) AND \ | |
| 607 | − | (json_extract(data, '$.number') IN (?) OR json_extract(data, '$.issue') IN (?)) AND time >= ? AND {AFTER}" | |
| 610 | + | (json_extract(data, '$.number') IN (?) OR json_extract(data, '$.item') IN (?)) AND time >= ? AND {AFTER}" | |
| 608 | 611 | ) | |
| 609 | 612 | ); | |
| 610 | 613 | // The cursor's id is bound once to find its place, and once for a | |
| 4 | 4 | //! (more for a very busy one); nothing written in the repository goes | |
| 5 | 5 | //! along, and who may see it is notify's to keep: a tab watches a | |
| 6 | 6 | //! repository only after the site checked its person may read it. | |
| 7 | + | //! | |
| 8 | + | //! A work item's events and comments go to every repository it is linked | |
| 9 | + | //! to (`repoIds`), not only its origin, which it may not have. | |
| 7 | 10 | ||
| 8 | 11 | use g1t_contracts::events::Event; | |
| 9 | − | use g1t_contracts::live::{REPO_CHANGED, RepoChangedArgs, changes_of}; | |
| 12 | + | use g1t_contracts::live::{MAX_CHANGES, REPO_CHANGED, RepoChange, RepoChangedArgs, change_of}; | |
| 10 | 13 | use worker::{Fetcher, Result}; | |
| 11 | 14 | ||
| 15 | + | /// The changes an event makes: one for its repository, and one for each | |
| 16 | + | /// other repository it names in `repoIds`. | |
| 17 | + | pub(crate) fn changes_for(event: &Event) -> Vec<RepoChange> { | |
| 18 | + | let mut changes: Vec<RepoChange> = change_of(event).into_iter().collect(); | |
| 19 | + | for repo_id in event.data["repoIds"].as_array().into_iter().flatten().filter_map(|id| id.as_str()) { | |
| 20 | + | if repo_id.is_empty() || changes.iter().any(|change| change.repo_id == repo_id) { | |
| 21 | + | continue; | |
| 22 | + | } | |
| 23 | + | let linked = Event { repo_id: Some(repo_id.to_owned()), ..event.clone() }; | |
| 24 | + | changes.extend(change_of(&linked)); | |
| 25 | + | } | |
| 26 | + | changes | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | /// A batch's changes, in order, in calls of at most [`MAX_CHANGES`]. | |
| 30 | + | pub(crate) fn changes_of(events: &[Event]) -> Vec<Vec<RepoChange>> { | |
| 31 | + | let changes: Vec<RepoChange> = events.iter().flat_map(changes_for).collect(); | |
| 32 | + | changes.chunks(MAX_CHANGES).map(<[RepoChange]>::to_vec).collect() | |
| 33 | + | } | |
| 34 | + | ||
| 12 | 35 | /// Tells notify of the batch's changes. Logged and left when it cannot be | |
| 13 | 36 | /// reached: pages then catch up by themselves, and nothing else waits on it. | |
| 14 | 37 | pub(crate) async fn tell(notify: &Fetcher, events: &[Event]) { | |
| ⋯ | |||
| 19 | 42 | } | |
| 20 | 43 | } | |
| 21 | 44 | } | |
| 45 | + | ||
| 46 | + | #[cfg(test)] | |
| 47 | + | mod tests { | |
| 48 | + | use super::*; | |
| 49 | + | use serde_json::json; | |
| 50 | + | ||
| 51 | + | fn event(kind: &str, repo_id: Option<&str>, data: serde_json::Value) -> Event { | |
| 52 | + | Event { | |
| 53 | + | id: "evt_1".into(), | |
| 54 | + | kind: kind.into(), | |
| 55 | + | source: "work".into(), | |
| 56 | + | time: "2026-10-11T10:00:00Z".into(), | |
| 57 | + | repo_id: repo_id.map(Into::into), | |
| 58 | + | actor: None, | |
| 59 | + | data, | |
| 60 | + | } | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | #[test] | |
| 64 | + | fn a_work_item_changes_every_repository_it_is_linked_to() { | |
| 65 | + | let data = json!({ "itemId": "wki_1", "workspace": "acme", "number": 40, "repoIds": ["rep_1", "rep_2"] }); | |
| 66 | + | let repos = |event: &Event| changes_for(event).into_iter().map(|change| (change.repo_id, change.number)).collect::<Vec<_>>(); | |
| 67 | + | let with_origin = event("work_item.updated", Some("rep_1"), data.clone()); | |
| 68 | + | assert_eq!(repos(&with_origin), vec![("rep_1".to_owned(), Some(40)), ("rep_2".to_owned(), Some(40))]); | |
| 69 | + | // Filed in the workspace: only the repositories it is linked to. | |
| 70 | + | let without = event("work_item.linked", None, data); | |
| 71 | + | assert_eq!(repos(&without).len(), 2); | |
| 72 | + | // What changes no page stays quiet. | |
| 73 | + | assert!(changes_for(&event("audit.recorded", None, json!({ "repoIds": ["rep_1"] }))).is_empty()); | |
| 74 | + | } | |
| 75 | + | } | |
| 1 | + | //! The events database as the migrations make it, for tests: SQLite (which | |
| 2 | + | //! D1 is), each migration in a transaction of its own as D1 applies it. | |
| 3 | + | ||
| 4 | + | use rusqlite::Connection; | |
| 5 | + | ||
| 6 | + | /// The migrations, in order: each file's name and SQL. | |
| 7 | + | pub(crate) fn migrations() -> Vec<(String, String)> { | |
| 8 | + | let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("migrations"); | |
| 9 | + | let mut names: Vec<String> = std::fs::read_dir(&dir) | |
| 10 | + | .unwrap() | |
| 11 | + | .map(|entry| entry.unwrap().file_name().into_string().unwrap()) | |
| 12 | + | .filter(|name| name.ends_with(".sql")) | |
| 13 | + | .collect(); | |
| 14 | + | names.sort(); | |
| 15 | + | names.into_iter().map(|name| (name.clone(), std::fs::read_to_string(dir.join(&name)).unwrap())).collect() | |
| 16 | + | } | |
| 17 | + | ||
| 18 | + | /// Applies one migration as D1 does: in one transaction. | |
| 19 | + | pub(crate) fn apply(db: &Connection, name: &str, sql: &str) { | |
| 20 | + | db.execute_batch(&format!("BEGIN;\n{sql}\nCOMMIT;")).unwrap_or_else(|error| panic!("{name}: {error}")); | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | /// An in-memory database with the migrations before `name` applied, for | |
| 24 | + | /// filling with what that migration then moves. | |
| 25 | + | pub(crate) fn database_before(name: &str) -> Connection { | |
| 26 | + | let db = Connection::open_in_memory().unwrap(); | |
| 27 | + | db.execute_batch("PRAGMA foreign_keys = ON;").unwrap(); | |
| 28 | + | for (file, sql) in migrations() { | |
| 29 | + | if file.as_str() >= name { | |
| 30 | + | break; | |
| 31 | + | } | |
| 32 | + | apply(&db, &file, &sql); | |
| 33 | + | } | |
| 34 | + | db | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | /// Applies the migrations from `name` on. | |
| 38 | + | pub(crate) fn migrate_from(db: &Connection, name: &str) { | |
| 39 | + | for (file, sql) in migrations() { | |
| 40 | + | if file.as_str() >= name { | |
| 41 | + | apply(db, &file, &sql); | |
| 42 | + | } | |
| 43 | + | } | |
| 44 | + | } |
| 1 | − | //! Who follows what, for the inbox (inbox.rs): subscriptions to issues and | |
| 1 | + | //! Who follows what, for the inbox (inbox.rs): subscriptions to work items and | |
| 2 | 2 | //! pull requests, how people watch repositories, and what each person | |
| 3 | 3 | //! chose about being told. See `g1t_contracts::inbox`. | |
| 4 | 4 | //! | |
| 5 | − | //! An issue's or pull request's author (or whoever asked g1t for it), its | |
| 5 | + | //! A work item's or pull request's author (or whoever asked g1t for it), its | |
| 6 | 6 | //! assignees and its reviewers are subscribed without a row. A row records | |
| 7 | 7 | //! the rest: someone who commented or was mentioned (subscribed as they | |
| 8 | 8 | //! did), or who subscribed, unsubscribed or ignored it by hand. Commenting | |
| 9 | 9 | //! or being mentioned subscribes again someone who unsubscribed; nothing | |
| 10 | 10 | //! but their own choice undoes ignoring. | |
| 11 | 11 | ||
| 12 | − | use std::collections::HashMap; | |
| 12 | + | use std::collections::{HashMap, HashSet}; | |
| 13 | 13 | ||
| 14 | 14 | use g1t_contracts::events::Event; | |
| 15 | 15 | use g1t_contracts::inbox::*; | |
| ⋯ | |||
| 19 | 19 | use worker::wasm_bindgen::JsValue; | |
| 20 | 20 | use worker::{D1Database, D1PreparedStatement, Fetcher, Result}; | |
| 21 | 21 | ||
| 22 | − | use crate::inbox::{is_g1t, is_g1t_id, numbered_thread}; | |
| 22 | + | use crate::inbox::{ITEM_THREAD, is_g1t, is_g1t_id, item_thread, numbered_thread, subscribable}; | |
| 23 | 23 | ||
| 24 | 24 | /// The most watchers read for one repository's event. | |
| 25 | 25 | const MAX_WATCHERS: u32 = 5000; | |
| ⋯ | |||
| 97 | 97 | .map(|row| (row.username.clone(), row.reason.unwrap_or(Reason::Manual))) | |
| 98 | 98 | } | |
| 99 | 99 | ||
| 100 | − | /// Everyone watching the repository for `kind` of activity: `issues`, | |
| 100 | + | /// Everyone watching the repository for `kind` of activity: `work_items`, | |
| 101 | 101 | /// `pulls`, `deployments` or `security`. | |
| 102 | 102 | pub fn watching<'a>(&'a self, kind: &'a str) -> impl Iterator<Item = String> + 'a { | |
| 103 | 103 | self.watchers | |
| ⋯ | |||
| 129 | 129 | } | |
| 130 | 130 | } | |
| 131 | 131 | ||
| 132 | − | /// An issue's or pull request's subscription rows. | |
| 132 | + | /// A work item's or pull request's subscription rows. | |
| 133 | 133 | pub async fn of_thread(db: &D1Database, thread: &str) -> Result<Vec<Subscription>> { | |
| 134 | 134 | Ok(db | |
| 135 | 135 | .prepare("SELECT username, state, reason, chosen_at FROM inbox_subscriptions WHERE thread = ?") | |
| ⋯ | |||
| 186 | 186 | .collect()) | |
| 187 | 187 | } | |
| 188 | 188 | ||
| 189 | − | /// Subscribes the people an event names to its issue or pull request: | |
| 189 | + | /// The watchers of a work item: its origin's (`own`) as they watch it, | |
| 190 | + | /// then those of the other repositories it is linked to who do not watch | |
| 191 | + | /// the origin, each once; ignoring another repository says nothing about | |
| 192 | + | /// it. Also who was found only through another repository. | |
| 193 | + | pub fn linked_watchers(own: Vec<Watcher>, others: &[Vec<Watcher>]) -> (Vec<Watcher>, HashSet<String>) { | |
| 194 | + | let mut watchers = own; | |
| 195 | + | let mut elsewhere = HashSet::new(); | |
| 196 | + | for watcher in others.iter().flatten() { | |
| 197 | + | if watcher.level == WatchLevel::Ignore || watchers.iter().any(|had| had.username.eq_ignore_ascii_case(&watcher.username)) { | |
| 198 | + | continue; | |
| 199 | + | } | |
| 200 | + | elsewhere.insert(watcher.username.to_lowercase()); | |
| 201 | + | watchers.push(watcher.clone()); | |
| 202 | + | } | |
| 203 | + | (watchers, elsewhere) | |
| 204 | + | } | |
| 205 | + | ||
| 206 | + | /// Subscribes the people an event names to its work item or pull request: | |
| 190 | 207 | /// anyone without a row, and anyone who had unsubscribed. Someone who | |
| 191 | − | /// ignores it stays ignoring it. | |
| 208 | + | /// ignores it stays ignoring it. `repo_id` is the pull request's | |
| 209 | + | /// repository, or the work item's origin. | |
| 192 | 210 | pub fn auto_subscribe( | |
| 193 | 211 | db: &D1Database, | |
| 194 | 212 | thread: &str, | |
| 195 | − | repo_id: &str, | |
| 213 | + | repo_id: Option<&str>, | |
| 196 | 214 | people: &[(String, Reason)], | |
| 197 | 215 | at: &str, | |
| 198 | 216 | ) -> Result<Vec<D1PreparedStatement>> { | |
| 199 | − | if !thread.contains('#') { | |
| 217 | + | if !subscribable(thread) { | |
| 200 | 218 | return Ok(Vec::new()); | |
| 201 | 219 | } | |
| 202 | 220 | people | |
| ⋯ | |||
| 211 | 229 | .bind(&[ | |
| 212 | 230 | username.as_str().into(), | |
| 213 | 231 | thread.into(), | |
| 214 | − | repo_id.into(), | |
| 232 | + | repo_id.map_or(JsValue::NULL, JsValue::from), | |
| 215 | 233 | reason.as_str().into(), | |
| 216 | 234 | at.into(), | |
| 217 | 235 | ]) | |
| ⋯ | |||
| 260 | 278 | thread: Option<String>, | |
| 261 | 279 | repo_id: Option<String>, | |
| 262 | 280 | repo: Option<String>, | |
| 281 | + | workspace: Option<String>, | |
| 263 | 282 | number: Option<f64>, | |
| 264 | 283 | subject: Option<String>, | |
| 265 | 284 | } | |
| 266 | 285 | ||
| 267 | − | /// The issue or pull request asked about: its thread, repository and | |
| 268 | − | /// number, and its `owner/name` when known. | |
| 286 | + | /// The work item or pull request asked about: its thread, its repository | |
| 287 | + | /// (a work item's origin), its id when a work item, its number, and where | |
| 288 | + | /// it is (`owner/name`, or a work item's workspace) when known. | |
| 269 | 289 | struct Target { | |
| 270 | 290 | thread: String, | |
| 271 | − | repo_id: String, | |
| 291 | + | repo_id: Option<String>, | |
| 292 | + | item_id: Option<String>, | |
| 272 | 293 | number: u32, | |
| 273 | 294 | repo: Option<String>, | |
| 274 | 295 | } | |
| 275 | 296 | ||
| 297 | + | impl Target { | |
| 298 | + | /// What the work service is asked, to know it is there and who takes part. | |
| 299 | + | fn subject_args(&self) -> InboxSubjectArgs { | |
| 300 | + | InboxSubjectArgs { | |
| 301 | + | repo_id: self.repo_id.clone(), | |
| 302 | + | number: self.number, | |
| 303 | + | item_id: self.item_id.clone(), | |
| 304 | + | comment_id: None, | |
| 305 | + | } | |
| 306 | + | } | |
| 307 | + | } | |
| 308 | + | ||
| 276 | 309 | async fn target(db: &D1Database, username: &str, a: &SubscriptionArgs) -> Result<Option<Target>> { | |
| 277 | 310 | if let Some(id) = &a.id { | |
| 278 | 311 | let row = db | |
| 279 | − | .prepare("SELECT thread, repo_id, repo, number, subject FROM inbox_items WHERE id = ? AND username = ?") | |
| 312 | + | .prepare("SELECT thread, repo_id, repo, workspace, number, subject FROM inbox_items WHERE id = ? AND username = ?") | |
| 280 | 313 | .bind(&[id.as_str().into(), username.into()])? | |
| 281 | 314 | .first::<ItemRow>(None) | |
| 282 | 315 | .await?; | |
| 283 | 316 | return Ok(row.and_then(|row| { | |
| 284 | − | let kind = row.subject.as_deref().and_then(SubjectKind::parse); | |
| 285 | − | if !matches!(kind, Some(SubjectKind::Issue | SubjectKind::Pull)) { | |
| 286 | − | return None; | |
| 317 | + | let thread = row.thread?; | |
| 318 | + | let number = row.number? as u32; | |
| 319 | + | match row.subject.as_deref().and_then(SubjectKind::parse) { | |
| 320 | + | Some(SubjectKind::Item) => Some(Target { | |
| 321 | + | item_id: Some(thread.strip_prefix(ITEM_THREAD)?.to_owned()), | |
| 322 | + | thread, | |
| 323 | + | repo_id: row.repo_id, | |
| 324 | + | number, | |
| 325 | + | repo: row.workspace, | |
| 326 | + | }), | |
| 327 | + | Some(SubjectKind::Pull) => Some(Target { | |
| 328 | + | thread, | |
| 329 | + | repo_id: Some(row.repo_id?), | |
| 330 | + | item_id: None, | |
| 331 | + | number, | |
| 332 | + | repo: row.repo, | |
| 333 | + | }), | |
| 334 | + | _ => None, | |
| 287 | 335 | } | |
| 288 | − | Some(Target { | |
| 289 | − | thread: row.thread?, | |
| 290 | − | repo_id: row.repo_id?, | |
| 291 | − | number: row.number? as u32, | |
| 292 | − | repo: row.repo, | |
| 293 | − | }) | |
| 294 | 336 | })); | |
| 295 | 337 | } | |
| 338 | + | if let (Some(item_id), Some(number)) = (&a.item_id, a.number) { | |
| 339 | + | return Ok(Some(Target { | |
| 340 | + | thread: item_thread(item_id), | |
| 341 | + | repo_id: None, | |
| 342 | + | item_id: Some(item_id.clone()), | |
| 343 | + | number, | |
| 344 | + | repo: None, | |
| 345 | + | })); | |
| 346 | + | } | |
| 296 | 347 | let (Some(repo_id), Some(number)) = (&a.repo_id, a.number) else { | |
| 297 | 348 | return Ok(None); | |
| 298 | 349 | }; | |
| 299 | 350 | Ok(Some(Target { | |
| 300 | 351 | thread: numbered_thread(repo_id, number), | |
| 301 | − | repo_id: repo_id.clone(), | |
| 352 | + | repo_id: Some(repo_id.clone()), | |
| 353 | + | item_id: None, | |
| 302 | 354 | number, | |
| 303 | 355 | repo: None, | |
| 304 | 356 | })) | |
| ⋯ | |||
| 329 | 381 | } | |
| 330 | 382 | } | |
| 331 | 383 | ||
| 332 | − | /// The person's subscription to an issue or pull request. None when there | |
| 333 | − | /// is no such issue or pull request, or it is a thread nobody subscribes to. | |
| 384 | + | /// The person's subscription to a work item or pull request. None when there | |
| 385 | + | /// is no such work item or pull request, or it is a thread nobody subscribes to. | |
| 334 | 386 | pub async fn subscription(db: &D1Database, work: &Fetcher, a: SubscriptionArgs) -> Result<Option<ThreadSubscription>> { | |
| 335 | 387 | let Some(viewer) = &a.viewer else { | |
| 336 | 388 | return Ok(None); | |
| ⋯ | |||
| 344 | 396 | .bind(&[target.thread.as_str().into(), username.as_str().into()])? | |
| 345 | 397 | .first::<SubscriptionRow>(None) | |
| 346 | 398 | .await?; | |
| 347 | − | // Without a row, whether they take part is the issue's to say; and an | |
| 348 | − | // issue that is not there has no subscription. | |
| 349 | − | let subject: Option<InboxSubject> = g1t_kit::call( | |
| 350 | − | work, | |
| 351 | − | "inbox_subject", | |
| 352 | − | &InboxSubjectArgs { | |
| 353 | − | repo_id: target.repo_id.clone(), | |
| 354 | − | number: target.number, | |
| 355 | − | comment_id: None, | |
| 356 | − | }, | |
| 357 | − | ) | |
| 358 | − | .await?; | |
| 399 | + | // Without a row, whether they take part is the work item's or pull | |
| 400 | + | // request's to say; and one that is not there has no subscription. | |
| 401 | + | let subject: Option<InboxSubject> = g1t_kit::call(work, "inbox_subject", &target.subject_args()).await?; | |
| 359 | 402 | let Some(subject) = subject else { | |
| 360 | 403 | return Ok(None); | |
| 361 | 404 | }; | |
| ⋯ | |||
| 383 | 426 | let Some(target) = target(db, &username, &a.on).await? else { | |
| 384 | 427 | return Ok(None); | |
| 385 | 428 | }; | |
| 386 | − | // Only an issue or pull request that is there. | |
| 387 | − | let found: Option<InboxSubject> = g1t_kit::call( | |
| 388 | − | work, | |
| 389 | − | "inbox_subject", | |
| 390 | − | &InboxSubjectArgs { | |
| 391 | − | repo_id: target.repo_id.clone(), | |
| 392 | − | number: target.number, | |
| 393 | − | comment_id: None, | |
| 394 | − | }, | |
| 395 | − | ) | |
| 396 | − | .await?; | |
| 429 | + | // Only a work item or pull request that is there. | |
| 430 | + | let found: Option<InboxSubject> = g1t_kit::call(work, "inbox_subject", &target.subject_args()).await?; | |
| 397 | 431 | if found.is_none() { | |
| 398 | 432 | return Ok(None); | |
| 399 | 433 | } | |
| ⋯ | |||
| 424 | 458 | .bind(&[ | |
| 425 | 459 | username.as_str().into(), | |
| 426 | 460 | target.thread.as_str().into(), | |
| 427 | − | target.repo_id.as_str().into(), | |
| 461 | + | target.repo_id.as_deref().map_or(JsValue::NULL, JsValue::from), | |
| 428 | 462 | state.as_str().into(), | |
| 429 | 463 | if state == State::Subscribed { Reason::Manual.as_str().into() } else { JsValue::NULL }, | |
| 430 | 464 | now.as_str().into(), | |
| ⋯ | |||
| 693 | 727 | vec![("cy".to_owned(), Reason::Mention), ("dee".to_owned(), Reason::Manual)] | |
| 694 | 728 | ); | |
| 695 | 729 | assert_eq!(audience.watching("deployments").collect::<Vec<_>>(), vec!["fay", "gus"]); | |
| 696 | − | assert_eq!(audience.watching("issues").collect::<Vec<_>>(), vec!["gus"]); | |
| 730 | + | assert_eq!(audience.watching("work_items").collect::<Vec<_>>(), vec!["gus"]); | |
| 697 | 731 | } | |
| 698 | 732 | ||
| 699 | 733 | #[test] | |
| 700 | 734 | fn choices_keep_only_what_is_known() { | |
| 701 | − | assert_eq!(custom_events(&["Pulls".into(), "releases".into(), "issues".into(), "pulls".into()]), vec!["issues", "pulls"]); | |
| 735 | + | assert_eq!(custom_events(&["Pulls".into(), "releases".into(), "issues".into(), "work_items".into(), "pulls".into()]), vec!["work_items", "pulls"]); | |
| 702 | 736 | assert_eq!(email_reasons(&["mention".into(), "nope".into(), "agent".into()]), vec![Reason::Agent, Reason::Mention]); | |
| 703 | 737 | let settings = SettingsRow { username: "ana".into(), email: None, default_watch: Some("participating".into()), security_alerts: None, security_digest: None }.into_settings(); | |
| 704 | 738 | assert_eq!(settings.email, DEFAULT_EMAIL.to_vec()); | |
| 1 | + | //! Issues became work items (migrations/0013_work_items.sql): what this | |
| 2 | + | //! service kept by a repository's issue number is rewritten to the work | |
| 3 | + | //! item it became when the work service announces where each went | |
| 4 | + | //! (`work.migrated`, a page of `MigratedItem`s at a time). | |
| 5 | + | //! | |
| 6 | + | //! Each page is recorded in `work_migrated`, then: | |
| 7 | + | //! | |
| 8 | + | //! - inbox threads `<repo_id>#<number>` become `item:<item_id>`, with the | |
| 9 | + | //! work item's workspace and number; a person who already has the work | |
| 10 | + | //! item's thread keeps that one, with the old one's history; | |
| 11 | + | //! - subscriptions follow their threads the same way; | |
| 12 | + | //! - the log's `issue.*` events become `work_item.*`, and its events about | |
| 13 | + | //! an issue (comments on it, and `issue` / `otherIssue` in coordination | |
| 14 | + | //! and pull request events) name the work item and its number. | |
| 15 | + | //! | |
| 16 | + | //! Every statement leaves what it already rewrote alone, so a page | |
| 17 | + | //! delivered again, or out of order, changes nothing more. | |
| 18 | + | ||
| 19 | + | use g1t_contracts::events::{Event, WorkMigrated}; | |
| 20 | + | use worker::{D1Database, D1PreparedStatement, Result}; | |
| 21 | + | ||
| 22 | + | /// The issue events of the log, by their old names. | |
| 23 | + | pub const ISSUE_EVENTS: [&str; 9] = [ | |
| 24 | + | "issue.opened", | |
| 25 | + | "issue.updated", | |
| 26 | + | "issue.assigned", | |
| 27 | + | "issue.labeled", | |
| 28 | + | "issue.unlabeled", | |
| 29 | + | "issue.milestoned", | |
| 30 | + | "issue.demilestoned", | |
| 31 | + | "issue.closed", | |
| 32 | + | "issue.reopened", | |
| 33 | + | ]; | |
| 34 | + | ||
| 35 | + | /// What one page does, in order: `?1` the event's id, `?2` its items as | |
| 36 | + | /// JSON, `?3` [`ISSUE_EVENTS`] as JSON. | |
| 37 | + | pub const MOVED: [&str; 12] = [ | |
| 38 | + | // The page, kept. | |
| 39 | + | "INSERT OR REPLACE INTO work_migrated (repo_id, legacy_number, workspace, number, item_id, event_id) | |
| 40 | + | SELECT json_extract(value, '$.repoId'), json_extract(value, '$.legacyNumber'), lower(json_extract(value, '$.workspace')), | |
| 41 | + | json_extract(value, '$.number'), json_extract(value, '$.itemId'), ?1 | |
| 42 | + | FROM json_each(?2)", | |
| 43 | + | // Threads the work item's own events started before it had its number. | |
| 44 | + | "UPDATE inbox_items SET number = m.number, workspace = m.workspace | |
| 45 | + | FROM work_migrated m | |
| 46 | + | WHERE m.event_id = ?1 AND inbox_items.thread = 'item:' || m.item_id AND inbox_items.number IS NOT m.number", | |
| 47 | + | // Someone with both threads: the old one's history joins the new one. | |
| 48 | + | "UPDATE inbox_activity SET item_id = n.id | |
| 49 | + | FROM work_migrated m, inbox_items o, inbox_items n | |
| 50 | + | WHERE m.event_id = ?1 AND o.thread = m.repo_id || '#' || m.legacy_number | |
| 51 | + | AND n.username = o.username AND n.thread = 'item:' || m.item_id AND inbox_activity.item_id = o.id", | |
| 52 | + | "UPDATE inbox_items AS n SET activity = n.activity + o.activity, created_at = min(n.created_at, o.created_at), | |
| 53 | + | saved = max(n.saved, o.saved) | |
| 54 | + | FROM work_migrated m, inbox_items o | |
| 55 | + | WHERE m.event_id = ?1 AND o.thread = m.repo_id || '#' || m.legacy_number | |
| 56 | + | AND n.username = o.username AND n.thread = 'item:' || m.item_id", | |
| 57 | + | "DELETE FROM inbox_items WHERE id IN ( | |
| 58 | + | SELECT o.id FROM work_migrated m | |
| 59 | + | JOIN inbox_items o ON o.thread = m.repo_id || '#' || m.legacy_number | |
| 60 | + | JOIN inbox_items n ON n.username = o.username AND n.thread = 'item:' || m.item_id | |
| 61 | + | WHERE m.event_id = ?1)", | |
| 62 | + | // The rest of the old threads become the work item's. | |
| 63 | + | "UPDATE inbox_items SET thread = 'item:' || m.item_id, subject = 'item', number = m.number, workspace = m.workspace | |
| 64 | + | FROM work_migrated m | |
| 65 | + | WHERE m.event_id = ?1 AND inbox_items.thread = m.repo_id || '#' || m.legacy_number", | |
| 66 | + | // Subscriptions: one chosen on the work item already stands. | |
| 67 | + | "DELETE FROM inbox_subscriptions WHERE (thread, username) IN ( | |
| 68 | + | SELECT o.thread, o.username FROM work_migrated m | |
| 69 | + | JOIN inbox_subscriptions o ON o.thread = m.repo_id || '#' || m.legacy_number | |
| 70 | + | JOIN inbox_subscriptions n ON n.username = o.username AND n.thread = 'item:' || m.item_id | |
| 71 | + | WHERE m.event_id = ?1)", | |
| 72 | + | "UPDATE inbox_subscriptions SET thread = 'item:' || m.item_id | |
| 73 | + | FROM work_migrated m | |
| 74 | + | WHERE m.event_id = ?1 AND inbox_subscriptions.thread = m.repo_id || '#' || m.legacy_number", | |
| 75 | + | // The log. Each statement reads the page's repositories' events once, | |
| 76 | + | // and finds each one's work item by its repository and old number. | |
| 77 | + | "UPDATE events SET type = 'work_item.' || substr(type, 7), | |
| 78 | + | data = json_set(json_remove(data, '$.issueId', '$.repoId'), | |
| 79 | + | '$.itemId', (SELECT m.item_id FROM work_migrated m WHERE m.repo_id = events.repo_id AND m.legacy_number = json_extract(events.data, '$.number')), | |
| 80 | + | '$.workspace', (SELECT m.workspace FROM work_migrated m WHERE m.repo_id = events.repo_id AND m.legacy_number = json_extract(events.data, '$.number')), | |
| 81 | + | '$.repoIds', json_array(events.repo_id), | |
| 82 | + | '$.number', (SELECT m.number FROM work_migrated m WHERE m.repo_id = events.repo_id AND m.legacy_number = json_extract(events.data, '$.number'))) | |
| 83 | + | WHERE repo_id IN (SELECT repo_id FROM work_migrated WHERE event_id = ?1) | |
| 84 | + | AND type IN (SELECT value FROM json_each(?3)) | |
| 85 | + | AND EXISTS (SELECT 1 FROM work_migrated m WHERE m.repo_id = events.repo_id AND m.legacy_number = json_extract(events.data, '$.number'))", | |
| 86 | + | // Comments on an issue: on no pull request, and naming no work item yet. | |
| 87 | + | "UPDATE events SET | |
| 88 | + | data = json_set(data, | |
| 89 | + | '$.itemId', (SELECT m.item_id FROM work_migrated m WHERE m.repo_id = events.repo_id AND m.legacy_number = json_extract(events.data, '$.number')), | |
| 90 | + | '$.workspace', (SELECT m.workspace FROM work_migrated m WHERE m.repo_id = events.repo_id AND m.legacy_number = json_extract(events.data, '$.number')), | |
| 91 | + | '$.repoIds', json_array(events.repo_id), | |
| 92 | + | '$.number', (SELECT m.number FROM work_migrated m WHERE m.repo_id = events.repo_id AND m.legacy_number = json_extract(events.data, '$.number'))) | |
| 93 | + | WHERE repo_id IN (SELECT repo_id FROM work_migrated WHERE event_id = ?1) | |
| 94 | + | AND type IN ('comment.created', 'comment.edited', 'comment.deleted') | |
| 95 | + | AND json_extract(data, '$.pullId') IS NULL AND json_extract(data, '$.itemId') IS NULL | |
| 96 | + | AND EXISTS (SELECT 1 FROM work_migrated m WHERE m.repo_id = events.repo_id AND m.legacy_number = json_extract(events.data, '$.number'))", | |
| 97 | + | // What names an issue it is about, or overlaps with: `issue`, `otherIssue`. | |
| 98 | + | "UPDATE events SET | |
| 99 | + | data = json_set(json_remove(data, '$.issue'), | |
| 100 | + | '$.item', (SELECT m.number FROM work_migrated m WHERE m.repo_id = events.repo_id AND m.legacy_number = json_extract(events.data, '$.issue'))) | |
| 101 | + | WHERE repo_id IN (SELECT repo_id FROM work_migrated WHERE event_id = ?1) | |
| 102 | + | AND json_extract(data, '$.issue') IS NOT NULL | |
| 103 | + | AND EXISTS (SELECT 1 FROM work_migrated m WHERE m.repo_id = events.repo_id AND m.legacy_number = json_extract(events.data, '$.issue'))", | |
| 104 | + | "UPDATE events SET | |
| 105 | + | data = json_set(json_remove(data, '$.otherIssue'), | |
| 106 | + | '$.otherItem', (SELECT m.number FROM work_migrated m WHERE m.repo_id = events.repo_id AND m.legacy_number = json_extract(events.data, '$.otherIssue'))) | |
| 107 | + | WHERE repo_id IN (SELECT repo_id FROM work_migrated WHERE event_id = ?1) | |
| 108 | + | AND json_extract(data, '$.otherIssue') IS NOT NULL | |
| 109 | + | AND EXISTS (SELECT 1 FROM work_migrated m WHERE m.repo_id = events.repo_id AND m.legacy_number = json_extract(events.data, '$.otherIssue'))", | |
| 110 | + | ]; | |
| 111 | + | ||
| 112 | + | /// How many of `?1`, `?2`, `?3` a statement reads: D1 takes no more values | |
| 113 | + | /// than it has. | |
| 114 | + | fn bound(sql: &str) -> usize { | |
| 115 | + | (1..=3).rev().find(|n| sql.contains(&format!("?{n}"))).unwrap_or(0) | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | /// The statements a `work.migrated` event from the work service calls for; | |
| 119 | + | /// none for any other event. | |
| 120 | + | pub fn moved(db: &D1Database, event: &Event) -> Result<Vec<D1PreparedStatement>> { | |
| 121 | + | if event.kind != "work.migrated" || event.source != "work" { | |
| 122 | + | return Ok(Vec::new()); | |
| 123 | + | } | |
| 124 | + | let Ok(page) = serde_json::from_value::<WorkMigrated>(event.data.clone()) else { | |
| 125 | + | worker::console_error!("work.migrated {} could not be read", event.id); | |
| 126 | + | return Ok(Vec::new()); | |
| 127 | + | }; | |
| 128 | + | if event.data["truncated"].as_bool() == Some(true) { | |
| 129 | + | worker::console_error!("work.migrated {} (page {}) was cut short: some issue numbers are left as they were", event.id, page.page); | |
| 130 | + | } | |
| 131 | + | if page.items.is_empty() { | |
| 132 | + | return Ok(Vec::new()); | |
| 133 | + | } | |
| 134 | + | let items = serde_json::to_string(&page.items)?; | |
| 135 | + | let issue_events = serde_json::to_string(&ISSUE_EVENTS)?; | |
| 136 | + | let values: [worker::wasm_bindgen::JsValue; 3] = [event.id.as_str().into(), items.as_str().into(), issue_events.as_str().into()]; | |
| 137 | + | MOVED.iter().map(|sql| db.prepare(*sql).bind(&values[..bound(sql)])).collect() | |
| 138 | + | } | |
| 139 | + | ||
| 140 | + | #[cfg(test)] | |
| 141 | + | mod tests { | |
| 142 | + | use super::*; | |
| 143 | + | use crate::schema::{database_before, migrate_from}; | |
| 144 | + | use rusqlite::{Connection, params}; | |
| 145 | + | ||
| 146 | + | const ITEM: &str = "INSERT INTO inbox_items (id, username, event_id, reason, severity, title, body, workspace, repo_id, repo, | |
| 147 | + | subject, number, created_at, thread, updated_at, bumped, activity, read_at, saved) | |
| 148 | + | VALUES (?1, ?2, ?3, 'author', 'info', 't', 'b', 'acme', ?4, 'acme/rocket', ?5, ?6, ?7, ?8, ?7, ?1, ?9, ?10, ?11)"; | |
| 149 | + | const EVENT: &str = "INSERT INTO events (id, type, source, time, repo_id, data, seq) VALUES (?1, ?2, 'work', 't', ?3, ?4, ?5)"; | |
| 150 | + | ||
| 151 | + | /// One page: rep_1's issue 3 is acme#40, its issue 5 acme#41. | |
| 152 | + | fn page() -> String { | |
| 153 | + | serde_json::json!([ | |
| 154 | + | { "repoId": "rep_1", "legacyNumber": 3, "workspace": "Acme", "number": 40, "itemId": "iss_3" }, | |
| 155 | + | { "repoId": "rep_1", "legacyNumber": 5, "workspace": "Acme", "number": 41, "itemId": "iss_5" }, | |
| 156 | + | ]) | |
| 157 | + | .to_string() | |
| 158 | + | } | |
| 159 | + | ||
| 160 | + | fn run(db: &Connection, event_id: &str, items: &str) { | |
| 161 | + | let issue_events = serde_json::to_string(&ISSUE_EVENTS).unwrap(); | |
| 162 | + | let values = [event_id, items, issue_events.as_str()]; | |
| 163 | + | for sql in MOVED { | |
| 164 | + | db.execute(sql, rusqlite::params_from_iter(&values[..bound(sql)])).unwrap_or_else(|error| panic!("{sql}: {error}")); | |
| 165 | + | } | |
| 166 | + | } | |
| 167 | + | ||
| 168 | + | fn logged(db: &Connection, id: &str) -> (String, serde_json::Value) { | |
| 169 | + | let (kind, data): (String, String) = | |
| 170 | + | db.query_row("SELECT type, data FROM events WHERE id = ?1", [id], |row| Ok((row.get(0)?, row.get(1)?))).unwrap(); | |
| 171 | + | (kind, serde_json::from_str(&data).unwrap()) | |
| 172 | + | } | |
| 173 | + | ||
| 174 | + | type ItemState = (String, String, String, Option<u32>, String, u32, Option<String>); | |
| 175 | + | ||
| 176 | + | fn items(db: &Connection) -> Vec<ItemState> { | |
| 177 | + | let mut statement = db | |
| 178 | + | .prepare("SELECT id, username, thread, number, workspace, activity, read_at FROM inbox_items ORDER BY id") | |
| 179 | + | .unwrap(); | |
| 180 | + | statement | |
| 181 | + | .query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?, row.get(4)?, row.get(5)?, row.get(6)?))) | |
| 182 | + | .unwrap() | |
| 183 | + | .map(Result::unwrap) | |
| 184 | + | .collect() | |
| 185 | + | } | |
| 186 | + | ||
| 187 | + | #[test] | |
| 188 | + | fn what_was_kept_by_issue_number_follows_the_work_item() { | |
| 189 | + | let db = database_before("0013_work_items.sql"); | |
| 190 | + | // ana: a thread on issue 3, and one on pull request 4. | |
| 191 | + | db.execute(ITEM, params!["ntf_1", "ana", "evt_1", "rep_1", "issue", 3, "2026-01-01", "rep_1#3", 2, None::<String>, 0]).unwrap(); | |
| 192 | + | db.execute(ITEM, params!["ntf_2", "ana", "evt_2", "rep_1", "pull", 4, "2026-01-02", "rep_1#4", 1, None::<String>, 0]).unwrap(); | |
| 193 | + | // bo: a thread on issue 5, and the work item's own since, read, before it had its number. | |
| 194 | + | db.execute(ITEM, params!["ntf_3", "bo", "evt_3", "rep_1", "issue", 5, "2026-01-01", "rep_1#5", 3, None::<String>, 1]).unwrap(); | |
| 195 | + | db.execute(ITEM, params!["ntf_4", "bo", "evt_4", "rep_1", "item", None::<u32>, "2026-02-01", "item:iss_5", 1, "2026-02-02", 0]) | |
| 196 | + | .unwrap(); | |
| 197 | + | for (id, item, event) in [("act_1", "ntf_3", "evt_3"), ("act_2", "ntf_4", "evt_4")] { | |
| 198 | + | db.execute( | |
| 199 | + | "INSERT INTO inbox_activity (id, item_id, username, event_id, reason, severity, title, body, created_at) | |
| 200 | + | VALUES (?1, ?2, 'bo', ?3, 'author', 'info', 't', 'b', 't')", | |
| 201 | + | params![id, item, event], | |
| 202 | + | ) | |
| 203 | + | .unwrap(); | |
| 204 | + | } | |
| 205 | + | for (user, thread, state) in | |
| 206 | + | [("ana", "rep_1#3", "subscribed"), ("ana", "rep_1#4", "ignored"), ("bo", "rep_1#5", "ignored"), ("bo", "item:iss_5", "subscribed")] | |
| 207 | + | { | |
| 208 | + | db.execute( | |
| 209 | + | "INSERT INTO inbox_subscriptions (username, thread, repo_id, state, created_at) VALUES (?1, ?2, 'rep_1', ?3, 't')", | |
| 210 | + | params![user, thread, state], | |
| 211 | + | ) | |
| 212 | + | .unwrap(); | |
| 213 | + | } | |
| 214 | + | db.execute( | |
| 215 | + | "INSERT INTO inbox_watching (username, repo_id, level, events, updated_at) | |
| 216 | + | VALUES ('cy', 'rep_1', 'custom', '[\"issues\",\"deployments\"]', 't')", | |
| 217 | + | [], | |
| 218 | + | ) | |
| 219 | + | .unwrap(); | |
| 220 | + | let events = [ | |
| 221 | + | ("evt_1", "issue.opened", r#"{"issueId":"iss_3","repoId":"rep_1","number":3}"#), | |
| 222 | + | ("evt_2", "pull.opened", r#"{"repoId":"rep_1","number":4,"issue":3}"#), | |
| 223 | + | ("evt_3", "comment.created", r#"{"commentId":"cmt_1","repoId":"rep_1","number":5}"#), | |
| 224 | + | ("evt_5", "comment.created", r#"{"commentId":"cmt_2","repoId":"rep_1","number":4,"pullId":"pr_4"}"#), | |
| 225 | + | ("evt_6", "work.overlap", r#"{"issue":5,"otherIssue":3}"#), | |
| 226 | + | ("evt_7", "issue.closed", r#"{"issueId":"iss_9","repoId":"rep_1","number":9}"#), | |
| 227 | + | ]; | |
| 228 | + | for (seq, (id, kind, json)) in events.iter().enumerate() { | |
| 229 | + | db.execute(EVENT, params![id, kind, "rep_1", json, seq as i64 + 1]).unwrap(); | |
| 230 | + | } | |
| 231 | + | ||
| 232 | + | migrate_from(&db, "0013_work_items.sql"); | |
| 233 | + | // What needs only names is done at once. | |
| 234 | + | let subject: String = db.query_row("SELECT subject FROM inbox_items WHERE id = 'ntf_1'", [], |row| row.get(0)).unwrap(); | |
| 235 | + | assert_eq!(subject, "item"); | |
| 236 | + | let watched: String = db.query_row("SELECT events FROM inbox_watching WHERE username = 'cy'", [], |row| row.get(0)).unwrap(); | |
| 237 | + | assert_eq!(watched, r#"["work_items","deployments"]"#); | |
| 238 | + | // A subscription to a work item with no origin has no repository. | |
| 239 | + | db.execute( | |
| 240 | + | "INSERT INTO inbox_subscriptions (username, thread, repo_id, state, created_at) VALUES ('dee', 'item:wki_9', NULL, 'subscribed', 't')", | |
| 241 | + | [], | |
| 242 | + | ) | |
| 243 | + | .unwrap(); | |
| 244 | + | ||
| 245 | + | // The page, twice: the second time changes nothing. | |
| 246 | + | run(&db, "evt_9", &page()); | |
| 247 | + | let first = items(&db); | |
| 248 | + | run(&db, "evt_9", &page()); | |
| 249 | + | assert_eq!(items(&db), first); | |
| 250 | + | assert_eq!( | |
| 251 | + | first, | |
| 252 | + | vec![ | |
| 253 | + | ("ntf_1".into(), "ana".into(), "item:iss_3".into(), Some(40), "acme".into(), 2, None), | |
| 254 | + | ("ntf_2".into(), "ana".into(), "rep_1#4".into(), Some(4), "acme".into(), 1, None), | |
| 255 | + | // bo's old thread joined the work item's, with its history. | |
| 256 | + | ("ntf_4".into(), "bo".into(), "item:iss_5".into(), Some(41), "acme".into(), 4, Some("2026-02-02".into())), | |
| 257 | + | ] | |
| 258 | + | ); | |
| 259 | + | let moved: u32 = db.query_row("SELECT count(*) FROM inbox_activity WHERE item_id = 'ntf_4'", [], |row| row.get(0)).unwrap(); | |
| 260 | + | assert_eq!(moved, 2); | |
| 261 | + | let since: String = db.query_row("SELECT created_at FROM inbox_items WHERE id = 'ntf_4'", [], |row| row.get(0)).unwrap(); | |
| 262 | + | assert_eq!(since, "2026-01-01"); | |
| 263 | + | ||
| 264 | + | let mut statement = db.prepare("SELECT username, thread, state FROM inbox_subscriptions ORDER BY username, thread").unwrap(); | |
| 265 | + | let subscriptions: Vec<(String, String, String)> = | |
| 266 | + | statement.query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?))).unwrap().map(Result::unwrap).collect(); | |
| 267 | + | assert_eq!( | |
| 268 | + | subscriptions, | |
| 269 | + | vec![ | |
| 270 | + | ("ana".into(), "item:iss_3".into(), "subscribed".into()), | |
| 271 | + | ("ana".into(), "rep_1#4".into(), "ignored".into()), | |
| 272 | + | // What bo chose on the work item stands. | |
| 273 | + | ("bo".into(), "item:iss_5".into(), "subscribed".into()), | |
| 274 | + | ("dee".into(), "item:wki_9".into(), "subscribed".into()), | |
| 275 | + | ] | |
| 276 | + | ); | |
| 277 | + | ||
| 278 | + | // The log names the work items. | |
| 279 | + | let (kind, opened) = logged(&db, "evt_1"); | |
| 280 | + | assert_eq!(kind, "work_item.opened"); | |
| 281 | + | assert_eq!(opened, serde_json::json!({ "itemId": "iss_3", "workspace": "acme", "number": 40, "repoIds": ["rep_1"] })); | |
| 282 | + | assert_eq!(logged(&db, "evt_2").1, serde_json::json!({ "repoId": "rep_1", "number": 4, "item": 40 })); | |
| 283 | + | let comment = logged(&db, "evt_3").1; | |
| 284 | + | assert_eq!((comment["number"].as_u64(), comment["itemId"].as_str()), (Some(41), Some("iss_5"))); | |
| 285 | + | assert_eq!(logged(&db, "evt_6").1, serde_json::json!({ "item": 41, "otherItem": 40 })); | |
| 286 | + | // A pull request's comment, and an issue no page has named yet, stay. | |
| 287 | + | assert_eq!(logged(&db, "evt_5").1["number"], 4); | |
| 288 | + | assert_eq!(logged(&db, "evt_7").0, "issue.closed"); | |
| 289 | + | } | |
| 290 | + | } |
Comments
Sign in to comment.