Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions crates/ogar-from-rails/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
74 changes: 74 additions & 0 deletions crates/ogar-vocab/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve the Message parent edge

When loading threaded forum messages, this self-edge does not represent the thread structure: in both Redmine and OpenProject last_reply_id is only a cached pointer from a topic to its latest reply, while the actual reply tree comes from acts_as_tree/parent_id. As a result, replies generally have no last_reply, so consumers using the canonical ProjectMessage shape cannot reconstruct parent/child discussions; add a canonical parent/children self-reference or avoid treating last_reply as the thread chain.

Useful? React with 👍 / 👎.

];
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).
//
Expand Down Expand Up @@ -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"));
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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();
Expand Down
Loading