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
18 changes: 14 additions & 4 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ SHELL := bash
COREPACK_ENABLE_DOWNLOAD_PROMPT ?= 0
export COREPACK_ENABLE_DOWNLOAD_PROMPT

.PHONY: doctor deps dev dev-gitea smoke-gitea test test-app lint config-check secrets ci
.PHONY: doctor deps dev dev-gitea smoke-gitea smoke-catalog test test-app tsc lint config-check secrets ci

## doctor — check Node, Corepack, bao, and required dev ports (no secret values printed)
doctor:
Expand All @@ -34,13 +34,23 @@ smoke-gitea:
node scripts/preflight-gitea.mjs
bash scripts/smoke-gitea.sh

## smoke-catalog — headless real-ingestion check for the custom Cycle/Saga kinds
## (stub mode — no cluster, no secrets; safe anywhere incl. CI)
smoke-catalog:
bash scripts/smoke-catalog.sh

## test — envelope tooling + BDD acceptance (jest-cucumber)
test:
corepack yarn jest --config jest.envelope.config.cjs

## test-app — the generated app/backend unit tests (backstage-cli)
## CI=true forces a single run — without it backstage-cli watches in a TTY and never exits.
test-app:
corepack yarn backstage-cli repo test
CI=true corepack yarn backstage-cli repo test

## tsc — typecheck the whole repo (catches wiring/import errors the linter misses)
tsc:
corepack yarn tsc

## lint — repo lint across all workspaces
lint:
Expand All @@ -54,5 +64,5 @@ config-check:
secrets:
bash scripts/dev-secrets

## ci — the gate: config-check, lint, envelope tests, app/backend unit tests
ci: config-check lint test test-app
## ci — the full local gate: config-check, lint, typecheck, envelope tests, app/backend unit tests
ci: config-check lint tsc test test-app
2 changes: 1 addition & 1 deletion app-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ catalog:
- type: file
target: ../../examples/mtl.yaml
rules:
- allow: [Group, Domain, System, Resource, Component, Cycle]
- allow: [Group, Domain, System, Resource, Component, Cycle, Saga]

## Uncomment these lines to add more example data
# - type: url
Expand Down
28 changes: 28 additions & 0 deletions docs/adrs/0008-saga-git-backed-kind.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# Saga: a Git-backed catalog kind for narrated effort records

- Status: accepted
- Date: 2026-07-06
- Deciders: Cervator, Claude (Opus 4.8)

## Context and Problem Statement

The two-family model (ADR 0007) pairs the structured/ingestable `Cycle` with a narrated/authored counterpart: an After-Action-Report a human *writes* about an effort — a season, a talent show, a fundraiser. Backstage has no home for such narrative, and cramming append-only history into a refreshable catalog node fights the catalog.

## Considered Options

- No kind — keep narratives as loose docs / TechDocs only.
- A `Saga` kind whose narrative body lives in the Backstage DB.
- A `Saga` kind that is a thin index entity, with the narrative body in Git.

## Decision Outcome

Chosen: a custom **`Saga`** kind (`apiVersion: siliconsaga.org/v1alpha1`) that is a **thin, Git-backed index entity**. The narrative body is a markdown file in the org's repo, referenced by the required `siliconsaga.org/saga-doc` annotation; the entity carries `spec.skald` (the authoring User — flair for "author"), `spec.timeframe`, `spec.touches` (entity refs the Saga narrates), and optional `spec.owner`. A `SagaProcessor` (new-backend-system catalog module) validates these and emits **built-in** relations: `spec.skald`/`spec.owner` → `ownedBy`/`ownerOf`, and `spec.touches[]` → `dependsOn`/`dependencyOf`.

### Consequences

- Good: nothing unique lives only in Backstage's DB — both the prose and the entity descriptor are Git files; the DB is a rebuildable cache.
- Good: reuses built-in relation types, so the catalog graph shows what a Saga touched with no bespoke wiring.
- A `Saga` is distinct from a `Cycle`: a season always exists as a `Cycle`; it becomes a `Saga` only if a Skald writes one (zero, one, or many per effort).
- **Deferred:** the authoring UX (catalog-backed `EntityPicker`s for `touches`) and blog/TechDocs-style rendering of `saga.md`. This ADR covers the catalog kind, not the reader/authoring surface — the `Saga` renders on the default entity page for now.

See ADR [0007](0007-cycle-custom-kind.md) (Cycle / two-family model) and the design doc §5.
1 change: 1 addition & 0 deletions docs/adrs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,4 @@ annotation. Until then they live here as plain Markdown.
| [0005](0005-guest-auth-first.md) | Guest auth first; Keycloak sign-in deferred |
| [0006](0006-bdd-from-day-one.md) | BDD from day one over TDD'd tooling |
| [0007](0007-cycle-custom-kind.md) | Cycle: a custom catalog kind for bounded groupings |
| [0008](0008-saga-git-backed-kind.md) | Saga: a Git-backed catalog kind for narrated effort records |
10 changes: 7 additions & 3 deletions docs/development/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,17 +17,21 @@ Backstage's own runner (`backstage-cli repo test`, exposed as `make test-app`) o

## 2. App / backend (`make test-app`)

The tests generated with the Backstage app (e.g. `packages/app/src/App.test.tsx`) plus in-repo backend module units — including the custom `Cycle` kind's processor (`packages/backend/src/modules/cycle/CycleProcessor.test.ts`) — run by `backstage-cli repo test`.
The tests generated with the Backstage app (e.g. `packages/app/src/App.test.tsx`) plus in-repo backend module units — including the custom `Cycle`/`Saga` kind processors (`packages/backend/src/modules/{cycle,saga}/*Processor.test.ts`) — run by `backstage-cli repo test`.

## The `ci` gate (`make ci`)

`make ci` runs the full gate: `config-check`, `lint`, `test` (envelope BDD), and `test-app` (app/backend + the `CycleProcessor` units).
`make ci` runs the full gate: `config-check`, `lint`, `tsc` (typecheck), `test` (envelope BDD), and `test-app` (app/backend + the `Cycle`/`Saga` processor units).

## Real catalog-ingestion smoke (`make smoke-catalog`)

`make smoke-catalog` is the committed, end-to-end proof for the custom kinds: it boots the backend headlessly in **stub mode** (no cluster, no secrets), waits for the MTL seed to process, and asserts via the catalog API that the `Cycle` and `Saga` entities ingested **with their emitted relations** (partOf/ownedBy/dependsOn). It tears the backend down on exit and is safe to run anywhere, including CI. This is the real-ingestion counterpart to the source-assertion BDD scenarios below — the stub-mode cousin of the `@live` `make smoke-gitea`.

## `@live` scenarios

Some acceptance scenarios are tagged `@live` (e.g. the real OpenBao → Gitea ingest). They need a live cluster + an unsealed OpenBao and are **excluded from the default run** (the steps file loads features with `tagFilter: 'not @live'`). Run them by hand against a real environment per [`openbao-setup.md`](openbao-setup.md).

Where a full backend boot would be heavy, a few catalog scenarios assert against the configured source/fixture instead (a pragmatic stand-in); the `@live` variant is the real proof. Replacing those with a real `startTestBackend` boot is queued as hardening.
The BDD catalog scenarios assert against the seed source (a pragmatic stand-in). The real end-to-end ingestion proof is `make smoke-catalog` (above), which boots the backend and checks the catalog API.

## Adding a test

Expand Down
19 changes: 19 additions & 0 deletions examples/mtl.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -98,3 +98,22 @@ spec:
of: group:default/mtl-soccer
owner: group:default/mtl-soccer
happensAt: [resource:default/field-1]
---
# A narrated After-Action-Report (Saga) about the season — authored, Git-backed.
# The narrative body lives beside the entity (siliconsaga.org/saga-doc); Backstage
# only indexes it. `touches` links the entities the Saga narrates.
apiVersion: siliconsaga.org/v1alpha1
kind: Saga
metadata:
name: saga-soccer-2026-spring
description: 'The Saga of MTL Soccer, Spring 2026'
annotations:
siliconsaga.org/saga-doc: ./sagas/soccer-2026-spring.md
spec:
skald: user:default/guest
timeframe: { start: '2026-03-01', end: '2026-06-15' }
touches:
- cycle:default/soccer-2026-spring
- group:default/mtl-soccer
- resource:default/field-1
owner: group:default/mtl-soccer
19 changes: 19 additions & 0 deletions examples/sagas/soccer-2026-spring.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# The Saga of MTL Soccer, Spring 2026

> Skald: @guest · Season: 2026-03-01 → 2026-06-15

A narrated After-Action-Report for the Spring 2026 MTL soccer season — the kind of
account no scrape produces, only a human writes. This is a placeholder narrative
that demonstrates the Git-backed `Saga` shape: the prose lives here in the repo,
the catalog `Saga` entity (in `../mtl.yaml`) points at it via the
`siliconsaga.org/saga-doc` annotation and links what it touched via `spec.touches`.

## What happened

_(Write the story here: how the season went, notable matches, weather woes, the
volunteers who carried it, what to do differently next time.)_

## Retrospective

_(The Saga can simply be the retrospective — what worked, what didn't, lessons for
the next Skald.)_
2 changes: 2 additions & 0 deletions packages/app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"lint": "backstage-cli package lint"
},
"dependencies": {
"@backstage/catalog-model": "^1.9.0",
"@backstage/cli": "^0.36.3",
"@backstage/core-components": "^0.18.11",
"@backstage/core-plugin-api": "^1.12.7",
Expand All @@ -27,6 +28,7 @@
"@backstage/plugin-catalog": "^2.0.6",
"@backstage/plugin-catalog-graph": "^0.6.5",
"@backstage/plugin-catalog-import": "^0.13.14",
"@backstage/plugin-catalog-react": "^3.1.0",
"@backstage/plugin-kubernetes": "^0.12.20",
"@backstage/plugin-notifications": "^0.5.18",
"@backstage/plugin-org": "^0.7.5",
Expand Down
3 changes: 2 additions & 1 deletion packages/app/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { createApp } from '@backstage/frontend-defaults';
import catalogPlugin from '@backstage/plugin-catalog/alpha';
import { navModule } from './modules/nav';
import { cycleModule } from './modules/cycle';

export default createApp({
features: [catalogPlugin, navModule],
features: [catalogPlugin, navModule, cycleModule],
});
60 changes: 60 additions & 0 deletions packages/app/src/modules/cycle/CycleCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { InfoCard, StructuredMetadataTable } from '@backstage/core-components';
import {
EntityRefLink,
EntityRefLinks,
useEntity,
} from '@backstage/plugin-catalog-react';
import { CompoundEntityRef, parseEntityRef } from '@backstage/catalog-model';

type CycleSpec = {
type?: string;
timeframe?: { start?: string; end?: string };
of?: string;
owner?: string;
happensAt?: string[];
};

// parseEntityRef throws on a malformed ref; never let a bad seed value break the
// card render — fall back to the dash placeholder instead.
const safeRef = (
ref: string,
defaultKind: string,
): CompoundEntityRef | undefined => {
try {
return parseEntityRef(ref, { defaultKind, defaultNamespace: 'default' });
} catch {
return undefined;
}
};

/**
* Curated overview card for the custom `Cycle` kind — surfaces the fields the
* default entity page buries: the type, the timeframe, and what the Cycle is
* of / happens at (rendered as clickable catalog links). Occurrences (matches,
* deployments) are not modelled as entities, so they are not shown here.
*/
export const CycleOverviewCard = () => {
const { entity } = useEntity();
const spec = (entity.spec ?? {}) as unknown as CycleSpec;
const tf = spec.timeframe ?? {};

const ofRef = spec.of ? safeRef(spec.of, 'Group') : undefined;
const happensAtRefs = (Array.isArray(spec.happensAt) ? spec.happensAt : [])
.map(t => safeRef(t, 'Resource'))
.filter((r): r is CompoundEntityRef => r !== undefined);

const metadata: Record<string, string | JSX.Element> = {
Type: spec.type ?? '—',
Timeframe: tf.start && tf.end ? `${tf.start} → ${tf.end}` : '—',
'Part of': ofRef ? <EntityRefLink entityRef={ofRef} /> : '—',
'Happens at': happensAtRefs.length > 0
? <EntityRefLinks entityRefs={happensAtRefs} />
: '—',
};

return (
<InfoCard title="Cycle">
<StructuredMetadataTable metadata={metadata} />
</InfoCard>
);
};
18 changes: 18 additions & 0 deletions packages/app/src/modules/cycle/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { createFrontendModule } from '@backstage/frontend-plugin-api';
import { EntityCardBlueprint } from '@backstage/plugin-catalog-react/alpha';

// A curated info-card on the entity Overview, shown only for `kind: Cycle`.
// Attaches to the catalog default overview automatically (EntityCardBlueprint
// attachTo: entity-content:catalog/overview) and is gated by the filter.
const cycleOverviewCard = EntityCardBlueprint.make({
name: 'cycle',
params: {
filter: { kind: 'cycle' },
loader: () => import('./CycleCard').then(m => <m.CycleOverviewCard />),
},
});

export const cycleModule = createFrontendModule({
pluginId: 'app',
extensions: [cycleOverviewCard],
});
3 changes: 2 additions & 1 deletion packages/backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,9 @@ backend.add(
// See https://backstage.io/docs/features/software-catalog/configuration#subscribing-to-catalog-errors
backend.add(import('@backstage/plugin-catalog-backend-module-logs'));

// custom community-domain kinds (Cycle)
// custom community-domain kinds (Cycle, Saga)
backend.add(import('./modules/cycle/catalogModuleCycle'));
backend.add(import('./modules/saga/catalogModuleSaga'));

// permission plugin
backend.add(import('@backstage/plugin-permission-backend'));
Expand Down
118 changes: 118 additions & 0 deletions packages/backend/src/modules/saga/SagaProcessor.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { Entity } from '@backstage/catalog-model';
import { CatalogProcessorResult } from '@backstage/plugin-catalog-node';
import { SagaProcessor } from './SagaProcessor';

const saga = (
opts: { spec?: unknown; annotations?: Record<string, string> } = {},
): Entity => ({
apiVersion: 'siliconsaga.org/v1alpha1',
kind: 'Saga',
metadata: {
name: 'saga-soccer-2026-spring',
annotations:
opts.annotations ?? {
'siliconsaga.org/saga-doc': './sagas/soccer-2026-spring.md',
},
},
spec: opts.spec as Entity['spec'],
});

const validSpec = {
skald: 'user:default/guest',
timeframe: { start: '2026-03-01', end: '2026-06-15' },
touches: [
'cycle:default/soccer-2026-spring',
'group:default/mtl-soccer',
'resource:default/field-1',
],
owner: 'group:default/mtl-soccer',
};

describe('SagaProcessor.validateEntityKind', () => {
const p = new SagaProcessor();

it('accepts a valid Saga', async () => {
await expect(p.validateEntityKind(saga({ spec: validSpec }))).resolves.toBe(true);
});

it('ignores non-Saga kinds', async () => {
const c: Entity = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: { name: 'x' },
};
await expect(p.validateEntityKind(c)).resolves.toBe(false);
});

it('rejects a Saga missing skald', async () => {
const { skald: _drop, ...noSkald } = validSpec;
await expect(p.validateEntityKind(saga({ spec: noSkald }))).rejects.toThrow(
/spec\.skald is required/,
);
});

it('rejects a Saga with empty touches', async () => {
await expect(
p.validateEntityKind(saga({ spec: { ...validSpec, touches: [] } })),
).rejects.toThrow(/spec\.touches/);
});
Comment thread
agent-refr marked this conversation as resolved.

it('rejects a Saga missing the saga-doc annotation', async () => {
await expect(
p.validateEntityKind(saga({ spec: validSpec, annotations: {} })),
).rejects.toThrow(/saga-doc/);
});

it('rejects a Saga with a malformed skald ref', async () => {
await expect(
p.validateEntityKind(saga({ spec: { ...validSpec, skald: 'user:default/' } })),
).rejects.toThrow(/spec\.skald is not a valid entity ref/);
});
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

describe('SagaProcessor.postProcessEntity', () => {
const p = new SagaProcessor();

it('emits ownedBy (skald + owner) and dependsOn (touches)', async () => {
const emitted: CatalogProcessorResult[] = [];
await p.postProcessEntity(
saga({ spec: validSpec }),
{ type: 'file', target: 'examples/mtl.yaml' } as any,
r => emitted.push(r),
);
const rels = emitted
.filter(r => r.type === 'relation')
.map(r => (r as any).relation);

expect(rels).toEqual(
expect.arrayContaining([
expect.objectContaining({
type: 'ownedBy',
target: expect.objectContaining({ kind: 'user', name: 'guest' }),
}),
expect.objectContaining({
type: 'ownedBy',
target: expect.objectContaining({ kind: 'group', name: 'mtl-soccer' }),
}),
expect.objectContaining({
type: 'dependsOn',
target: expect.objectContaining({ kind: 'cycle', name: 'soccer-2026-spring' }),
}),
expect.objectContaining({
type: 'dependsOn',
target: expect.objectContaining({ kind: 'resource', name: 'field-1' }),
}),
]),
);
});

it('emits nothing for non-Saga kinds', async () => {
const emitted: CatalogProcessorResult[] = [];
await p.postProcessEntity(
{ apiVersion: 'backstage.io/v1alpha1', kind: 'Component', metadata: { name: 'x' } },
{ type: 'file', target: 'x' } as any,
r => emitted.push(r),
);
expect(emitted).toHaveLength(0);
});
});
Loading