From 5ab5e11e305f59113a6d10255d9738cb92d226ed Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 19 Jun 2026 13:12:59 +0000 Subject: [PATCH] feat(ogar-vocab): ProjectNews + ProjectMessage (2 more promotions) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Continuing the OP <-> Redmine ladder from the probe overlap. Both News and Message are cross-curator with substantive shape: | Canonical | id | Redmine | OpenProject | |--------------------|---------|---------|-------------| | project_news | 0x0114 | News | News | | project_message | 0x0115 | Message | Message | ProjectNews — universal shape (project + author + comments + title/ summary/description). Both curators ship the same AR structure. ProjectMessage — threaded forum/board discussion. Note the parent- container divergence: Redmine 'Board' vs OP 'Forum' (a future project_forum promotion can close that). The self-referential 'last_reply' edge captures the acts_as_tree thread chain. - CODEBOOK entries: 0x0114 and 0x0115 in the project-mgmt block. - Resolver arms (singular/plural/PascalCase per the doctrine). - Canonical class functions with Language::Unknown, typed attrs, and the obvious family edges to existing canonicals (project, author -> project_actor, comments -> project_comment for News; author -> project_actor, last_reply -> ProjectMessage self-ref for Message). - Extended codebook_ids_are_domain_prefixed_and_consistent, project_- mgmt_batch_promotions_each_have_a_codebook_id_and_shape, and project_- mgmt_resolver_arms_collapse_curator_names to cover the 2 new concepts. - New real-corpus test: redmine_and_openproject_news_and_message_converge iterates both curators' News + Message and asserts canonical_concept + canonical_id match. ogar-vocab 41, ogar-from-ruff 24, real-corpus 12 (+1); clippy clean. Project-mgmt block (0x01XX) is now 21 concepts. --- crates/ogar-from-rails/src/lib.rs | 45 +++++++++++++++++++ crates/ogar-vocab/src/lib.rs | 74 +++++++++++++++++++++++++++++++ 2 files changed, 119 insertions(+) diff --git a/crates/ogar-from-rails/src/lib.rs b/crates/ogar-from-rails/src/lib.rs index 18bad75..e1866e4 100644 --- a/crates/ogar-from-rails/src/lib.rs +++ b/crates/ogar-from-rails/src/lib.rs @@ -655,6 +655,51 @@ mod tests { } } + /// **News + Message convergence** on real source. Both curators ship + /// `News` (project news/blog) and `Message` (threaded forum/board + /// discussion) — the latter has parent-container divergence (Redmine + /// `Board` vs OP `Forum`), but `Message` itself converges. + #[test] + #[ignore = "requires Redmine + OpenProject checkouts"] + fn redmine_and_openproject_news_and_message_converge() { + let Ok(redmine_src) = std::env::var("REDMINE_SRC") else { + eprintln!("skipping: REDMINE_SRC not set"); + return; + }; + let op_src = std::env::var("OPENPROJECT_SRC") + .unwrap_or_else(|_| "/home/user/openproject".to_string()); + let op_path = PathBuf::from(&op_src); + if !op_path.exists() { + eprintln!("skipping: OpenProject not present at {op_src}"); + return; + } + + let redmine = extract(&PathBuf::from(redmine_src)); + let openproject = extract(&op_path); + let news_id = ogar_vocab::canonical_concept_id("project_news"); + let msg_id = ogar_vocab::canonical_concept_id("project_message"); + assert!(news_id.is_some() && msg_id.is_some()); + + for (curator, classes) in [("Redmine", &redmine), ("OpenProject", &openproject)] { + for (class_name, concept, expected_id) in [ + ("News", "project_news", news_id), + ("Message", "project_message", msg_id), + ] { + let c = classes + .iter() + .find(|c| c.name == class_name) + .unwrap_or_else(|| panic!("{curator} ships a {class_name} model")); + assert_eq!( + c.canonical_concept.as_deref(), + Some(concept), + "{curator} {class_name} -> {concept}", + ); + assert_eq!(c.source_domain.as_deref(), Some("project")); + assert_eq!(c.canonical_id(), expected_id); + } + } + } + /// Exactly-one-Model invariant post AdaWorldAPI/ruff#26: OP's /// `app/models/work_package/` sub-files reopen `class WorkPackage` /// without adding ontology declarations; before the fix this produced diff --git a/crates/ogar-vocab/src/lib.rs b/crates/ogar-vocab/src/lib.rs index 257ef10..a4107ae 100644 --- a/crates/ogar-vocab/src/lib.rs +++ b/crates/ogar-vocab/src/lib.rs @@ -1086,6 +1086,8 @@ const CODEBOOK: &[(&str, u16)] = &[ ("project_relation", 0x0111), // Redmine IssueRelation ↔ OP Relation (name divergence) ("project_changeset", 0x0112), // Redmine Changeset ↔ OP Changeset ("project_watcher", 0x0113), // Redmine Watcher ↔ OP Watcher + ("project_news", 0x0114), // Redmine News ↔ OP News + ("project_message", 0x0115), // Redmine Message ↔ OP Message (board/forum divergence) // ── 0x02XX — commerce / billing / ERP domain (OSB ↔ Odoo) ── // Promoted from the parallel session's `lance-graph-ontology::ar_shape` @@ -1582,6 +1584,22 @@ pub fn canonical_concept(name: &str) -> String { ) { return "project_watcher".to_string(); } + // ProjectNews — project news/blog post. Both curators ship `News` + // with belongs_to :project + :author + has_many :comments. Same + // shape across both. + if matches!(lower.as_str(), + "news" | "project_news" | "projectnews" + ) { + return "project_news".to_string(); + } + // ProjectMessage — forum/board message (threaded discussion). Both + // curators ship `Message`, but the parent container diverges in + // name (Redmine `Board`, OpenProject `Forum`). + if matches!(lower.as_str(), + "message" | "messages" | "project_message" | "projectmessage" + ) { + return "project_message".to_string(); + } // ── Commerce / billing / ERP domain (OSB ↔ Odoo) ── // CommercialLineItem — line on a commercial document. OSB // `InvoiceLineItem`, Odoo `account_move_line` (ruff normalises @@ -2155,6 +2173,57 @@ pub fn project_watcher() -> Class { c } +/// `News` — a project news post / announcement. Both curators ship +/// `News` with `belongs_to :project` + `belongs_to :author` (a +/// [`project_actor`]) + `has_many :comments` (to [`project_comment`]). +/// Universal `title` / `summary` / `description` attributes. +#[must_use] +pub fn project_news() -> Class { + let mut c = Class::new("ProjectNews"); + c.language = Language::Unknown; + c.canonical_concept = Some("project_news".to_string()); + c.associations = vec![ + family_edge("project", "Project"), + family_edge("author", "ProjectActor"), + family_has_many("comments", "ProjectComment"), + ]; + let mut title = Attribute::new("title"); + title.type_name = Some("string".to_string()); + let mut summary = Attribute::new("summary"); + summary.type_name = Some("string".to_string()); + let mut description = Attribute::new("description"); + description.type_name = Some("text".to_string()); + c.attributes = vec![title, summary, description]; + c +} + +/// `Message` — threaded forum/board discussion post. Both curators ship +/// `Message` (curator divergence on the parent container: Redmine +/// `Board`, OpenProject `Forum`). Universal: `belongs_to :author`, a +/// self-reference `last_reply` for thread chaining, `acts_as_tree` for +/// nested replies, and `subject` / `content` / `replies_count` scalars. +#[must_use] +pub fn project_message() -> Class { + let mut c = Class::new("ProjectMessage"); + c.language = Language::Unknown; + c.canonical_concept = Some("project_message".to_string()); + c.associations = vec![ + family_edge("author", "ProjectActor"), + // Tree self-reference: both curators use `acts_as_tree` with + // `belongs_to :last_reply, class_name: "Message"` linking the + // thread chain. + family_edge("last_reply", "ProjectMessage"), + ]; + let mut subject = Attribute::new("subject"); + subject.type_name = Some("string".to_string()); + let mut content = Attribute::new("content"); + content.type_name = Some("text".to_string()); + let mut replies_count = Attribute::new("replies_count"); + replies_count.type_name = Some("integer".to_string()); + c.attributes = vec![subject, content, replies_count]; + c +} + // ───────────────────────────────────────────────────────────────────── // Commerce / billing / ERP domain canonical classes (OSB ↔ Odoo). // @@ -2780,6 +2849,7 @@ mod tests { "project_version", "project_wiki_page", "project_query", "project_attachment", "project_comment", "project_custom_field", "project_relation", "project_changeset", "project_watcher", + "project_news", "project_message", ] { let id = canonical_concept_id(project_concept) .unwrap_or_else(|| panic!("{project_concept} missing from codebook")); @@ -2828,6 +2898,8 @@ mod tests { (project_relation(), "ProjectRelation", 0x0111), (project_changeset(), "ProjectChangeset", 0x0112), (project_watcher(), "ProjectWatcher", 0x0113), + (project_news(), "ProjectNews", 0x0114), + (project_message(), "ProjectMessage", 0x0115), ] { assert_eq!(canonical.name, name); assert_eq!(canonical.language, Language::Unknown); @@ -2906,6 +2978,8 @@ mod tests { ("project_relation", &["IssueRelation", "issue_relation", "Relation", "relations", "ProjectRelation"]), ("project_changeset", &["Changeset", "changesets", "ProjectChangeset"]), ("project_watcher", &["Watcher", "watchers", "ProjectWatcher"]), + ("project_news", &["News", "news", "ProjectNews"]), + ("project_message", &["Message", "messages", "ProjectMessage"]), ]; for (concept, aliases) in cases { let id = canonical_concept_id(concept).unwrap();