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
27 changes: 27 additions & 0 deletions packages/confluence/src/digest.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import assert from 'node:assert/strict';
import test from 'node:test';

import { digest, type DigestContext } from './digest.js';

test('digest returns null for Confluence M1 no-op activity windows', async () => {
const ctx: DigestContext = {
provider: 'confluence',
window: { from: '2026-05-12T00:00:00.000Z', to: '2026-05-13T00:00:00.000Z' },
async changeEvents() {
return [
{
id: 'evt-1',
timestamp: '2026-05-12T08:00:00.000Z',
canonicalPath: '/confluence/pages/123__release-plan.json',
},
];
},
};

// Determinism: two runs over the same fixture produce byte-identical
// results (WI 2 acceptance criterion 4 in the workspace-primitives spec).
const first = await digest(ctx);
const second = await digest(ctx);
assert.deepEqual(second, first);
assert.equal(first, null);
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
25 changes: 25 additions & 0 deletions packages/confluence/src/digest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
export interface DigestContext {
readonly provider: string;
readonly window: {
readonly from: string;
readonly to: string;
};
changeEvents(filter?: {
providers?: string[];
paths?: string[];
}): Promise<readonly unknown[]>;
}

export interface DigestBullet {
readonly text: string;
readonly canonicalPath: string;
}

export interface DigestSection {
readonly provider: string;
readonly bullets: readonly DigestBullet[];
}

export type DigestHandler = (ctx: DigestContext) => Promise<DigestSection | null>;

export const digest: DigestHandler = async () => null;
2 changes: 2 additions & 0 deletions packages/confluence/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export {
ConfluenceAdapter,
} from './confluence-adapter.js';
export * from './digest.js';
export type {
ConnectionProvider,
IngestError,
Expand Down Expand Up @@ -53,6 +54,7 @@ export {
confluenceLayoutPromptFile,
CONFLUENCE_LAYOUT_PROMPT,
} from './layout-prompt.js';
export * from './layout.js';
export * from './summary.js';

export {
Expand Down
26 changes: 26 additions & 0 deletions packages/confluence/src/layout.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import assert from 'node:assert/strict';
import test from 'node:test';

import { layoutManifest } from './layout.js';

const CANONICAL_ALIAS_SEGMENTS = new Set(['by-id', 'by-name', 'by-state', 'by-title']);

test('layoutManifest exposes Confluence resources with canonical aliases and writeback schema pointers', () => {
const manifest = layoutManifest();

assert.equal(manifest.provider, 'confluence');
assert.deepEqual(manifest.aliasSegments, ['by-id', 'by-title', 'by-state']);

for (const resource of manifest.resources) {
assert.ok(resource.path.startsWith('confluence/'));
assert.doesNotMatch(resource.path, /^\//u);
for (const alias of resource.aliasSegments) {
assert.ok(CANONICAL_ALIAS_SEGMENTS.has(alias), `unexpected alias segment ${alias}`);
}
for (const writeback of resource.writebackResources) {
assert.ok(writeback.path.startsWith('confluence/'));
assert.doesNotMatch(writeback.path, /^\//u);
assert.match(writeback.schemaId, /^confluence\/[a-z-]+$/u);
}
}
});
Comment on lines +8 to +26

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Add collision coverage for alias subtrees declared by the manifest.

This suite validates allowed alias segment names but does not verify collision behavior (e.g., two inputs normalizing to the same alias key) for the declared alias subtrees. Please add at least one collision test per subtree to meet the repository test contract.

As per coding guidelines packages/**/src/**/*.test.ts: Each alias subtree needs a collision test.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/confluence/src/layout.test.ts` around lines 8 - 26, Add collision
tests for each alias subtree declared by layoutManifest: call layoutManifest(),
iterate manifest.aliasSegments and for each segment add a unit test (e.g.,
"alias collision for <segment>") that uses the same normalization function used
by the implementation (import the normalize/alias-key helper the code uses,
e.g., normalizeAlias or aliasKeyFrom) to produce two different inputs that
normalize to the same alias key, then assert the manifest/resource behavior on
collision (for manifest.resources[*].aliasSegments and the corresponding alias
subtree — e.g., check the produced alias key is identical and that
lookups/registry expose the expected single resolved entry or the defined
collision resolution). Ensure tests reference layoutManifest,
manifest.resources, manifest.aliasSegments and CANONICAL_ALIAS_SEGMENTS so each
declared subtree has one collision test.

47 changes: 47 additions & 0 deletions packages/confluence/src/layout.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
export type MaterializationMode = 'eager' | 'lazy';

export interface WritebackResourceManifest {
readonly path: string;
readonly schemaId: string;
}

export interface LayoutResourceManifest {
readonly path: string;
readonly title: string;
readonly materialization: MaterializationMode;
readonly aliasSegments: readonly string[];
readonly writebackResources: readonly WritebackResourceManifest[];
}

export interface LayoutManifest {
readonly provider: string;
readonly filenameConvention: string;
readonly aliasSegments: readonly string[];
readonly resources: readonly LayoutResourceManifest[];
}

export type LayoutManifestProvider = () => LayoutManifest;

export const layoutManifest: LayoutManifestProvider = () => ({
provider: 'confluence',
filenameConvention: '<slug>__<id>.json',
aliasSegments: ['by-id', 'by-title', 'by-state'],
resources: [
{
path: 'confluence/pages',
title: 'Pages',
materialization: 'eager',
aliasSegments: ['by-id', 'by-title', 'by-state'],
writebackResources: [
{ path: 'confluence/pages', schemaId: 'confluence/page' },
],
},
{
path: 'confluence/spaces',
title: 'Spaces',
materialization: 'eager',
aliasSegments: ['by-id', 'by-title'],
writebackResources: [],
},
],
});
86 changes: 86 additions & 0 deletions packages/github/src/digest.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import assert from 'node:assert/strict';
import test from 'node:test';

import { digest, type DigestContext } from './digest.js';

test('digest returns deterministic GitHub bullets sorted by event time and id', async () => {
const ctx: DigestContext = {
provider: 'github',
window: { from: '2026-05-12T00:00:00.000Z', to: '2026-05-13T00:00:00.000Z' },
async changeEvents(filter) {
assert.deepEqual(filter, { providers: ['github'] });
return [
{
id: 'evt-2',
timestamp: '2026-05-12T09:00:00.000Z',
action: 'closed',
canonicalPath: 'github/repos/acme/api/issues/43__remove-flake/meta.json',
},
{
id: 'evt-1',
timestamp: '2026-05-12T08:00:00.000Z',
action: 'opened',
canonicalPath: '/github/repos/acme/api/issues/42__add-login/meta.json',
},
];
},
};

const first = await digest(ctx);
const second = await digest(ctx);

assert.deepEqual(first, second);
assert.deepEqual(first, {
provider: 'github',
bullets: [
{
text: '#42 was opened',
canonicalPath: 'github/repos/acme/api/issues/42__add-login/meta.json',
},
{
text: '#43 was closed',
canonicalPath: 'github/repos/acme/api/issues/43__remove-flake/meta.json',
},
],
});
});

test('digest classifies "reopened" as updated, not opened (word-boundary regex)', async () => {
const ctx: DigestContext = {
provider: 'github',
window: { from: '2026-05-12T00:00:00.000Z', to: '2026-05-13T00:00:00.000Z' },
async changeEvents() {
return [
{
id: 'evt-1',
timestamp: '2026-05-12T08:00:00.000Z',
action: 'reopened',
canonicalPath: 'github/repos/acme/api/issues/42__add-login/meta.json',
},
];
},
};

const result = await digest(ctx);
assert.deepEqual(result, {
provider: 'github',
bullets: [
{
text: '#42 was updated',
canonicalPath: 'github/repos/acme/api/issues/42__add-login/meta.json',
},
],
});
});

test('digest returns null for an empty GitHub event window', async () => {
const ctx: DigestContext = {
provider: 'github',
window: { from: '2026-05-12T00:00:00.000Z', to: '2026-05-13T00:00:00.000Z' },
async changeEvents() {
return [];
},
};

assert.equal(await digest(ctx), null);
});
115 changes: 115 additions & 0 deletions packages/github/src/digest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
export interface DigestWindow {
readonly from: string;
readonly to: string;
}

export interface DigestChangeEvent {
readonly id?: string;
readonly timestamp?: string;
readonly occurredAt?: string;
readonly eventType?: string;
readonly type?: string;
readonly action?: string;
readonly canonicalPath?: string;
readonly path?: string;
}

export interface DigestContext {
readonly provider: string;
readonly window: DigestWindow;
changeEvents(filter?: {
providers?: string[];
paths?: string[];
}): Promise<readonly DigestChangeEvent[]>;
}

export interface DigestBullet {
readonly text: string;
readonly canonicalPath: string;
}

export interface DigestSection {
readonly provider: string;
readonly bullets: readonly DigestBullet[];
}

export type DigestHandler = (ctx: DigestContext) => Promise<DigestSection | null>;

export const digest: DigestHandler = async (ctx) => {
const events = await ctx.changeEvents({ providers: [ctx.provider] });
const bullets = events
.filter(hasCanonicalPath)
.slice()
.sort(compareEvents)
.map((event) => {
const canonicalPath = normalizeDigestPath(event.canonicalPath);
return {
text: `${githubIdentifier(canonicalPath)} ${pastTense(event)}`,
canonicalPath,
};
});

return bullets.length === 0 ? null : { provider: ctx.provider, bullets };
};

function hasCanonicalPath(event: DigestChangeEvent): event is DigestChangeEvent & { canonicalPath: string } {
return (
typeof event.canonicalPath === 'string'
&& (event.canonicalPath === 'github' || event.canonicalPath.startsWith('github/') || event.canonicalPath.startsWith('/github/'))
);
}

function compareEvents(left: DigestChangeEvent, right: DigestChangeEvent): number {
// Compare parsed timestamps in ms rather than ISO strings: lexicographic
// string compare misorders events whose timestamps describe the same
// instant with different textual offsets (e.g. `Z` vs `+00:00`).
const leftMs = eventTimeMs(left);
const rightMs = eventTimeMs(right);
return (
leftMs - rightMs
|| (left.id ?? '').localeCompare(right.id ?? '')
|| (left.canonicalPath ?? '').localeCompare(right.canonicalPath ?? '')
);
}

function eventTime(event: DigestChangeEvent): string {
return event.timestamp ?? event.occurredAt ?? '';
}

function eventTimeMs(event: DigestChangeEvent): number {
const raw = eventTime(event);
if (!raw) return Number.NEGATIVE_INFINITY;
const ms = Date.parse(raw);
return Number.isNaN(ms) ? Number.NEGATIVE_INFINITY : ms;
}

function normalizeDigestPath(path: string): string {
return path.replace(/^\/+/u, '');
}

function githubIdentifier(path: string): string {
const segments = path.split('/').filter(Boolean);
const segment = segments.at(-1) === 'meta.json' || segments.at(-1) === 'metadata.json'
? segments.at(-2) ?? path
: segments.at(-1) ?? path;
const basename = segment.replace(/\.[^.]+$/u, '');
const separatorIndex = basename.lastIndexOf('__');
return separatorIndex > 0 ? `#${basename.slice(0, separatorIndex)}` : basename;
}

function pastTense(event: DigestChangeEvent): string {
const action = (event.action ?? event.eventType ?? event.type ?? '').toLowerCase();
// Word boundaries (\b) prevent substring matches like "reopened" being
// misclassified as "opened" — a real GitHub webhook action that would
// otherwise produce a semantically wrong digest line.
if (/\b(open|opened|create|created|add|added|write|written)\b/u.test(action)) {
return 'was opened';
}
if (/\b(close|closed|merge|merged)\b/u.test(action)) {
return 'was closed';
}
if (/\b(delete|deleted|remove|removed)\b/u.test(action)) {
return 'was deleted';
}
return 'was updated';
}
2 changes: 2 additions & 0 deletions packages/github/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,9 @@ import { createRouter } from './webhook/router.js';
import { GitHubWritebackHandler } from './writeback.js';

export * from './emit-auxiliary-files.js';
export * from './digest.js';
export * from './index-emitter.js';
export * from './layout.js';
export * from './layout-prompt.js';
export * from './summary.js';
export * from './thread.js';
Expand Down
40 changes: 40 additions & 0 deletions packages/github/src/layout.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import assert from 'node:assert/strict';
import test from 'node:test';

import { layoutManifest } from './layout.js';

const CANONICAL_ALIAS_SEGMENTS = new Set(['by-id', 'by-name', 'by-state', 'by-title']);

test('layoutManifest exposes GitHub resources with canonical aliases and writeback schema pointers', () => {
const manifest = layoutManifest();

assert.equal(manifest.provider, 'github');
assert.deepEqual(manifest.aliasSegments, ['by-id', 'by-name', 'by-title']);
assert.ok(manifest.resources.length > 0);

for (const resource of manifest.resources) {
assert.ok(resource.path.startsWith('github/'));
assert.doesNotMatch(resource.path, /^\//u);
for (const alias of resource.aliasSegments) {
assert.ok(CANONICAL_ALIAS_SEGMENTS.has(alias), `unexpected alias segment ${alias}`);
}
for (const writeback of resource.writebackResources) {
assert.ok(writeback.path.startsWith('github/'));
assert.doesNotMatch(writeback.path, /^\//u);
assert.match(writeback.schemaId, /^github\/[a-z-]+$/u);
}
}
});
Comment on lines +8 to +27

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Add alias-collision coverage for declared alias subtrees.

This test validates allowed alias names, but it does not verify collision behavior for the alias subtrees exposed by the manifest (for example by-title/by-name). Please add collision tests so colliding aliases are proven to resolve deterministically.

As per coding guidelines, "packages/**/src/**/*.test.ts: Each alias subtree needs a collision test."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/github/src/layout.test.ts` around lines 8 - 27, Add a new test in
the same file that uses layoutManifest() to exercise alias-subtree collisions:
iterate manifest.resources and for each resource build the per-alias subtree key
(using manifest.aliasSegments and resource.aliasSegments) and assert there are
no ambiguous/duplicate exposed targets without a deterministic tie-breaker;
specifically verify colliding alias names (e.g., the documented case like
by-title vs by-name) are resolved consistently by checking that when two aliases
map to the same subtree the manifest always prefers the same alias according to
manifest.aliasSegments order. Use the existing symbols layoutManifest,
manifest.aliasSegments, resource.aliasSegments and CANONICAL_ALIAS_SEGMENTS to
locate and validate alias collision behavior and add assertions that collisions
either do not occur or resolve deterministically.


test('layoutManifest top-level aliasSegments contains the union of all resource alias segments', () => {
const manifest = layoutManifest();
const declared = new Set(manifest.aliasSegments);
for (const resource of manifest.resources) {
for (const alias of resource.aliasSegments) {
assert.ok(
declared.has(alias),
`top-level aliasSegments missing ${alias} declared by resource ${resource.path}`,
);
}
}
});
Loading
Loading