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
77 changes: 74 additions & 3 deletions crates/ogar-from-rails/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -489,9 +489,80 @@ mod tests {
}
}

/// **Priority convergence** on real source. Both curators use
/// `IssuePriority < Enumeration`; both lift to the canonical
/// `priority` concept (codebook id `0x000D`).
/// **ProjectStatus + ProjectType convergence** on real source. Both
/// curators carry these lookup tables under divergent names:
///
/// | Canonical concept | Redmine | OpenProject |
/// |-------------------|-------------|-------------|
/// | project_status | IssueStatus | Status |
/// | project_type | Tracker | Type |
///
/// All four extracted classes converge through their respective
/// canonical concept and codebook id (`0x0105` / `0x0106`).
#[test]
#[ignore = "requires Redmine + OpenProject checkouts"]
fn redmine_and_openproject_status_and_type_converge_through_canonical() {
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 status_id = ogar_vocab::canonical_concept_id("project_status");
let type_id = ogar_vocab::canonical_concept_id("project_type");
assert!(status_id.is_some() && type_id.is_some());

// Each (curator, class_name) tuple must lift to (concept, id).
// Cross-curator name divergence: IssueStatus/Status, Tracker/Type.
for (curator, classes, table) in [
(
"Redmine",
&redmine,
&[
("IssueStatus", "project_status", status_id),
("Tracker", "project_type", type_id),
][..],
),
(
"OpenProject",
&openproject,
&[
("Status", "project_status", status_id),
("Type", "project_type", type_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,
"{curator} {class_name} -> codebook id for {concept}",
);
}
}
}

/// Both curators use `IssuePriority < Enumeration`; both lift to the
/// canonical `priority` concept (codebook id `0x0107` post-#66).
#[test]
#[ignore = "requires Redmine + OpenProject checkouts"]
fn redmine_and_openproject_issuepriority_converge_through_canonical() {
Expand Down
142 changes: 139 additions & 3 deletions crates/ogar-vocab/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1067,8 +1067,8 @@ const CODEBOOK: &[(&str, u16)] = &[
("project_work_item", 0x0102),
("billable_work_entry", 0x0103),
("project_actor", 0x0104),
// 0x0105–0x0106 reserved for in-flight #63 (project_status, project_type);
// OGAR#63 rebases by switching its ids to those reserved slots.
("project_status", 0x0105), // Redmine IssueStatus ↔ OP Status
("project_type", 0x0106), // Redmine Tracker ↔ OP Type
("priority", 0x0107),

// New project-mgmt promotions from the cross-curator overlap probe
Expand Down Expand Up @@ -1480,6 +1480,26 @@ pub fn canonical_concept(name: &str) -> String {
) {
return "project_actor".to_string();
}
// ProjectStatus — the workflow-state lookup. Redmine `IssueStatus`,
// OpenProject `Status`. Cross-curator name divergence; same concept.
if matches!(
lower.as_str(),
"issuestatus" | "issue_status"
| "status" | "statuses"
| "project_status" | "projectstatus"
) {
return "project_status".to_string();
}
// ProjectType — the work-item categorisation lookup. Redmine `Tracker`,
// OpenProject `Type`. Cross-curator name divergence; same concept.
if matches!(
lower.as_str(),
"tracker" | "trackers"
| "type" | "types"
| "project_type" | "projecttype"
Comment on lines +1497 to +1499

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 Avoid globally promoting generic Type aliases

This bare type/types mapping is not scoped to OpenProject/Redmine: ogar_from_ruff::lift_model applies canonical_concept(&model.name) to every extracted model before/independent of source_domain, so any unrelated Rails corpus with a class literally named Type/Types (and similarly the Status aliases just above) will now get the project-domain project_type codebook id via canonical_id()/LabelDTO::from_alias. Keep only unambiguous aliases such as Tracker/ProjectType, or make this resolution domain-aware before assigning the project lookup-table identity.

Useful? React with 👍 / 👎.

) {
return "project_type".to_string();
}
// ── Project-mgmt promotions from the cross-curator overlap probe ──
if matches!(lower.as_str(),
"member" | "members" | "project_membership" | "projectmembership"
Expand Down Expand Up @@ -1810,6 +1830,59 @@ pub fn project_actor() -> Class {
c
}

/// The promoted canonical class for **project status** — the workflow
/// state lookup applied to project work items. Referenced by
/// [`project_work_item`]'s `status` family edge.
///
/// Cross-curator name divergence: Redmine `IssueStatus`, OpenProject
/// `Status`. Same concept, same canonical id (`0x0105`). Both curators
/// carry the universal `name` / `position` / `is_closed` triple (closed
/// = workflow terminal state) plus a `has_many :workflows` collection
/// of allowed transitions.
#[must_use]
pub fn project_status() -> Class {
let mut c = Class::new("ProjectStatus");
c.language = Language::Unknown;
c.canonical_concept = Some("project_status".to_string());
c.associations = vec![
family_has_many("workflows", "WorkflowTransition"),
];
let mut name = Attribute::new("name");
name.type_name = Some("string".to_string());
let mut position = Attribute::new("position");
position.type_name = Some("integer".to_string());
let mut is_closed = Attribute::new("is_closed");
is_closed.type_name = Some("boolean".to_string());
c.attributes = vec![name, position, is_closed];
c
}

/// The promoted canonical class for **project type** — the work-item
/// categorisation lookup. Referenced by [`project_work_item`]'s `type`
/// family edge.
///
/// Cross-curator name divergence: Redmine `Tracker`, OpenProject `Type`.
/// Same concept, same canonical id (`0x0106`). Both carry `name` /
/// `position` / `is_default` plus the back-reference `has_many
/// :work_items` to [`project_work_item`].
#[must_use]
pub fn project_type() -> Class {
let mut c = Class::new("ProjectType");
c.language = Language::Unknown;
c.canonical_concept = Some("project_type".to_string());
c.associations = vec![
family_has_many("work_items", "ProjectWorkItem"),
];
let mut name = Attribute::new("name");
name.type_name = Some("string".to_string());
let mut position = Attribute::new("position");
position.type_name = Some("integer".to_string());
let mut is_default = Attribute::new("is_default");
is_default.type_name = Some("boolean".to_string());
c.attributes = vec![name, position, is_default];
c
}

// ─────────────────────────────────────────────────────────────────────
// Project-mgmt batch promotions from the cross-curator overlap probe
// (Redmine ↔ OpenProject). Minimal-by-design canonical shapes; both
Expand Down Expand Up @@ -2598,7 +2671,7 @@ mod tests {
// Existing concepts must live in their correct domain block.
for project_concept in [
"project", "project_work_item", "billable_work_entry",
"project_actor", "priority",
"project_actor", "project_status", "project_type", "priority",
"project_membership", "project_journal", "project_repository",
"project_version", "project_wiki_page", "project_query",
"project_attachment", "project_comment", "project_custom_field",
Expand Down Expand Up @@ -2839,6 +2912,69 @@ mod tests {
}
}

#[test]
fn project_status_and_project_type_are_promoted_canonical_classes() {
// ProjectStatus — Redmine IssueStatus / OP Status converge here.
let s = project_status();
assert_eq!(s.name, "ProjectStatus");
assert_eq!(s.canonical_concept.as_deref(), Some("project_status"));
assert_eq!(s.language, Language::Unknown);
assert_eq!(s.canonical_id(), Some(0x0105));
let s_attr = |n: &str, t: &str| {
assert_eq!(
s.attributes.iter().find(|a| a.name == n).and_then(|a| a.type_name.as_deref()),
Some(t),
);
};
s_attr("name", "string");
s_attr("position", "integer");
s_attr("is_closed", "boolean");

// ProjectType — Redmine Tracker / OP Type converge here.
let t = project_type();
assert_eq!(t.name, "ProjectType");
assert_eq!(t.canonical_concept.as_deref(), Some("project_type"));
assert_eq!(t.language, Language::Unknown);
assert_eq!(t.canonical_id(), Some(0x0106));
let t_attr = |n: &str, ty: &str| {
assert_eq!(
t.attributes.iter().find(|a| a.name == n).and_then(|a| a.type_name.as_deref()),
Some(ty),
);
};
t_attr("name", "string");
t_attr("position", "integer");
t_attr("is_default", "boolean");
// ProjectType back-references ProjectWorkItem.
let work_items = t
.associations
.iter()
.find(|a| a.name == "work_items")
.expect("work_items edge");
assert_eq!(work_items.kind, AssociationKind::HasMany);
assert_eq!(work_items.class_name.as_deref(), Some("ProjectWorkItem"));
}

#[test]
fn project_status_and_type_resolver_collapses_curator_name_divergence() {
// ProjectStatus: Redmine IssueStatus / OP Status — same id.
let status_id = canonical_concept_id("project_status");
assert!(status_id.is_some());
for src in ["IssueStatus", "issuestatus", "issue_status", "Status", "statuses", "ProjectStatus", "projectstatus"] {
assert_eq!(canonical_concept(src), "project_status");
assert_eq!(ogar_codebook(src), status_id, "{src} -> project_status id");
}
// ProjectType: Redmine Tracker / OP Type — same id.
let type_id = canonical_concept_id("project_type");
assert!(type_id.is_some());
for src in ["Tracker", "trackers", "Type", "types", "ProjectType", "projecttype"] {
assert_eq!(canonical_concept(src), "project_type");
assert_eq!(ogar_codebook(src), type_id, "{src} -> project_type id");
}
// The two canonical concepts have distinct ids.
assert_ne!(status_id, type_id);
}

#[test]
fn openproject_enrichment_does_not_break_redmine_ar_overlap() {
// OpenProject's WorkPackage is the richer organism (extra includes
Expand Down
Loading