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
60 changes: 60 additions & 0 deletions crates/ogar-from-rails/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -595,6 +595,66 @@ mod tests {
}
}

/// **ProjectRelation + Changeset + Watcher convergence** on real
/// source. Closes the last `project_work_item` forward reference
/// (`.relations`) and lands two natural neighbours that pair with
/// already-canonical concepts:
///
/// | Canonical concept | Redmine | OpenProject |
/// |---------------------|----------------|-------------|
/// | project_relation | IssueRelation | Relation |
/// | project_changeset | Changeset | Changeset |
/// | project_watcher | Watcher | Watcher |
#[test]
#[ignore = "requires Redmine + OpenProject checkouts"]
fn redmine_and_openproject_relation_changeset_watcher_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 rel_id = ogar_vocab::canonical_concept_id("project_relation");
let cs_id = ogar_vocab::canonical_concept_id("project_changeset");
let w_id = ogar_vocab::canonical_concept_id("project_watcher");
assert!(rel_id.is_some() && cs_id.is_some() && w_id.is_some());

for (curator, classes, table) in [
("Redmine", &redmine, &[
("IssueRelation", "project_relation", rel_id),
("Changeset", "project_changeset", cs_id),
("Watcher", "project_watcher", w_id),
][..]),
("OpenProject", &openproject, &[
("Relation", "project_relation", rel_id),
("Changeset", "project_changeset", cs_id),
("Watcher", "project_watcher", w_id),
][..]),
] {
for (class_name, concept, expected_id) in table {
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
115 changes: 115 additions & 0 deletions crates/ogar-vocab/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1083,6 +1083,9 @@ const CODEBOOK: &[(&str, u16)] = &[
("project_attachment", 0x010E), // Redmine Attachment ↔ OP Attachment
("project_comment", 0x010F), // Redmine Comment ↔ OP Comment
("project_custom_field", 0x0110), // Redmine CustomField ↔ OP CustomField
("project_relation", 0x0111), // Redmine IssueRelation ↔ OP Relation (name divergence)
("project_changeset", 0x0112), // Redmine Changeset ↔ OP Changeset
("project_watcher", 0x0113), // Redmine Watcher ↔ OP Watcher

// ── 0x02XX — commerce / billing / ERP domain (OSB ↔ Odoo) ──
// Promoted from the parallel session's `lance-graph-ontology::ar_shape`
Expand Down Expand Up @@ -1548,6 +1551,33 @@ pub fn canonical_concept(name: &str) -> String {
) {
return "project_custom_field".to_string();
}
// ProjectRelation — work-item ↔ work-item edge. Cross-curator name
// divergence: Redmine `IssueRelation`, OpenProject `Relation`. Same
// concept (precedes/blocks/relates_to between two work items).
if matches!(lower.as_str(),
"issuerelation" | "issue_relation"
| "issuerelations" | "issue_relations"
| "relation" | "relations"
| "project_relation" | "projectrelation"
Comment on lines +1558 to +1561

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 Handle Redmine relation table aliases

Please add the tableized Redmine plural (issue_relations, and likely IssueRelations) to this resolver arm. ogar_codebook calls canonical_concept only once, so issue_relations currently falls through to the lexical fallback as issue_relation, which is not in CODEBOOK, and downstream consumers that resolve Rails table labels get None instead of the new project_relation id; this is the same plural-table alias case already guarded for other promoted concepts in plural_table_aliases_resolve_to_promoted_codebook_id.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Agreed and fixed directly on this branch (force-pushed ebe7631). Added "issuerelations" | "issue_relations" to the project_relation promoted arm — same pattern as #67 (wiki_pages / custom_fields). The plural_table_aliases test gained 3 new entries (issue_relations, issuerelations, IssueRelations → project_relation codebook id).

Also did a proactive audit and found the same defect class on 4 more already-merged concepts (Rails irregular pluralization the single-s lex can't reverse): time_entries, issue_statuses, issue_priorities, memberships. Those are landed in #69 (separate PR off main since they touch merged code). Thanks for the catch.

) {
return "project_relation".to_string();
}
// ProjectChangeset — VCS commit on a [`project_repository`]. Both
// curators ship `Changeset` with the same shape (belongs_to repository
// + user, revision/comments/commit_date).
if matches!(lower.as_str(),
"changeset" | "changesets" | "project_changeset" | "projectchangeset"
) {
return "project_changeset".to_string();
}
// ProjectWatcher — the per-user follow-relationship on a polymorphic
// watchable. Both curators ship `Watcher` with `belongs_to :user` +
// `belongs_to :watchable, polymorphic: true`.
if matches!(lower.as_str(),
"watcher" | "watchers" | "project_watcher" | "projectwatcher"
) {
return "project_watcher".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 @@ -2052,6 +2082,74 @@ pub fn project_custom_field() -> Class {
c
}

/// `IssueRelation`/`Relation` — directed work-item edge. The canonical
/// target of [`project_work_item`]'s `relations` family edge. Both
/// curators have it under divergent names (Redmine `IssueRelation` →
/// `issue_from`/`issue_to`; OP `Relation` → `from`/`to`); the canonical
/// shape uses universal `from`/`to` family edges to [`project_work_item`].
#[must_use]
pub fn project_relation() -> Class {
let mut c = Class::new("ProjectRelation");
c.language = Language::Unknown;
c.canonical_concept = Some("project_relation".to_string());
c.associations = vec![
family_edge("from", "ProjectWorkItem"),
family_edge("to", "ProjectWorkItem"),
];
let mut relation_type = Attribute::new("relation_type");
relation_type.type_name = Some("string".to_string());
// Redmine names it `delay`, OP names it `lag` — same semantic
// (offset in days between predecessor and successor). Canonical:
// `lag` (the more common project-scheduling term).
let mut lag = Attribute::new("lag");
lag.type_name = Some("integer".to_string());
c.attributes = vec![relation_type, lag];
c
}

/// `Changeset` — VCS commit on a [`project_repository`]. Both curators
/// ship `Changeset` with identical shape: `belongs_to :repository`,
/// `belongs_to :user` (committer mapped to a [`project_actor`]), with
/// `revision` / `commit_date` / `comments` scalars.
#[must_use]
pub fn project_changeset() -> Class {
let mut c = Class::new("ProjectChangeset");
c.language = Language::Unknown;
c.canonical_concept = Some("project_changeset".to_string());
c.associations = vec![
family_edge("repository", "ProjectRepository"),
family_edge("user", "ProjectActor"),
];
let mut revision = Attribute::new("revision");
revision.type_name = Some("string".to_string());
let mut commit_date = Attribute::new("commit_date");
commit_date.type_name = Some("date".to_string());
let mut comments = Attribute::new("comments");
comments.type_name = Some("text".to_string());
c.attributes = vec![revision, commit_date, comments];
c
}

/// `Watcher` — a follow-relationship: a [`project_actor`] watches a
/// polymorphic watchable (work item, project, wiki page, …). Both
/// curators ship `Watcher` with `belongs_to :user` + `belongs_to
/// :watchable, polymorphic: true`. The polymorphic target stays opaque
/// at the canonical layer (the `watchable_type` discriminator is the
/// curator-local cell).
#[must_use]
pub fn project_watcher() -> Class {
let mut c = Class::new("ProjectWatcher");
c.language = Language::Unknown;
c.canonical_concept = Some("project_watcher".to_string());
c.associations = vec![
family_edge("user", "ProjectActor"),
];
let mut watchable_type = Attribute::new("watchable_type");
watchable_type.type_name = Some("string".to_string());
c.attributes = vec![watchable_type];
c
}

// ─────────────────────────────────────────────────────────────────────
// Commerce / billing / ERP domain canonical classes (OSB ↔ Odoo).
//
Expand Down Expand Up @@ -2676,6 +2774,7 @@ mod tests {
"project_membership", "project_journal", "project_repository",
"project_version", "project_wiki_page", "project_query",
"project_attachment", "project_comment", "project_custom_field",
"project_relation", "project_changeset", "project_watcher",
] {
let id = canonical_concept_id(project_concept)
.unwrap_or_else(|| panic!("{project_concept} missing from codebook"));
Expand Down Expand Up @@ -2721,6 +2820,9 @@ mod tests {
(project_attachment(), "ProjectAttachment", 0x010E),
(project_comment(), "ProjectComment", 0x010F),
(project_custom_field(), "ProjectCustomField", 0x0110),
(project_relation(), "ProjectRelation", 0x0111),
(project_changeset(), "ProjectChangeset", 0x0112),
(project_watcher(), "ProjectWatcher", 0x0113),
] {
assert_eq!(canonical.name, name);
assert_eq!(canonical.language, Language::Unknown);
Expand Down Expand Up @@ -2748,6 +2850,15 @@ mod tests {
("project_custom_field", "custom_fields"),
("project_custom_field", "customfields"),
("project_custom_field", "CustomFields"),
// Codex P2 on PR #68 — Redmine `issue_relations` is the
// Rails-tableized form of `IssueRelation`. The single-`s`
// lexical fallback drops to `issue_relation`, which is the
// singular curator alias but NOT a canonical concept name —
// so canonical_concept_id returned None. Adding the plural
// form to the promoted arm.
("project_relation", "issue_relations"),
("project_relation", "issuerelations"),
("project_relation", "IssueRelations"),
] {
let canonical_id = canonical_concept_id(singular);
assert!(canonical_id.is_some(), "{singular} must be in codebook");
Expand All @@ -2773,6 +2884,10 @@ mod tests {
("project_attachment", &["Attachment", "attachments", "ProjectAttachment"]),
("project_comment", &["Comment", "comments", "ProjectComment"]),
("project_custom_field", &["CustomField", "custom_field", "ProjectCustomField"]),
// Cross-curator name divergence: Redmine `IssueRelation` ↔ OP `Relation`.
("project_relation", &["IssueRelation", "issue_relation", "Relation", "relations", "ProjectRelation"]),
("project_changeset", &["Changeset", "changesets", "ProjectChangeset"]),
("project_watcher", &["Watcher", "watchers", "ProjectWatcher"]),
];
for (concept, aliases) in cases {
let id = canonical_concept_id(concept).unwrap();
Expand Down
Loading