From 06badeab82dd74e060d84c92d1073a6430be841f Mon Sep 17 00:00:00 2001 From: Michiel de Jong Date: Wed, 9 Sep 2026 14:22:02 +0200 Subject: [PATCH 1/2] Support DID subjects and links in native Atomic Data --- README.md | 3 +- __tests__/atomic/lens.test.ts | 33 ++++++++++++++++++++ __tests__/atomic/resources.test.ts | 50 +++++++++++++++++++++++++++++- docs/atomic-data.md | 6 ++-- src/atomic/IdentityMap.ts | 2 ++ src/atomic/Resource.ts | 27 ++++++++++++---- 6 files changed, 111 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index b9ff2c9..a2bcc60 100644 --- a/README.md +++ b/README.md @@ -108,4 +108,5 @@ git push git push --tags pnpm build:release pnpm publish -``` \ No newline at end of file +``` +The native resource API accepts HTTP(S) and DID identities, including AtomicServer `did:ad:` resources, properties and links. Identity allocation still uses an HTTP(S) base; bind existing DID resources explicitly. See [Atomic Data API](docs/atomic-data.md). diff --git a/__tests__/atomic/lens.test.ts b/__tests__/atomic/lens.test.ts index 24bfa7b..ea25394 100644 --- a/__tests__/atomic/lens.test.ts +++ b/__tests__/atomic/lens.test.ts @@ -185,3 +185,36 @@ describe('Atomic Extract Entity lens', () => { expect(connector.delete).toHaveBeenCalledTimes(2); }); }); + +it('ingests and republishes an explicitly bound DID after restart without duplication', async () => { + const first = setup(); + const subject = 'did:ad:existing/resource+signature=='; + const scope = { scope: 'did:ad:agent:account=', entity: 'event' }; + const makeLens = (state: ReturnType): AtomicLens => + new AtomicLens({ + store: state.store, + identities: state.ids, + connector: state.connector, + ...scope, + read: (record: FlatOrder): { set: Record } => ({ + set: { [v.quantity]: record.quantity }, + }), + write: (resource, previous): FlatOrder => ({ + ...previous!, + quantity: resource[v.quantity] as number, + }), + }); + first.ids.bind(scope, input.id, subject); + await makeLens(first).ingest(input); + const restored = setup(first.store.toJSONAD()); + restored.records.set(input.id, input); + const lens = makeLens(restored); + expect(await lens.ingest(input)).toBe(subject); + restored.store.patch(subject, { set: { [v.quantity]: 4 } }); + await lens.publish(subject); + expect(restored.connector.create).not.toHaveBeenCalled(); + expect(restored.records.get(input.id)?.quantity).toBe(4); + expect(restored.store.all().filter((r) => r['@id'] === subject)).toHaveLength( + 1, + ); +}); diff --git a/__tests__/atomic/resources.test.ts b/__tests__/atomic/resources.test.ts index 10057c9..1d12e0c 100644 --- a/__tests__/atomic/resources.test.ts +++ b/__tests__/atomic/resources.test.ts @@ -44,7 +44,9 @@ describe('AtomicStore', () => { original[link][name] = 'mutated'; const copy = store.get(a)!; (copy[link] as Record)[name] = 'also mutated'; - const [parsed] = new JSONADParser().parseArray(JSON.parse(store.toJSONAD())); + const [parsed] = new JSONADParser().parseArray( + JSON.parse(store.toJSONAD()), + ); expect(parsed).toHaveLength(1); const restored = setup(); restored.loadJSONAD(store.toJSONAD()); @@ -143,3 +145,49 @@ describe('AtomicIdentityMap', () => { expect(() => ids.bind(scope, '', b)).toThrow(); }); }); + +describe('DID resources', () => { + const subject = 'did:ad:abc/def+ghi=='; + const property = 'did:ad:property'; + const classId = 'did:ad:class'; + const scope = { scope: 'did:ad:agent:abc+def=', entity: 'event' }; + const create = (): AtomicStore => + new AtomicStore(new AtomicSchema().property(property, Datatype.ATOMIC_URL)); + + it('preserves DID subjects, property keys, classes, links and identity mappings across restart', () => { + const store = create(); + const ids = new AtomicIdentityMap(store, 'https://example.com/bridge'); + store.put({ '@id': subject, [property]: scope.scope, [IS_A]: [classId] }); + ids.bind(scope, 'event-1', subject); + const restored = create(); + const restoredIds = new AtomicIdentityMap( + restored, + 'https://example.com/bridge', + ); + restored.loadJSONAD(store.toJSONAD()); + expect(restored.all(classId)[0]['@id']).toBe(subject); + expect(restoredIds.subjectFor(scope, 'event-1')).toBe(subject); + expect(restoredIds.externalId(scope, subject)).toBe('event-1'); + restored.patch(subject, { set: { [property]: 'did:key:z6MkExample' } }); + expect(restored.get(subject)?.[property]).toBe('did:key:z6MkExample'); + restored.patch(subject, { unset: [property] }); + expect(restored.get(subject)?.[property]).toBeUndefined(); + }); + + it.each([ + 'did:', + 'did:ad:', + 'did::abc', + 'did:AD:abc', + 'did:ad:has space', + 'did:ad:%ZZ', + 'javascript:alert(1)', + 'file:///tmp/resource', + ])('rejects invalid subject %s', (id) => { + expect(() => create().put({ '@id': id })).toThrow(); + }); + + it('requires an HTTP(S) allocator base even when resources use DIDs', () => { + expect(() => new AtomicIdentityMap(create(), subject)).toThrow('HTTP(S)'); + }); +}); diff --git a/docs/atomic-data.md b/docs/atomic-data.md index 433bdac..3e640a6 100644 --- a/docs/atomic-data.md +++ b/docs/atomic-data.md @@ -22,9 +22,9 @@ store.patch(subject, { unset: [title] }); `apply([{ subject, patch }, ...])` validates and applies related resource changes as a local transaction. A validation failure leaves all resources untouched. `transaction(callback)` groups synchronous store and identity operations with rollback; the callback must not be async or launch asynchronous work. -`AtomicSchema` is an offline property catalog. Define every property before importing data; unknown properties are rejected. It uses `@tomic/lib` datatype validation, with additional primitive, finite-number and link checks. This release supports the SDK's string, markdown, slug, boolean, integer, float, date, timestamp, atomicURL and resourceArray types. Links may be HTTP(S) URLs or nested resources with property URL keys and no `@id`. Named resources are permitted at the JSON-AD document root or in its root array. +`AtomicSchema` is an offline property catalog. Define every property before importing data; unknown properties are rejected. It uses `@tomic/lib` datatype validation, with additional primitive, finite-number and link checks. This release supports the SDK's string, markdown, slug, boolean, integer, float, date, timestamp, atomicURL and resourceArray types. Links may be HTTP(S) URLs or DID identifiers or nested resources with property URL keys and no `@id`. Named resources are permitted at the JSON-AD document root or in its root array. -This is a deliberately bounded JSON-AD profile: HTTP(S) subjects, explicitly registered datatypes, no arbitrary JSON or Yjs datatypes, no automatic remote schema resolution, and no required-property/class-constraint validation. Application lenses must check required fields. Publish your property's Atomic Schema definitions at their URLs for interoperability; declaring a local catalog does not publish them. The example.com vocabulary in examples is illustrative. +This is a deliberately bounded JSON-AD profile: HTTP(S) or DID subjects, explicitly registered datatypes, no arbitrary JSON or Yjs datatypes, no automatic remote schema resolution, and no required-property/class-constraint validation. Application lenses must check required fields. Publish your property's Atomic Schema definitions at their URLs for interoperability; declaring a local catalog does not publish them. The example.com vocabulary in examples is illustrative. ## External identities @@ -105,3 +105,5 @@ There are no revision clocks, stale-event rejection, cross-system conflict resol The behavioral tests cover distinct identical records, scoped identities, round-trip snapshots, malformed input rollback, linked entity extraction, reverse updates, field preservation/removal, replay, queued creates, lost responses and deletion failures. Specification references: [JSON-AD](https://docs.atomicdata.dev/core/json-ad.html), [Atomic Schema](https://docs.atomicdata.dev/schema/intro.html), [Atomic Commits](https://docs.atomicdata.dev/commits/intro.html), [TypeScript SDK](https://atomicdata-dev.github.io/atomic-data-browser/docs/modules/_tomic_lib.html). + +DID identifiers (including AtomicServer `did:ad:` base64 identities) are accepted as resource subjects, property keys, classes, links and connector scopes. Identifiers are preserved exactly; validation does not resolve DIDs or verify signatures. Identity allocator bases still require HTTP(S) URLs because the allocator appends child paths. Bind existing DID resources explicitly before ingesting their external records. diff --git a/src/atomic/IdentityMap.ts b/src/atomic/IdentityMap.ts index 12f88ca..f526a9a 100644 --- a/src/atomic/IdentityMap.ts +++ b/src/atomic/IdentityMap.ts @@ -27,6 +27,8 @@ export class AtomicIdentityMap { ) { assertSubject(baseURL); const url = new URL(baseURL); + if (!['http:', 'https:'].includes(url.protocol)) + throw new Error('Identity base must be an HTTP(S) URL'); if (url.search || url.hash) throw new Error('Identity base URL cannot contain a query or fragment'); this.base = baseURL.replace(/\/$/, ''); diff --git a/src/atomic/Resource.ts b/src/atomic/Resource.ts index 85f804c..21d5c9f 100644 --- a/src/atomic/Resource.ts +++ b/src/atomic/Resource.ts @@ -17,14 +17,29 @@ export interface AtomicPatch { } export const IS_A = 'https://atomicdata.dev/properties/isA'; -/** Require absolute HTTP(S) URLs for this implementation's resource profile. */ +/** Accept HTTP(S) and DID identifiers without normalizing identity strings. + * Atomic's did:ad profile includes base64 (+, /, =) identifiers. This is + * syntactic validation only, not DID resolution or signature verification. + */ export function assertSubject(value: string): void { - if (typeof value !== 'string' || /\s/.test(value)) - throw new Error('Expected an absolute URL without whitespace'); + if ( + typeof value !== 'string' || + /\s/.test(value) || + [...value].some((c) => c.charCodeAt(0) < 32 || c.charCodeAt(0) === 127) + ) + throw new Error( + 'Expected an absolute identifier without whitespace or controls', + ); const url = new URL(value); - if (!['http:', 'https:'].includes(url.protocol)) { - throw new Error(`Expected an HTTP(S) URL: ${value}`); - } + if (['http:', 'https:'].includes(url.protocol)) return; + if ( + /^did:[a-z0-9]+:[A-Za-z0-9._:%+/=-]+(?:[?#][A-Za-z0-9._~:/?#[\]@!$&'()*+,;=%-]*)?$/.test( + value, + ) && + !/%(?![0-9a-fA-F]{2})/.test(value) + ) + return; + throw new Error(`Expected an HTTP(S) URL or DID: ${value}`); } /** Offline property catalog. Callers supply definitions rather than fetching schemas. */ From c7d09929e09e2cc9955a3c955cadfd588f04f2e9 Mon Sep 17 00:00:00 2001 From: Michiel de Jong Date: Fri, 18 Sep 2026 15:39:38 +0200 Subject: [PATCH 2/2] Claude/magical hopper wg0dt8 (#21) * Support DID subjects and links in native Atomic Data (#9) Co-authored-by: Michiel de Jong * Extract GitHub Devonian lens package * Make GitHub lens package consumable from Devonian * Extract Clockify and Notion integration lenses * Publish stable GitHub lens entrypoints * Expose bundled integration plugins * Extract Google Calendar lens package * Describe Google Calendar lens package * Export Devonian source entry for browser consumers * Include Devonian source runtime in package * Export Google Calendar lens entrypoints * Add Atomic lib dev dependency for lens tests * Add generic import query descriptor * Expose calendar lens defaults * Export Google Calendar import lens * Export atomic integration lens subpaths * Publish OAuth provider descriptors * Make unified lens package CI self-contained * Describe provider token handling * Share Atomic JSON value types with consumers * Export shared calendar JSON value type * Move GitHub integration implementation into Devonian * Preserve Calendar import scope semantics * Export Google Calendar host UI * Describe direct setup in integration registry * Export external integration evidence registry * Use Atomic import identity property URLs * Run GitHub tests through host aliases * Resolve relocated package test fixtures * Publish integration presentation metadata * Import registered upgrade component * Remove extracted Atomic integrations (#16) Co-authored-by: Michiel de Jong * Extract passive platform lens modules (#17) Co-authored-by: Michiel de Jong * Document GitHub issue and comment mapping example (#18) Co-authored-by: Michiel de Jong --------- Co-authored-by: Michiel de Jong Co-authored-by: Claude --- README.md | 12 + __tests__/unit/platformLenses.test.ts | 174 ++++++ __tests__/unit/reconcileRecord.test.ts | 26 + __tests__/vitest.config.ts | 11 +- docs/atomic-data.md | 31 + eslint.config.mjs | 23 + package.json | 26 +- platform-lenses/github-issues/README.md | 133 ++++ platform-lenses/github-issues/adapter.test.ts | 91 +++ platform-lenses/github-issues/adapter.ts | 260 ++++++++ .../github-issues/atomic.live.test.ts | 117 ++++ platform-lenses/github-issues/atomic.ts | 256 ++++++++ .../github-issues/automation.test.ts | 27 + platform-lenses/github-issues/bridge.mjs | 222 +++++++ platform-lenses/github-issues/bridge.test.mjs | 133 ++++ platform-lenses/github-issues/descriptor.json | 1 + .../github-issues/github.live.test.ts | 329 ++++++++++ platform-lenses/github-issues/index.mjs | 23 + platform-lenses/github-issues/lens/index.ts | 92 +++ .../github-issues/lens/resources.mjs | 33 + .../github-issues/manifest.fixture.json | 92 +++ platform-lenses/github-issues/plugin.js | 577 ++++++++++++++++++ platform-lenses/github-issues/plugin.ts | 388 ++++++++++++ platform-lenses/github-issues/ports.mjs | 320 ++++++++++ platform-lenses/github-issues/ports.test.mjs | 236 +++++++ platform-lenses/github-issues/proxy.mjs | 199 ++++++ platform-lenses/github-issues/proxy.test.mjs | 100 +++ .../github-issues/tracker-actions.ts | 190 ++++++ platform-lenses/github-issues/types.mjs | 1 + platform-lenses/github-issues/types.ts | 20 + .../github-issues/vitest.config.mjs | 14 + platform-lenses/google-calendar/README.md | 17 + .../google-calendar/descriptor.json | 6 + .../google-calendar/import.test.ts | 25 + platform-lenses/google-calendar/import.ts | 31 + platform-lenses/google-calendar/index.ts | 6 + platform-lenses/google-calendar/lens/edit.ts | 100 +++ platform-lenses/google-calendar/lens/index.ts | 5 + .../google-calendar/lens/projection.ts | 159 +++++ .../google-calendar/lens/recurrence.ts | 93 +++ platform-lenses/google-calendar/lens/types.ts | 24 + platform-lenses/google-calendar/projection.ts | 2 + platform-lenses/google-calendar/recurrence.ts | 2 + platform-lenses/google-calendar/sync.ts | 195 ++++++ platform-lenses/google-calendar/types.ts | 2 + pnpm-lock.yaml | 12 +- pnpm-workspace.yaml | 3 + src/reconcileRecord.ts | 113 ++++ 48 files changed, 4945 insertions(+), 7 deletions(-) create mode 100644 __tests__/unit/platformLenses.test.ts create mode 100644 __tests__/unit/reconcileRecord.test.ts create mode 100644 platform-lenses/github-issues/README.md create mode 100644 platform-lenses/github-issues/adapter.test.ts create mode 100644 platform-lenses/github-issues/adapter.ts create mode 100644 platform-lenses/github-issues/atomic.live.test.ts create mode 100644 platform-lenses/github-issues/atomic.ts create mode 100644 platform-lenses/github-issues/automation.test.ts create mode 100644 platform-lenses/github-issues/bridge.mjs create mode 100644 platform-lenses/github-issues/bridge.test.mjs create mode 100644 platform-lenses/github-issues/descriptor.json create mode 100644 platform-lenses/github-issues/github.live.test.ts create mode 100644 platform-lenses/github-issues/index.mjs create mode 100644 platform-lenses/github-issues/lens/index.ts create mode 100644 platform-lenses/github-issues/lens/resources.mjs create mode 100644 platform-lenses/github-issues/manifest.fixture.json create mode 100644 platform-lenses/github-issues/plugin.js create mode 100644 platform-lenses/github-issues/plugin.ts create mode 100644 platform-lenses/github-issues/ports.mjs create mode 100644 platform-lenses/github-issues/ports.test.mjs create mode 100644 platform-lenses/github-issues/proxy.mjs create mode 100644 platform-lenses/github-issues/proxy.test.mjs create mode 100644 platform-lenses/github-issues/tracker-actions.ts create mode 100644 platform-lenses/github-issues/types.mjs create mode 100644 platform-lenses/github-issues/types.ts create mode 100644 platform-lenses/github-issues/vitest.config.mjs create mode 100644 platform-lenses/google-calendar/README.md create mode 100644 platform-lenses/google-calendar/descriptor.json create mode 100644 platform-lenses/google-calendar/import.test.ts create mode 100644 platform-lenses/google-calendar/import.ts create mode 100644 platform-lenses/google-calendar/index.ts create mode 100644 platform-lenses/google-calendar/lens/edit.ts create mode 100644 platform-lenses/google-calendar/lens/index.ts create mode 100644 platform-lenses/google-calendar/lens/projection.ts create mode 100644 platform-lenses/google-calendar/lens/recurrence.ts create mode 100644 platform-lenses/google-calendar/lens/types.ts create mode 100644 platform-lenses/google-calendar/projection.ts create mode 100644 platform-lenses/google-calendar/recurrence.ts create mode 100644 platform-lenses/google-calendar/sync.ts create mode 100644 platform-lenses/google-calendar/types.ts create mode 100644 pnpm-workspace.yaml create mode 100644 src/reconcileRecord.ts diff --git a/README.md b/README.md index a2bcc60..a7f9bfc 100644 --- a/README.md +++ b/README.md @@ -110,3 +110,15 @@ pnpm build:release pnpm publish ``` The native resource API accepts HTTP(S) and DID identities, including AtomicServer `did:ad:` resources, properties and links. Identity allocation still uses an HTTP(S) base; bind existing DID resources explicitly. See [Atomic Data API](docs/atomic-data.md). + +### Passive platform lenses + +Provider transformations live in `platform-lenses/github-issues/lens/` and +`platform-lenses/google-calendar/lens/`, exposed through the corresponding +`devonian/platform-lenses//lens` package entry points. These functions +consume supplied data and return projections or patches; they do not fetch, +subscribe, persist, or checkpoint. Their surrounding platform modules retain +existing runtime behavior and compatibility entry points. + +See [the platform lens boundaries](docs/atomic-data.md#passive-platform-lenses) +for the forward and reverse mappings and their scope. diff --git a/__tests__/unit/platformLenses.test.ts b/__tests__/unit/platformLenses.test.ts new file mode 100644 index 0000000..2545f5c --- /dev/null +++ b/__tests__/unit/platformLenses.test.ts @@ -0,0 +1,174 @@ +import { describe, expect, it } from 'vitest'; +import { + project, + unproject, + issuePatch, + type Issue, +} from '../../platform-lenses/github-issues/lens/index.js'; +import { planCalendarValues } from '../../platform-lenses/google-calendar/lens/edit.js'; +import { + applyCalendarEdit, + planCalendarEdit, +} from '../../platform-lenses/google-calendar/sync.js'; + +const issue: Issue & { assignee: string } = { + number: 42, + title: 'Before', + body: null, + state: 'open', + labels: ['bug', { name: 'ATOMIC:DOING' }], + assignee: 'someone', +}; +const name = 'https://atomicdata.dev/properties/name'; +const baseline = { + [name]: 'Before', + summary: 'Before', + description: 'Details', +}; +const properties = { + summary: 'summary', + description: 'description', + start: 'start', + end: 'end', +}; +const remote = { + summary: 'Before', + description: 'Details', + attendees: [{ email: 'person@example.com' }], +}; + +describe('passive platform lenses', () => { + it.each(['Todo', 'Doing', 'Done'] as const)( + 'round trips GitHub %s without changing unrelated data', + (status) => { + const desired = { title: 'After', body: 'Edited', status }; + const previous = structuredClone(issue); + const result = unproject(desired, issue); + expect(project(result)).toEqual(desired); + expect(result.number).toBe(42); + expect(result.assignee).toBe('someone'); + expect(result.labels).toContain('bug'); + expect(issue).toEqual(previous); + expect(unproject(desired, result)).toEqual(result); + }, + ); + + it('keeps the GitHub runtime patch minimal', () => { + const before = project(issue); + expect(issuePatch(before, before)).toEqual({}); + expect(issuePatch({ ...before, status: 'Done' }, before)).toEqual({ + state: 'closed', + }); + }); + + it('maps Calendar edits without replacing provider-only fields or mutating input', () => { + const row = { ...baseline, [name]: 'After', description: '' }; + const previous = structuredClone(row); + const edit = planCalendarValues(row, baseline, properties, remote)!; + expect(edit.patch).toEqual({ summary: 'After', description: '' }); + expect({ ...remote, ...edit.patch }.attendees).toEqual(remote.attendees); + expect(row).toEqual(previous); + const acknowledged = { ...row, ...edit.acknowledged }; + expect( + planCalendarValues(acknowledged, acknowledged, properties, { + ...remote, + ...edit.patch, + }), + ).toBeUndefined(); + }); + + it('rejects competing Calendar edits and invalid intervals', () => { + expect(() => + planCalendarValues( + { ...baseline, [name]: 'Local' }, + baseline, + properties, + { ...remote, summary: 'Remote' }, + ), + ).toThrow('conflict'); + expect(() => + planCalendarValues( + { ...baseline, start: { date: '2026-09-12' } }, + baseline, + properties, + { ...remote, end: { date: '2026-09-11' } }, + ), + ).toThrow('end must follow'); + }); +}); + +describe('Calendar runtime after lens extraction', () => { + const baselineKey = 'https://atomicdata.dev/properties/importBaseline'; + const config = { + platform: 'google-calendar', + destinations: { event: { table: 'urn:calendar', rowClass: 'urn:event' } }, + properties, + }; + const row = { + ...baseline, + [name]: 'After', + [baselineKey]: { values: baseline }, + 'https://atomicdata.dev/properties/localId': JSON.stringify([ + 'google-calendar', + 'event', + 'primary', + '42', + ]), + 'https://atomicdata.dev/properties/parent': 'urn:calendar', + 'https://atomicdata.dev/properties/isA': ['urn:event'], + }; + + it('does not checkpoint failed writes', async () => { + const edit = planCalendarEdit('urn:event:42', row, config, { + ...remote, + etag: 'v1', + })!; + let checkpointed = false; + await expect( + applyCalendarEdit( + edit, + async () => row, + async () => ({ status: 503, body: '' }), + async () => { + checkpointed = true; + }, + ), + ).rejects.toThrow('503'); + expect(checkpointed).toBe(false); + }); + + it('recovers a lost checkpoint without repeating the remote write', async () => { + const edit = planCalendarEdit('urn:event:42', row, config, { + ...remote, + etag: 'v1', + })!; + const savedRemote = { ...remote, ...edit.patch, etag: 'v2' }; + await expect( + applyCalendarEdit( + edit, + async () => row, + async () => ({ status: 200, body: JSON.stringify(savedRemote) }), + async () => { + throw new Error('Checkpoint unavailable'); + }, + ), + ).rejects.toThrow('Checkpoint unavailable'); + const replay = planCalendarEdit('urn:event:42', row, config, savedRemote)!; + expect(replay.patch).toEqual({}); + let writes = 0; + let checkpoint: unknown; + await applyCalendarEdit( + replay, + async () => row, + async () => { + writes++; + throw new Error('Unexpected write'); + }, + async (values) => { + checkpoint = values; + }, + ); + expect(writes).toBe(0); + expect(checkpoint).toMatchObject({ [name]: 'After', summary: 'After' }); + }); +}); diff --git a/__tests__/unit/reconcileRecord.test.ts b/__tests__/unit/reconcileRecord.test.ts new file mode 100644 index 0000000..d7b17c0 --- /dev/null +++ b/__tests__/unit/reconcileRecord.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest'; +import { reconcileRecord } from '../../src/reconcileRecord.js'; + +describe('reconcileRecord', () => { + it('marks equal observations as agreed without changes', () => { + const decision = reconcileRecord( + { title: 'before' }, + { title: 'same' }, + { title: 'same' }, + ); + expect(decision.conflicts).toEqual([]); + expect(decision.agreed).toEqual({ title: 'same' }); + expect(decision.local).toEqual({}); + expect(decision.remote).toEqual({}); + }); + + it('reports a conflict when both sides edit the same field', () => { + const decision = reconcileRecord( + { title: 'before' }, + { title: 'local' }, + { title: 'remote' }, + ); + expect(decision.conflicts).toHaveLength(1); + expect(decision.conflicts[0].property).toBe('title'); + }); +}); diff --git a/__tests__/vitest.config.ts b/__tests__/vitest.config.ts index f27285c..3249a25 100644 --- a/__tests__/vitest.config.ts +++ b/__tests__/vitest.config.ts @@ -6,7 +6,16 @@ import { export default defineConfig({ test: { - exclude: [...configDefaults.exclude, 'build/**/*'], + exclude: [ + ...configDefaults.exclude, + 'build/**/*', + // These GitHub package tests exercise the Atomic host contract and run + // in the consuming application, which provides the host dependencies. + 'platform-lenses/github-issues/adapter.test.ts', + 'platform-lenses/github-issues/atomic.live.test.ts', + 'platform-lenses/github-issues/automation.test.ts', + 'platform-lenses/github-issues/github.live.test.ts', + ], coverage: { provider: 'v8', exclude: [...coverageConfigDefaults.exclude, 'build/**/*'], diff --git a/docs/atomic-data.md b/docs/atomic-data.md index 3e640a6..dd89b97 100644 --- a/docs/atomic-data.md +++ b/docs/atomic-data.md @@ -107,3 +107,34 @@ The behavioral tests cover distinct identical records, scoped identities, round- Specification references: [JSON-AD](https://docs.atomicdata.dev/core/json-ad.html), [Atomic Schema](https://docs.atomicdata.dev/schema/intro.html), [Atomic Commits](https://docs.atomicdata.dev/commits/intro.html), [TypeScript SDK](https://atomicdata-dev.github.io/atomic-data-browser/docs/modules/_tomic_lib.html). DID identifiers (including AtomicServer `did:ad:` base64 identities) are accepted as resource subjects, property keys, classes, links and connector scopes. Identifiers are preserved exactly; validation does not resolve DIDs or verify signatures. Identity allocator bases still require HTTP(S) URLs because the allocator appends child paths. Bind existing DID resources explicitly before ingesting their external records. + +## Passive platform lenses + +The `platform-lenses/github-issues/lens/` and +`platform-lenses/google-calendar/lens/` directories contain passive mappings. +A caller supplies resource data; these modules neither access datasets nor own +credentials, subscriptions, identity lookups, or durable synchronization state. + +- GitHub's `project(issue)` maps issue fields to title/body/status. + `unproject(value, previousIssue)` maps back while preserving the issue number, + unrelated fields, and labels other than `atomic:doing`. The reverse mapping + normalizes that workflow label to the requested status. `issueFields` and + `issuePatch` support the existing runtime's field writes. The bridge's Atomic + property mapping for issues and comments lives in `lens/resources.mjs`. +- Calendar's `calendarProjection` and `calendarRecurrenceProjection` add Atomic + display/recurrence properties while retaining provider fields. + `planCalendarValues(row, baseline, properties, remote)` computes reverse + patches for summary, description, location, start, and end from supplied data. + It retains the existing field conflict and interval validation. It does not + write recurrence changes or replace the complete Google event. Empty strings + explicitly clear supported text fields. + +Import these through `devonian/platform-lenses/github-issues/lens` or +`devonian/platform-lenses/google-calendar/lens`. Calendar's projection helpers +still require the consuming application's compatible `@tomic/lib` calendar +helpers. Previous adapter, projection, recurrence, types, and sync entry points +remain available. + +Transport, dataset membership checks, preview workflows, ETags, and checkpointing +remain in the surrounding platform modules. This extraction does not introduce +an event handler, dataset abstraction, Loro replay, or distributed convergence. diff --git a/eslint.config.mjs b/eslint.config.mjs index 82fbb4e..116e7a2 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -63,4 +63,27 @@ export default tseslint.config( }, }, }, + { + files: ['platform-lenses/**/*.{js,mjs,ts}'], + plugins: { + '@typescript-eslint': tseslint.plugin, + }, + languageOptions: { + parserOptions: { + project: null, + }, + globals: { + ...globals.browser, + ...globals.node, + settings: 'readonly', + }, + }, + rules: { + '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/no-unused-vars': [ + 'error', + { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }, + ], + }, + }, ); diff --git a/package.json b/package.json index 8cf87d8..b6fc8e4 100644 --- a/package.json +++ b/package.json @@ -7,12 +7,28 @@ "node": ">= 22.11 < 23" }, "exports": { - ".": "./build/src/main.js" + ".": "./src/main.ts", + "./platform-lenses/github-issues": "./platform-lenses/github-issues/index.mjs", + "./platform-lenses/github-issues/bridge": "./platform-lenses/github-issues/bridge.mjs", + "./platform-lenses/github-issues/ports": "./platform-lenses/github-issues/ports.mjs", + "./platform-lenses/github-issues/proxy": "./platform-lenses/github-issues/proxy.mjs", + "./platform-lenses/github-issues/adapter": "./platform-lenses/github-issues/adapter.ts", + "./platform-lenses/github-issues/tracker-actions": "./platform-lenses/github-issues/tracker-actions.ts", + "./platform-lenses/github-issues/plugin": "./platform-lenses/github-issues/plugin.ts", + "./platform-lenses/github-issues/plugin.js": "./platform-lenses/github-issues/plugin.js", + "./platform-lenses/github-issues/atomic": "./platform-lenses/github-issues/atomic.ts", + "./platform-lenses/google-calendar": "./platform-lenses/google-calendar/index.ts", + "./platform-lenses/google-calendar/sync": "./platform-lenses/google-calendar/sync.ts", + "./platform-lenses/google-calendar/recurrence": "./platform-lenses/google-calendar/recurrence.ts", + "./platform-lenses/github-issues/lens": "./platform-lenses/github-issues/lens/index.ts", + "./platform-lenses/google-calendar/lens": "./platform-lenses/google-calendar/lens/index.ts" }, "files": [ "docs", "build", - "vocab" + "vocab", + "platform-lenses", + "src" ], "devDependencies": { "@automerge/automerge-repo-network-broadcastchannel": "^2.0.0", @@ -20,6 +36,7 @@ "@eslint/js": "~9.17", "@types/eslint__js": "~8.42", "@types/node": "~20", + "@tomic/lib": "^0.40.0", "@typescript-eslint/parser": "~8.19", "@vitest/coverage-v8": "^3.0.8", "@vitest/eslint-plugin": "~1.1", @@ -54,10 +71,13 @@ "license": "Apache-2.0", "dependencies": { "@automerge/automerge-repo": "^2.0.0", - "@tomic/lib": "^0.40.0", + "fast-json-stable-stringify": "^2.1.0", "effect": "^3.16.0", "tslib": "~2.8" }, + "peerDependencies": { + "@tomic/lib": ">=0.40.0" + }, "volta": { "node": "22.12.0" } diff --git a/platform-lenses/github-issues/README.md b/platform-lenses/github-issues/README.md new file mode 100644 index 0000000..07c4f11 --- /dev/null +++ b/platform-lenses/github-issues/README.md @@ -0,0 +1,133 @@ +# Devonian GitHub issues lens + +This package contains the GitHub issue and comment lens used by the browser +demo. It depends on Devonian's generic native Atomic resource API and receives +the host runtime, connector ports, credential transport, and persistence from +the caller. + +The stable entry point is `createBridge(host, options)` from `index.mjs`. +Hosts select the package by the versioned `descriptor.json`; provider code is +kept beside the lens and is never imported by Devonian's generic runtime. + +The copied browser tests retain deterministic fixture and recovery coverage. +They require the host browser dependencies and are run by the consuming +application until this package has a standalone browser test harness. + +Pure forward/reverse mappings now live in `lens/`, with a public entry point at +`devonian/platform-lenses/github-issues/lens`. `project` reads GitHub issues; +`unproject` writes the projection back onto a supplied issue without dropping +unmanaged fields. `issueFields` and `issuePatch` are used by the existing runtime. +`lens/resources.mjs` holds the bridge's bidirectional Atomic property mapping. +The adapter re-exports its previous mapping API for compatibility. The bridge, +ports, proxy, and plugin continue to own effects and synchronization state. + +## Example: a GitHub issue and its comments + +Here is a representative GitHub issue response, together with two entries from +its separate comments endpoint. The outer `issue` / `comments` wrapper is only +for this example; GitHub returns them through separate requests. + +```json +{ + "issue": { + "number": 42, + "title": "Keep the selected calendar after refresh", + "body": "Refreshing the page resets the selection to **All calendars**.", + "state": "open", + "labels": [{ "name": "bug" }, { "name": "atomic:doing" }], + "html_url": "https://github.com/example/calendar/issues/42" + }, + "comments": [ + { + "id": 101, + "body": "I can reproduce this in Firefox.", + "issue_url": "https://api.github.com/repos/example/calendar/issues/42", + "user": { "login": "alice" }, + "html_url": "https://github.com/example/calendar/issues/42#issuecomment-101" + }, + { + "id": 102, + "body": "The fix is ready for review.", + "issue_url": "https://api.github.com/repos/example/calendar/issues/42", + "user": { "login": "bob" }, + "html_url": "https://github.com/example/calendar/issues/42#issuecomment-102" + } + ] +} +``` + +The corresponding Atomic representation is a graph of three resources: one +tracker row and two native `Message` resources linked to it through `about`. +Comments are not embedded in the issue object. This JSON-AD example shows the +content and membership properties written through `AtomicPort`; runtime identity +and provenance bookkeeping are omitted. + +The `https://example.com/atomic/...` subjects below are illustrative, +installation-specific identities. The tracker row class and GitHub-number +property are configured by the installation; there is no fixed global issue +class in this mapping. The `https://atomicdata.dev/...` properties and status tag +are the native Atomic vocabulary actually used by the implementation. + +```json +[ + { + "@id": "https://example.com/atomic/issues/issue-a", + "https://atomicdata.dev/properties/isA": [ + "https://example.com/atomic/schema/github-issue" + ], + "https://atomicdata.dev/properties/parent": "https://example.com/atomic/issues", + "https://atomicdata.dev/properties/name": "Keep the selected calendar after refresh", + "https://atomicdata.dev/task/v1/body": "Refreshing the page resets the selection to **All calendars**.", + "https://atomicdata.dev/task/v1/status": [ + "https://atomicdata.dev/task/v1/doing" + ], + "https://example.com/atomic/schema/github-issue-number": 42 + }, + { + "@id": "https://example.com/atomic/comments/comment-a", + "https://atomicdata.dev/properties/isA": [ + "https://atomicdata.dev/classes/Message" + ], + "https://atomicdata.dev/properties/parent": "https://example.com/atomic/comments", + "https://atomicdata.dev/properties/about": "https://example.com/atomic/issues/issue-a", + "https://atomicdata.dev/properties/description": "I can reproduce this in Firefox." + }, + { + "@id": "https://example.com/atomic/comments/comment-b", + "https://atomicdata.dev/properties/isA": [ + "https://atomicdata.dev/classes/Message" + ], + "https://atomicdata.dev/properties/parent": "https://example.com/atomic/comments", + "https://atomicdata.dev/properties/about": "https://example.com/atomic/issues/issue-a", + "https://atomicdata.dev/properties/description": "The fix is ready for review." + } +] +``` + +The field correspondence is: + +| GitHub | Atomic tracker representation | +| --- | --- | +| Issue `title` | `https://atomicdata.dev/properties/name` | +| Issue `body` | `https://atomicdata.dev/task/v1/body` | +| Open issue with `atomic:doing` label | Status `[https://atomicdata.dev/task/v1/doing]` | +| Open issue without that label | Status `[https://atomicdata.dev/task/v1/todo]` | +| Closed issue, regardless of labels | Status `[https://atomicdata.dev/task/v1/done]` | +| Issue `number` | Installation's GitHub-number property, kept as a number | +| Comment `body` | Message `https://atomicdata.dev/properties/description` | +| Comment's issue association | Message `https://atomicdata.dev/properties/about`, pointing to the Atomic issue subject | + +The pure issue lens first produces +`{ title, body, status: 'Doing' }`; the resource mapping expresses those fields +using Atomic property URLs. The bridge temporarily represents comment bodies +with the task body property internally, while `AtomicPort` writes native Messages +using `description` and `about`, as shown above. + +In the reverse direction, editing the Atomic title/body updates the corresponding +GitHub fields, changing the status to Done closes the issue, and editing a +Message's description updates its linked GitHub comment. Unrelated labels such +as `bug` remain outside the mapped fields. GitHub comment IDs and issue numbers +are bound to Atomic subjects within their repository/entity scopes, rather than +being inferred from text or array positions. Author names and source URLs are +retained as metadata through the configured provenance property; they are not +turned into Atomic authorship claims. Atomic's Blocked status is not mapped. diff --git a/platform-lenses/github-issues/adapter.test.ts b/platform-lenses/github-issues/adapter.test.ts new file mode 100644 index 0000000..a144f85 --- /dev/null +++ b/platform-lenses/github-issues/adapter.test.ts @@ -0,0 +1,91 @@ +import { describe, it, expect } from 'vitest'; +import { preview, project, manifest, type Issue } from './adapter.js'; +import { validateManifest } from '@integration-host/plugin-manifest'; +import { readFile } from 'node:fs/promises'; +import { execFileSync } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import { join } from 'node:path'; +const issue = (number: number): Issue => ({ + number, + title: `Issue ${number}`, + body: null, + state: 'open', + labels: ['bug'], +}); +describe('GitHub package', () => { + it('uses a strict repository-scoped manifest', () => { + expect(validateManifest(manifest('owner/repo')).operations).toHaveLength(6); + expect(() => manifest('../escape')).toThrow(); + expect(() => manifest('owner/repo?token=x')).toThrow(); + }); + it('ships exactly the artifact tested by the sandbox', async () => { + const built = execFileSync( + existsSync('./browser/node_modules/.bin/esbuild') + ? './browser/node_modules/.bin/esbuild' + : './browser/node_modules/.pnpm/node_modules/.bin/esbuild', + [ + join(import.meta.dirname, 'plugin.ts'), + '--bundle', + '--format=esm', + '--platform=neutral', + '--target=es2022', + `--alias:@integration-host/import-records=${join(process.cwd(), 'browser/lib/src/import-records.ts')}`, + `--alias:@integration-host/plugin-connection=${join(process.cwd(), 'browser/lib/src/plugin-connection.ts')}`, + ], + { encoding: 'utf8' }, + ); + const withoutSourcePaths = (value: string) => + value.replace(/^\/\/ .*\.(?:ts|js)$/gm, '// generated source'); + expect( + withoutSourcePaths(await readFile(join(import.meta.dirname, 'plugin.js'), 'utf8')), + ).toBe(withoutSourcePaths(built)); + }); + it('reads every page and excludes pull requests', async () => { + const issues = Array.from({ length: 101 }, (_, i) => issue(i + 1)); + issues[49].pull_request = {}; + const result = await preview( + { + read: async intent => { + const page = Number(new URL(intent.url).searchParams.get('page')); + return { + status: 200, + body: JSON.stringify(issues.slice((page - 1) * 100, page * 100)), + }; + }, + cards: async () => [], + state: async () => ({ revision: 0, records: {}, cursor: null }), + }, + 'owner/repo', + ); + expect(result.changes).toHaveLength(100); + }); + it('refuses failed reads instead of treating them as deletions', async () => { + await expect( + preview( + { + read: async () => ({ status: 429, body: '{}' }), + cards: async () => [], + state: async () => ({ revision: 0, records: {}, cursor: null }), + }, + 'owner/repo', + ), + ).rejects.toThrow('429'); + }); + it('normalizes closed issues with a remaining doing label', () => { + expect( + project({ ...issue(1), state: 'closed', labels: ['atomic:doing'] }) + .status, + ).toBe('Done'); + }); +}); + +it('keeps the sandbox action manifest fixture current', async () => { + expect( + JSON.parse( + await readFile( + join(import.meta.dirname, 'manifest.fixture.json'), + 'utf8', + ), + ), + ).toEqual(manifest('atomic-fixtures/issues')); +}); diff --git a/platform-lenses/github-issues/adapter.ts b/platform-lenses/github-issues/adapter.ts new file mode 100644 index 0000000..a547dbb --- /dev/null +++ b/platform-lenses/github-issues/adapter.ts @@ -0,0 +1,260 @@ +/** GitHub-specific mapping; the host owns credentials, effects and persistence. */ +import { + reconcileRecord, + type SyncRecord, +} from '../../src/reconcileRecord.js'; +import type { + ConnectionState, + ExternalIntent, + ExternalReceipt, +} from './types.js'; + +import { project, validate, type Projection, type Issue } from './lens/index.js'; +export { project, type Status, type Projection, type Issue } from './lens/index.js'; +export interface Card { + subject: string; + number?: number; + value: Projection; +} +export interface Host { + read(intent: ExternalIntent): Promise; + cards(): Promise; + state(): Promise; +} + +export interface Change { + subject?: string; + number?: number; + local?: Projection; + remote?: Projection; + desired: Projection; +} +export interface Preview { + repository: string; + revision: number; + changes: Change[]; + conflicts: Array<{ subject?: string; number?: number; fields: string[] }>; +} + +const headers = { + Accept: 'application/vnd.github+json', + 'User-Agent': 'Atomic-GitHub-Issues-Pilot', + 'X-GitHub-Api-Version': '2022-11-28', + Authorization: 'secret:github', + 'Content-Type': 'application/json', +}; +export function endpoint(repository: string): string { + if ( + !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository) || + repository.split('/').some(p => p === '.' || p === '..') + ) + throw new Error('Repository must be owner/name'); + return `https://api.github.com/repos/${repository}/issues`; +} +export function manifest(repository: string) { + const url = endpoint(repository); + return { + schemaVersion: 1, + actions: [ + { + name: 'get_issue', + title: 'Get an issue', + description: 'Read one issue from this connected repository.', + operation: 'get', + inputSchema: { + type: 'object', + properties: { + number: { type: 'integer', description: 'Positive issue number' }, + }, + required: ['number'], + additionalProperties: false, + }, + }, + { + name: 'create_issue', + title: 'Create an issue', + description: + 'Prepare a new issue in this repository for review. Nothing is posted until approved.', + operation: 'create', + inputSchema: { + type: 'object', + properties: { + title: { type: 'string', description: 'Issue title' }, + body: { type: 'string', description: 'Markdown description' }, + }, + required: ['title'], + additionalProperties: false, + }, + }, + ], + secrets: [ + { + name: 'github', + origin: 'https://api.github.com', + description: + 'GitHub token with Issues read/write access to this repository', + }, + ], + operations: [ + { id: 'list', method: 'GET', url, effect: 'read' }, + { id: 'get', method: 'GET', url: `${url}/{number}`, effect: 'read' }, + { id: 'create', method: 'POST', url, effect: 'write' }, + { + id: 'update', + method: 'PATCH', + url: `${url}/{number}`, + effect: 'write', + }, + { + id: 'doing-add', + method: 'POST', + url: `${url}/{number}/labels`, + effect: 'write', + }, + { + id: 'doing-remove', + method: 'DELETE', + url: `${url}/{number}/labels/atomic%3Adoing`, + effect: 'write', + }, + ], + }; +} +function parse(response: ExternalReceipt): T { + if (response.status < 200 || response.status >= 300) + throw new Error( + `GitHub returned ${response.status}; no checkpoint was advanced. Resolve access/rate limits before retrying.`, + ); + return JSON.parse(response.body) as T; +} +export function request( + operation: string, + method: string, + url: string, + id: string, + body?: unknown, +): ExternalIntent { + return { + operation, + method, + url, + id, + headers, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }; +} +export async function get( + host: Host, + root: string, + number: number, +): Promise { + const issue = parse( + await host.read(request('get', 'GET', `${root}/${number}`, 'read')), + ); + if ('pull_request' in issue || issue.number !== number) + throw new Error('Expected an issue, not a pull request or another record'); + project(issue); + return issue; +} + +/** Full scans avoid interpreting a partial page as deletion. A pilot cap fails loudly. */ +export async function preview( + host: Host, + repository: string, +): Promise { + const root = endpoint(repository); + const state = await host.state(); + if (state.cursor) + throw new Error( + 'A saved sync is pending; resume it before previewing another run', + ); + const issues = new Map(); + for (let page = 1; ; page++) { + if (page > 100) + throw new Error('Pilot supports at most 10,000 issues/PRs per scan'); + const rows = parse( + await host.read( + request( + 'list', + 'GET', + `${root}?state=all&per_page=100&page=${page}&sort=created&direction=asc`, + `page-${page}`, + ), + ), + ); + if (!Array.isArray(rows)) + throw new Error('GitHub issue page must be an array'); + for (const issue of rows) + if (!('pull_request' in issue)) { + project(issue); + issues.set(issue.number, issue); + } + if (rows.length < 100) break; + } + const cards = await host.cards(); + const byNumber = new Map(); + for (const card of cards) { + validate(card.value); + if (card.number !== undefined) { + if (byNumber.has(card.number)) + throw new Error(`Duplicate cards for issue #${card.number}`); + byNumber.set(card.number, card); + } + } + const result: Preview = { + repository, + revision: state.revision, + changes: [], + conflicts: [], + }; + for (const [number, issue] of issues) { + const remote = project(issue); + const binding = state.records[String(number)]; + const card = byNumber.get(number); + if (binding && (!card || binding.local !== card.subject)) { + result.conflicts.push({ + number, + fields: ['Missing or rebound local card'], + }); + continue; + } + const decision = reconcileRecord( + binding?.baseline as SyncRecord, + card?.value, + remote, + ); + if (decision.conflicts.length) { + result.conflicts.push({ + subject: card?.subject, + number, + fields: decision.conflicts.map(c => c.property), + }); + continue; + } + const desired = { ...remote, ...decision.remote } as Projection; + validate(desired); + // Include unchanged records so their identity/baseline is established on first import. + result.changes.push({ + subject: card?.subject, + number, + local: card?.value, + remote, + desired, + }); + } + for (const card of cards) { + if (card.number === undefined) + result.changes.push({ + subject: card.subject, + local: card.value, + desired: card.value, + }); + else if (!issues.has(card.number)) + result.conflicts.push({ + subject: card.subject, + number: card.number, + fields: ['Issue missing or inaccessible; no deletion inferred'], + }); + } + return result; +} diff --git a/platform-lenses/github-issues/atomic.live.test.ts b/platform-lenses/github-issues/atomic.live.test.ts new file mode 100644 index 0000000..80fd418 --- /dev/null +++ b/platform-lenses/github-issues/atomic.live.test.ts @@ -0,0 +1,117 @@ +import { it, expect } from 'vitest'; +import { readFile } from 'node:fs/promises'; +import { + Agent, + Store, + core, + dataBrowser, +} from '@tomic/lib'; +import { enableLoro } from '@integration-host/loro-loader'; +import { readExternalOperation } from '@integration-host/plugin-connection'; +import { install } from './atomic.js'; + +it.skipIf(!process.env.ATOMIC_GITHUB_TEST_SERVER)( + 'installs a private package and real kanban, then reads and edits cards through Atomic', + async () => { + await enableLoro(); + const keys = await Agent.generateKeyPair(); + const agent = Agent.fromSecret( + Agent.buildSecret(keys.privateKey, `did:ad:agent:${keys.publicKey}`), + 'js', + ); + const store = new Store({ + serverUrl: process.env.ATOMIC_GITHUB_TEST_SERVER!, + agent, + }); + store.setServerConnected(true); + const drive = await store.createDrive('GitHub pilot integration test', { + agentName: 'Pilot test', + description: '[atomic-data:dev-drive]', + }); + store.setDrive(drive.subject); + const connection = await install( + store, + drive.subject, + 'atomic-pilot-fixture/kanban', + await readFile('integrations/github-issues/plugin.js', 'utf8'), + ); + const table = await store.fetchResourceFromServer(connection.table, { + noWebSocket: true, + }); + const views = table.get(dataBrowser.properties.tableViews) as string[]; + expect(views).toHaveLength(1); + const view = await store.getResource(views[0]); + expect(view.get(dataBrowser.properties.viewKind)).toBe('kanban'); + expect(view.get(dataBrowser.properties.viewGroupBy)).toBe( + connection.status, + ); + + await expect( + readExternalOperation(store, { + drive: connection.drive, + plugin: connection.plugin, + release: connection.release, + run: 'preview', + intent: { + id: 'no-write', + operation: 'update', + method: 'PATCH', + url: 'https://api.github.com/repos/atomic-pilot-fixture/kanban/issues/42', + body: '{}', + }, + }), + ).rejects.toThrow('read operation'); + + expect(connection.release).toMatch(/^blake3:/); + }, + 60000, +); + +it.skipIf(!process.env.ATOMIC_GITHUB_TEST_SERVER)( + 'reads changing connection membership without merging obsolete query snapshots', + async () => { + const { readConnectionSubjects } = + await import('@integration-host/plugin-connection'); + await enableLoro(); + const keys = await Agent.generateKeyPair(); + const agent = Agent.fromSecret( + Agent.buildSecret(keys.privateKey, `did:ad:agent:${keys.publicKey}`), + 'js', + ); + const store = new Store({ + serverUrl: process.env.ATOMIC_GITHUB_TEST_SERVER!, + agent, + }); + store.setServerConnected(true); + const drive = await store.createDrive('Membership snapshot test', { + agentName: 'Test', + description: '[atomic-data:dev-drive]', + }); + store.setDrive(drive.subject); + const parent = await store.newResource({ + parent: drive.subject, + propVals: { [core.properties.name]: 'Inbox' }, + }); + await parent.save(); + const expected: string[] = []; + for (let i = 0; i < 5; i++) { + const child = await store.newResource({ + parent: parent.subject, + propVals: { [core.properties.name]: `Notification ${i}` }, + }); + await child.save(); + expected.push(child.subject); + expect( + ( + await readConnectionSubjects( + store, + drive.subject, + core.properties.parent, + parent.subject, + ) + ).sort(), + ).toEqual([...expected].sort()); + } + }, + 60000, +); diff --git a/platform-lenses/github-issues/atomic.ts b/platform-lenses/github-issues/atomic.ts new file mode 100644 index 0000000..5b18dd5 --- /dev/null +++ b/platform-lenses/github-issues/atomic.ts @@ -0,0 +1,256 @@ +/** Installs schema, kanban, sandbox source and a private connection. */ +import { + Store, + core, + dataBrowser, + Datatype, + ensureSchema, + findSchema, + pluginSchema, + signRequest, + taskSchema, + readConnectionSubjects, +} from '@tomic/lib'; +import { pinPluginRelease } from '@integration-host/plugin-connection'; +import { manifest, type Status } from './adapter.js'; + +export interface Connection { + repository: string; + drive: string; + plugin: string; + release: string; + table: string; + rowClass: string; + status: string; + number: string; + body: string; + arrival: string; + tags: Record; +} +export async function install( + store: Store, + drive: string, + repository: string, + source: string, + token?: string, + targetTable?: string, +): Promise { + const declaration = manifest(repository); + const target = targetTable + ? (await compatibleTables(store, drive)).find( + t => t.subject === targetTable, + ) + : undefined; + if (targetTable && !target) + throw new Error('Choose a compatible task table on this drive'); + const schema = await ensureSchema(store, drive, pluginSchema()); + const plugin = await store.newResource({ + parent: drive, + isA: [schema.classes['plugin-script']], + propVals: { + [core.properties.name]: `GitHub issues: ${repository}`, + [dataBrowser.properties.emoji]: '🐙', + [schema.properties['plugin-source']]: + `${source}\nexport const manifest = ${JSON.stringify(declaration)};`, + }, + }); + await plugin.save(); + const create = async ( + parent: string, + isA: string[], + propVals: Record, + ) => { + const r = await store.newResource({ + parent, + isA, + propVals: { + [core.properties.description]: 'GitHub issues pilot', + ...propVals, + }, + }); + if ((await r.save()) === 'offline') + throw new Error('AtomicServer disconnected during installation'); + await store.fetchResourceFromServer(r.subject, { noWebSocket: true }); + return r; + }; + const status = await store.getResource(taskSchema.properties.status); + const tags: Record = { + Todo: taskSchema.tags.Todo, + Doing: taskSchema.tags.Doing, + Done: taskSchema.tags.Done, + }; + const number = await create(plugin.subject, [core.classes.property], { + [core.properties.name]: 'GitHub issue number', + [core.properties.shortname]: 'github-issue-number', + [core.properties.datatype]: Datatype.INTEGER, + }); + const body = await store.getResource(taskSchema.properties.body); + const arrival = await create(plugin.subject, [core.classes.property], { + [core.properties.name]: 'Issue discovery', + [core.properties.shortname]: 'github-issue-discovery', + [core.properties.datatype]: Datatype.STRING, + }); + const rowClass = target + ? await store.getResource(target.rowClass) + : await create(plugin.subject, [core.classes.class], { + [core.properties.name]: 'GitHub issue', + [core.properties.shortname]: 'github-issue', + [core.properties.requires]: [core.properties.name], + [core.properties.recommends]: [ + core.properties.name, + body.subject, + status.subject, + number.subject, + ], + }); + const table = target + ? await store.getResource(target.subject) + : await create(plugin.subject, [dataBrowser.classes.table], { + [core.properties.name]: `${repository} issues`, + [core.properties.classtype]: rowClass.subject, + }); + if (!target) { + const view = await create(table.subject, [dataBrowser.classes.view], { + [core.properties.name]: 'Kanban', + [dataBrowser.properties.viewKind]: 'kanban', + [dataBrowser.properties.viewGroupBy]: status.subject, + [dataBrowser.properties.viewColumns]: [ + core.properties.name, + body.subject, + status.subject, + number.subject, + ], + }); + await table.set(dataBrowser.properties.tableViews, [view.subject]); + await table.set(dataBrowser.properties.tableDefaultView, view.subject); + await table.save(); + } + if (token) { + const url = `${store.getServerUrl()}/plugin-secret`; + const response = await fetch(url, { + method: 'POST', + headers: { + ...(await signRequest(url, store.getAgent()!, {})), + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + drive, + plugin: plugin.subject, + name: 'github', + value: `Bearer ${token}`, + origins: ['https://api.github.com'], + }), + }); + if (!response.ok) + throw new Error('Could not store GitHub credential on AtomicServer'); + } + await plugin.set(schema.properties['plugin-schemas'], { + row: rowClass.subject, + status: status.subject, + number: number.subject, + body: body.subject, + }); + await plugin.save(); + const pinned = await pinPluginRelease(store, { + drive, + plugin: plugin.subject, + }); + const connection = { + repository, + drive, + plugin: plugin.subject, + release: pinned.id, + table: table.subject, + rowClass: rowClass.subject, + status: status.subject, + number: number.subject, + body: body.subject, + arrival: arrival.subject, + tags, + }; + await plugin.set(schema.properties['plugin-workspace'], table.subject); + await plugin.set(schema.properties['plugin-connection'], { + release: pinned.id, + config: connection, + events: [ + { + id: 'issue-discovered', + name: 'New issue discovered', + description: + 'Fires for issues first discovered after the initial sync. Excludes initial imports and issues created from Atomic cards.', + filters: [ + { property: core.properties.parent, value: table.subject }, + { property: core.properties.isA, value: rowClass.subject }, + { property: number.subject }, + { property: arrival.subject, value: 'remote' }, + ], + }, + ], + }); + await plugin.save(); + return connection; +} + +/** Identity-based compatibility. A matching display label is not a schema mapping. */ +export async function compatibleTables(store: Store, drive: string) { + const subjects = await readConnectionSubjects( + store, + drive, + core.properties.isA, + dataBrowser.classes.table, + ); + const result: Array<{ subject: string; name: string; rowClass: string }> = []; + const pluginTerms = await findSchema(store, drive, pluginSchema()); + const pluginClass = pluginTerms.classes?.['plugin-script']; + const connectionProperty = pluginTerms.properties?.['plugin-connection']; + const occupied = new Set(); + if (pluginClass && connectionProperty) { + for (const subject of await readConnectionSubjects( + store, + drive, + core.properties.isA, + pluginClass, + )) { + const resource = await store.getResource(subject); + const raw = resource.get(connectionProperty); + const connection = typeof raw === 'string' ? JSON.parse(raw) : raw; + if ( + connection && + typeof connection === 'object' && + !Array.isArray(connection) + ) { + const config = connection.config; + if ( + config && + typeof config === 'object' && + !Array.isArray(config) && + typeof config.table === 'string' + ) + occupied.add(config.table); + } + } + } + for (const subject of subjects) { + if (occupied.has(subject)) continue; + const table = await store.getResource(subject); + const klass = table.get(core.properties.classtype); + if (typeof klass !== 'string') continue; + const row = await store.getResource(klass); + const properties = [ + ...((row.get(core.properties.requires) as string[]) ?? []), + ...((row.get(core.properties.recommends) as string[]) ?? []), + ]; + if ( + [taskSchema.properties.status, taskSchema.properties.body].every(p => + properties.includes(p), + ) + ) { + result.push({ + subject, + rowClass: klass, + name: String(table.get(core.properties.name) ?? subject), + }); + } + } + return result; +} diff --git a/platform-lenses/github-issues/automation.test.ts b/platform-lenses/github-issues/automation.test.ts new file mode 100644 index 0000000..117f427 --- /dev/null +++ b/platform-lenses/github-issues/automation.test.ts @@ -0,0 +1,27 @@ +import { it, expect } from 'vitest'; +import { eventAutomationSource } from '@integration-host/integration-automation'; +it('wires arbitrary events to a sandbox script without prescribing an action', async () => { + const source = eventAutomationSource({ + id: 'enrollment', + name: 'Student enrolled', + description: 'A different domain', + filters: [ + { property: 'class', value: 'school"class' }, + { property: 'enrolled' }, + ], + }); + const action = await import( + /* @vite-ignore */ `data:text/javascript;base64,${Buffer.from(source).toString('base64')}` + ); + const read = () => ({ class: 'school"class', enrolled: true }); + expect( + action.run({ trigger: { subject: 'did:ad:student' }, read }).intents, + ).toEqual([]); + expect( + action.run({ trigger: {}, read, query: () => [] }).problems[0].severity, + ).toBe('error'); + expect( + action.run({ trigger: {}, read, query: () => ['did:ad:student'] }).problems, + ).toEqual([]); + expect(source).not.toContain('New issue:'); +}); diff --git a/platform-lenses/github-issues/bridge.mjs b/platform-lenses/github-issues/bridge.mjs new file mode 100644 index 0000000..149ff5c --- /dev/null +++ b/platform-lenses/github-issues/bridge.mjs @@ -0,0 +1,222 @@ +import { reconcileRecord } from '../../src/reconcileRecord.js'; + +import { properties, value as resourceValue, propertiesByField as p } from './lens/resources.mjs'; +const equal = (a, b) => JSON.stringify(a) === JSON.stringify(b); +const copy = value => structuredClone(value); + +/** Single-writer, checkpointed reconciliation. Ports own transport and durable writes. */ +export class Bridge { + constructor({ devonian, local, remote, base, snapshot, save }) { + this.api = devonian; + this.local = local; + this.remote = remote; + this.save = save; + const { AtomicSchema, AtomicStore, AtomicIdentityMap, Datatype } = devonian; + this.store = new AtomicStore( + new AtomicSchema() + .property(p.title, Datatype.STRING) + .property(p.body, Datatype.MARKDOWN) + .property(p.status, Datatype.RESOURCEARRAY), + ); + this.identities = new AtomicIdentityMap(this.store, base); + this.binding = { base, local: local.scope, remote: remote.scope }; + if (snapshot && !equal(snapshot.binding, this.binding)) + throw new Error('State belongs to another connection'); + if (snapshot?.graph) this.store.loadJSONAD(snapshot.graph); + this.records = copy(snapshot?.records ?? {}); + } + + scope(side, entity) { + return { scope: this[side].scope, entity }; + } + id(side, entity, subject) { + return this.identities.externalId(this.scope(side, entity), subject); + } + context(side, entity) { + if (entity === 'issue') return {}; + const parent = entity.slice('comment:'.length); + const issueId = this.id(side, 'issue', parent); + if (issueId === undefined) + throw new Error('Issue must be mapped before comments'); + return { issueId }; + } + async checkpoint() { + await this.save({ + version: 1, + binding: this.binding, + graph: this.store.toJSONAD(), + records: copy(this.records), + }); + } + properties(value) { + return properties(value); + } + value(resource, entity) { + return resourceValue(resource, entity); + } + lens(side, entity, operation, metadata) { + const port = this[side], + context = this.context(side, entity); + return new this.api.AtomicLens({ + store: this.store, + identities: this.identities, + ...this.scope(side, entity), + connector: { + id: row => row.id, + get: id => port.get(entity, id, context), + create: (row, key) => + port.create(entity, row.value, key, metadata, context), + update: (id, row) => + port.update( + entity, + id, + row.value, + `${operation}:${side}`, + metadata, + context, + ), + delete: async () => { + throw new Error('Deletion is outside issue sync'); + }, + }, + read: row => ({ set: this.properties(row.value) }), + write: (resource, previous) => ({ + ...previous, + value: this.value(resource, entity), + }), + }); + } + + async sync() { + // Resume saved operations BEFORE discovering their newly-created counterparts. + for (const [subject, record] of Object.entries(this.records)) { + if (record.pending) await this.finish(subject, record); + } + await this.syncEntity('issue'); + for (const [subject, record] of Object.entries(this.records)) { + if (record.entity === 'issue') + await this.syncEntity(`comment:${subject}`); + } + } + + async syncEntity(entity) { + const lists = {}; + for (const side of ['remote', 'local']) { + const rows = await this[side].list(entity, this.context(side, entity)); + lists[side] = new Map(); + for (const row of rows) { + if (lists[side].has(row.id)) + throw new Error('Duplicate external identity'); + lists[side].set(row.id, row); + } + } + // An existing pilot's explicit issue-number column is an identity, never a title match. + for (const row of lists.local.values()) { + if (row.remoteId === undefined) continue; + const scope = this.scope('remote', entity); + const subject = this.identities.subjectFor(scope, row.remoteId); + this.identities.bind(scope, row.remoteId, subject); + this.identities.bind(this.scope('local', entity), row.id, subject); + this.records[subject] ??= { entity }; + } + for (const side of ['remote', 'local']) { + for (const row of lists[side].values()) { + let subject = this.identities.lookup(this.scope(side, entity), row.id); + if (!subject) subject = await this.lens(side, entity).ingest(row); + this.records[subject] ??= { entity }; + } + } + await this.checkpoint(); + for (const [subject, record] of Object.entries(this.records)) { + if (record.entity !== entity) continue; + const rows = {}; + for (const side of ['local', 'remote']) { + const id = this.id(side, entity, subject); + rows[side] = id === undefined ? undefined : lists[side].get(id); + if (id !== undefined && !rows[side]) + throw new Error(`Missing ${side} record: ${subject}`); + } + const decision = reconcileRecord( + record.baseline, + rows.local?.value, + rows.remote?.value, + ); + if (decision.conflicts.length) + throw new Error( + `Conflict on ${subject}: ${decision.conflicts.map(c => c.property).join(', ')}`, + ); + const desired = { + ...(rows.remote?.value ?? rows.local?.value), + ...decision.remote, + }; + const metadata = rows.remote?.metadata; + if ( + rows.local && + rows.remote && + equal(rows.local.value, rows.remote.value) && + equal(rows.local.metadata, metadata) + ) { + record.baseline = copy(desired); + await this.checkpoint(); + continue; + } + record.pending = { + operation: crypto.randomUUID(), + local: rows.local?.value, + remote: rows.remote?.value, + desired, + metadata, + }; + this.store.patch(subject, { set: this.properties(desired) }); + await this.checkpoint(); + await this.finish(subject, record); + } + } + + async finish(subject, record) { + const { pending, entity } = record; + // A retry accepts only the original observation or this operation's exact result. + for (const side of ['local', 'remote']) { + const id = this.id(side, entity, subject); + if (id === undefined) continue; + const row = await this[side].get(entity, id, this.context(side, entity)); + if ( + !equal(row.value, pending[side]) && + !equal(row.value, pending.desired) + ) + throw new Error(`Conflict during saved operation on ${subject}`); + } + for (const side of ['remote', 'local']) { + const id = this.id(side, entity, subject); + const row = + id === undefined + ? undefined + : await this[side].get(entity, id, this.context(side, entity)); + if ( + !row || + !equal(row.value, pending.desired) || + (side === 'local' && !equal(row.metadata, pending.metadata)) + ) { + await this.lens( + side, + entity, + pending.operation, + pending.metadata, + ).publish(subject); + await this.checkpoint(); + } + } + for (const side of ['local', 'remote']) { + const row = await this[side].get( + entity, + this.id(side, entity, subject), + this.context(side, entity), + ); + if (!equal(row.value, pending.desired)) + throw new Error(`Concurrent edit after write on ${subject}`); + } + record.baseline = copy(pending.desired); + delete record.pending; + await this.checkpoint(); + } +} diff --git a/platform-lenses/github-issues/bridge.test.mjs b/platform-lenses/github-issues/bridge.test.mjs new file mode 100644 index 0000000..672a7b1 --- /dev/null +++ b/platform-lenses/github-issues/bridge.test.mjs @@ -0,0 +1,133 @@ +import { expect, it } from 'vitest'; +import * as devonian from 'devonian'; +import { Bridge } from './bridge.mjs'; + +function fixture(snapshot) { + let saved = snapshot; + const makePort = scope => ({ + scope, + rows: new Map(), + receipts: new Map(), + writes: 0, + lose: false, + async list(entity) { + return [...this.rows.values()].filter(r => r.entity === entity); + }, + async get(entity, id) { + const row = this.rows.get(id); + if (!row || row.entity !== entity) throw new Error('Missing record'); + return structuredClone(row); + }, + async create(entity, value, key, metadata) { + if (this.receipts.has(key)) return this.receipts.get(key); + const id = this.rows.size + 1; + const row = { id, entity, value: structuredClone(value), metadata }; + this.rows.set(id, row); + this.receipts.set(key, row); + this.writes++; + if (this.lose) { + this.lose = false; + throw new Error('Lost response'); + } + return row; + }, + async update(entity, id, value) { + const row = await this.get(entity, id); + this.rows.set(id, { ...row, value: structuredClone(value) }); + this.writes++; + }, + }); + const local = makePort('https://atomic.example/bridge'); + const remote = makePort('https://github.com/acme/repo'); + const open = () => + new Bridge({ + devonian, + local, + remote, + snapshot: saved, + base: 'https://bridge.example/sync', + save: async s => { + saved = structuredClone(s); + }, + }); + return { local, remote, open, saved: () => saved }; +} +const issue = (id, title = 'Same title') => ({ + id, + entity: 'issue', + value: { title, body: '', status: 'Todo' }, +}); + +it('syncs creation both ways without deduplicating equal content, then no-ops after restart', async () => { + const f = fixture(); + f.local.rows.set(1, issue(1)); + f.remote.rows.set(1, issue(1)); + await f.open().sync(); + expect(f.local.rows.size).toBe(2); + expect(f.remote.rows.size).toBe(2); + const writes = f.local.writes + f.remote.writes; + await f.open().sync(); + expect(f.local.writes + f.remote.writes).toBe(writes); +}); + +it('merges independent title/body edits and propagates close/reopen', async () => { + const f = fixture(); + f.remote.rows.set(1, issue(1)); + await f.open().sync(); + f.local.rows.get(1).value.title = 'Local title'; + f.remote.rows.get(1).value.body = 'Remote body'; + f.local.rows.get(1).value.status = 'Done'; + await f.open().sync(); + expect(f.remote.rows.get(1).value).toEqual({ + title: 'Local title', + body: 'Remote body', + status: 'Done', + }); + f.remote.rows.get(1).value.status = 'Todo'; + await f.open().sync(); + expect(f.local.rows.get(1).value.status).toBe('Todo'); +}); + +it('syncs comments and edits both ways, preserving source metadata', async () => { + const f = fixture(); + f.remote.rows.set(1, issue(1)); + await f.open().sync(); + const parent = Object.keys(f.saved().records)[0]; + const entity = `comment:${parent}`; + f.remote.rows.set(2, { + id: 2, + entity, + value: { body: 'GitHub comment' }, + metadata: { author: 'octocat' }, + }); + f.local.rows.set(2, { id: 2, entity, value: { body: 'Atomic comment' } }); + await f.open().sync(); + expect(f.local.rows.get(3).metadata).toEqual({ author: 'octocat' }); + expect(f.remote.rows.get(3).value.body).toBe('Atomic comment'); + f.local.rows.get(3).value.body = 'Edited'; + await f.open().sync(); + expect(f.remote.rows.get(2).value.body).toBe('Edited'); +}); + +it('stops on same-field conflicts and missing records without deleting either side', async () => { + const f = fixture(); + f.remote.rows.set(1, issue(1)); + await f.open().sync(); + f.local.rows.get(1).value.title = 'A'; + f.remote.rows.get(1).value.title = 'B'; + await expect(f.open().sync()).rejects.toThrow('Conflict'); + expect(f.local.rows.get(1).value.title).toBe('A'); + f.remote.rows.delete(1); + await expect(f.open().sync()).rejects.toThrow('Missing'); + expect(f.local.rows.size).toBe(1); +}); + +it('reuses the same create identity after a lost receipt and restart', async () => { + const f = fixture(); + f.local.rows.set(1, issue(1)); + f.remote.lose = true; + await expect(f.open().sync()).rejects.toThrow('Lost response'); + await f.open().sync(); + expect(f.remote.rows.size).toBe(1); + expect(f.remote.writes).toBe(1); +}); diff --git a/platform-lenses/github-issues/descriptor.json b/platform-lenses/github-issues/descriptor.json new file mode 100644 index 0000000..95c4b5f --- /dev/null +++ b/platform-lenses/github-issues/descriptor.json @@ -0,0 +1 @@ +{"id":"github-issues","version":"1.0.0","lens":"./bridge.mjs","provider":"github"} diff --git a/platform-lenses/github-issues/github.live.test.ts b/platform-lenses/github-issues/github.live.test.ts new file mode 100644 index 0000000..29314cb --- /dev/null +++ b/platform-lenses/github-issues/github.live.test.ts @@ -0,0 +1,329 @@ +/** Explicit opt-in: mutates only the dedicated private Ontola sandbox repo. */ +import { it, expect } from 'vitest'; +import { readFile } from 'node:fs/promises'; +import { + Agent, + Store, + core, + ensureSchema, + pluginSchema, + signRequest, + planVerdict, + planHostFromStore, + applyPlan, + applyHostFromStore, + recordRun, +} from '@tomic/lib'; +import { enableLoro } from '@integration-host/loro-loader'; +import { + previewPluginSync, + applyPluginSync, + getPluginSync, + pluginSyncSchedule, + readConnectionSubjects, +} from '@integration-host/plugin-connection'; +import { install } from './atomic.js'; + +const repository = 'ontola/atomic-github-sync-sandbox'; +it.skipIf(process.env.ATOMIC_LIVE_GITHUB_REPO !== repository)( + 'syncs real GitHub issues through WASM, kanban and an independent JS automation', + async () => { + const token = process.env.GITHUB_TOKEN; + if (!token) + throw new Error( + 'GITHUB_TOKEN is required for the explicitly enabled live test', + ); + const serverUrl = process.env.ATOMIC_GITHUB_TEST_SERVER; + if (!serverUrl || !/^http:\/\/(localhost|127\.0\.0\.1):/.test(serverUrl)) + throw new Error('Use an isolated loopback Atomic test server'); + const github = async (path: string, method = 'GET', body?: unknown) => { + const response = await fetch( + `https://api.github.com/repos/${repository}${path}`, + { + method, + headers: { + Authorization: `Bearer ${token}`, + Accept: 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2022-11-28', + 'Content-Type': 'application/json', + }, + body: body === undefined ? undefined : JSON.stringify(body), + }, + ); + if (!response.ok) + throw new Error(`GitHub ${method} ${path}: ${response.status}`); + return response.status === 204 ? undefined : response.json(); + }; + expect((await github('')).private).toBe(true); + await enableLoro(); + const keys = await Agent.generateKeyPair(); + const agent = Agent.fromSecret( + Agent.buildSecret(keys.privateKey, `did:ad:agent:${keys.publicKey}`), + 'js', + ); + const store = new Store({ serverUrl, agent }); + store.setServerConnected(true); + const drive = await store.createDrive('Live GitHub sandbox', { + agentName: 'Live integration test', + description: '[atomic-data:dev-drive]', + }); + store.setDrive(drive.subject); + const c = await install( + store, + drive.subject, + repository, + await readFile('integrations/github-issues/plugin.js', 'utf8'), + token, + ); + const target = { drive: c.drive, plugin: c.plugin }; + const marker = `Atomic live ${Date.now()}`; + const issueNumbers = new Set(); + let scheduled = false; + let lastRun = ''; + const post = async (path: string, body: unknown) => { + const url = serverUrl + path; + const res = await fetch(url, { + method: 'POST', + headers: { + ...(await signRequest(url, agent, {})), + 'Content-Type': 'application/json', + }, + body: JSON.stringify(body), + }); + if (!res.ok) + throw new Error(`${path}: ${res.status} ${await res.text()}`); + return res.json(); + }; + const sync = async () => { + let run = await previewPluginSync(store, { + ...target, + release: c.release, + config: c, + }); + expect(run.problems.filter(p => p.severity === 'error')).toEqual([]); + lastRun = run.run; + + for (let i = 0; i < 100; i++) { + run = await applyPluginSync(store, { ...target, run: run.run }); + if (run.status !== 'running') break; + } + expect(run.error).toBeNull(); + expect(run.status).toBe('complete'); + return run; + }; + const fresh = async (subject: string) => { + const r = await store.fetchResourceFromServer(subject, { + noWebSocket: true, + }); + r.getLoroDoc(); + return r; + }; + const rows = async () => + Promise.all( + ( + await readConnectionSubjects( + store, + c.drive, + core.properties.parent, + c.table, + ) + ).map(fresh), + ); + const card = async (number: number) => { + const all = await rows(); + + const found = all.find(r => r.get(c.number) === number); + expect(found, `Atomic card for issue ${number}`).toBeDefined(); + return found!; + }; + try { + const remote = await github('/issues', 'POST', { + title: marker, + body: 'Created on GitHub', + labels: ['bug'], + }); + issueNumbers.add(remote.number); + // GitHub create is acknowledged before its list endpoint always includes it. + // Wait for the backfill fixture to become list-visible before first sync. + for (let i = 0; i < 30; i++) { + const visible = await github( + '/issues?state=all&per_page=100&sort=created&direction=asc', + ); + if (visible.some((r: any) => r.number === remote.number)) break; + await new Promise(r => setTimeout(r, 1000)); + if (i === 29) + throw new Error('Created issue did not become list-visible'); + } + await sync(); + let row = await card(remote.number); + expect(row.get(c.body)).toBe('Created on GitHub'); + expect(row.get(c.arrival)).toBeUndefined(); + console.log('PASS GitHub backfill -> Atomic card; no discovery event'); + await row.set(core.properties.name, marker + ' edited in Atomic'); + await row.set(c.status, [c.tags.Doing]); + await row.save(); + await sync(); + const external = await github(`/issues/${remote.number}`); + expect(external.title).toBe(marker + ' edited in Atomic'); + expect(external.labels.map((l: any) => l.name)).toEqual( + expect.arrayContaining(['bug', 'atomic:doing']), + ); + row = await card(remote.number); + await row.set(c.status, [c.tags.Done]); + await row.save(); + await sync(); + expect((await github(`/issues/${remote.number}`)).state).toBe('closed'); + await github(`/issues/${remote.number}`, 'PATCH', { + state: 'open', + body: 'Updated remotely', + }); + await sync(); + row = await card(remote.number); + expect(row.get(c.body)).toBe('Updated remotely'); + expect(row.get(c.status)).toEqual([c.tags.Todo]); + console.log( + 'PASS bidirectional edits, Doing labels, Done closes, remote reopen', + ); + const local = await store.newResource({ + parent: c.table, + isA: [c.rowClass], + propVals: { + [core.properties.name]: marker + ' local card', + [c.body]: 'Created in Atomic', + [c.status]: [c.tags.Todo], + }, + }); + await local.save(); + await sync(); + const updated = await fresh(local.subject); + const number = updated.get(c.number) as number; + expect(number).toBeGreaterThan(0); + issueNumbers.add(number); + expect((await github(`/issues/${number}`)).body).toBe( + 'Created in Atomic', + ); + expect(updated.get(c.arrival)).toBeUndefined(); + console.log('PASS local card -> real GitHub issue'); + + const schema = await ensureSchema(store, c.drive, pluginSchema()); + const inbox = await store.newResource({ + parent: c.drive, + propVals: { [core.properties.name]: 'Test notifications' }, + }); + await inbox.save(); + const source = `export const manifest = {schemaVersion:1,secrets:[],operations:[]}; + export function run(ctx) { const row=ctx.read(ctx.trigger.subject); return {problems:[],intents:[{ + op:'create',localId:'notification',parent:${JSON.stringify(inbox.subject)},isA:[],set:{ + ${JSON.stringify(core.properties.name)}:row[${JSON.stringify(core.properties.name)}], + ${JSON.stringify(core.properties.description)}:'Issue discovered: '+ctx.trigger.subject}}]}; }`; + const automation = await store.newResource({ + parent: c.drive, + isA: [schema.classes['plugin-script']], + propVals: { + [core.properties.name]: 'Issue discovery notification', + [schema.properties['plugin-source']]: source, + [schema.properties['automation-integrations']]: [c.plugin], + [schema.properties['automation-trigger']]: { + integration: c.plugin, + event: 'issue-discovered', + }, + }, + }); + await automation.save(); + const trigger = { + kind: 'manual' as const, + at: Date.now(), + subject: row.subject, + }; + const sample = await post('/plugin-run', { + drive: c.drive, + plugin: automation.subject, + source, + input: JSON.stringify({ trigger }), + }); + expect(sample.error).toBeNull(); + const plan = await planVerdict( + JSON.parse(sample.verdict), + planHostFromStore(store), + ); + expect(plan.blocked, JSON.stringify(plan)).toBe(false); + const report = await applyPlan(plan, applyHostFromStore(store)); + await recordRun(store, { + parent: automation.subject, + drive: c.drive, + source, + trigger, + plan, + report, + }); + await post('/plugin-trigger', { + drive: c.drive, + plugin: automation.subject, + onEnter: true, + onLeave: false, + autoApply: true, + filters: [ + { property: core.properties.parent, value: c.table }, + { property: c.arrival, value: 'remote' }, + ], + }); + const discovery = await github('/issues', 'POST', { + title: marker + ' new discovery', + body: 'Must trigger automation', + }); + issueNumbers.add(discovery.number); + await pluginSyncSchedule(store, { + ...target, + run: lastRun, + interval_seconds: 60, + }); + scheduled = true; + const deadline = Date.now() + 100000; + let notifications: string[] = []; + while (Date.now() < deadline) { + await new Promise(r => setTimeout(r, 2000)); + try { + notifications = await readConnectionSubjects( + store, + c.drive, + core.properties.parent, + inbox.subject, + ); + } catch (error) { + // A concurrent insertion can invalidate a paginated read. Retry only + // incomplete/changing membership; never turn authorization into empty. + if ( + error instanceof Error && + /^(Incomplete connection query response|Connection membership changed during pagination)/.test( + error.message, + ) + ) + continue; + throw error; + } + if (notifications.length === 2) break; + } + expect(notifications).toHaveLength(2); + const notificationRows = await Promise.all(notifications.map(fresh)); + expect(notificationRows.map(r => r.get(core.properties.name))).toContain( + discovery.title, + ); + expect((await card(discovery.number)).get(c.arrival)).toBe('remote'); + expect((await getPluginSync(store, target))?.run).not.toBe(lastRun); + console.log( + 'PASS background real-GitHub discovery -> independent JS notification, without a browser', + ); + } finally { + if (scheduled) + await pluginSyncSchedule(store, { + ...target, + run: lastRun, + interval_seconds: 0, + }); + for (const number of issueNumbers) + await github(`/issues/${number}`, 'PATCH', { state: 'closed' }); + console.log(`Test issues closed in ${repository}`); + } + }, + 360000, +); diff --git a/platform-lenses/github-issues/index.mjs b/platform-lenses/github-issues/index.mjs new file mode 100644 index 0000000..07d9101 --- /dev/null +++ b/platform-lenses/github-issues/index.mjs @@ -0,0 +1,23 @@ +/** Versioned entry point for the GitHub issue Atomic lens. */ +import { Bridge } from './bridge.mjs'; + +export const descriptor = { + id: 'github-issues', + version: '1.0.0', + platform: 'github', + lens: 'github-issues', +}; + +/** Create a GitHub lens with host supplied runtime, ports and persistence. */ +export function createBridge(host, options) { + if (!host || typeof host !== 'object') throw new Error('Lens host is required'); + if (!options || typeof options !== 'object') throw new Error('Lens options are required'); + return new Bridge({ ...options, devonian: host }); +} + +export { Bridge } from './bridge.mjs'; +export * from './ports.mjs'; +export * from './proxy.mjs'; +export * from './adapter.ts'; +export * from './tracker-actions.ts'; +export * from './types.mjs'; diff --git a/platform-lenses/github-issues/lens/index.ts b/platform-lenses/github-issues/lens/index.ts new file mode 100644 index 0000000..3e73c7c --- /dev/null +++ b/platform-lenses/github-issues/lens/index.ts @@ -0,0 +1,92 @@ +/** Passive GitHub issue mapping. No store, transport or synchronization state. */ +export type Status = 'Todo' | 'Doing' | 'Done'; +export type Projection = { + title: string; + body: string; + status: Status; +}; +export interface Issue { + number: number; + title: string; + body: string | null; + state: 'open' | 'closed'; + labels: Array; + pull_request?: unknown; +} +export function project(issue: Issue): Projection { + if ( + !Number.isSafeInteger(issue.number) || + issue.number <= 0 || + typeof issue.title !== 'string' || + !(issue.body === null || typeof issue.body === 'string') || + !['open', 'closed'].includes(issue.state) || + !Array.isArray(issue.labels) + ) + throw new Error('GitHub returned an invalid issue'); + return { + title: issue.title, + body: issue.body ?? '', + status: + issue.state === 'closed' + ? 'Done' + : issue.labels.some( + (l) => + (typeof l === 'string' ? l : l.name).toLowerCase() === + 'atomic:doing', + ) + ? 'Doing' + : 'Todo', + }; +} +export function validate(value: Projection): void { + if ( + typeof value.title !== 'string' || + !value.title.trim() || + typeof value.body !== 'string' || + !['Todo', 'Doing', 'Done'].includes(value.status) + ) + throw new Error( + 'Cards require a title, Markdown body and exactly one Todo/Doing/Done status', + ); +} + +/** Fields owned by the lens; labels are managed separately without replacing other labels. */ +export function issueFields(value: Projection): { + title: string; + body: string; + state: 'open' | 'closed'; +} { + return { + title: value.title, + body: value.body, + state: value.status === 'Done' ? 'closed' : 'open', + }; +} + +/** Reverse mapping preserves every field and label outside the projection. */ +export function unproject(value: Projection, previous: T): T { + validate(value); + const labels = previous.labels.filter( + (label) => + (typeof label === 'string' ? label : label.name).toLowerCase() !== + 'atomic:doing', + ); + if (value.status === 'Doing') labels.push('atomic:doing'); + return { ...previous, ...issueFields(value), labels }; +} + +/** Minimal reverse patch used by the existing reviewed synchronization workflow. */ +export function issuePatch( + desired: Projection, + previous?: Projection, +): Record { + const patch: Record = {}; + if (previous && previous.title !== desired.title) patch.title = desired.title; + if (previous && previous.body !== desired.body) patch.body = desired.body; + if ( + (!previous && desired.status === 'Done') || + (previous && (previous.status === 'Done') !== (desired.status === 'Done')) + ) + patch.state = desired.status === 'Done' ? 'closed' : 'open'; + return patch; +} diff --git a/platform-lenses/github-issues/lens/resources.mjs b/platform-lenses/github-issues/lens/resources.mjs new file mode 100644 index 0000000..19919f1 --- /dev/null +++ b/platform-lenses/github-issues/lens/resources.mjs @@ -0,0 +1,33 @@ +/** Passive mapping between issue/comment projections and Atomic resource properties. */ +export const propertiesByField = { + title: 'https://atomicdata.dev/properties/name', + body: 'https://atomicdata.dev/task/v1/body', + status: 'https://atomicdata.dev/task/v1/status', +}; +const tag = 'https://atomicdata.dev/task/v1/'; +export function properties(value) { + return { + [propertiesByField.body]: value.body, + ...(value.title === undefined + ? {} + : { + [propertiesByField.title]: value.title, + [propertiesByField.status]: [`${tag}${value.status.toLowerCase()}`], + }), + }; +} +export function value(resource, entity) { + if (entity !== 'issue') return { body: resource[propertiesByField.body] }; + const status = { + [`${tag}todo`]: 'Todo', + [`${tag}doing`]: 'Doing', + [`${tag}done`]: 'Done', + }[resource[propertiesByField.status]?.[0]]; + if (!status || resource[propertiesByField.status].length !== 1) + throw new Error('Unsupported task status'); + return { + title: resource[propertiesByField.title], + body: resource[propertiesByField.body], + status, + }; +} diff --git a/platform-lenses/github-issues/manifest.fixture.json b/platform-lenses/github-issues/manifest.fixture.json new file mode 100644 index 0000000..1be9ab9 --- /dev/null +++ b/platform-lenses/github-issues/manifest.fixture.json @@ -0,0 +1,92 @@ +{ + "schemaVersion": 1, + "actions": [ + { + "name": "get_issue", + "title": "Get an issue", + "description": "Read one issue from this connected repository.", + "operation": "get", + "inputSchema": { + "type": "object", + "properties": { + "number": { + "type": "integer", + "description": "Positive issue number" + } + }, + "required": [ + "number" + ], + "additionalProperties": false + } + }, + { + "name": "create_issue", + "title": "Create an issue", + "description": "Prepare a new issue in this repository for review. Nothing is posted until approved.", + "operation": "create", + "inputSchema": { + "type": "object", + "properties": { + "title": { + "type": "string", + "description": "Issue title" + }, + "body": { + "type": "string", + "description": "Markdown description" + } + }, + "required": [ + "title" + ], + "additionalProperties": false + } + } + ], + "secrets": [ + { + "name": "github", + "origin": "https://api.github.com", + "description": "GitHub token with Issues read/write access to this repository" + } + ], + "operations": [ + { + "id": "list", + "method": "GET", + "url": "https://api.github.com/repos/atomic-fixtures/issues/issues", + "effect": "read" + }, + { + "id": "get", + "method": "GET", + "url": "https://api.github.com/repos/atomic-fixtures/issues/issues/{number}", + "effect": "read" + }, + { + "id": "create", + "method": "POST", + "url": "https://api.github.com/repos/atomic-fixtures/issues/issues", + "effect": "write" + }, + { + "id": "update", + "method": "PATCH", + "url": "https://api.github.com/repos/atomic-fixtures/issues/issues/{number}", + "effect": "write" + }, + { + "id": "doing-add", + "method": "POST", + "url": "https://api.github.com/repos/atomic-fixtures/issues/issues/{number}/labels", + "effect": "write" + }, + { + "id": "doing-remove", + "method": "DELETE", + "url": "https://api.github.com/repos/atomic-fixtures/issues/issues/{number}/labels/atomic%3Adoing", + "effect": "write" + } + ] +} diff --git a/platform-lenses/github-issues/plugin.js b/platform-lenses/github-issues/plugin.js new file mode 100644 index 0000000..5accb79 --- /dev/null +++ b/platform-lenses/github-issues/plugin.js @@ -0,0 +1,577 @@ +var __create = Object.create; +var __defProp = Object.defineProperty; +var __getOwnPropDesc = Object.getOwnPropertyDescriptor; +var __getOwnPropNames = Object.getOwnPropertyNames; +var __getProtoOf = Object.getPrototypeOf; +var __hasOwnProp = Object.prototype.hasOwnProperty; +var __commonJS = (cb, mod) => function __require() { + try { + return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports; + } catch (e) { + throw mod = 0, e; + } +}; +var __copyProps = (to, from, except, desc) => { + if (from && typeof from === "object" || typeof from === "function") { + for (let key of __getOwnPropNames(from)) + if (!__hasOwnProp.call(to, key) && key !== except) + __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); + } + return to; +}; +var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps( + // If the importer is in node compatibility mode or this is not an ESM + // file that has been converted to a CommonJS file using a Babel- + // compatible transform (i.e. "__esModule" has not been set), then set + // "default" to the CommonJS "module.exports" for node compatibility. + isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target, + mod +)); + +// ../../localthought/devonian/node_modules/.pnpm/fast-json-stable-stringify@2.1.0/node_modules/fast-json-stable-stringify/index.js +var require_fast_json_stable_stringify = __commonJS({ + "../../localthought/devonian/node_modules/.pnpm/fast-json-stable-stringify@2.1.0/node_modules/fast-json-stable-stringify/index.js"(exports, module) { + "use strict"; + module.exports = function(data, opts) { + if (!opts) opts = {}; + if (typeof opts === "function") opts = { cmp: opts }; + var cycles = typeof opts.cycles === "boolean" ? opts.cycles : false; + var cmp = opts.cmp && /* @__PURE__ */ (function(f) { + return function(node) { + return function(a, b) { + var aobj = { key: a, value: node[a] }; + var bobj = { key: b, value: node[b] }; + return f(aobj, bobj); + }; + }; + })(opts.cmp); + var seen = []; + return (function stringify2(node) { + if (node && node.toJSON && typeof node.toJSON === "function") { + node = node.toJSON(); + } + if (node === void 0) return; + if (typeof node == "number") return isFinite(node) ? "" + node : "null"; + if (typeof node !== "object") return JSON.stringify(node); + var i, out; + if (Array.isArray(node)) { + out = "["; + for (i = 0; i < node.length; i++) { + if (i) out += ","; + out += stringify2(node[i]) || "null"; + } + return out + "]"; + } + if (node === null) return "null"; + if (seen.indexOf(node) !== -1) { + if (cycles) return JSON.stringify("__cycle__"); + throw new TypeError("Converting circular structure to JSON"); + } + var seenIndex = seen.push(node) - 1; + var keys = Object.keys(node).sort(cmp && cmp(node)); + out = ""; + for (i = 0; i < keys.length; i++) { + var key = keys[i]; + var value = stringify2(node[key]); + if (!value) continue; + if (out) out += ","; + out += JSON.stringify(key) + ":" + value; + } + seen.splice(seenIndex, 1); + return "{" + out + "}"; + })(data); + }; + } +}); + +// ../../localthought/devonian/platform-lenses/github-issues/lens/index.ts +function project(issue) { + if (!Number.isSafeInteger(issue.number) || issue.number <= 0 || typeof issue.title !== "string" || !(issue.body === null || typeof issue.body === "string") || !["open", "closed"].includes(issue.state) || !Array.isArray(issue.labels)) + throw new Error("GitHub returned an invalid issue"); + return { + title: issue.title, + body: issue.body ?? "", + status: issue.state === "closed" ? "Done" : issue.labels.some( + (l) => (typeof l === "string" ? l : l.name).toLowerCase() === "atomic:doing" + ) ? "Doing" : "Todo" + }; +} +function validate(value) { + if (typeof value.title !== "string" || !value.title.trim() || typeof value.body !== "string" || !["Todo", "Doing", "Done"].includes(value.status)) + throw new Error( + "Cards require a title, Markdown body and exactly one Todo/Doing/Done status" + ); +} +function issuePatch(desired, previous) { + const patch = {}; + if (previous && previous.title !== desired.title) patch.title = desired.title; + if (previous && previous.body !== desired.body) patch.body = desired.body; + if (!previous && desired.status === "Done" || previous && previous.status === "Done" !== (desired.status === "Done")) + patch.state = desired.status === "Done" ? "closed" : "open"; + return patch; +} + +// browser/lib/src/import-records.ts +var IMPORT_LOCAL_ID = "https://atomicdata.dev/properties/localId"; +var PARENT = "https://atomicdata.dev/properties/parent"; +var pure = (subject) => typeof subject === "string" && subject.startsWith("did:") ? subject.split("?")[0] : subject; +function claimImportIdentity(host, parent2, sourceId, subject) { + const matches = host.query(IMPORT_LOCAL_ID, sourceId).filter((id) => pure(host.read(id)[PARENT]) === pure(parent2)); + if (matches.length > 1 || matches.some((id) => pure(id) !== pure(subject))) + throw new Error( + "Import identity already exists; reconcile the binding before syncing" + ); + const old = subject ? host.read(subject)[IMPORT_LOCAL_ID] : void 0; + if (old !== void 0 && old !== sourceId) + throw new Error("Resource belongs to another import identity"); + return { [IMPORT_LOCAL_ID]: sourceId }; +} + +// ../../localthought/devonian/src/reconcileRecord.ts +var import_fast_json_stable_stringify = __toESM(require_fast_json_stable_stringify(), 1); +var equal = (a, b) => (0, import_fast_json_stable_stringify.default)(a) === (0, import_fast_json_stable_stringify.default)(b); +function reconcileRecord(base, local, remote) { + const result = { + local: {}, + remote: {}, + createLocal: false, + createRemote: false, + deleteLocal: false, + deleteRemote: false, + conflicts: [] + }; + if (equal(local, remote)) { + if (local !== void 0) result.agreed = structuredClone(local); + return result; + } + if (local === null || remote === null) { + if (equal(local, base) && remote === null) result.deleteLocal = true; + else if (equal(remote, base) && local === null) result.deleteRemote = true; + else + result.conflicts.push({ + property: "@record", + base, + local, + remote + }); + return result; + } + if (base !== void 0 && (local === void 0 || remote === void 0)) + return result; + result.createLocal = base === void 0 && local === void 0 && remote !== void 0; + result.createRemote = base === void 0 && remote === void 0 && local !== void 0; + const properties = /* @__PURE__ */ new Set([ + ...Object.keys(base ?? {}), + ...Object.keys(local ?? {}), + ...Object.keys(remote ?? {}) + ]); + for (const property of properties) { + const before = base?.[property]; + const here = local?.[property]; + const there = remote?.[property]; + if (equal(here, there)) continue; + if (equal(here, before)) result.local[property] = structuredClone(there); + else if (equal(there, before)) + result.remote[property] = structuredClone(here); + else + result.conflicts.push({ + property, + base: before, + local: here, + remote: there + }); + } + return result; +} + +// ../../localthought/devonian/platform-lenses/github-issues/adapter.ts +var headers = { + Accept: "application/vnd.github+json", + "User-Agent": "Atomic-GitHub-Issues-Pilot", + "X-GitHub-Api-Version": "2022-11-28", + Authorization: "secret:github", + "Content-Type": "application/json" +}; +function endpoint(repository) { + if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository) || repository.split("/").some((p) => p === "." || p === "..")) + throw new Error("Repository must be owner/name"); + return `https://api.github.com/repos/${repository}/issues`; +} +function parse(response) { + if (response.status < 200 || response.status >= 300) + throw new Error( + `GitHub returned ${response.status}; no checkpoint was advanced. Resolve access/rate limits before retrying.` + ); + return JSON.parse(response.body); +} +function request(operation, method, url, id, body) { + return { + operation, + method, + url, + id, + headers, + ...body === void 0 ? {} : { body: JSON.stringify(body) } + }; +} +async function preview(host, repository) { + const root = endpoint(repository); + const state = await host.state(); + if (state.cursor) + throw new Error( + "A saved sync is pending; resume it before previewing another run" + ); + const issues = /* @__PURE__ */ new Map(); + for (let page = 1; ; page++) { + if (page > 100) + throw new Error("Pilot supports at most 10,000 issues/PRs per scan"); + const rows = parse( + await host.read( + request( + "list", + "GET", + `${root}?state=all&per_page=100&page=${page}&sort=created&direction=asc`, + `page-${page}` + ) + ) + ); + if (!Array.isArray(rows)) + throw new Error("GitHub issue page must be an array"); + for (const issue of rows) + if (!("pull_request" in issue)) { + project(issue); + issues.set(issue.number, issue); + } + if (rows.length < 100) break; + } + const cards = await host.cards(); + const byNumber = /* @__PURE__ */ new Map(); + for (const card of cards) { + validate(card.value); + if (card.number !== void 0) { + if (byNumber.has(card.number)) + throw new Error(`Duplicate cards for issue #${card.number}`); + byNumber.set(card.number, card); + } + } + const result = { + repository, + revision: state.revision, + changes: [], + conflicts: [] + }; + for (const [number, issue] of issues) { + const remote = project(issue); + const binding = state.records[String(number)]; + const card = byNumber.get(number); + if (binding && (!card || binding.local !== card.subject)) { + result.conflicts.push({ + number, + fields: ["Missing or rebound local card"] + }); + continue; + } + const decision = reconcileRecord( + binding?.baseline, + card?.value, + remote + ); + if (decision.conflicts.length) { + result.conflicts.push({ + subject: card?.subject, + number, + fields: decision.conflicts.map((c) => c.property) + }); + continue; + } + const desired = { ...remote, ...decision.remote }; + validate(desired); + result.changes.push({ + subject: card?.subject, + number, + local: card?.value, + remote, + desired + }); + } + for (const card of cards) { + if (card.number === void 0) + result.changes.push({ + subject: card.subject, + local: card.value, + desired: card.value + }); + else if (!issues.has(card.number)) + result.conflicts.push({ + subject: card.subject, + number: card.number, + fields: ["Issue missing or inaccessible; no deletion inferred"] + }); + } + return result; +} + +// ../../localthought/devonian/platform-lenses/github-issues/plugin.ts +var parent = "https://atomicdata.dev/properties/parent"; +var isA = "https://atomicdata.dev/properties/isA"; +var name = "https://atomicdata.dev/properties/name"; +globalThis.structuredClone ??= ((value) => value === void 0 ? void 0 : JSON.parse(JSON.stringify(value))); +var equal2 = (a, b) => a === b || !!a && !!b && a.title === b.title && a.body === b.body && a.status === b.status; +async function run(input) { + const config = input.config; + if (input.phase === "action") { + const root2 = endpoint(config.repository); + const args = input.arguments ?? {}; + if (input.action === "get_issue") { + if (!Number.isSafeInteger(args.number) || Number(args.number) <= 0) + throw new Error("Choose a positive issue number"); + return request("get", "GET", `${root2}/${args.number}`, "action"); + } + if (input.action === "create_issue") { + if (typeof args.title !== "string" || !args.title.trim() || args.title.length > 256) + throw new Error("Issue title must contain 1 to 256 characters"); + return request("create", "POST", root2, "action", { + title: args.title, + body: args.body ?? "" + }); + } + throw new Error("Unknown integration action"); + } + if (!config || !config.table || !config.rowClass || !config.tags || Object.keys(config.tags).length !== 3) + throw new Error("Configure the connection before running it"); + const root = endpoint(config.repository); + const card = (subject) => { + const row = input.read(subject); + if (!row[isA]?.includes(config.rowClass) || row[parent] !== config.table) + return; + const selected = row[config.status]; + const status = selected?.length ? Object.entries(config.tags).find( + ([, id]) => selected.length === 1 && selected[0] === id + )?.[0] : "Todo"; + if (!status) throw new Error("Choose exactly one kanban status"); + const number = row[config.number]; + if (number !== void 0 && (!Number.isSafeInteger(number) || Number(number) <= 0)) + throw new Error("Invalid GitHub issue number"); + return { + subject, + ...number === void 0 ? {} : { number }, + value: { + title: String(row[name] ?? ""), + body: String(row[config.body] ?? ""), + status + } + }; + }; + const find = (number) => { + const matches = input.query(config.number, String(number)).map(card).filter((r) => r !== void 0); + if (matches.length > 1) throw new Error("Duplicate issue identity"); + return matches[0]; + }; + const issue = (number) => { + const response = input.http( + request("get", "GET", `${root}/${number}`, "read") + ); + if (response.status !== 200) + throw new Error( + `GitHub read failed (${response.status}); no deletion inferred` + ); + const value = JSON.parse(response.body); + if ("pull_request" in value || value.number !== number) + throw new Error("Expected the selected issue"); + project(value); + return value; + }; + if (input.phase === "preview") { + const proposal = await preview( + { + read: async (intent) => input.http(intent), + state: async () => input.connection, + cards: async () => input.query(parent, config.table).map(card).filter((r) => r !== void 0) + }, + config.repository + ); + return { + kind: "preview", + proposal, + problems: proposal.conflicts.map((c) => ({ + severity: "error", + message: `Issue ${c.number ?? c.subject}: ${c.fields.join(", ")}` + })) + }; + } + if (!input.proposal || input.proposal.repository !== config.repository || input.proposal.conflicts.length) + throw new Error("An approved conflict-free proposal is required"); + let cursor = input.cursor ?? { + index: 0, + stage: "start", + records: [] + }; + const effect = (value, next) => ({ + kind: "effect", + effect: value, + cursor: next + }); + const external = (operation, method, url, suffix, next, body) => { + const id = `${cursor.index}:${suffix}`; + return effect( + { + kind: "external", + id, + request: request(operation, method, url, id, body) + }, + next + ); + }; + for (let transitions = 0; transitions < 12; transitions++) { + if (cursor.stage === "done") return { kind: "complete" }; + if (cursor.index === input.proposal.changes.length) + return effect( + { kind: "checkpoint", id: "checkpoint", records: cursor.records }, + { ...cursor, stage: "done" } + ); + const change = input.proposal.changes[cursor.index]; + const desired = change.desired; + if (cursor.stage === "start") { + const local = change.subject ? card(change.subject) : change.number ? find(change.number) : void 0; + if (change.subject && (!local || !equal2(local.value, change.local))) + throw new Error("Card changed after preview"); + if (!change.subject && local) + throw new Error("An imported card appeared after preview"); + if (change.number && local && local.number !== change.number) + throw new Error("Card identity changed after preview"); + if (change.number === void 0) + return external( + "create", + "POST", + root, + "create", + { ...cursor, stage: "created", subject: local?.subject }, + { + title: desired.title, + body: desired.body, + labels: desired.status === "Doing" ? ["atomic:doing"] : [] + } + ); + const remote = project(issue(change.number)); + if (!equal2(remote, change.remote)) + throw new Error("Issue changed after preview"); + cursor = { + ...cursor, + stage: "patch", + number: change.number, + subject: local?.subject + }; + } else if (cursor.stage === "created") { + const created = JSON.parse(input.result.body); + project(created); + cursor = { ...cursor, number: created.number, stage: "patch" }; + } else if (cursor.stage === "patch") { + const patch = issuePatch(desired, change.remote); + const next = { ...cursor, stage: "labels" }; + if (Object.keys(patch).length) + return external( + "update", + "PATCH", + `${root}/${cursor.number}`, + "update", + next, + patch + ); + cursor = next; + } else if (cursor.stage === "labels") { + const current = issue(cursor.number); + const doing = current.labels.some( + (l) => (typeof l === "string" ? l : l.name).toLowerCase() === "atomic:doing" + ); + const next = { ...cursor, stage: "local" }; + if (desired.status === "Doing" && !doing) + return external( + "doing-add", + "POST", + `${root}/${cursor.number}/labels`, + "label", + next, + { + labels: ["atomic:doing"] + } + ); + if (desired.status !== "Doing" && doing) + return external( + "doing-remove", + "DELETE", + `${root}/${cursor.number}/labels/atomic%3Adoing`, + "label", + next + ); + cursor = next; + } else if (cursor.stage === "local") { + if (!equal2(project(issue(cursor.number)), desired)) + throw new Error("GitHub did not converge; checkpoint paused"); + const local = cursor.subject ? card(cursor.subject) : find(cursor.number); + if (cursor.subject && (!local || !equal2(local.value, change.local) && !equal2(local.value, desired))) + throw new Error("Card changed during sync"); + if (!cursor.subject && local) + throw new Error( + "An unexpected card appeared; reconcile before creating" + ); + const identity = `github:${config.repository.toLowerCase()}:issue:${cursor.number}`; + const set = { + ...claimImportIdentity(input, config.table, identity, local?.subject), + [name]: desired.title, + [config.body]: desired.body, + [config.status]: [config.tags[desired.status]], + [config.number]: cursor.number, + ...config.arrival && !local && change.remote && input.connection.revision > 0 ? { [config.arrival]: "remote" } : {} + }; + if (local && equal2(local.value, desired) && local.number === cursor.number && input.read(local.subject)[IMPORT_LOCAL_ID] === identity) + cursor = { ...cursor, subject: local.subject, stage: "verify" }; + else + return effect( + { + kind: "atomic", + id: `${cursor.index}:card`, + verdict: { + intents: [ + local ? { op: "set", subject: local.subject, set } : { + op: "create", + localId: "card", + parent: config.table, + isA: [config.rowClass], + set + } + ], + problems: [] + } + }, + { ...cursor, stage: "written" } + ); + } else if (cursor.stage === "written") { + const subject = input.result.outcomes?.[0]?.subject; + if (!subject) throw new Error("Atomic receipt is missing its subject"); + cursor = { ...cursor, subject, stage: "verify" }; + } else if (cursor.stage === "verify") { + const local = card(cursor.subject); + const remote = project(issue(cursor.number)); + if (!local || local.number !== cursor.number || !equal2(local.value, desired) || !equal2(remote, desired)) + throw new Error("Both sides must agree before checkpointing"); + cursor = { + index: cursor.index + 1, + stage: "start", + records: [ + ...cursor.records, + { + remote: String(cursor.number), + local: local.subject, + local_projection: local.value, + remote_projection: remote + } + ] + }; + return { kind: "continue", cursor }; + } else throw new Error("Unknown connection continuation"); + } + throw new Error("Too many pure transitions"); +} +export { + run +}; diff --git a/platform-lenses/github-issues/plugin.ts b/platform-lenses/github-issues/plugin.ts new file mode 100644 index 0000000..681bfde --- /dev/null +++ b/platform-lenses/github-issues/plugin.ts @@ -0,0 +1,388 @@ +import { issuePatch } from './lens/index.js'; +import { + claimImportIdentity, + IMPORT_LOCAL_ID, +} from '@integration-host/import-records'; +/** This entire module runs inside Atomic's QuickJS/WASM sandbox. */ +import { + preview, + project, + endpoint, + request, + type Preview, + type Card, + type Issue, + type Projection, +} from './adapter.js'; +import type { + ConnectionState, + ExternalReceipt, +} from '@integration-host/plugin-connection'; + +const parent = 'https://atomicdata.dev/properties/parent'; +const isA = 'https://atomicdata.dev/properties/isA'; +const name = 'https://atomicdata.dev/properties/name'; +// QuickJS has no structuredClone. This plugin only clones JSON projections. +globalThis.structuredClone ??= ((value: unknown) => + value === undefined + ? undefined + : JSON.parse(JSON.stringify(value))) as typeof structuredClone; + +interface Config { + repository: string; + table: string; + rowClass: string; + status: string; + number: string; + body: string; + arrival?: string; + tags: Record; +} +interface Cursor { + index: number; + stage: string; + number?: number; + subject?: string; + records: unknown[]; +} +interface Input { + phase: 'preview' | 'step' | 'action'; + action?: string; + arguments?: Record; + config: Config; + connection: ConnectionState; + proposal?: Preview; + cursor?: Cursor; + result?: any; + http(request: unknown): ExternalReceipt; + read(subject: string): Record; + query(property: string, value: string): string[]; +} +const equal = (a: Projection | undefined, b: Projection | undefined) => + a === b || + (!!a && + !!b && + a.title === b.title && + a.body === b.body && + a.status === b.status); +export async function run(input: Input): Promise { + const config = input.config; + if (input.phase === 'action') { + const root = endpoint(config.repository); + const args = input.arguments ?? {}; + if (input.action === 'get_issue') { + if (!Number.isSafeInteger(args.number) || Number(args.number) <= 0) + throw new Error('Choose a positive issue number'); + return request('get', 'GET', `${root}/${args.number}`, 'action'); + } + if (input.action === 'create_issue') { + if ( + typeof args.title !== 'string' || + !args.title.trim() || + args.title.length > 256 + ) + throw new Error('Issue title must contain 1 to 256 characters'); + return request('create', 'POST', root, 'action', { + title: args.title, + body: args.body ?? '', + }); + } + throw new Error('Unknown integration action'); + } + if ( + !config || + !config.table || + !config.rowClass || + !config.tags || + Object.keys(config.tags).length !== 3 + ) + throw new Error('Configure the connection before running it'); + const root = endpoint(config.repository); + const card = (subject: string): Card | undefined => { + const row = input.read(subject); + if ( + !(row[isA] as string[] | undefined)?.includes(config.rowClass) || + row[parent] !== config.table + ) + return; + const selected = row[config.status] as string[] | undefined; + const status = selected?.length + ? Object.entries(config.tags).find( + ([, id]) => selected.length === 1 && selected[0] === id, + )?.[0] + : 'Todo'; + if (!status) throw new Error('Choose exactly one kanban status'); + const number = row[config.number]; + if ( + number !== undefined && + (!Number.isSafeInteger(number) || Number(number) <= 0) + ) + throw new Error('Invalid GitHub issue number'); + return { + subject, + ...(number === undefined ? {} : { number: number as number }), + value: { + title: String(row[name] ?? ''), + body: String(row[config.body] ?? ''), + status: status as Projection['status'], + }, + }; + }; + const find = (number: number) => { + const matches = input + .query(config.number, String(number)) + .map(card) + .filter((r): r is Card => r !== undefined); + if (matches.length > 1) throw new Error('Duplicate issue identity'); + return matches[0]; + }; + const issue = (number: number): Issue => { + const response = input.http( + request('get', 'GET', `${root}/${number}`, 'read'), + ); + if (response.status !== 200) + throw new Error( + `GitHub read failed (${response.status}); no deletion inferred`, + ); + const value = JSON.parse(response.body) as Issue; + if ('pull_request' in value || value.number !== number) + throw new Error('Expected the selected issue'); + project(value); + return value; + }; + if (input.phase === 'preview') { + const proposal = await preview( + { + read: async intent => input.http(intent), + state: async () => input.connection, + cards: async () => + input + .query(parent, config.table) + .map(card) + .filter((r): r is Card => r !== undefined), + }, + config.repository, + ); + return { + kind: 'preview', + proposal, + problems: proposal.conflicts.map(c => ({ + severity: 'error', + message: `Issue ${c.number ?? c.subject}: ${c.fields.join(', ')}`, + })), + }; + } + if ( + !input.proposal || + input.proposal.repository !== config.repository || + input.proposal.conflicts.length + ) + throw new Error('An approved conflict-free proposal is required'); + let cursor: Cursor = input.cursor ?? { + index: 0, + stage: 'start', + records: [], + }; + const effect = (value: unknown, next: Cursor) => ({ + kind: 'effect', + effect: value, + cursor: next, + }); + const external = ( + operation: string, + method: string, + url: string, + suffix: string, + next: Cursor, + body?: unknown, + ) => { + const id = `${cursor.index}:${suffix}`; + return effect( + { + kind: 'external', + id, + request: request(operation, method, url, id, body), + }, + next, + ); + }; + // Pure transitions may be folded into the same invocation. Every write yields. + for (let transitions = 0; transitions < 12; transitions++) { + if (cursor.stage === 'done') return { kind: 'complete' }; + if (cursor.index === input.proposal.changes.length) + return effect( + { kind: 'checkpoint', id: 'checkpoint', records: cursor.records }, + { ...cursor, stage: 'done' }, + ); + const change = input.proposal.changes[cursor.index]; + const desired = change.desired; + if (cursor.stage === 'start') { + const local = change.subject + ? card(change.subject) + : change.number + ? find(change.number) + : undefined; + if (change.subject && (!local || !equal(local.value, change.local))) + throw new Error('Card changed after preview'); + if (!change.subject && local) + throw new Error('An imported card appeared after preview'); + if (change.number && local && local.number !== change.number) + throw new Error('Card identity changed after preview'); + if (change.number === undefined) + return external( + 'create', + 'POST', + root, + 'create', + { ...cursor, stage: 'created', subject: local?.subject }, + { + title: desired.title, + body: desired.body, + labels: desired.status === 'Doing' ? ['atomic:doing'] : [], + }, + ); + const remote = project(issue(change.number)); + if (!equal(remote, change.remote)) + throw new Error('Issue changed after preview'); + cursor = { + ...cursor, + stage: 'patch', + number: change.number, + subject: local?.subject, + }; + } else if (cursor.stage === 'created') { + const created = JSON.parse(input.result.body) as Issue; + project(created); + cursor = { ...cursor, number: created.number, stage: 'patch' }; + } else if (cursor.stage === 'patch') { + const patch = issuePatch(desired, change.remote); + const next = { ...cursor, stage: 'labels' }; + if (Object.keys(patch).length) + return external( + 'update', + 'PATCH', + `${root}/${cursor.number}`, + 'update', + next, + patch, + ); + cursor = next; + } else if (cursor.stage === 'labels') { + const current = issue(cursor.number!); + const doing = current.labels.some( + l => + (typeof l === 'string' ? l : l.name).toLowerCase() === 'atomic:doing', + ); + const next = { ...cursor, stage: 'local' }; + if (desired.status === 'Doing' && !doing) + return external( + 'doing-add', + 'POST', + `${root}/${cursor.number}/labels`, + 'label', + next, + { + labels: ['atomic:doing'], + }, + ); + if (desired.status !== 'Doing' && doing) + return external( + 'doing-remove', + 'DELETE', + `${root}/${cursor.number}/labels/atomic%3Adoing`, + 'label', + next, + ); + cursor = next; + } else if (cursor.stage === 'local') { + if (!equal(project(issue(cursor.number!)), desired)) + throw new Error('GitHub did not converge; checkpoint paused'); + const local = cursor.subject + ? card(cursor.subject) + : find(cursor.number!); + if ( + cursor.subject && + (!local || + (!equal(local.value, change.local) && !equal(local.value, desired))) + ) + throw new Error('Card changed during sync'); + if (!cursor.subject && local) + throw new Error( + 'An unexpected card appeared; reconcile before creating', + ); + const identity = `github:${config.repository.toLowerCase()}:issue:${cursor.number}`; + const set = { + ...claimImportIdentity(input, config.table, identity, local?.subject), + [name]: desired.title, + [config.body]: desired.body, + [config.status]: [config.tags[desired.status]], + [config.number]: cursor.number, + ...(config.arrival && + !local && + change.remote && + input.connection.revision > 0 + ? { [config.arrival]: 'remote' } + : {}), + }; + if ( + local && + equal(local.value, desired) && + local.number === cursor.number && + input.read(local.subject)[IMPORT_LOCAL_ID] === identity + ) + cursor = { ...cursor, subject: local.subject, stage: 'verify' }; + else + return effect( + { + kind: 'atomic', + id: `${cursor.index}:card`, + verdict: { + intents: [ + local + ? { op: 'set', subject: local.subject, set } + : { + op: 'create', + localId: 'card', + parent: config.table, + isA: [config.rowClass], + set, + }, + ], + problems: [], + }, + }, + { ...cursor, stage: 'written' }, + ); + } else if (cursor.stage === 'written') { + const subject = input.result.outcomes?.[0]?.subject; + if (!subject) throw new Error('Atomic receipt is missing its subject'); + cursor = { ...cursor, subject, stage: 'verify' }; + } else if (cursor.stage === 'verify') { + const local = card(cursor.subject!); + const remote = project(issue(cursor.number!)); + if ( + !local || + local.number !== cursor.number || + !equal(local.value, desired) || + !equal(remote, desired) + ) + throw new Error('Both sides must agree before checkpointing'); + cursor = { + index: cursor.index + 1, + stage: 'start', + records: [ + ...cursor.records, + { + remote: String(cursor.number), + local: local.subject, + local_projection: local.value, + remote_projection: remote, + }, + ], + }; + // Return a read-only continuation instead of doing unbounded work per invocation. + return { kind: 'continue', cursor }; + } else throw new Error('Unknown connection continuation'); + } + throw new Error('Too many pure transitions'); +} diff --git a/platform-lenses/github-issues/ports.mjs b/platform-lenses/github-issues/ports.mjs new file mode 100644 index 0000000..1380dd7 --- /dev/null +++ b/platform-lenses/github-issues/ports.mjs @@ -0,0 +1,320 @@ +import { project, issueFields } from './lens/index.js'; +import { core, dataBrowser } from '@tomic/lib'; + +export const digest = async value => + Array.from( + new Uint8Array( + await crypto.subtle.digest('SHA-256', new TextEncoder().encode(value)), + ), + byte => byte.toString(16).padStart(2, '0'), + ).join(''); +const equal = (a, b) => JSON.stringify(a) === JSON.stringify(b); + +/** Reuses the GitHub integration mapping with an injected browser proxy transport. */ +export class GitHubPort { + constructor(store, connection, call) { + this.store = store; + this.connection = connection; + this.scope = `https://github.com/${connection.repository}`; + if (!call) + throw new Error('A browser integration-proxy transport is required'); + this.call = call; + } + async request(action, args, key) { + const receipt = await this.call( + action, + args, + await digest(key ?? `${action}:${JSON.stringify(args)}`), + ); + if (receipt.status < 200 || receipt.status >= 300) + throw new Error(`GitHub ${action} returned ${receipt.status}`); + return receipt.body ? JSON.parse(receipt.body) : undefined; + } + row(entity, raw, context = {}) { + if (entity === 'issue') { + if (raw.pull_request) throw new Error('Pull requests are excluded'); + return { + id: raw.number, + value: project(raw), + metadata: this.metadata(raw, { number: raw.number }), + }; + } + if ( + !Number.isSafeInteger(raw.id) || + raw.id <= 0 || + typeof raw.body !== 'string' + ) + throw new Error('Invalid GitHub comment'); + const expected = `https://api.github.com/repos/${this.connection.repository}/issues/${context.issueId}`; + if (raw.issue_url !== expected) + throw new Error('Comment belongs to another issue'); + return { + id: raw.id, + value: { body: raw.body }, + metadata: this.metadata(raw, { commentId: raw.id }), + }; + } + metadata(raw, identity) { + return { + ...identity, + ...(raw.user?.login ? { author: raw.user.login } : {}), + ...(raw.html_url ? { url: raw.html_url } : {}), + ...(raw.created_at ? { createdAt: raw.created_at } : {}), + ...(raw.updated_at ? { updatedAt: raw.updated_at } : {}), + }; + } + async list(entity, context = {}) { + const result = []; + for (let page = 1; page <= 100; page++) { + const raw = await this.request( + entity === 'issue' ? 'list_issues' : 'list_comments', + { + page, + ...(entity === 'issue' ? {} : { number: context.issueId }), + }, + ); + if (!Array.isArray(raw)) throw new Error('Invalid GitHub page'); + for (const r of raw) { + if (entity === 'issue' && 'pull_request' in r) continue; + result.push(this.row(entity, r, context)); + } + if (raw.length < 100) return result; + } + throw new Error('More than 10,000 records in one scan'); + } + async get(entity, id, context) { + const raw = await this.request( + entity === 'issue' ? 'get_issue' : 'get_comment', + entity === 'issue' ? { number: id } : { id }, + ); + const row = this.row(entity, raw, context); + if (row.id !== id) throw new Error('Provider returned another identity'); + return row; + } + async create(entity, value, key, metadata, context) { + const raw = await this.request( + entity === 'issue' ? 'create_issue' : 'create_comment', + entity === 'issue' + ? { title: value.title, body: value.body } + : { number: context.issueId, body: value.body }, + `${key}:create`, + ); + const row = this.row(entity, raw, context); + if (entity === 'issue' && value.status !== 'Todo') + await this.update( + entity, + row.id, + value, + `${key}:initial-state`, + metadata, + context, + ); + return { ...row, value }; + } + async update(entity, id, value, key, _metadata, context) { + if (entity !== 'issue') { + await this.get(entity, id, context); // validate issue membership before writing + await this.request( + 'update_comment', + { id, body: value.body }, + `${key}:body`, + ); + return; + } + if (!['Todo', 'Doing', 'Done'].includes(value.status)) + throw new Error('Unsupported task status'); + const current = await this.get(entity, id, context); + await this.request( + 'update_issue', + { + number: id, + ...issueFields(value), + }, + `${key}:fields`, + ); + // Only this workflow label is managed. Closing preserves the label like the pilot. + if (value.status === 'Doing' && current.value.status !== 'Doing') { + await this.request('add_doing_label', { number: id }, `${key}:doing`); + } else if (value.status === 'Todo') { + // Closed issues may still carry the doing label, so inspect raw labels. + const raw = await this.request('get_issue', { number: id }); + if ( + raw.labels.some( + l => + (typeof l === 'string' ? l : l.name).toLowerCase() === + 'atomic:doing', + ) + ) { + await this.request('remove_doing_label', { number: id }, `${key}:todo`); + } + } + } +} + +/** Signed SDK writes build on the fetched Loro state, preserving unmanaged fields. */ +export class AtomicPort { + constructor(store, config) { + this.store = store; + this.config = config; + this.connection = config.connection; + this.scope = `https://atomicdata.dev/devonian-local/${encodeURIComponent(this.connection.table)}`; + } + async subjects(property, value) { + const result = await this.store.queryLocalDb({ + drive: this.connection.drive, + property, + value, + limit: 10001, + }); + if ( + !result || + result.count > 10000 || + result.subjects.length !== result.count + ) + throw new Error('Incomplete local database query'); + return result.subjects; + } + async findByLocalId(parent, localId) { + const rows = await Promise.all( + (await this.subjects(core.properties.localId, localId)).map(id => + this.resource(id), + ), + ); + const matches = rows.filter(r => r.get(core.properties.parent) === parent); + if (matches.length > 1) + throw new Error('Duplicate Atomic creation identity'); + return matches[0]; + } + async resource(subject) { + const r = await this.store.getResource(subject); + if (r.error) throw r.error; + r.getLoroDoc(); + return r; + } + row(entity, r, context = {}) { + const c = this.connection; + const classes = r.get(core.properties.isA) ?? []; + if (entity === 'issue') { + if ( + !classes.includes(c.rowClass) || + r.get(core.properties.parent) !== c.table + ) + throw new Error('Resource is not a row of this issue tracker'); + } else if ( + !classes.includes(dataBrowser.classes.message) || + r.get(dataBrowser.properties.about) !== context.issueId + ) + throw new Error('Message belongs to another issue'); + let metadata = r.get(this.config.provenance); + if (typeof metadata === 'string') metadata = JSON.parse(metadata); + if (entity !== 'issue') + return { + id: r.subject, + value: { body: r.get(core.properties.description) ?? '' }, + ...(metadata ? { metadata } : {}), + }; + const statuses = r.get(c.status) ?? [c.tags.Todo]; + const status = Object.keys(c.tags).find(s => c.tags[s] === statuses[0]); + if (statuses.length !== 1 || !status) + throw new Error( + 'Choose exactly one Todo/Doing/Done status; Blocked is unmapped', + ); + const title = r.get(core.properties.name), + body = r.get(c.body) ?? ''; + if (typeof title !== 'string' || !title.trim() || typeof body !== 'string') + throw new Error('Invalid Atomic issue'); + const remoteId = r.get(c.number); + if ( + remoteId !== undefined && + (!Number.isSafeInteger(remoteId) || remoteId <= 0) + ) + throw new Error('Invalid GitHub issue number'); + return { + id: r.subject, + remoteId, + value: { title, body, status }, + ...(metadata ? { metadata } : {}), + }; + } + async list(entity, context = {}) { + const ids = await this.subjects( + entity === 'issue' + ? core.properties.parent + : dataBrowser.properties.about, + entity === 'issue' ? this.connection.table : context.issueId, + ); + const rows = []; + for (const id of ids) { + const resource = await this.resource(id); + if ( + !(resource.get(core.properties.isA) ?? []).includes( + entity === 'issue' + ? this.connection.rowClass + : dataBrowser.classes.message, + ) + ) + continue; + rows.push(this.row(entity, resource, context)); + } + return rows; + } + async get(entity, id, context) { + return this.row(entity, await this.resource(id), context); + } + values(entity, value, metadata, context) { + const c = this.connection; + return { + ...(entity === 'issue' + ? { + [core.properties.name]: value.title, + [c.body]: value.body, + [c.status]: [c.tags[value.status]], + ...(metadata?.number ? { [c.number]: metadata.number } : {}), + } + : { + [core.properties.description]: value.body, + [dataBrowser.properties.about]: context.issueId, + }), + ...(metadata ? { [this.config.provenance]: metadata } : {}), + }; + } + async create(entity, value, key, metadata, context) { + const parent = + entity === 'issue' ? this.connection.table : this.config.commentsFolder; + const localId = `devonian:${await digest(key)}`; + const existing = await this.findByLocalId(parent, localId); + if (existing) { + const row = await this.get(entity, existing.subject, context); + if (!equal(row.value, value)) + throw new Error( + 'Recovered Atomic create was edited; reconcile before retry', + ); + return row; + } + const r = await this.store.newResource({ + parent, + isA: [ + entity === 'issue' + ? this.connection.rowClass + : dataBrowser.classes.message, + ], + propVals: { + ...this.values(entity, value, metadata, context), + [core.properties.localId]: localId, + }, + }); + if ((await r.save()) === 'offline') + throw new Error('AtomicServer disconnected'); + return this.get(entity, r.subject, context); + } + async update(entity, id, value, _key, metadata, context) { + const r = await this.resource(id); + this.row(entity, r, context); + for (const [p, v] of Object.entries( + this.values(entity, value, metadata, context), + )) + await r.set(p, v); + if ((await r.save()) === 'offline') + throw new Error('AtomicServer disconnected'); + } +} diff --git a/platform-lenses/github-issues/ports.test.mjs b/platform-lenses/github-issues/ports.test.mjs new file mode 100644 index 0000000..6993110 --- /dev/null +++ b/platform-lenses/github-issues/ports.test.mjs @@ -0,0 +1,236 @@ +import { expect, it } from 'vitest'; +import { GitHubPort, AtomicPort } from './ports.mjs'; +import { + trackerAction, + trackerActions, + trackerOperations, +} from './tracker-actions.js'; +import { manifest } from './adapter.js'; + +it('declares and prepares scoped issue/comment actions with validated arguments', () => { + const original = manifest('owner/repo'); + const m = { + ...original, + actions: [...original.actions, ...trackerActions], + operations: [ + ...original.operations, + ...trackerOperations('https://api.github.com/repos/owner/repo/issues'), + ], + }; + for (const [action, args] of [ + ['list_issues', { page: 1 }], + ['list_comments', { number: 5, page: 2 }], + ['create_comment', { number: 5, body: 'Hello' }], + ['get_comment', { id: 9 }], + ['update_comment', { id: 9, body: 'Edit' }], + ['update_issue', { number: 5, title: 'Title', body: '', state: 'closed' }], + ['add_doing_label', { number: 5 }], + ['remove_doing_label', { number: 5 }], + ]) { + const intent = trackerAction('owner/repo', action, args); + expect( + intent.url.startsWith('https://api.github.com/repos/owner/repo/issues'), + ).toBe(true); + expect(m.actions.find(a => a.name === action).operation).toBe( + intent.operation, + ); + expect( + m.operations.some( + o => o.id === intent.operation && o.method === intent.method, + ), + ).toBe(true); + } + expect(() => + trackerAction('owner/repo', 'get_comment', { id: '../escape' }), + ).toThrow(); + expect(() => + trackerAction('owner/repo', 'list_issues', { page: 101 }), + ).toThrow(); + expect(() => + trackerAction('owner/repo', 'create_comment', { number: 1, body: '' }), + ).toThrow(); +}); + +function github() { + const issues = new Map([ + [ + 1, + { number: 1, title: 'First', body: '', state: 'open', labels: ['bug'] }, + ], + ]); + const comments = new Map(); + const receipts = new Map(); + const writes = []; + const port = new GitHubPort( + null, + { repository: 'owner/repo' }, + async (action, args, id) => { + const isWrite = /^(create|update|add|remove)_/.test(action); + if (isWrite && receipts.has(id)) return receipts.get(id); + let value; + if (isWrite) writes.push(action); + switch (action) { + case 'get_issue': + value = issues.get(args.number); + break; + case 'list_issues': + value = [...issues.values()]; + break; + case 'create_issue': + value = { + number: issues.size + 1, + ...args, + state: 'open', + labels: [], + }; + issues.set(value.number, value); + break; + case 'update_issue': + value = issues.get(args.number); + Object.assign(value, args); + break; + case 'add_doing_label': + value = issues.get(args.number); + value.labels.push('atomic:doing'); + break; + case 'remove_doing_label': + value = issues.get(args.number); + value.labels = value.labels.filter(l => l !== 'atomic:doing'); + break; + case 'create_comment': + value = { + id: comments.size + 1, + body: args.body, + issue_url: `https://api.github.com/repos/owner/repo/issues/${args.number}`, + }; + comments.set(value.id, value); + break; + case 'get_comment': + value = comments.get(args.id); + break; + case 'list_comments': + value = [...comments.values()]; + break; + case 'update_comment': + value = comments.get(args.id); + value.body = args.body; + break; + default: + throw new Error(action); + } + const receipt = { status: 200, body: JSON.stringify(value) }; + if (isWrite) receipts.set(id, receipt); + return receipt; + }, + ); + return { port, issues, comments, writes }; +} + +it('creates closed/doing issues and reopens while preserving unrelated labels', async () => { + const { port, issues } = github(); + const value = { title: 'Created closed', body: 'Markdown', status: 'Done' }; + const row = await port.create('issue', value, 'stable-create'); + expect(issues.get(row.id).state).toBe('closed'); + await port.create('issue', value, 'stable-create'); + expect(issues.size).toBe(2); + await port.update('issue', 1, { ...value, status: 'Doing' }, 'doing'); + expect(issues.get(1).labels).toEqual(['bug', 'atomic:doing']); + await port.update('issue', 1, value, 'close'); + await port.update('issue', 1, { ...value, status: 'Todo' }, 'reopen'); + expect(issues.get(1).state).toBe('open'); + expect(issues.get(1).labels).toEqual(['bug']); +}); + +it('creates/edits comments with replay-safe IDs and rejects another issue’s comment', async () => { + const { port, comments } = github(); + const context = { issueId: 1 }; + await port.create( + 'comment:x', + { body: 'Hi' }, + 'comment-create', + undefined, + context, + ); + await port.create( + 'comment:x', + { body: 'Hi' }, + 'comment-create', + undefined, + context, + ); + expect(comments.size).toBe(1); + await port.update( + 'comment:x', + 1, + { body: 'Edited' }, + 'comment-edit', + undefined, + context, + ); + expect(comments.get(1).body).toBe('Edited'); + await expect(port.get('comment:x', 1, { issueId: 2 })).rejects.toThrow( + 'another issue', + ); +}); + +it('fails on provider errors and scans all pages without importing pull requests', async () => { + const f = github(); + const row = f.issues.get(1); + let calls = 0; + const port = new GitHubPort(null, { repository: 'owner/repo' }, async () => ({ + status: 200, + body: JSON.stringify( + ++calls === 1 + ? Array.from({ length: 100 }, (_, i) => ({ + ...row, + number: i + 1, + ...(i === 0 ? { pull_request: {} } : {}), + })) + : [], + ), + })); + expect(await port.list('issue')).toHaveLength(99); + expect(calls).toBe(2); + port.call = async () => ({ status: 429, body: '{}' }); + await expect(port.list('issue')).rejects.toThrow('429'); +}); + +it('Atomic creates reuse native localId after a lost acknowledgement', async () => { + const stored = new Map(); + let lose = true; + const store = { + getServerUrl: () => 'https://atomic.example', + findByLocalId: async (_drive, parent, key) => + [...stored.values()].find(r => r.parent === parent && r.key === key), + newResource: async ({ parent, propVals }) => { + const r = { + subject: `did:ad:${stored.size + 1}`, + parent, + key: propVals['https://atomicdata.dev/properties/localId'], + async save() { + stored.set(r.subject, r); + if (lose) { + lose = false; + throw new Error('Lost acknowledgement'); + } + }, + }; + return r; + }, + }; + const port = new AtomicPort(store, { + connection: { + table: 'did:ad:table', + drive: 'did:ad:drive', + tags: { Todo: 'https://atomicdata.dev/task/v1/todo' }, + }, + }); + const value = { title: 'Same', body: '', status: 'Todo' }; + port.get = async (_entity, id) => ({ id, value }); + port.findByLocalId = (parent, key) => store.findByLocalId('', parent, key); + await expect(port.create('issue', value, 'key')).rejects.toThrow( + 'Lost acknowledgement', + ); + expect((await port.create('issue', value, 'key')).id).toBe('did:ad:1'); + expect(stored.size).toBe(1); +}); diff --git a/platform-lenses/github-issues/proxy.mjs b/platform-lenses/github-issues/proxy.mjs new file mode 100644 index 0000000..986a19f --- /dev/null +++ b/platform-lenses/github-issues/proxy.mjs @@ -0,0 +1,199 @@ +import { endpoint, request } from './adapter.js'; +import { trackerAction } from './tracker-actions.js'; + +/** Direct browser transport. Codes stay in tab storage, never in Atomic resources. */ +export function proxyTransport({ + url, + repository, + getCode, + setCode, + journal, + save, + fetcher = fetch, + dispatch, +}) { + const origin = new URL(url); + if ( + origin.protocol !== 'https:' && + !( + origin.protocol === 'http:' && + ['localhost', '127.0.0.1'].includes(origin.hostname) + ) + ) + throw new Error('Use an HTTPS proxy or loopback HTTP'); + const root = endpoint(repository); + let pending = Promise.resolve(); + return (action, args, id) => { + const operation = async () => { + if ( + action === 'get_issue' && + (!Number.isSafeInteger(args.number) || args.number <= 0) + ) + throw new Error('Invalid issue number'); + if ( + action === 'create_issue' && + (typeof args.title !== 'string' || + !args.title.trim() || + args.title.length > 256 || + (args.body !== undefined && typeof args.body !== 'string')) + ) + throw new Error('Invalid issue title or body'); + const intent = + trackerAction(repository, action, args) ?? + (action === 'get_issue' + ? request('get', 'GET', `${root}/${args.number}`, id) + : action === 'create_issue' + ? request('create', 'POST', root, id, args) + : undefined); + if (!intent) throw new Error('Unsupported GitHub action'); + const writes = intent.method !== 'GET'; + const signature = JSON.stringify({ action, args }); + const old = journal[id]; + if (writes && old) { + if (old.signature !== signature) + throw new Error('Operation identity reused with different arguments'); + if (old.receipt) return old.receipt; + throw new Error( + `Uncertain GitHub write (${action}). Inspect its outcome before retrying; it will not be resent.`, + ); + } + if (writes) { + journal[id] = { signature }; + await save(); + } + const target = new URL(intent.url); + let receipt; + let next = true; + if (dispatch) { + receipt = await dispatch(`${target.pathname}${target.search}`, { + method: intent.method, + ...(intent.body ? { body: intent.body } : {}), + }).catch(() => { + throw new Error( + 'Proxy request failed. Check CORS and reconnect; an uncertain write will not be resent.', + ); + }); + } else { + const code = getCode(); + if (!code) + throw new Error( + 'Connect to the proxy or supply a fresh connection code', + ); + setCode(''); + const destination = new URL( + `/proxy/github-issues${target.pathname}${target.search}`, + origin, + ); + const response = await fetcher(destination.href, { + method: intent.method, + redirect: 'error', + credentials: 'omit', + headers: { + Authorization: `Bearer ${code}`, + Accept: 'application/vnd.github+json', + 'Content-Type': 'application/json', + }, + ...(intent.body ? { body: intent.body } : {}), + }).catch(() => { + throw new Error( + 'Proxy request failed. Check CORS and reconnect; an uncertain write will not be resent.', + ); + }); + next = response.headers.get('X-Connection-Code'); + if (next) setCode(next); + receipt = { status: response.status, body: await response.text() }; + } + if (writes && receipt.status >= 200 && receipt.status < 300) { + journal[id].receipt = receipt; + await save(); + } + if (!next) + throw new Error( + 'Proxy did not expose X-Connection-Code. Enable CORS exposure and reconnect.', + ); + return receipt; + }; + const next = pending.then(operation); + pending = next.catch(() => {}); + return next; + }; +} + +/** An explicitly labelled GitHub fixture; no network or real GitHub mutations. */ +export function fixtureTransport(state, save) { + state.issues ??= [ + { + number: 1, + title: 'Welcome from GitHub', + body: 'Edit either tracker, then sync again.', + state: 'open', + labels: ['demo'], + }, + ]; + state.comments ??= []; + state.receipts ??= {}; + return async (action, args, id) => { + if (state.receipts[id]) return state.receipts[id]; + let value; + const issue = state.issues.find(r => r.number === args.number); + const comment = state.comments.find(r => r.id === args.id); + switch (action) { + case 'list_issues': + value = state.issues.slice((args.page - 1) * 100, args.page * 100); + break; + case 'get_issue': + value = issue; + break; + case 'create_issue': + value = { + number: Math.max(0, ...state.issues.map(r => r.number)) + 1, + ...args, + state: 'open', + labels: [], + }; + state.issues.push(value); + break; + case 'update_issue': + value = Object.assign(issue, args); + break; + case 'add_doing_label': + issue.labels = [...new Set([...issue.labels, 'atomic:doing'])]; + value = issue; + break; + case 'remove_doing_label': + issue.labels = issue.labels.filter(l => l !== 'atomic:doing'); + value = issue; + break; + case 'list_comments': + value = state.comments + .filter(c => c.issue_url.endsWith(`/${args.number}`)) + .slice((args.page - 1) * 100, args.page * 100); + break; + case 'get_comment': + value = comment; + break; + case 'create_comment': + value = { + id: Math.max(0, ...state.comments.map(r => r.id)) + 1, + body: args.body, + issue_url: `https://api.github.com/repos/demo/issues/issues/${args.number}`, + user: { login: 'demo-user' }, + }; + state.comments.push(value); + break; + case 'update_comment': + value = Object.assign(comment, { body: args.body }); + break; + default: + throw new Error(`Unsupported fixture action: ${action}`); + } + const receipt = { + status: value ? 200 : 404, + body: JSON.stringify(value ?? {}), + }; + if (!action.startsWith('get_') && !action.startsWith('list_')) + state.receipts[id] = receipt; + await save(); + return receipt; + }; +} diff --git a/platform-lenses/github-issues/proxy.test.mjs b/platform-lenses/github-issues/proxy.test.mjs new file mode 100644 index 0000000..48bf983 --- /dev/null +++ b/platform-lenses/github-issues/proxy.test.mjs @@ -0,0 +1,100 @@ +import { expect, it } from 'vitest'; +import { proxyTransport } from './proxy.mjs'; + +it('serializes rotating codes, preserves query strings and checkpoints successful writes', async () => { + let code = 'first'; + const seen = []; + const journal = {}; + let saves = 0; + const call = proxyTransport({ + url: 'https://proxy.example', + repository: 'owner/repo', + journal, + getCode: () => code, + setCode: c => { + code = c; + }, + save: async () => { + saves++; + }, + fetcher: async (url, opts) => { + seen.push({ url, ...opts }); + return new Response('{"id":1}', { + headers: { 'X-Connection-Code': `next-${seen.length}` }, + }); + }, + }); + await Promise.all([ + call('list_issues', { page: 2 }, 'read'), + call('create_comment', { number: 1, body: 'Hello' }, 'write'), + ]); + expect(seen[0].url).toContain( + '/proxy/github-issues/repos/owner/repo/issues?state=all&per_page=100&page=2', + ); + expect(seen.map(r => r.headers.Authorization)).toEqual([ + 'Bearer first', + 'Bearer next-1', + ]); + expect(code).toBe('next-2'); + expect(saves).toBe(2); + await call('create_comment', { number: 1, body: 'Hello' }, 'write'); + expect(seen).toHaveLength(2); + await expect( + call('create_comment', { number: 1, body: 'Changed' }, 'write'), + ).rejects.toThrow('different arguments'); +}); + +it('never retries an uncertain create after a lost response or restart', async () => { + const journal = {}; + let writes = 0; + let code = 'first'; + const options = { + url: 'https://proxy.example', + repository: 'owner/repo', + journal, + getCode: () => code, + setCode: c => { + code = c; + }, + save: async () => {}, + fetcher: async () => { + writes++; + throw new Error('network'); + }, + }; + await expect( + proxyTransport(options)('create_issue', { title: 'Title' }, 'create'), + ).rejects.toThrow('Proxy request failed'); + code = 'reconnected'; + await expect( + proxyTransport(options)('create_issue', { title: 'Title' }, 'create'), + ).rejects.toThrow('Uncertain GitHub write'); + expect(writes).toBe(1); +}); + +it('retains rotated codes on provider errors and identifies missing exposed headers', async () => { + let code = 'first'; + const options = { + url: 'https://proxy.example', + repository: 'owner/repo', + journal: {}, + getCode: () => code, + setCode: c => { + code = c; + }, + save: async () => {}, + fetcher: async () => + new Response('{}', { + status: 403, + headers: { 'X-Connection-Code': 'rotated' }, + }), + }; + expect( + (await proxyTransport(options)('get_issue', { number: 1 }, 'read')).status, + ).toBe(403); + expect(code).toBe('rotated'); + options.fetcher = async () => new Response('{}'); + await expect( + proxyTransport(options)('get_issue', { number: 1 }, 'read'), + ).rejects.toThrow('expose X-Connection-Code'); +}); diff --git a/platform-lenses/github-issues/tracker-actions.ts b/platform-lenses/github-issues/tracker-actions.ts new file mode 100644 index 0000000..47e9096 --- /dev/null +++ b/platform-lenses/github-issues/tracker-actions.ts @@ -0,0 +1,190 @@ +/** Additional repository-scoped actions used by the Devonian tracker bridge. */ +import { endpoint, request } from './adapter.js'; + +const integer = { + type: 'integer' as const, + description: 'Positive identifier or page number', +}; +const string = { + type: 'string' as const, + description: 'Issue or comment field value', +}; +const definitions = [ + ['list_issues', 'List issues', 'list', { page: integer }, ['page']], + [ + 'update_issue', + 'Update an issue', + 'update', + { number: integer, title: string, body: string, state: string }, + ['number', 'title', 'body', 'state'], + ], + [ + 'add_doing_label', + 'Mark an issue as doing', + 'doing-add', + { number: integer }, + ['number'], + ], + [ + 'remove_doing_label', + 'Remove the doing label', + 'doing-remove', + { number: integer }, + ['number'], + ], + [ + 'list_comments', + 'List issue comments', + 'comments-list', + { number: integer, page: integer }, + ['number', 'page'], + ], + [ + 'get_comment', + 'Get an issue comment', + 'comments-get', + { id: integer }, + ['id'], + ], + [ + 'create_comment', + 'Create an issue comment', + 'comments-create', + { number: integer, body: string }, + ['number', 'body'], + ], + [ + 'update_comment', + 'Update an issue comment', + 'comments-update', + { id: integer, body: string }, + ['id', 'body'], + ], +] as const; + +export const trackerActions = definitions.map( + ([name, title, operation, properties, required]) => ({ + name, + title, + description: `${title} in this connected repository. Writes require approval.`, + operation, + inputSchema: { + type: 'object', + properties, + required: [...required], + additionalProperties: false, + }, + }), +); + +export function trackerOperations(root: string) { + return [ + { + id: 'comments-list', + method: 'GET', + url: `${root}/{number}/comments`, + effect: 'read', + }, + { + id: 'comments-get', + method: 'GET', + url: `${root}/comments/{id}`, + effect: 'read', + }, + { + id: 'comments-create', + method: 'POST', + url: `${root}/{number}/comments`, + effect: 'write', + }, + { + id: 'comments-update', + method: 'PATCH', + url: `${root}/comments/{id}`, + effect: 'write', + }, + ]; +} + +export function trackerAction( + repository: string, + action: string, + args: Record, +) { + const definition = definitions.find(d => d[0] === action); + if (!definition) return undefined; + const [, , operation, properties, required] = definition; + for (const key of required) { + if (!(key in args)) throw new Error(`Missing ${key}`); + } + for (const [key, value] of Object.entries(args)) { + if (!(key in properties)) throw new Error(`Unexpected ${key}`); + if (['number', 'id', 'page'].includes(key)) { + if ( + !Number.isSafeInteger(value) || + Number(value) <= 0 || + (key === 'page' && Number(value) > 100) + ) + throw new Error(`Invalid ${key}`); + } else if (typeof value !== 'string') throw new Error(`Invalid ${key}`); + } + const root = endpoint(repository); + switch (action) { + case 'list_issues': + return request( + operation, + 'GET', + `${root}?state=all&per_page=100&page=${args.page}&sort=created&direction=asc`, + 'action', + ); + case 'list_comments': + return request( + operation, + 'GET', + `${root}/${args.number}/comments?per_page=100&page=${args.page}`, + 'action', + ); + case 'get_comment': + return request(operation, 'GET', `${root}/comments/${args.id}`, 'action'); + case 'create_comment': + case 'update_comment': + if (!(args.body as string).trim()) + throw new Error('Comment body cannot be empty'); + return request( + operation, + action === 'create_comment' ? 'POST' : 'PATCH', + action === 'create_comment' + ? `${root}/${args.number}/comments` + : `${root}/comments/${args.id}`, + 'action', + { body: args.body }, + ); + case 'add_doing_label': + return request( + operation, + 'POST', + `${root}/${args.number}/labels`, + 'action', + { + labels: ['atomic:doing'], + }, + ); + case 'remove_doing_label': + return request( + operation, + 'DELETE', + `${root}/${args.number}/labels/atomic%3Adoing`, + 'action', + ); + case 'update_issue': + if (!['open', 'closed'].includes(String(args.state))) + throw new Error('Invalid issue state'); + if (!(args.title as string).trim() || (args.title as string).length > 256) + throw new Error('Invalid issue title'); + return request(operation, 'PATCH', `${root}/${args.number}`, 'action', { + title: args.title, + body: args.body, + state: args.state, + }); + } +} diff --git a/platform-lenses/github-issues/types.mjs b/platform-lenses/github-issues/types.mjs new file mode 100644 index 0000000..7d93160 --- /dev/null +++ b/platform-lenses/github-issues/types.mjs @@ -0,0 +1 @@ +// Runtime marker for the host-facing GitHub lens protocol; types are erased. diff --git a/platform-lenses/github-issues/types.ts b/platform-lenses/github-issues/types.ts new file mode 100644 index 0000000..46a5fc7 --- /dev/null +++ b/platform-lenses/github-issues/types.ts @@ -0,0 +1,20 @@ +/** Host-facing protocol types used by the GitHub lens. */ +export interface ExternalIntent { + id: string; + operation: string; + method: string; + url: string; + headers?: Record; + body?: string; +} + +export interface ExternalReceipt { + status: number; + body: string; +} + +export interface ConnectionState { + revision: number; + records: Record | null }>; + cursor: unknown; +} diff --git a/platform-lenses/github-issues/vitest.config.mjs b/platform-lenses/github-issues/vitest.config.mjs new file mode 100644 index 0000000..f1bfbc6 --- /dev/null +++ b/platform-lenses/github-issues/vitest.config.mjs @@ -0,0 +1,14 @@ +import { resolve } from 'node:path'; + +export default { + resolve: { + alias: { + devonian: process.env.DEVONIAN_PATH + ? resolve(process.env.DEVONIAN_PATH, 'build/src/main.js') + : resolve('../../src/main.ts'), + '@tomic/lib': process.env.ATOMIC_LIB_PATH ?? '@tomic/lib', + vitest: process.env.VITEST_PATH ?? 'vitest', + }, + }, + test: { include: ['platform-lenses/github-issues/*.test.*'] }, +}; diff --git a/platform-lenses/google-calendar/README.md b/platform-lenses/google-calendar/README.md new file mode 100644 index 0000000..cf448f0 --- /dev/null +++ b/platform-lenses/google-calendar/README.md @@ -0,0 +1,17 @@ +# Google Calendar platform modules + +`lens/` contains passive display/recurrence projections and reverse field mapping. +Import it through `devonian/platform-lenses/google-calendar/lens`. + +- `calendarProjection` adds Atomic calendar display properties. +- `calendarRecurrenceProjection` preserves a recurrence payload for Atomic views. +- `planCalendarValues` computes supported Google field changes from supplied + local, baseline, and remote values without fetching or writing anything. + +The projection helpers use the consuming application's compatible `@tomic/lib` +calendar helpers. They preserve provider fields; recurrence display is not a +reverse synchronization of Google recurrence rules. + +`sync.ts` retains membership/identity checks, remote reads, ETags, and checkpoint +handling. `import.ts` retains query configuration. The old projection, recurrence, +and types modules re-export the moved modules so existing imports keep working. diff --git a/platform-lenses/google-calendar/descriptor.json b/platform-lenses/google-calendar/descriptor.json new file mode 100644 index 0000000..c4cf35e --- /dev/null +++ b/platform-lenses/google-calendar/descriptor.json @@ -0,0 +1,6 @@ +{ + "id": "google-calendar", + "version": "1.0.0", + "platform": "google-calendar", + "lens": "google-calendar" +} diff --git a/platform-lenses/google-calendar/import.test.ts b/platform-lenses/google-calendar/import.test.ts new file mode 100644 index 0000000..8beda79 --- /dev/null +++ b/platform-lenses/google-calendar/import.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest'; +import { calendarImportQuery } from './import.js'; + +describe('calendar import selection', () => { + it('bounds expanded instances and retains deleted events', () => { + const selection = calendarImportQuery({ start: '2026-03-01', end: '2026-04-01' }); + expect(selection.query_overrides[0].values).toMatchObject({ + singleEvents: true, showDeleted: true, + timeMin: '2026-03-01T00:00:00Z', timeMax: '2026-04-01T00:00:00Z', + orderBy: 'startTime', + }); + }); + + it('removes bounds for retained series', () => { + const selection = calendarImportQuery({ start: '2026-03-01', end: '2026-04-01', series: true }); + expect(selection.query_overrides[0].values).toMatchObject({ + singleEvents: false, showDeleted: true, timeMin: null, timeMax: null, orderBy: null, + }); + }); + + it('rejects impossible civil dates', () => { + expect(() => calendarImportQuery({ start: '2026-99-99', end: '2027-01-01' })).toThrow(); + expect(() => calendarImportQuery({ start: '2026-02-29', end: '2026-03-01' })).toThrow(); + }); +}); diff --git a/platform-lenses/google-calendar/import.ts b/platform-lenses/google-calendar/import.ts new file mode 100644 index 0000000..de2b2f5 --- /dev/null +++ b/platform-lenses/google-calendar/import.ts @@ -0,0 +1,31 @@ +export interface CalendarRange { start: string; end: string; series?: boolean } +export const googleCalendarLens = { + platform: 'google-calendar', + defaultConstants: { calendarId: 'primary' }, + isFor(platform: string) { return platform === 'google-calendar'; }, + query: calendarImportQuery, +}; + +/** Query overrides consumed by the generic WASM import host. */ +export function calendarImportQuery(range: CalendarRange) { + const validDate = (value: string) => { + if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return false; + const time = Date.parse(`${value}T00:00:00Z`); + return Number.isFinite(time) && new Date(time).toISOString().slice(0, 10) === value; + }; + if (!validDate(range.start) || !validDate(range.end) || range.start >= range.end) + throw new Error('Calendar range must be increasing YYYY-MM-DD dates'); + const values: Record = { + singleEvents: !range.series, + showDeleted: true, + }; + if (range.series) { + // Full series scans must include moved exceptions, including exceptions + // outside the selected display range. Null removes catalog defaults. + values.timeMin = null; + values.timeMax = null; + values.orderBy = null; + } + else { values.timeMin = `${range.start}T00:00:00Z`; values.timeMax = `${range.end}T00:00:00Z`; values.orderBy = 'startTime'; } + return { query_overrides: [{ path: '/calendars/{calendarId}/events', values }] }; +} diff --git a/platform-lenses/google-calendar/index.ts b/platform-lenses/google-calendar/index.ts new file mode 100644 index 0000000..3d819bd --- /dev/null +++ b/platform-lenses/google-calendar/index.ts @@ -0,0 +1,6 @@ +/** Google Calendar lens: provider mapping and reviewed event synchronization. */ +export * from './types.js'; +export * from './projection.js'; +export * from './recurrence.js'; +export * from './sync.js'; +export * from './import.js'; diff --git a/platform-lenses/google-calendar/lens/edit.ts b/platform-lenses/google-calendar/lens/edit.ts new file mode 100644 index 0000000..f570de1 --- /dev/null +++ b/platform-lenses/google-calendar/lens/edit.ts @@ -0,0 +1,100 @@ +/** Passive reverse mapping of the supported Calendar fields. */ +export type Values = Record; +const NAME = 'https://atomicdata.dev/properties/name'; +export function canonical(value: unknown): string { + if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`; + if (value && typeof value === 'object') + return `{${Object.entries(value) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([key, v]) => `${JSON.stringify(key)}:${canonical(v)}`) + .join(',')}}`; + return JSON.stringify(value) ?? 'undefined'; +} +const same = (a: unknown, b: unknown): boolean => canonical(a) === canonical(b); +function object(value: unknown): Values { + if (!value || typeof value !== 'object' || Array.isArray(value)) + throw new Error('Calendar sync needs a valid imported baseline'); + return value as Values; +} +function validateTimes(start: unknown, end: unknown): void { + const a = object(start), + b = object(end); + const allDay = typeof a.date === 'string'; + const parse = (v: Values) => { + if (allDay) { + if ( + typeof v.date !== 'string' || + v.dateTime !== undefined || + !/^\d{4}-\d{2}-\d{2}$/.test(v.date) + ) + throw new Error('Invalid all-day event dates'); + const time = Date.parse(`${v.date}T00:00:00Z`); + if ( + !Number.isFinite(time) || + new Date(time).toISOString().slice(0, 10) !== v.date + ) + throw new Error('Invalid all-day event dates'); + return time; + } + if ( + typeof v.dateTime !== 'string' || + v.date !== undefined || + !/^\d{4}-\d{2}-\d{2}T.*(?:Z|[+-]\d{2}:\d{2})$/.test(v.dateTime) || + !Number.isFinite(Date.parse(v.dateTime)) + ) + throw new Error('Event times need an explicit UTC offset'); + return Date.parse(v.dateTime); + }; + if (parse(b) <= parse(a)) throw new Error('Event end must follow its start'); +} + +export interface CalendarValuesEdit { + patch: Values; + observed: Values; + acknowledged: Values; +} +/** Compute a field patch from supplied values; never reads or writes a dataset. */ +export function planCalendarValues( + row: Values, + baseline: Values, + propertyMap: Record, + remote: Values, +): CalendarValuesEdit | undefined { + const patch: Values = {}, + observed: Values = {}, + acknowledged: Values = {}; + for (const field of ['summary', 'description', 'location', 'start', 'end']) { + const properties = + field === 'summary' + ? [NAME, propertyMap.summary].filter(Boolean) + : [propertyMap[field]].filter(Boolean); + const changed = properties.filter((p) => !same(row[p], baseline[p])); + if (!changed.length) continue; + const local = row[changed[0]] ?? ''; + if (changed.some((p) => !same(row[p] ?? '', local))) + throw new Error( + 'Title and Summary disagree; make them match before syncing', + ); + if ( + ['summary', 'description', 'location'].includes(field) && + typeof local !== 'string' + ) + throw new Error(`Calendar ${field} must be text`); + if ( + !same(remote[field] ?? '', local) && + changed.some((p) => !same(remote[field] ?? '', baseline[p] ?? '')) + ) + throw new Error( + `Calendar conflict in ${field}; fetch and resolve the source/local conflict first`, + ); + for (const p of properties) { + observed[p] = row[p]; + acknowledged[p] = local; + } + if (!same(remote[field] ?? '', local)) patch[field] = local; + } + if (!Object.keys(acknowledged).length) return; + if (patch.start || patch.end) + validateTimes(patch.start ?? remote.start, patch.end ?? remote.end); + return { patch, observed, acknowledged }; +} diff --git a/platform-lenses/google-calendar/lens/index.ts b/platform-lenses/google-calendar/lens/index.ts new file mode 100644 index 0000000..a2d6444 --- /dev/null +++ b/platform-lenses/google-calendar/lens/index.ts @@ -0,0 +1,5 @@ +/** Passive Calendar projections and reverse field mapping. */ +export * from './types.js'; +export * from './projection.js'; +export * from './recurrence.js'; +export * from './edit.js'; diff --git a/platform-lenses/google-calendar/lens/projection.ts b/platform-lenses/google-calendar/lens/projection.ts new file mode 100644 index 0000000..6aa7323 --- /dev/null +++ b/platform-lenses/google-calendar/lens/projection.ts @@ -0,0 +1,159 @@ +// @wc-ignore-file +import { + Datatype, + calendarFields, + isCalendarDate as validDay, +} from '@tomic/lib'; +import type { JSONValue, FetchedPlatform, Term } from './types.js'; +export { calendarFields } from '@tomic/lib'; + +/** An additional projection, never a replacement for the provider's fields. + * One DATE column supports both all-day dates and timed events in a single view. + * Timed events use the date in Google's supplied offset; raw start/end retain + * the instant and zone. All-day ends are also projected for range rendering. + */ +export function calendarProjection(fetched: FetchedPlatform): FetchedPlatform { + if (fetched.platform !== 'google-calendar') return fetched; + const event = fetched.ontology.terms.find( + (t) => t.kind === 'class' && t.shortname === 'event', + ); + if (!event) return fetched; + const definitions: [string, Datatype, string][] = [ + [ + calendarFields.day, + Datatype.DATE, + "Start date in the event's supplied offset; all-day dates stay unchanged.", + ], + [ + calendarFields.endDay, + Datatype.DATE, + 'Exclusive end of an all-day event; this date is not occupied.', + ], + [ + calendarFields.allDay, + Datatype.BOOLEAN, + 'Whether Google represents this as an all-day event.', + ], + [ + calendarFields.notes, + Datatype.STRING, + 'Display limitations and Google-only features. Edits here remain local.', + ], + ]; + const terms: Term[] = definitions.map( + ([shortname, datatype, description]) => ({ + path: `urn:atomic:google-calendar:${shortname}`, + kind: 'property', + shortname, + datatype, + description, + requires: [], + recommends: [], + }), + ); + if ( + fetched.ontology.terms.some((t) => + terms.some((extra) => extra.shortname === t.shortname), + ) + ) + throw new Error( + 'Calendar projection property collides with provider ontology', + ); + return { + ...fetched, + ontology: { + ...fetched.ontology, + terms: [ + ...fetched.ontology.terms.map((t) => + t === event + ? { + ...t, + recommends: [ + ...t.recommends, + ...terms.map((extra) => extra.path), + ], + } + : t, + ), + ...terms, + ], + }, + records: fetched.records.map((row) => { + if (row.resource !== 'event') return row; + const start = object(row.values.start); + const end = object(row.values.end); + const date = start.date ?? start.dateTime; + const cancelled = row.values.status === 'cancelled'; + if ( + !cancelled && + (typeof date !== 'string' || !validDay(date.slice(0, 10))) + ) + throw new Error(`Calendar event ${row.id} has no valid start date`); + if ( + start.date !== undefined && + (typeof start.date !== 'string' || !validDay(start.date)) + ) + throw new Error(`Calendar event ${row.id} has an invalid all-day date`); + if ( + start.dateTime !== undefined && + (typeof start.dateTime !== 'string' || + !/^\d{4}-\d{2}-\d{2}T.*(?:Z|[+-]\d{2}:\d{2})$/.test(start.dateTime) || + !Number.isFinite(Date.parse(start.dateTime))) + ) + throw new Error( + `Calendar event ${row.id} has no offset-qualified start time`, + ); + const allDay = typeof start.date === 'string'; + if ( + !cancelled && + ((allDay && + (start.dateTime !== undefined || + end.dateTime !== undefined || + !validDay(end.date) || + end.date <= start.date!)) || + (!allDay && end.date !== undefined)) + ) + throw new Error( + `Calendar event ${row.id} has an invalid all-day interval`, + ); + const notes = [ + 'Use Preview edits for Google to sync Name, Description, Location, Start and End', + ]; + if (cancelled) notes.push('Cancelled in Google; retained in Atomic'); + if ( + row.values['recurring-event-id'] || + row.values.recurringEventId || + row.values.recurringeventid || + row.values.recurrence + ) + notes.push('Recurring event: manage the series in Google'); + if (Array.isArray(row.values.attendees) && row.values.attendees.length) + notes.push('Attendees and RSVP: manage in Google'); + if (row.values.reminders) notes.push('Reminders: manage in Google'); + if ( + row.values['conference-data'] || + row.values.conferenceData || + row.values.conferencedata + ) + notes.push('Conferencing: manage in Google'); + if (!allDay && (end.date || end.dateTime)) + notes.push('Duration retained in End; shown on start day only'); + const values: Record = { + ...row.values, + [calendarFields.notes]: notes.join('. '), + }; + if (typeof date === 'string' && validDay(date.slice(0, 10))) { + values[calendarFields.day] = date.slice(0, 10); + values[calendarFields.allDay] = allDay; + if (allDay && validDay(end.date)) + values[calendarFields.endDay] = end.date; + } + return { ...row, values }; + }), + }; +} +function object(value: JSONValue | undefined): Record { + return value && typeof value === 'object' && !Array.isArray(value) + ? value + : {}; +} diff --git a/platform-lenses/google-calendar/lens/recurrence.ts b/platform-lenses/google-calendar/lens/recurrence.ts new file mode 100644 index 0000000..8062685 --- /dev/null +++ b/platform-lenses/google-calendar/lens/recurrence.ts @@ -0,0 +1,93 @@ +// @wc-ignore-file +import { Datatype } from '@tomic/lib'; +import { + calendarRecurrenceShortname, + validateCalendarEvent, + type CalendarEvent, +} from '@tomic/lib'; +import type { JSONValue, FetchedPlatform, Term } from './types.js'; + +/** Additional projection: keep the provider's fields and retain an atomic + * event payload so import reconciliation clears removed recurrence/exception + * fields together instead of leaving stale optional columns behind. */ +export function calendarRecurrenceProjection( + fetched: FetchedPlatform, +): FetchedPlatform { + if (fetched.platform !== 'google-calendar') return fetched; + const eventClass = fetched.ontology.terms.find( + (t) => t.kind === 'class' && t.shortname === 'event', + ); + if (!eventClass) return fetched; + const term: Term = { + path: `urn:atomic:google-calendar:${calendarRecurrenceShortname}`, + kind: 'property', + shortname: calendarRecurrenceShortname, + datatype: Datatype.JSON, + description: + 'Timed calendar event and source calendar identity. Recurring chips open the series; changing this payload changes all its generated occurrences. Google remains unchanged.', + requires: [], + recommends: [], + }; + if (fetched.ontology.terms.some((t) => t.shortname === term.shortname)) + throw new Error( + 'Calendar recurrence property collides with provider ontology', + ); + + return { + ...fetched, + ontology: { + ...fetched.ontology, + terms: [ + ...fetched.ontology.terms.map((t) => + t === eventClass + ? { ...t, recommends: [...t.recommends, term.path] } + : t, + ), + term, + ], + }, + records: fetched.records.map((row) => { + if (row.resource !== 'event') return row; + const read = (key: string) => + row.values[key] ?? + row.values[ + key.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`) + ] ?? + row.values[key.toLowerCase()]; + const event: CalendarEvent = { id: row.id }; + + for (const key of [ + 'start', + 'end', + 'status', + 'recurrence', + 'recurringEventId', + 'originalStartTime', + ]) { + const value = read(key); + if (value !== undefined && value !== null) event[key] = value; + } + + // The civil-date adapter owns all-day rendering. An explicit empty + // payload also clears a previous timed representation on reimport. + if ( + (event.start?.date || event.originalStartTime?.date) && + !event.recurrence?.length && + !event.recurringEventId + ) + return { ...row, values: { ...row.values, [term.shortname]: {} } }; + validateCalendarEvent(event); + + return { + ...row, + values: { + ...row.values, + [term.shortname]: { + calendarId: row.namespace, + event, + } as unknown as JSONValue, + }, + }; + }), + }; +} diff --git a/platform-lenses/google-calendar/lens/types.ts b/platform-lenses/google-calendar/lens/types.ts new file mode 100644 index 0000000..d467923 --- /dev/null +++ b/platform-lenses/google-calendar/lens/types.ts @@ -0,0 +1,24 @@ +import type { Datatype, JSONValue } from '@tomic/lib'; +export type { JSONValue } from '@tomic/lib'; + +export interface Term { + path: string; + kind: 'class' | 'property'; + shortname: string; + description: string; + datatype: Datatype; + requires: string[]; + recommends: string[]; +} +export interface FetchedRecord { + resource: string; + namespace: string; + id: string; + name: string; + values: Record; +} +export interface FetchedPlatform { + platform: string; + ontology: { description: string; terms: Term[] }; + records: FetchedRecord[]; +} diff --git a/platform-lenses/google-calendar/projection.ts b/platform-lenses/google-calendar/projection.ts new file mode 100644 index 0000000..c9ac6f6 --- /dev/null +++ b/platform-lenses/google-calendar/projection.ts @@ -0,0 +1,2 @@ +/** Compatibility export; passive mappings live in lens/. */ +export * from './lens/projection.js'; diff --git a/platform-lenses/google-calendar/recurrence.ts b/platform-lenses/google-calendar/recurrence.ts new file mode 100644 index 0000000..de95167 --- /dev/null +++ b/platform-lenses/google-calendar/recurrence.ts @@ -0,0 +1,2 @@ +/** Compatibility export; passive mappings live in lens/. */ +export * from './lens/recurrence.js'; diff --git a/platform-lenses/google-calendar/sync.ts b/platform-lenses/google-calendar/sync.ts new file mode 100644 index 0000000..e634c54 --- /dev/null +++ b/platform-lenses/google-calendar/sync.ts @@ -0,0 +1,195 @@ +import { canonical, planCalendarValues } from './lens/edit.js'; +export { canonical } from './lens/edit.js'; +// @wc-ignore-file +const IMPORT_BASELINE = 'https://atomicdata.dev/properties/importBaseline'; +const IMPORT_LOCAL_ID = 'https://atomicdata.dev/properties/localId'; +export interface Config { + platform: string; + destinations: Record; + properties: Record; +} + +const NAME = 'https://atomicdata.dev/properties/name'; +const PARENT = 'https://atomicdata.dev/properties/parent'; +const IS_A = 'https://atomicdata.dev/properties/isA'; +type Values = Record; +export type CalendarRequest = ( + path: string, + init?: { + method?: string; + body?: string; + ifMatch?: string; + }, +) => Promise<{ status: number; body: string }>; +export interface CalendarEdit { + subject: string; + name: string; + path: string; + etag: string; + patch: Values; + observed: Values; + acknowledged: Values; +} +const same = (a: unknown, b: unknown) => canonical(a) === canonical(b); +function object(value: unknown): Values { + if (!value || typeof value !== 'object' || Array.isArray(value)) + throw new Error('Calendar sync needs a valid imported baseline'); + return value as Values; +} +export function eventPath(row: Values, config: Config): string | undefined { + if (config.platform !== 'google-calendar') return; + const target = config.destinations.event; + if ( + !target || + row[PARENT] !== target.table || + !Array.isArray(row[IS_A]) || + !(row[IS_A] as unknown[]).includes(target.rowClass) + ) + return; + const raw = row[IMPORT_LOCAL_ID]; + if (typeof raw !== 'string') return; + let id: unknown; + try { + id = JSON.parse(raw); + } catch { + return; + } + if ( + !Array.isArray(id) || + id.length !== 4 || + id[0] !== 'google-calendar' || + id[1] !== 'event' || + typeof id[2] !== 'string' || + !id[2] || + ['.', '..'].includes(id[2]) || + typeof id[3] !== 'string' || + !id[3] || + ['.', '..'].includes(id[3]) + ) + return; + return `/calendar/v3/calendars/${encodeURIComponent(id[2])}/events/${encodeURIComponent(id[3])}`; +} +/** Three-way merge only the supported provider fields; no full event replacement. */ +export function planCalendarEdit( + subject: string, + row: Values, + config: Config, + remote: Values, +): CalendarEdit | undefined { + const path = eventPath(row, config); + if (!path) return; + const baseline = object(object(row[IMPORT_BASELINE]).values); + const values = planCalendarValues(row, baseline, config.properties, remote); + if (!values) return; + const { patch, observed, acknowledged } = values; + if (remote.status === 'cancelled') + throw new Error('This event was cancelled in Google'); + if (typeof remote.etag !== 'string' || !remote.etag) + throw new Error('Google did not return an event ETag'); + observed[IMPORT_LOCAL_ID] = row[IMPORT_LOCAL_ID]; + observed[IMPORT_BASELINE] = row[IMPORT_BASELINE]; + observed[PARENT] = row[PARENT]; + observed[IS_A] = row[IS_A]; + return { + subject, + name: String(row[NAME] ?? 'Event'), + path, + etag: remote.etag, + patch: structuredClone(patch), + observed: structuredClone(observed), + acknowledged: structuredClone(acknowledged), + }; +} +export async function previewCalendarEdits( + rows: Map, + config: Config, + request: CalendarRequest, +) { + const edits: CalendarEdit[] = []; + const identities = new Set(); + for (const row of rows.values()) { + const path = eventPath(row, config); + if (path && identities.has(path)) + throw new Error( + 'Duplicate Calendar identity; resolve duplicate records before syncing', + ); + if (path) identities.add(path); + } + for (const [subject, row] of rows) { + const path = eventPath(row, config); + if (!path) continue; + const baseline = object(object(row[IMPORT_BASELINE]).values); + if ( + ![ + NAME, + ...['summary', 'description', 'location', 'start', 'end'] + .map(f => config.properties[f]) + .filter(Boolean), + ].some(p => !same(row[p], baseline[p])) + ) + continue; + if (edits.length >= 200) + throw new Error('Sync at most 200 changed events at once'); + const response = await request(path); + if (response.status !== 200) + throw new Error( + `Could not read Google event (HTTP ${response.status}); reconnect if access expired`, + ); + const remote = object(JSON.parse(response.body)); + const edit = planCalendarEdit(subject, row, config, remote); + if (edit) edits.push(edit); + } + return edits; +} +/** Revalidate local review, condition the write on Google's ETag, then checkpoint. + * A lost acknowledgement is recovered by comparing fresh Google values on preview. + */ +export async function applyCalendarEdit( + edit: CalendarEdit, + read: () => Promise, + request: CalendarRequest, + checkpoint: (values: Values) => Promise, +) { + const check = async () => { + const current = await read(); + if ( + Object.entries(edit.observed).some( + ([p, value]) => !same(current[p], value), + ) + ) + throw new Error('Calendar event changed after preview; preview again'); + return current; + }; + await check(); + if (Object.keys(edit.patch).length) { + const response = await request(`${edit.path}?sendUpdates=all`, { + method: 'PATCH', + body: JSON.stringify(edit.patch), + ifMatch: edit.etag, + }); + if (response.status === 412) + throw new Error('Google event changed after preview; preview again'); + if (response.status !== 200) + throw new Error( + `Google Calendar write failed (HTTP ${response.status}); reconnect with Calendar write access if needed`, + ); + const remote = object(JSON.parse(response.body)); + if ( + Object.entries(edit.patch).some( + ([p, value]) => !same(remote[p] ?? '', value), + ) + ) + throw new Error( + 'Google returned different event values; fetch and review before retrying', + ); + } + const current = await check(); + const baseline = object(object(current[IMPORT_BASELINE]).values); + await checkpoint({ + ...edit.acknowledged, + [IMPORT_BASELINE]: { + values: { ...baseline, ...edit.acknowledged }, + previous: baseline, + }, + }); +} diff --git a/platform-lenses/google-calendar/types.ts b/platform-lenses/google-calendar/types.ts new file mode 100644 index 0000000..88d8fc3 --- /dev/null +++ b/platform-lenses/google-calendar/types.ts @@ -0,0 +1,2 @@ +/** Compatibility export; passive mappings live in lens/. */ +export * from './lens/types.js'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 04731a7..49fb3fb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,16 +11,22 @@ importers: '@automerge/automerge-repo': specifier: ^2.0.0 version: 2.0.0(supports-color@7.2.0) - '@tomic/lib': - specifier: ^0.40.0 - version: 0.40.0 effect: specifier: ^3.16.0 version: 3.16.0 + fast-json-stable-stringify: + specifier: ^2.1.0 + version: 2.1.0 tslib: specifier: ~2.8 version: 2.8.1 + peerDependencies: + '@tomic/lib': + specifier: '>=0.40.0' devDependencies: + '@tomic/lib': + specifier: ^0.40.0 + version: 0.40.0 '@automerge/automerge-repo-network-broadcastchannel': specifier: ^2.0.0 version: 2.0.0(supports-color@7.2.0) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..f893f64 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,3 @@ +allowBuilds: + cbor-extract: true + esbuild: true diff --git a/src/reconcileRecord.ts b/src/reconcileRecord.ts new file mode 100644 index 0000000..3f18abd --- /dev/null +++ b/src/reconcileRecord.ts @@ -0,0 +1,113 @@ +import stringify from 'fast-json-stable-stringify'; + +export type SyncValue = + | null + | string + | number + | boolean + | SyncValue[] + | { [key: string]: SyncValue }; + +/** Canonical schema projection; null is an explicit deletion, undefined unseen. */ +export type SyncRecord = Record | null | undefined; +export interface SyncConflict { + property: string; + base: SyncValue | undefined; + local: SyncValue | undefined; + remote: SyncValue | undefined; +} +export interface SyncDecision { + local: Record; + remote: Record; + createLocal: boolean; + createRemote: boolean; + deleteLocal: boolean; + deleteRemote: boolean; + conflicts: SyncConflict[]; + /** Only present when both observed sides agree, never on an unacknowledged write. */ + agreed?: Record | null; +} +const equal = (a: unknown, b: unknown): boolean => + stringify(a) === stringify(b); + +/** Three-way reconciliation over canonical properties. Provider adapters own + * normalization; the shared engine owns conflict and checkpoint decisions. */ +export function reconcileRecord( + base: SyncRecord, + local: SyncRecord, + remote: SyncRecord, +): SyncDecision { + const result: SyncDecision = { + local: {}, + remote: {}, + createLocal: false, + createRemote: false, + deleteLocal: false, + deleteRemote: false, + conflicts: [], + }; + + if (equal(local, remote)) { + if (local !== undefined) result.agreed = structuredClone(local); + + return result; + } + + if (local === null || remote === null) { + if (equal(local, base) && remote === null) result.deleteLocal = true; + else if (equal(remote, base) && local === null) result.deleteRemote = true; + else + result.conflicts.push({ + property: '@record', + base, + local, + remote, + }); + + return result; + } + + // An unseen side can be created from a known side only when there is no + // prior baseline. Absence from a partial API page is never a deletion. + if (base !== undefined && (local === undefined || remote === undefined)) + return result; + result.createLocal = + base === undefined && local === undefined && remote !== undefined; + result.createRemote = + base === undefined && remote === undefined && local !== undefined; + const properties = new Set([ + ...Object.keys(base ?? {}), + ...Object.keys(local ?? {}), + ...Object.keys(remote ?? {}), + ]); + + for (const property of properties) { + const before = base?.[property]; + const here = local?.[property]; + const there = remote?.[property]; + if (equal(here, there)) continue; + if (equal(here, before)) result.local[property] = structuredClone(there); + else if (equal(there, before)) + result.remote[property] = structuredClone(here); + else + result.conflicts.push({ + property, + base: before, + local: here, + remote: there, + }); + } + + return result; +} + +/** A receipt is not an agreement until the actual canonical projections match. */ +export function acknowledgedBaseline( + local: Exclude, + remote: Exclude, +): Exclude { + if (!equal(local, remote)) + throw new Error('provider and Atomic projections still differ'); + + return structuredClone(local); +}