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
11 changes: 11 additions & 0 deletions .changeset/project-org-ownership.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"@rolexjs/core": minor
"@rolexjs/prototype": minor
---

feat: add bidirectional ownership relation between project and organization

- Add ownership relation on project structure pointing to organization
- project.launch accepts optional `org` parameter to link project to an organization
- census.list tree view displays projects under their owning organization
- Add BDD scenario and link assertion step for ownership verification
10 changes: 10 additions & 0 deletions bdd/features/project-lifecycle.feature
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,16 @@ Feature: Project lifecycle
And the result state name should be "project"
And the result state id should be "rolex-v2"

Scenario: Launch creates a project owned by an organization
Given organization "deepractice" exists
When I direct "!project.launch" with:
| content | Feature: RoleX v2 |
| id | rolex-v2-org |
| org | deepractice |
Then the result process should be "launch"
And the result state id should be "rolex-v2-org"
And the result state should have link "ownership" to "deepractice"

# ===== scope =====

Scenario: Scope defines project boundary
Expand Down
13 changes: 13 additions & 0 deletions bdd/steps/direct.steps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,19 @@ Then("the direct result should contain {string}", function (this: BddWorld, text
);
});

Then(
"the result state should have link {string} to {string}",
function (this: BddWorld, relation: string, targetId: string) {
assert.ok(this.directRaw, `Expected a result but got error: ${this.error?.message ?? "none"}`);
const links = this.directRaw.state.links ?? [];
const found = links.some((l: any) => l.relation === relation && l.target.id === targetId);
assert.ok(
found,
`Expected link "${relation}" to "${targetId}" but got: ${JSON.stringify(links.map((l: any) => `${l.relation} → ${l.target.id}`))}`
);
}
);

Then("it should fail", function (this: BddWorld) {
assert.ok(this.error, "Expected an error but operation succeeded");
});
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/structures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
* │ │ └── duty "Responsibilities of position" │
* │ ├── project "A process container" │
* │ │ │ ∿ participation → individual │
* │ │ │ ∿ ownership → organization │
* │ │ ├── scope "Project boundary" │
* │ │ ├── milestone "Key checkpoint" │
* │ │ ├── deliverable "Project output" │
Expand Down Expand Up @@ -105,6 +106,7 @@ export const requirement = structure("requirement", "Required skill for this pos

export const project = structure("project", "A process container for organized work", society, [
relation("participation", "Who participates in this project", individual),
relation("ownership", "Which organization owns this project", organization),
]);
export const scope = structure(
"scope",
Expand Down
7 changes: 6 additions & 1 deletion packages/prototype/src/instructions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -470,8 +470,13 @@ const projectLaunch = def(
},
id: { type: "string", required: false, description: "User-facing identifier (kebab-case)" },
alias: { type: "string[]", required: false, description: "Alternative names" },
org: {
type: "string",
required: false,
description: "Organization id that owns this project",
},
},
["content", "id", "alias"]
["content", "id", "alias", "org"]
);

const projectScope = def(
Expand Down
16 changes: 13 additions & 3 deletions packages/prototype/src/ops.ts
Original file line number Diff line number Diff line change
Expand Up @@ -322,10 +322,12 @@ export function createOps(ctx: OpsContext): Ops {
async "project.launch"(
content?: string,
id?: string,
alias?: readonly string[]
alias?: readonly string[],
org?: string
): Promise<OpResult> {
validateGherkin(content);
const node = await rt.create(society, C.project, content, id, alias);
if (org) await rt.link(node, await resolve(org), "ownership", "project");
return ok(node, "launch");
},

Expand Down Expand Up @@ -507,10 +509,18 @@ export function createOps(ctx: OpsContext): Ops {
const tag = org.tag ? ` #${org.tag}` : "";
lines.push(`${org.id}${alias}${tag}`);

// Projects owned by this org
const projects = org.links?.filter((l) => l.relation === "project") ?? [];
for (const p of projects) {
const pAlias = p.target.alias?.length ? ` (${p.target.alias.join(", ")})` : "";
const pTag = p.target.tag ? ` #${p.target.tag}` : "";
lines.push(` 📦 ${p.target.id ?? "(no id)"}${pAlias}${pTag}`);
}

// Members of this org
const members = org.links?.filter((l) => l.relation === "membership") ?? [];
if (members.length === 0) {
lines.push(" (no members)");
if (members.length === 0 && projects.length === 0) {
lines.push(" (empty)");
}
for (const m of members) {
affiliatedIndividuals.add(m.target.id ?? "");
Expand Down