diff --git a/docs/PATH_SLUGIFICATION_SPEC.md b/docs/PATH_SLUGIFICATION_SPEC.md index 2546b170..ae8155dc 100644 --- a/docs/PATH_SLUGIFICATION_SPEC.md +++ b/docs/PATH_SLUGIFICATION_SPEC.md @@ -70,7 +70,7 @@ function slugWithIdSuffix(title: string, id: string): string { | Function | Change | |---|---| | `linearIssuePath(issueId, title?)` | When `title` provided: `/linear/issues/.json` | -| `linearCommentPath(issueId, commentId)` | No change — comments have no title | +| `linearCommentPath(commentId, humanReadable?)` | Directory record: `/linear/comments/__/meta.json` | | `linearMetadataPath` | No change — workspace-level | **Callers to update:** wherever issues are ingested, pass the issue `title` into the path mapper. @@ -155,4 +155,4 @@ function slugWithIdSuffix(title: string, id: string): string { ## Out of Scope -- **The "this model does not support image input" error** — this comes from an external AI provider (OpenAI Codex), not from sync code. It fires when the model reads content containing markdown image links. Fix: filter image blocks from content before sending to models that don't support images, or configure the model to allow image URLs. \ No newline at end of file +- **The "this model does not support image input" error** — this comes from an external AI provider (OpenAI Codex), not from sync code. It fires when the model reads content containing markdown image links. Fix: filter image blocks from content before sending to models that don't support images, or configure the model to allow image URLs. diff --git a/docs/architecture/writeback-resource-patterns.md b/docs/architecture/writeback-resource-patterns.md new file mode 100644 index 00000000..c661763b --- /dev/null +++ b/docs/architecture/writeback-resource-patterns.md @@ -0,0 +1,107 @@ +# Writeback Resource Patterns + +Conventions adapter authors must follow when choosing the mount path shape for +a provider record. These patterns exist because relayfile workspaces are +materialized onto POSIX filesystems by the mount daemon — path shapes that are +legal in a virtual key/value namespace can be impossible to mirror on disk. + +## Directory records: never emit a flat leaf where children can nest + +### The collision + +A record emitted as a flat leaf file + +``` +X/.json +``` + +collides the moment any child resource of that record nests under the same +stem: + +``` +X//reactions/... +X//replies/... +``` + +`.json` and `/` are distinct keys in the virtual filesystem, but on a +POSIX mount one name cannot be both a file and a directory. The mirror fails +every sync cycle with `mkdir .../X/.json: not a directory`, never completes +bootstrap, and the teardown writeback flush hangs. This is not hypothetical: it +wedged Slack mounts when thread replies were flat files +(`threads//replies/.json`) while reply reactions nested under +`replies//reactions/...` (see commits `dea03fc` and `f5ca1ce`, +PR #162). + +### The pattern + +Emit every record that has — or could plausibly grow — child resources as a +**directory record**: the stem is a directory keyed by the stable provider id, +and the canonical payload lives in a well-known file inside it: + +``` +X//meta.json ← canonical record +X//reactions/... ← children are siblings of meta.json +X//replies/... +``` + +Collision is then impossible by construction: the record and its children share +one directory. + +"Could plausibly grow" should be read generously. If the provider's API exposes +any per-record child collection (reactions, nested replies, attachments, +statuses, history), assume a future adapter version will materialize it. The +cost of a directory record up front is one extra path segment; the cost of +migrating later is legacy-path compatibility shims forever. + +Current directory-record adopters: + +| Adapter | Record | Canonical path | +| --- | --- | --- | +| slack | channel message | `/slack/channels//messages//meta.json` | +| slack | thread reply | `/slack/channels//threads//replies//meta.json` | +| slack | DM thread reply | `/slack/users//messages//replies//meta.json` | +| github | issue / pull request | `/github/repos///issues/__/meta.json` | +| github | issue comment | `/github/repos///issues/__/comments//meta.json` | +| linear | comment | `/linear/comments/__/meta.json` | + +Leaf records with genuinely no child surface (index rows, alias lookups like +`by-id/.json`, append-only event captures) may stay flat files. + +## Migrating a flat leaf to a directory record + +When an existing adapter shipped the flat shape, the migration must keep +pre-migration mirrors readable and routable: + +1. **Writer**: change the canonical path helper to `/meta.json`. Document + why in the helper's doc comment. +2. **Legacy helper**: keep the old flat path available as + `LegacyPath(...)`, marked `@deprecated`, for back-compat reads (and + tombstone deletes of legacy mirrors). +3. **Read candidates**: expose `ReadCandidatePaths(...)` returning + `[currentPath, legacyPath]` so readers resolve records mirrored by either + adapter generation. +4. **Parsers/routers**: every regex or matcher that recognizes the record path + must accept both `/meta.json` and the legacy `.json` (see the Slack + `thread.ts` reply-listing regex and the GitHub + `ISSUE_COMMENT_WRITEBACK_PATH`). +5. **Writeback resource config**: if the record is a writeback target, the + resource `pathPattern` must match the `/meta.json` form and the `idPattern` + must accept the literal `meta` stem (the handler re-derives the real id from + the full path). Slack's `messages` resource and GitHub's `issue-comments` + resource are the references. These live in + `scripts/writeback-discovery-data.mjs` / `writeback-discovery-normalizer.mjs` + and are regenerated into each adapter's `src/resources.ts` and discovery + `.adapter.md` by `scripts/generate-writeback-discovery.mjs`. +6. **Docs**: update the adapter's `layout-prompt.ts` (the mounted `LAYOUT.md`) + and discovery read-path docs so agents construct the new shape. +7. **Tests**: add a regression test pinning (a) the directory-record path, (b) + the nesting invariant for a child path, and (c) the read-candidate fallback + order. See `packages/slack/src/__tests__/path-mapper-v2.test.ts` + (`threadReplyPath is a directory record ...`), + `packages/github/src/__tests__/path-mapper.test.ts` + (`githubIssueCommentPath`), and + `packages/linear/src/__tests__/path-mapper.test.ts` (`linearCommentPath`). + +Do not represent the migration as a delete of the legacy file unless the +upstream object was actually deleted — pre-migration mirrors keep their flat +files until the record is next written or tombstoned. diff --git a/packages/core/src/runtime/file-native-router.test.ts b/packages/core/src/runtime/file-native-router.test.ts index 22fe0dc4..36be24eb 100644 --- a/packages/core/src/runtime/file-native-router.test.ts +++ b/packages/core/src/runtime/file-native-router.test.ts @@ -45,6 +45,17 @@ const resources: readonly AdapterResourceConfig[] = [ createExample: "discovery/github/repos/{owner}/{repo}/pulls/{pullNumber}/merge.json/.create.example.json", }, + { + name: "issue-comments", + path: "/github/repos/{owner}/{repo}/issues/{issueNumber}/comments", + pathPattern: + /^\/github\/repos\/[^/]+\/[^/]+\/issues\/[^/]+\/comments(?:\/[^/]+(?:\.json|\/meta\.json)?)?$/, + idPattern: /^(?:meta|\d+)$/, + schema: + "discovery/github/repos/{owner}/{repo}/issues/{issueNumber}/comments/.schema.json", + createExample: + "discovery/github/repos/{owner}/{repo}/issues/{issueNumber}/comments/.create.example.json", + }, ]; const issueId = "11111111-1111-1111-1111-111111111111"; @@ -100,6 +111,18 @@ test("classifyWrite maps slugged exact-file resources to patch", () => { assert.equal(route?.resource.name, "merge"); }); +test("classifyWrite maps directory-record meta.json resources to patch", () => { + const route = classifyWrite( + "/github/repos/acme/widgets/issues/42/comments/123/meta.json", + resources + ); + + assert.equal(route?.kind, "patch"); + assert.equal(route?.canonical, true); + assert.equal(route?.id, "meta"); + assert.equal(route?.resource.name, "issue-comments"); +}); + test("classifyWrite ignores temporary and partial writeback filenames", () => { for (const path of [ "/linear/issues/.tmp.json", diff --git a/packages/core/src/writeback-paths/catalog.generated.json b/packages/core/src/writeback-paths/catalog.generated.json index 75bb56a5..4e4dc25c 100644 --- a/packages/core/src/writeback-paths/catalog.generated.json +++ b/packages/core/src/writeback-paths/catalog.generated.json @@ -108,6 +108,12 @@ ] }, "dropbox": { + "cursors": [ + { + "path": "/dropbox/cursors", + "params": [] + } + ], "files": [ { "path": "/dropbox/files", diff --git a/packages/core/src/writeback-paths/catalog.generated.ts b/packages/core/src/writeback-paths/catalog.generated.ts index 3578146c..86d08074 100644 --- a/packages/core/src/writeback-paths/catalog.generated.ts +++ b/packages/core/src/writeback-paths/catalog.generated.ts @@ -117,6 +117,12 @@ export const WRITEBACK_PATH_CATALOG = { ] }, "dropbox": { + "cursors": [ + { + "path": "/dropbox/cursors", + "params": [] + } + ], "files": [ { "path": "/dropbox/files", diff --git a/packages/dropbox/discovery/dropbox/.adapter.md b/packages/dropbox/discovery/dropbox/.adapter.md index 02d91be0..91053b6f 100644 --- a/packages/dropbox/discovery/dropbox/.adapter.md +++ b/packages/dropbox/discovery/dropbox/.adapter.md @@ -10,6 +10,9 @@ Resources: | Resource | Schema | Create example | ID pattern | What it does | |---|---|---|---|---| | `/dropbox/files/.json` | `/dropbox/files/.schema.json` | `/dropbox/files/.create.example.json` | `^[A-Za-z0-9_.:-]+$` | Uploads a Dropbox file. | +| `/dropbox/folders/.json` | `/dropbox/folders/.schema.json` | `/dropbox/folders/.create.example.json` | `^[A-Za-z0-9_.:-]+$` | Creates or updates Dropbox folder metadata. | +| `/dropbox/shared-folders/.json` | `/dropbox/shared-folders/.schema.json` | `/dropbox/shared-folders/.create.example.json` | `^[A-Za-z0-9_.:-]+$` | Creates or updates Dropbox shared folder metadata. | +| `/dropbox/shared-links/.json` | `/dropbox/shared-links/.schema.json` | `/dropbox/shared-links/.create.example.json` | `^[A-Za-z0-9_.:-]+$` | Creates or updates Dropbox shared link metadata. | | `/dropbox/cursors/.json` | `/dropbox/cursors/.schema.json` | `/dropbox/cursors/.create.example.json` | `^[A-Za-z0-9_.:-]+$` | Stores a list_folder cursor. | ## Operations @@ -24,6 +27,9 @@ Resources: ## ID Patterns - `/dropbox/files/.json`: `^[A-Za-z0-9_.:-]+$`. Filenames that do not match this pattern are treated as create drafts. +- `/dropbox/folders/.json`: `^[A-Za-z0-9_.:-]+$`. Filenames that do not match this pattern are treated as create drafts. +- `/dropbox/shared-folders/.json`: `^[A-Za-z0-9_.:-]+$`. Filenames that do not match this pattern are treated as create drafts. +- `/dropbox/shared-links/.json`: `^[A-Za-z0-9_.:-]+$`. Filenames that do not match this pattern are treated as create drafts. - `/dropbox/cursors/.json`: `^[A-Za-z0-9_.:-]+$`. Filenames that do not match this pattern are treated as create drafts. ## Write field contracts @@ -42,6 +48,45 @@ Fields: - `contentBase64` (optional, string) - Base64 content. - `mode` (optional, string) - Upload mode. +### Create Dropbox folder + +Resource: `/dropbox/folders/.json` +Schema: `/dropbox/folders/.schema.json` +Create example: `/dropbox/folders/.create.example.json` +Required fields: `path_display`. +Optional fields: `name`. + +Fields: + +- `path_display` (required, string) - Dropbox display path. +- `name` (optional, string) - Folder name. + +### Create Dropbox shared folder marker + +Resource: `/dropbox/shared-folders/.json` +Schema: `/dropbox/shared-folders/.schema.json` +Create example: `/dropbox/shared-folders/.create.example.json` +Required fields: `id`. +Optional fields: `name`. + +Fields: + +- `id` (required, string) - Dropbox shared folder id. +- `name` (optional, string) - Shared folder name. + +### Create Dropbox shared link marker + +Resource: `/dropbox/shared-links/.json` +Schema: `/dropbox/shared-links/.schema.json` +Create example: `/dropbox/shared-links/.create.example.json` +Required fields: `url`. +Optional fields: `name`. + +Fields: + +- `url` (required, string) - Dropbox shared link URL. +- `name` (optional, string) - Shared link name. + ### Create Dropbox cursor Resource: `/dropbox/cursors/.json` diff --git a/packages/dropbox/discovery/dropbox/folders/.create.example.json b/packages/dropbox/discovery/dropbox/folders/.create.example.json index e14e9c10..152e531a 100644 --- a/packages/dropbox/discovery/dropbox/folders/.create.example.json +++ b/packages/dropbox/discovery/dropbox/folders/.create.example.json @@ -1,4 +1,3 @@ { - "path_display": "/Team/Docs", - "name": "Docs" + "path_display": "/Team" } diff --git a/packages/dropbox/discovery/dropbox/folders/.schema.json b/packages/dropbox/discovery/dropbox/folders/.schema.json index 56a22a3f..6d4f3653 100644 --- a/packages/dropbox/discovery/dropbox/folders/.schema.json +++ b/packages/dropbox/discovery/dropbox/folders/.schema.json @@ -8,22 +8,78 @@ "properties": { "path_display": { "type": "string", - "description": "Dropbox display path for the folder." + "description": "Dropbox display path." }, "name": { "type": "string", "description": "Folder name." }, - "path_lower": { + "id": { "type": "string", - "description": "Normalized lower-case folder path.", + "description": "Provider canonical record id.", "readOnly": true }, - "parent_shared_folder_id": { + "createdAt": { "type": "string", - "description": "Parent shared-folder id when this folder is in a shared mount." + "format": "date-time", + "description": "Provider creation timestamp.", + "readOnly": true + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "Provider last update timestamp.", + "readOnly": true + }, + "url": { + "type": "string", + "format": "uri", + "description": "Provider URL for the record.", + "readOnly": true + }, + "identifier": { + "type": "string", + "description": "Provider human-readable identifier or key.", + "readOnly": true + }, + "provider": { + "type": "string", + "description": "Relayfile provider name.", + "readOnly": true + }, + "objectType": { + "type": "string", + "description": "Relayfile object type.", + "readOnly": true + }, + "objectId": { + "type": "string", + "description": "Relayfile object id.", + "readOnly": true + }, + "workspaceId": { + "type": "string", + "description": "Relayfile workspace id.", + "readOnly": true + }, + "connectionId": { + "type": "string", + "description": "Relayfile connection id.", + "readOnly": true + }, + "_webhook": { + "type": "object", + "description": "Provider webhook metadata captured during sync.", + "readOnly": true, + "additionalProperties": true + }, + "_connection": { + "type": "object", + "description": "Relayfile connection metadata captured during sync.", + "readOnly": true, + "additionalProperties": true } }, - "additionalProperties": true, - "description": "Full Dropbox folder metadata schema for Relayfile discovery." + "additionalProperties": false, + "description": "Full resource record schema. Fields marked readOnly are synced from the provider and cannot be written by agents." } diff --git a/packages/dropbox/discovery/dropbox/shared-folders/.create.example.json b/packages/dropbox/discovery/dropbox/shared-folders/.create.example.json index e5a77fd2..63e1bd8c 100644 --- a/packages/dropbox/discovery/dropbox/shared-folders/.create.example.json +++ b/packages/dropbox/discovery/dropbox/shared-folders/.create.example.json @@ -1,4 +1,3 @@ { - "shared_folder_id": "845281924", - "shared_folder_name": "Finance Shared" + "id": "845281924" } diff --git a/packages/dropbox/discovery/dropbox/shared-folders/.schema.json b/packages/dropbox/discovery/dropbox/shared-folders/.schema.json index 54f94c5d..0e076611 100644 --- a/packages/dropbox/discovery/dropbox/shared-folders/.schema.json +++ b/packages/dropbox/discovery/dropbox/shared-folders/.schema.json @@ -1,28 +1,81 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "title": "Dropbox shared folder", + "title": "Dropbox shared folder marker", "type": "object", "required": [ - "shared_folder_id" + "id" ], "properties": { - "shared_folder_id": { + "id": { "type": "string", - "description": "Dropbox shared-folder id." + "description": "Provider canonical record id.", + "readOnly": true }, - "shared_folder_name": { + "name": { "type": "string", - "description": "Human-readable shared folder name." + "description": "Shared folder name." }, - "is_team_folder": { - "type": "boolean", - "description": "Whether this is a team folder." + "createdAt": { + "type": "string", + "format": "date-time", + "description": "Provider creation timestamp.", + "readOnly": true + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "Provider last update timestamp.", + "readOnly": true + }, + "url": { + "type": "string", + "format": "uri", + "description": "Provider URL for the record.", + "readOnly": true + }, + "identifier": { + "type": "string", + "description": "Provider human-readable identifier or key.", + "readOnly": true + }, + "provider": { + "type": "string", + "description": "Relayfile provider name.", + "readOnly": true }, - "parent_shared_folder_id": { + "objectType": { "type": "string", - "description": "Parent shared-folder id when nested." + "description": "Relayfile object type.", + "readOnly": true + }, + "objectId": { + "type": "string", + "description": "Relayfile object id.", + "readOnly": true + }, + "workspaceId": { + "type": "string", + "description": "Relayfile workspace id.", + "readOnly": true + }, + "connectionId": { + "type": "string", + "description": "Relayfile connection id.", + "readOnly": true + }, + "_webhook": { + "type": "object", + "description": "Provider webhook metadata captured during sync.", + "readOnly": true, + "additionalProperties": true + }, + "_connection": { + "type": "object", + "description": "Relayfile connection metadata captured during sync.", + "readOnly": true, + "additionalProperties": true } }, - "additionalProperties": true, - "description": "Full Dropbox shared-folder metadata schema for Relayfile discovery." + "additionalProperties": false, + "description": "Full resource record schema. Fields marked readOnly are synced from the provider and cannot be written by agents." } diff --git a/packages/dropbox/discovery/dropbox/shared-links/.create.example.json b/packages/dropbox/discovery/dropbox/shared-links/.create.example.json index 892d9e55..6db8435a 100644 --- a/packages/dropbox/discovery/dropbox/shared-links/.create.example.json +++ b/packages/dropbox/discovery/dropbox/shared-links/.create.example.json @@ -1,4 +1,3 @@ { - "url": "https://www.dropbox.com/scl/fi/example/q2-plan.md", - "name": "Q2 Plan Link" + "url": "https://www.dropbox.com/scl/fi/example/report.pdf?dl=0" } diff --git a/packages/dropbox/discovery/dropbox/shared-links/.schema.json b/packages/dropbox/discovery/dropbox/shared-links/.schema.json index b800e3f7..5fe527fb 100644 --- a/packages/dropbox/discovery/dropbox/shared-links/.schema.json +++ b/packages/dropbox/discovery/dropbox/shared-links/.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "title": "Dropbox shared link", + "title": "Dropbox shared link marker", "type": "object", "required": [ "url" @@ -9,30 +9,73 @@ "url": { "type": "string", "format": "uri", - "description": "Dropbox shared URL." + "description": "Provider URL for the record.", + "readOnly": true }, "name": { "type": "string", - "description": "Shared link display name." + "description": "Shared link name." }, - "path_lower": { + "id": { "type": "string", - "description": "Underlying file/folder normalized path." + "description": "Provider canonical record id.", + "readOnly": true }, - "type": { + "createdAt": { "type": "string", - "enum": [ - "file", - "folder" - ], - "description": "Shared link target object type." + "format": "date-time", + "description": "Provider creation timestamp.", + "readOnly": true }, - "expires": { + "updatedAt": { "type": "string", "format": "date-time", - "description": "Expiration timestamp when applicable." + "description": "Provider last update timestamp.", + "readOnly": true + }, + "identifier": { + "type": "string", + "description": "Provider human-readable identifier or key.", + "readOnly": true + }, + "provider": { + "type": "string", + "description": "Relayfile provider name.", + "readOnly": true + }, + "objectType": { + "type": "string", + "description": "Relayfile object type.", + "readOnly": true + }, + "objectId": { + "type": "string", + "description": "Relayfile object id.", + "readOnly": true + }, + "workspaceId": { + "type": "string", + "description": "Relayfile workspace id.", + "readOnly": true + }, + "connectionId": { + "type": "string", + "description": "Relayfile connection id.", + "readOnly": true + }, + "_webhook": { + "type": "object", + "description": "Provider webhook metadata captured during sync.", + "readOnly": true, + "additionalProperties": true + }, + "_connection": { + "type": "object", + "description": "Relayfile connection metadata captured during sync.", + "readOnly": true, + "additionalProperties": true } }, - "additionalProperties": true, - "description": "Full Dropbox shared-link metadata schema for Relayfile discovery." + "additionalProperties": false, + "description": "Full resource record schema. Fields marked readOnly are synced from the provider and cannot be written by agents." } diff --git a/packages/dropbox/src/layout-prompt.ts b/packages/dropbox/src/layout-prompt.ts index 50b06f29..3aa18b41 100644 --- a/packages/dropbox/src/layout-prompt.ts +++ b/packages/dropbox/src/layout-prompt.ts @@ -41,6 +41,7 @@ Read discovery schemas before writeback: - \`discovery/dropbox/folders/.schema.json\` - \`discovery/dropbox/shared-folders/.schema.json\` - \`discovery/dropbox/shared-links/.schema.json\` +- \`discovery/dropbox/cursors/.schema.json\` ## Notes diff --git a/packages/dropbox/src/resources.ts b/packages/dropbox/src/resources.ts index 9d8908cc..fadc242d 100644 --- a/packages/dropbox/src/resources.ts +++ b/packages/dropbox/src/resources.ts @@ -5,44 +5,52 @@ export interface AdapterResourceConfig { readonly idPattern: RegExp; readonly schema: string; readonly createExample: string; - readonly sampleIndexPath?: string; } export const resources = [ { - name: 'files', - path: '/dropbox/files', - pathPattern: /^\/dropbox\/files\/(?!_index\.json$)[^/]+\.json$/, - idPattern: /^[A-Za-z0-9_.:@%+-][A-Za-z0-9_.:@%+-]*$/, - schema: 'discovery/dropbox/files/.schema.json', - createExample: 'discovery/dropbox/files/.create.example.json', + name: "files", + path: "/dropbox/files", + pathPattern: /^\/dropbox\/files\/(?!_index\.json$)(?!by-(?:id|path)\/)[^\/]+(?:\.json)?$/, + idPattern: /^[A-Za-z0-9_.:-]+$/, + schema: "discovery/dropbox/files/.schema.json", + createExample: "discovery/dropbox/files/.create.example.json", }, { - name: 'folders', - path: '/dropbox/folders', - pathPattern: /^\/dropbox\/folders\/(?!_index\.json$)[^/]+\.json$/, - idPattern: /^[A-Za-z0-9_.:@%+-][A-Za-z0-9_.:@%+-]*$/, - schema: 'discovery/dropbox/folders/.schema.json', - createExample: 'discovery/dropbox/folders/.create.example.json', + name: "folders", + path: "/dropbox/folders", + pathPattern: /^\/dropbox\/folders\/(?!_index\.json$)(?!by-(?:id|path)\/)[^\/]+(?:\.json)?$/, + idPattern: /^[A-Za-z0-9_.:-]+$/, + schema: "discovery/dropbox/folders/.schema.json", + createExample: "discovery/dropbox/folders/.create.example.json", }, { - name: 'shared-folders', - path: '/dropbox/shared-folders', - pathPattern: /^\/dropbox\/shared-folders\/(?:(?!_index\.json$)[^/]+|by-id\/(?!_index\.json$)[^/]+)\.json$/, - idPattern: /^[A-Za-z0-9_.:@-]+$/, - schema: 'discovery/dropbox/shared-folders/.schema.json', - createExample: 'discovery/dropbox/shared-folders/.create.example.json', + name: "shared-folders", + path: "/dropbox/shared-folders", + pathPattern: /^\/dropbox\/shared-folders\/(?!_index\.json$)(?:by-id\/)?[^\/]+(?:\.json)?$/, + idPattern: /^[A-Za-z0-9_.:-]+$/, + schema: "discovery/dropbox/shared-folders/.schema.json", + createExample: "discovery/dropbox/shared-folders/.create.example.json", }, { - name: 'shared-links', - path: '/dropbox/shared-links', - pathPattern: /^\/dropbox\/shared-links\/(?:(?!_index\.json$)[^/]+|by-id\/(?!_index\.json$)[^/]+)\.json$/, - idPattern: /^[A-Za-z0-9_.:@-]+$/, - schema: 'discovery/dropbox/shared-links/.schema.json', - createExample: 'discovery/dropbox/shared-links/.create.example.json', + name: "shared-links", + path: "/dropbox/shared-links", + pathPattern: /^\/dropbox\/shared-links\/(?!_index\.json$)(?:by-id\/)?[^\/]+(?:\.json)?$/, + idPattern: /^[A-Za-z0-9_.:-]+$/, + schema: "discovery/dropbox/shared-links/.schema.json", + createExample: "discovery/dropbox/shared-links/.create.example.json", + }, + { + name: "cursors", + path: "/dropbox/cursors", + pathPattern: /^\/dropbox\/cursors(?:\/[^\/]+(?:\.json)?)?$/, + idPattern: /^[A-Za-z0-9_.:-]+$/, + schema: "discovery/dropbox/cursors/.schema.json", + createExample: "discovery/dropbox/cursors/.create.example.json", }, ] as const satisfies readonly AdapterResourceConfig[]; export function findResourceByPath(path: string): AdapterResourceConfig | undefined { - return resources.find((resource) => resource.pathPattern.test(path)); + const normalizedPath = path.endsWith(".json") ? path : path.replace(/\/$/, ""); + return resources.find((resource) => resource.pathPattern.test(normalizedPath)); } diff --git a/packages/github/discovery/github/.adapter.md b/packages/github/discovery/github/.adapter.md index dfb25e63..307cc52f 100644 --- a/packages/github/discovery/github/.adapter.md +++ b/packages/github/discovery/github/.adapter.md @@ -6,6 +6,7 @@ Read-only mounts: - `/github/repos///pulls//meta.json` - Pull request metadata. - `/github/repos///pulls//files/` - Pull request file records. - `/github/repos///issues//meta.json` - Issue metadata. +- `/github/repos///issues//comments//meta.json` - Issue comment records (directory records so per-comment children such as reactions can nest without a file/dir collision). - `/github/repos///commits//metadata.json` - Commit metadata. Resources: @@ -13,7 +14,7 @@ Resources: | Resource | Schema | Create example | ID pattern | What it does | |---|---|---|---|---| | `/github/repos/{owner}/{repo}/issues/.json` | `/github/repos/{owner}/{repo}/issues/.schema.json` | `/github/repos/{owner}/{repo}/issues/.create.example.json` | `^[1-9]\d*$` | Creates a GitHub issue. | -| `/github/repos/{owner}/{repo}/issues/{issueNumber}/comments/.json` | `/github/repos/{owner}/{repo}/issues/{issueNumber}/comments/.schema.json` | `/github/repos/{owner}/{repo}/issues/{issueNumber}/comments/.create.example.json` | `^\d+$` | Creates or updates a GitHub issue comment. | +| `/github/repos/{owner}/{repo}/issues/{issueNumber}/comments/.json` | `/github/repos/{owner}/{repo}/issues/{issueNumber}/comments/.schema.json` | `/github/repos/{owner}/{repo}/issues/{issueNumber}/comments/.create.example.json` | `^(?:meta\|\d+)$` | Creates or updates a GitHub issue comment. | | `/github/repos/{owner}/{repo}/pulls/{pullNumber}/reviews/.json` | `/github/repos/{owner}/{repo}/pulls/{pullNumber}/reviews/.schema.json` | `/github/repos/{owner}/{repo}/pulls/{pullNumber}/reviews/.create.example.json` | `^\d+$` | Submits a pull request review with optional inline comments. | | `/github/repos/{owner}/{repo}/pulls/{pullNumber}/merge.json` | `/github/repos/{owner}/{repo}/pulls/{pullNumber}/merge.json/.schema.json` | `/github/repos/{owner}/{repo}/pulls/{pullNumber}/merge.json/.create.example.json` | exact file path | Merges a pull request. Uses the repository default merge strategy when no merge method is supplied. | @@ -29,7 +30,7 @@ Resources: ## ID Patterns - `/github/repos/{owner}/{repo}/issues/.json`: `^[1-9]\d*$`. Filenames that do not match this pattern are treated as create drafts. -- `/github/repos/{owner}/{repo}/issues/{issueNumber}/comments/.json`: `^\d+$`. Filenames that do not match this pattern are treated as create drafts. +- `/github/repos/{owner}/{repo}/issues/{issueNumber}/comments/.json`: `^(?:meta|\d+)$`. Filenames that do not match this pattern are treated as create drafts. - `/github/repos/{owner}/{repo}/pulls/{pullNumber}/reviews/.json`: `^\d+$`. Filenames that do not match this pattern are treated as create drafts. - `/github/repos/{owner}/{repo}/pulls/{pullNumber}/merge.json`: exact file path. diff --git a/packages/github/docs/adapter-spec.md b/packages/github/docs/adapter-spec.md index d655b6f1..598d4564 100644 --- a/packages/github/docs/adapter-spec.md +++ b/packages/github/docs/adapter-spec.md @@ -185,7 +185,7 @@ export interface IngestResult { statuses/{status_id}.json issues/{number}/ meta.json - comments/{comment_id}.json + comments/{comment_id}/meta.json branches/{name}.json actions/runs/{run_id}.json ``` diff --git a/packages/github/github.mapping.yaml b/packages/github/github.mapping.yaml index 3da4b6b2..a659e6a4 100644 --- a/packages/github/github.mapping.yaml +++ b/packages/github/github.mapping.yaml @@ -25,7 +25,7 @@ webhooks: pull_request_review_thread.resolved: path: /github/repos/{{repository.owner.login}}/{{repository.name}}/pulls/{{pull_request.number}}/review-threads/{{thread.id}}.json issue_comment.created: - path: /github/repos/{{repository.owner.login}}/{{repository.name}}/issues/{{issue.number}}/comments/{{comment.id}}.json + path: /github/repos/{{repository.owner.login}}/{{repository.name}}/issues/{{issue.number}}/comments/{{comment.id}}/meta.json extract: - action - issue.number diff --git a/packages/github/src/__tests__/e2e-issue-ingest.test.ts b/packages/github/src/__tests__/e2e-issue-ingest.test.ts index ad8602e4..d19dfb47 100644 --- a/packages/github/src/__tests__/e2e-issue-ingest.test.ts +++ b/packages/github/src/__tests__/e2e-issue-ingest.test.ts @@ -120,7 +120,7 @@ test('GitHubAdapter ingests an issue and its comments into the VFS', async () => assert.equal(commentFiles.length, mockIssueComments.length); assert.deepEqual( commentFiles, - mockIssueComments.map((comment) => `${commentsPath}${comment.id}.json`).sort(), + mockIssueComments.map((comment) => `${commentsPath}${comment.id}/meta.json`).sort(), ); const commentBodies = commentFiles.map((path) => readJsonFile(vfs, path).body); @@ -279,7 +279,7 @@ async function writeIssueComments( result, await writeFile( vfs, - `${issueCommentsPath(owner, repo, issueNumber, title)}${commentId}.json`, + `${issueCommentsPath(owner, repo, issueNumber, title)}${commentId}/meta.json`, `${JSON.stringify(payload, null, 2)}\n`, ), ); diff --git a/packages/github/src/__tests__/path-mapper.test.ts b/packages/github/src/__tests__/path-mapper.test.ts index 020db08e..92d0e6b1 100644 --- a/packages/github/src/__tests__/path-mapper.test.ts +++ b/packages/github/src/__tests__/path-mapper.test.ts @@ -15,6 +15,9 @@ import { githubCheckRunPath, githubCommitPath, githubDeploymentStatusPath, + githubIssueCommentLegacyPath, + githubIssueCommentPath, + githubIssueCommentReadCandidatePaths, githubIssuePath, githubLegacyByTitleAliasPath, githubNumberedByTitleAliasPath, @@ -30,6 +33,7 @@ import { normalizeNangoGitHubModel, GITHUB_PATH_ROOT, } from '../path-mapper.js'; +import { mapIssueComment } from '../issues/comment-mapper.js'; describe('path-mapper', () => { describe('encodeGitHubPathSegment', () => { @@ -385,4 +389,59 @@ describe('path-mapper', () => { assert.equal(tryNormalizeGitHubObjectType('deployments'), undefined); }); }); + + describe('githubIssueCommentPath', () => { + it('is a directory record and cannot collide with child records under the comment id', () => { + const comment = githubIssueCommentPath('octocat', 'hello-world', 10, 7001, 'Fix login bug'); + assert.equal( + comment, + '/github/repos/octocat/hello-world/issues/10__fix-login-bug/comments/7001/meta.json', + ); + + // A comment's children (e.g. per-comment reactions, which GitHub exposes + // at /repos/{o}/{r}/issues/comments/{id}/reactions) must nest UNDER the + // comment's directory — never as a sibling that shares the comment's + // name with a different node type. This is the invariant whose violation + // wedges a POSIX mount: a flat leaf file `comments/.json` cannot + // coexist with a `comments//` directory + // (`mkdir ... : not a directory`). + const commentDir = comment.replace(/\/meta\.json$/u, ''); + assert.ok(commentDir.endsWith('/comments/7001'), 'comment stem is the directory key'); + const hypotheticalReaction = `${commentDir}/reactions/+1--octocat.json`; + assert.ok( + hypotheticalReaction.startsWith(`${commentDir}/`), + 'children must nest under the comment directory', + ); + assert.notEqual( + comment, + githubIssueCommentLegacyPath('octocat', 'hello-world', 10, 7001, 'Fix login bug'), + 'comment stem must be a directory record, not the flat .json leaf', + ); + + // Back-compat: readers can still resolve a comment mirrored by a + // pre-migration adapter at the legacy flat path. + assert.deepEqual( + githubIssueCommentReadCandidatePaths('octocat', 'hello-world', 10, 7001, 'Fix login bug'), + [comment, githubIssueCommentLegacyPath('octocat', 'hello-world', 10, 7001, 'Fix login bug')], + ); + assert.equal( + githubIssueCommentLegacyPath('octocat', 'hello-world', 10, 7001, 'Fix login bug'), + '/github/repos/octocat/hello-world/issues/10__fix-login-bug/comments/7001.json', + ); + }); + + it('agrees with mapIssueComment on the canonical record path', () => { + const mapped = mapIssueComment( + { id: 7001, body: 'Looks good', user: { login: 'octocat' } }, + 'octocat', + 'hello-world', + 10, + 'Fix login bug', + ); + assert.equal( + `/github/repos/octocat/hello-world/${mapped.vfsPath}`, + githubIssueCommentPath('octocat', 'hello-world', 10, 7001, 'Fix login bug'), + ); + }); + }); }); diff --git a/packages/github/src/index.ts b/packages/github/src/index.ts index d1d38c26..4eade077 100644 --- a/packages/github/src/index.ts +++ b/packages/github/src/index.ts @@ -331,12 +331,15 @@ export class GitHubAdapter extends LocalIntegrationAdapter implements WebhookAda return `/github/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/review-threads/${objectId}.json`; } case 'issue_comment': { + // Directory records (`comments//meta.json`) so a comment's stem can + // hold child records (e.g. reactions) without a file/dir collision on a + // POSIX mount. See `githubIssueCommentPath` in `./path-mapper.ts`. const issue = asRecord(payload.issue); const issueNumber = readNumericLike(issue?.number); if (issueNumber) { - return `/github/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues/${issueNumber}/comments/${objectId}.json`; + return `/github/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues/${issueNumber}/comments/${objectId}/meta.json`; } - return `/github/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues/comments/${objectId}.json`; + return `/github/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues/comments/${objectId}/meta.json`; } case 'check_run': return `/github/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/checks/${objectId}.json`; diff --git a/packages/github/src/issues/__tests__/issue-mapping.test.ts b/packages/github/src/issues/__tests__/issue-mapping.test.ts index dfe7b3f6..12fb8d4e 100644 --- a/packages/github/src/issues/__tests__/issue-mapping.test.ts +++ b/packages/github/src/issues/__tests__/issue-mapping.test.ts @@ -276,8 +276,8 @@ describe('issue mapping', () => { '/github/repos/octocat__hello-world/issues/_index.json', '/github/repos/octocat__hello-world/issues/by-id/10.json', '/github/repos/octocat__hello-world/issues/by-title/track-adapter-issue-ingestion-coverage__10.json', - '/github/repos/octocat/hello-world/issues/10__track-adapter-issue-ingestion-coverage/comments/7001.json', - '/github/repos/octocat/hello-world/issues/10__track-adapter-issue-ingestion-coverage/comments/7002.json', + '/github/repos/octocat/hello-world/issues/10__track-adapter-issue-ingestion-coverage/comments/7001/meta.json', + '/github/repos/octocat/hello-world/issues/10__track-adapter-issue-ingestion-coverage/comments/7002/meta.json', '/github/repos/octocat/hello-world/issues/_index.json', '/github/repos/octocat/hello-world/pulls/_index.json', '/github/repos/_index.json', @@ -298,7 +298,7 @@ describe('issue mapping', () => { assert.strictEqual(meta.number, 10); assert.strictEqual(meta.title, 'Track adapter issue ingestion coverage'); assert.deepStrictEqual(meta.labels, ['bug']); - const comment7001 = JSON.parse(writes.get('/github/repos/octocat/hello-world/issues/10__track-adapter-issue-ingestion-coverage/comments/7001.json') ?? ''); + const comment7001 = JSON.parse(writes.get('/github/repos/octocat/hello-world/issues/10__track-adapter-issue-ingestion-coverage/comments/7001/meta.json') ?? ''); assert.strictEqual(comment7001.id, 7001); assert.strictEqual(comment7001.author.login, 'monalisa'); // PR 2's alias artifacts (`__` prefix) are written to the @@ -310,8 +310,8 @@ describe('issue mapping', () => { filesDeleted: 0, paths: [ '/github/repos/octocat/hello-world/issues/10__track-adapter-issue-ingestion-coverage/meta.json', - '/github/repos/octocat/hello-world/issues/10__track-adapter-issue-ingestion-coverage/comments/7001.json', - '/github/repos/octocat/hello-world/issues/10__track-adapter-issue-ingestion-coverage/comments/7002.json', + '/github/repos/octocat/hello-world/issues/10__track-adapter-issue-ingestion-coverage/comments/7001/meta.json', + '/github/repos/octocat/hello-world/issues/10__track-adapter-issue-ingestion-coverage/comments/7002/meta.json', '/github/repos/octocat/hello-world/issues/_index.json', '/github/repos/octocat/hello-world/pulls/_index.json', '/github/repos/_index.json', @@ -348,8 +348,8 @@ describe('issue mapping', () => { filesUpdated: 0, filesDeleted: 0, paths: [ - '/github/repos/octocat/hello-world/issues/10/comments/7001.json', - '/github/repos/octocat/hello-world/issues/10/comments/7002.json', + '/github/repos/octocat/hello-world/issues/10/comments/7001/meta.json', + '/github/repos/octocat/hello-world/issues/10/comments/7002/meta.json', ], errors: [], }); @@ -416,14 +416,14 @@ describe('issue mapping', () => { ); assert.strictEqual(issueMapping.vfsPath, 'issues/10__track-adapter-issue-ingestion-coverage/meta.json'); - assert.strictEqual(commentMapping.vfsPath, 'issues/10/comments/7001.json'); + assert.strictEqual(commentMapping.vfsPath, 'issues/10/comments/7001/meta.json'); assert.strictEqual( `/github/repos/${encodeURIComponent(mockRepoContext.owner)}/${encodeURIComponent(mockRepoContext.repo)}/${issueMapping.vfsPath}`, '/github/repos/octocat/hello-world/issues/10__track-adapter-issue-ingestion-coverage/meta.json', ); assert.strictEqual( `/github/repos/${encodeURIComponent(mockRepoContext.owner)}/${encodeURIComponent(mockRepoContext.repo)}/${commentMapping.vfsPath}`, - '/github/repos/octocat/hello-world/issues/10/comments/7001.json', + '/github/repos/octocat/hello-world/issues/10/comments/7001/meta.json', ); }); diff --git a/packages/github/src/issues/comment-mapper.test.ts b/packages/github/src/issues/comment-mapper.test.ts index 91b67a0f..6a4d16a7 100644 --- a/packages/github/src/issues/comment-mapper.test.ts +++ b/packages/github/src/issues/comment-mapper.test.ts @@ -14,7 +14,7 @@ describe('issue comment mapper', () => { 10, ); - assert.strictEqual(mapped.vfsPath, 'issues/10/comments/7001.json'); + assert.strictEqual(mapped.vfsPath, 'issues/10/comments/7001/meta.json'); assert.deepStrictEqual(JSON.parse(mapped.content), { id: 7001, body: 'I can pick this up after the PR ingestion flow lands.', @@ -63,8 +63,8 @@ describe('issue comment mapper', () => { filesUpdated: 0, filesDeleted: 0, paths: [ - '/github/repos/octocat/hello-world/issues/10/comments/7001.json', - '/github/repos/octocat/hello-world/issues/10/comments/7002.json', + '/github/repos/octocat/hello-world/issues/10/comments/7001/meta.json', + '/github/repos/octocat/hello-world/issues/10/comments/7002/meta.json', ], errors: [], }); diff --git a/packages/github/src/issues/comment-mapper.ts b/packages/github/src/issues/comment-mapper.ts index a05ce4ad..2cf970f0 100644 --- a/packages/github/src/issues/comment-mapper.ts +++ b/packages/github/src/issues/comment-mapper.ts @@ -72,7 +72,11 @@ export function mapIssueComment( void repo; return { - vfsPath: `issues/${githubNumberSlug(issueNumber, issueTitle)}/comments/${commentId}.json`, + // Directory record (`comments//meta.json`), NOT a flat leaf file: a + // comment can grow child records (e.g. per-comment reactions), and a flat + // `comments/.json` cannot coexist with a `comments//` directory on + // a POSIX mount. See `githubIssueCommentPath` in `../path-mapper.ts`. + vfsPath: `issues/${githubNumberSlug(issueNumber, issueTitle)}/comments/${commentId}/meta.json`, content: `${JSON.stringify(mapped, null, 2)}\n`, }; } @@ -137,7 +141,7 @@ function buildAbsoluteVfsPath(owner: string, repo: string, relativePath: string) function buildFallbackPath(comment: JsonObject, issueNumber: number): string { const commentId = comment.id; return typeof commentId === 'number' && Number.isInteger(commentId) && commentId > 0 - ? `issues/${issueNumber}/comments/${commentId}.json` + ? `issues/${issueNumber}/comments/${commentId}/meta.json` : `issues/${issueNumber}/comments/unknown.json`; } diff --git a/packages/github/src/layout-prompt.ts b/packages/github/src/layout-prompt.ts index 56238fd7..e9bcdb3d 100644 --- a/packages/github/src/layout-prompt.ts +++ b/packages/github/src/layout-prompt.ts @@ -7,6 +7,7 @@ Always run \`ls\` before constructing a path. PR 0 standardizes issue and pull r \`/github/LAYOUT.md\` is this guide. \`/github/repos/_index.json\` lists materialized repositories. \`/github/repos///issues/\` and \`/github/repos///pulls/\` each own a sibling \`_index.json\` plus per-record subdirectories named \`__\`. +\`issues/__/comments//meta.json\` holds issue comment records (each a directory record, so per-comment children such as reactions can nest under \`comments//\` without a file/directory collision). \`pulls/__/diff.patch\`, \`pulls/__/files/**\`, and \`pulls/__/base/**\` are nested artifacts and should not be treated as canonical records. Issue and pull request aliases are materialized under \`/github/repos/__//...\`, distinct from the canonical \`/github/repos///...\` tree. Alias views include \`by-id/.json\`, \`by-title/__.json\`, \`by-state//.json\`, \`by-assignee//.json\`, \`by-creator//.json\`, \`by-priority//.json\`, and \`by-edited/YYYY-MM-DD/.json\`. The edited-date bucket uses the provider update timestamp, or a merge/close timestamp when that is the most recent activity-summary fallback date. diff --git a/packages/github/src/path-mapper.ts b/packages/github/src/path-mapper.ts index 094e6237..55469024 100644 --- a/packages/github/src/path-mapper.ts +++ b/packages/github/src/path-mapper.ts @@ -155,6 +155,64 @@ export function githubRepoIssuesIndexPath(owner: string, repo: string): string { return `${githubRepoPrefix(owner, repo)}/issues/_index.json`; } +/** + * Canonical issue-comment record path. The comment is a **directory record** + * (`comments//meta.json`) — matching `githubIssuePath` and + * `githubPullRequestPath`, which both use `__/meta.json`. This is + * deliberate: a comment can grow children (GitHub exposes per-comment reactions + * at `/repos/{owner}/{repo}/issues/comments/{id}/reactions`, which would + * materialize under `comments//reactions/...`), so its stem MUST be + * a directory. A flat leaf file `comments/.json` would collide with + * that same `` directory — one name as both a file and a directory + * cannot be materialized on a POSIX mount (`mkdir ... : not a directory`), + * wedging the whole mirror. Readers should fall back to the legacy filename via + * {@link githubIssueCommentReadCandidatePaths}. + */ +export function githubIssueCommentPath( + owner: string, + repo: string, + issueNumber: number | string, + commentId: number | string, + issueTitle?: string, +): string { + return `${githubRepoPrefix(owner, repo)}/issues/${githubNumberSlug(issueNumber, issueTitle)}/comments/${encodeGitHubPathSegment(String(commentId))}/meta.json`; +} + +/** + * @deprecated Pre-0.9.x emitted a flat `.../comments/.json` leaf + * file, which collides with any same-named `` child directory on a + * POSIX mount. Use {@link githubIssueCommentPath}. Retained for back-compat + * reads only — see {@link githubIssueCommentReadCandidatePaths}. + */ +export function githubIssueCommentLegacyPath( + owner: string, + repo: string, + issueNumber: number | string, + commentId: number | string, + issueTitle?: string, +): string { + return `${githubRepoPrefix(owner, repo)}/issues/${githubNumberSlug(issueNumber, issueTitle)}/comments/${encodeGitHubPathSegment(String(commentId))}.json`; +} + +/** + * Reader hint: candidate paths for a GitHub issue-comment canonical record, in + * order of preference — current (`/meta.json`) then legacy + * (`.json`) — so a comment mirrored by either the current or a + * pre-0.9.x adapter still reads. + */ +export function githubIssueCommentReadCandidatePaths( + owner: string, + repo: string, + issueNumber: number | string, + commentId: number | string, + issueTitle?: string, +): string[] { + return [ + githubIssueCommentPath(owner, repo, issueNumber, commentId, issueTitle), + githubIssueCommentLegacyPath(owner, repo, issueNumber, commentId, issueTitle), + ]; +} + export function githubPullRequestPath( owner: string, repo: string, diff --git a/packages/github/src/resources.ts b/packages/github/src/resources.ts index 86e74d69..fc6e7cf9 100644 --- a/packages/github/src/resources.ts +++ b/packages/github/src/resources.ts @@ -19,8 +19,8 @@ export const resources = [ { name: "issue-comments", path: "/github/repos/{owner}/{repo}/issues/{issueNumber}/comments", - pathPattern: /^\/github\/repos\/[^\/]+\/[^\/]+\/issues\/[^\/]+\/comments(?:\/[^\/]+(?:\.json)?)?$/, - idPattern: /^\d+$/, + pathPattern: /^\/github\/repos\/[^\/]+\/[^\/]+\/issues\/[^\/]+\/comments(?:\/[^\/]+(?:\.json|\/meta\.json)?)?$/, + idPattern: /^(?:meta|\d+)$/, schema: "discovery/github/repos/{owner}/{repo}/issues/{issueNumber}/comments/.schema.json", createExample: "discovery/github/repos/{owner}/{repo}/issues/{issueNumber}/comments/.create.example.json", }, diff --git a/packages/github/src/webhook/__tests__/webhook-router.test.ts b/packages/github/src/webhook/__tests__/webhook-router.test.ts index 56064484..69a1fba2 100644 --- a/packages/github/src/webhook/__tests__/webhook-router.test.ts +++ b/packages/github/src/webhook/__tests__/webhook-router.test.ts @@ -41,7 +41,7 @@ function createAdapterMocks() { createResult('/github/repos/acme/widgets/pulls/7/review-threads/5.json'), ), ingestIssueComment: mock.method(adapter, 'ingestIssueComment', async () => - createResult('/github/repos/acme/widgets/issues/9/comments/3.json'), + createResult('/github/repos/acme/widgets/issues/9/comments/3/meta.json'), ), ingestPushCommits: mock.method(adapter, 'ingestPushCommits', async () => createResult('/github/repos/acme/widgets/commits/head.json'), diff --git a/packages/github/src/webhook/router.test.ts b/packages/github/src/webhook/router.test.ts index ec96aae1..8bc18415 100644 --- a/packages/github/src/webhook/router.test.ts +++ b/packages/github/src/webhook/router.test.ts @@ -56,7 +56,7 @@ class RecordingAdapter extends GitHubAdapter { override async ingestIssueComment(payload: Record): Promise { this.calls.push(`ingestIssueComment:${String(payload.action ?? '')}`); - return createResult('/github/repos/acme/widgets/issues/9/comments/3.json'); + return createResult('/github/repos/acme/widgets/issues/9/comments/3/meta.json'); } override async ingestPushCommits(_payload: Record): Promise { @@ -192,7 +192,7 @@ test('WebhookRouter routes issue_comment.created to ingestIssueComment', async ( ); assert.deepEqual(adapter.calls, ['ingestIssueComment:created']); - assert.deepEqual(result.paths, ['/github/repos/acme/widgets/issues/9/comments/3.json']); + assert.deepEqual(result.paths, ['/github/repos/acme/widgets/issues/9/comments/3/meta.json']); }); test('WebhookRouter routes issues.labeled to updateIssue', async () => { diff --git a/packages/github/src/writeback.test.ts b/packages/github/src/writeback.test.ts index 63c59355..73ea9415 100644 --- a/packages/github/src/writeback.test.ts +++ b/packages/github/src/writeback.test.ts @@ -382,6 +382,19 @@ describe('writeback', () => { assert.deepStrictEqual(update.body, { body: 'Updated comment body.' }); }); + it('resolves directory-record issue comment updates (comments//meta.json)', () => { + // Canonical comment records are directory records; editing the meta.json + // must patch the same comment the legacy flat path addressed. + const update = resolveWritebackRequest( + '/github/repos/acme/widgets/issues/42/comments/123/meta.json', + JSON.stringify({ body: 'Updated via directory record.' }), + ); + + assert.strictEqual(update.method, 'PATCH'); + assert.strictEqual(update.endpoint, '/repos/acme/widgets/issues/comments/123'); + assert.deepStrictEqual(update.body, { body: 'Updated via directory record.' }); + }); + it('resolves pull request merge writebacks to GitHub merge requests', () => { const request = resolveWritebackRequest( '/github/repos/acme/widgets/pulls/42/merge.json', diff --git a/packages/github/src/writeback.ts b/packages/github/src/writeback.ts index 4621dfd9..ebeb73b7 100644 --- a/packages/github/src/writeback.ts +++ b/packages/github/src/writeback.ts @@ -34,8 +34,12 @@ const MERGE_WRITEBACK_PATH = /^\/github\/repos\/([^/]+)\/([^/]+)\/pulls\/([1-9]\d*)(?:__[^/]+)?\/merge\.json$/; const ISSUE_WRITEBACK_PATH = /^\/github\/repos\/([^/]+)\/([^/]+)\/issues\/([^/]+?)(?:\.json)?$/; +// Issue comments are directory records (`comments//meta.json`); accept the +// legacy flat leaf (`comments/.json`) too so writebacks against a +// pre-migration mirror still resolve. Create drafts (`comments/.json`) +// continue to match via the bare `.json` alternative. const ISSUE_COMMENT_WRITEBACK_PATH = - /^\/github\/repos\/([^/]+)\/([^/]+)\/issues\/([1-9]\d*)(?:__[^/]+)?\/comments\/([^/]+?)(?:\.json)?$/; + /^\/github\/repos\/([^/]+)\/([^/]+)\/issues\/([1-9]\d*)(?:__[^/]+)?\/comments\/([^/]+?)(?:\.json|\/meta\.json)?$/; interface GitHubReviewResponse { id: number; diff --git a/packages/github/workflows/026-github-issue-mapping.ts b/packages/github/workflows/026-github-issue-mapping.ts index 8a50226d..b5c9563e 100644 --- a/packages/github/workflows/026-github-issue-mapping.ts +++ b/packages/github/workflows/026-github-issue-mapping.ts @@ -35,7 +35,7 @@ Plan issue mapping: - Write meta.json to /github/repos/{owner}/{repo}/issues/{number}/meta.json - Issue JSON: number, title, state, body, author, labels, assignees, milestone, created_at, updated_at, closed_at - Fetch comments via GET /repos/{owner}/{repo}/issues/{number}/comments -- Write each to /issues/{number}/comments/{comment_id}.json +- Write each to /issues/{number}/comments/{comment_id}/meta.json - Comment JSON: id, body, author, created_at, updated_at, reactions - Handle issues.opened and issues.closed events @@ -93,7 +93,7 @@ Export async function ingestIssue(provider, owner, repo, number, vfs): Export function mapIssueComment(comment, owner, repo, issueNumber): - Transform to: { id, body, author: { login, avatarUrl }, created_at, updated_at, reactions: { total_count, '+1', '-1', laugh, etc } } -- Return { vfsPath: 'issues/{number}/comments/{comment_id}.json', content: JSON } +- Return { vfsPath: 'issues/{number}/comments/{comment_id}/meta.json', content: JSON } Export async function ingestIssueComments(provider, owner, repo, number, vfs): - Fetch all comments with pagination @@ -142,7 +142,7 @@ Mock provider.proxy() with fixture data.`, Verify: - Correct GitHub API endpoints for issues - PR filtering via pull_request field -- VFS paths: /issues/{number}/meta.json, /issues/{number}/comments/{id}.json +- VFS paths: /issues/{number}/meta.json, /issues/{number}/comments/{id}/meta.json - Pagination handling - Tests cover happy path and edge cases diff --git a/packages/hubspot/discovery/hubspot/.adapter.md b/packages/hubspot/discovery/hubspot/.adapter.md index 955c5522..c219e4e4 100644 --- a/packages/hubspot/discovery/hubspot/.adapter.md +++ b/packages/hubspot/discovery/hubspot/.adapter.md @@ -12,10 +12,10 @@ Resources: | Resource | Schema | Create example | ID pattern | What it does | |---|---|---|---|---| -| `/hubspot/contacts/.json` | `/hubspot/contacts/.schema.json` | `/hubspot/contacts/.create.example.json` | `^(?:[A-Za-z0-9_.~-]+--)?\d+$` | Creates a HubSpot contact. | -| `/hubspot/companies/.json` | `/hubspot/companies/.schema.json` | `/hubspot/companies/.create.example.json` | `^(?:[A-Za-z0-9_.~-]+--)?\d+$` | Creates a HubSpot company. | -| `/hubspot/deals/.json` | `/hubspot/deals/.schema.json` | `/hubspot/deals/.create.example.json` | `^(?:[A-Za-z0-9_.~-]+--)?\d+$` | Creates a HubSpot deal. | -| `/hubspot/tickets/.json` | `/hubspot/tickets/.schema.json` | `/hubspot/tickets/.create.example.json` | `^(?:[A-Za-z0-9_.~-]+--)?\d+$` | Creates a HubSpot ticket. | +| `/hubspot/contacts/.json` | `/hubspot/contacts/.schema.json` | `/hubspot/contacts/.create.example.json` | `^\d+$` | Creates a HubSpot contact. | +| `/hubspot/companies/.json` | `/hubspot/companies/.schema.json` | `/hubspot/companies/.create.example.json` | `^\d+$` | Creates a HubSpot company. | +| `/hubspot/deals/.json` | `/hubspot/deals/.schema.json` | `/hubspot/deals/.create.example.json` | `^\d+$` | Creates a HubSpot deal. | +| `/hubspot/tickets/.json` | `/hubspot/tickets/.schema.json` | `/hubspot/tickets/.create.example.json` | `^\d+$` | Creates a HubSpot ticket. | ## Operations @@ -28,10 +28,10 @@ Resources: | Delete | `rm ` for canonical records. | ## ID Patterns -- `/hubspot/contacts/.json`: `^(?:[A-Za-z0-9_.~-]+--)?\d+$`. Filenames that do not match this pattern are treated as create drafts. -- `/hubspot/companies/.json`: `^(?:[A-Za-z0-9_.~-]+--)?\d+$`. Filenames that do not match this pattern are treated as create drafts. -- `/hubspot/deals/.json`: `^(?:[A-Za-z0-9_.~-]+--)?\d+$`. Filenames that do not match this pattern are treated as create drafts. -- `/hubspot/tickets/.json`: `^(?:[A-Za-z0-9_.~-]+--)?\d+$`. Filenames that do not match this pattern are treated as create drafts. +- `/hubspot/contacts/.json`: `^\d+$`. Filenames that do not match this pattern are treated as create drafts. +- `/hubspot/companies/.json`: `^\d+$`. Filenames that do not match this pattern are treated as create drafts. +- `/hubspot/deals/.json`: `^\d+$`. Filenames that do not match this pattern are treated as create drafts. +- `/hubspot/tickets/.json`: `^\d+$`. Filenames that do not match this pattern are treated as create drafts. ## Write field contracts diff --git a/packages/hubspot/src/resources.ts b/packages/hubspot/src/resources.ts index ece5641c..66592f95 100644 --- a/packages/hubspot/src/resources.ts +++ b/packages/hubspot/src/resources.ts @@ -7,13 +7,12 @@ export interface AdapterResourceConfig { readonly createExample: string; } -// HubSpot CRM object ids are numeric strings - no slug-prefix form (tightened from 0.2.x) export const resources = [ { name: "contacts", path: "/hubspot/contacts", pathPattern: /^\/hubspot\/contacts(?:\/[^\/]+(?:\.json)?)?$/, - idPattern: /^[0-9]+$/, + idPattern: /^\d+$/, schema: "discovery/hubspot/contacts/.schema.json", createExample: "discovery/hubspot/contacts/.create.example.json", }, @@ -21,7 +20,7 @@ export const resources = [ name: "companies", path: "/hubspot/companies", pathPattern: /^\/hubspot\/companies(?:\/[^\/]+(?:\.json)?)?$/, - idPattern: /^[0-9]+$/, + idPattern: /^\d+$/, schema: "discovery/hubspot/companies/.schema.json", createExample: "discovery/hubspot/companies/.create.example.json", }, @@ -29,7 +28,7 @@ export const resources = [ name: "deals", path: "/hubspot/deals", pathPattern: /^\/hubspot\/deals(?:\/[^\/]+(?:\.json)?)?$/, - idPattern: /^[0-9]+$/, + idPattern: /^\d+$/, schema: "discovery/hubspot/deals/.schema.json", createExample: "discovery/hubspot/deals/.create.example.json", }, @@ -37,7 +36,7 @@ export const resources = [ name: "tickets", path: "/hubspot/tickets", pathPattern: /^\/hubspot\/tickets(?:\/[^\/]+(?:\.json)?)?$/, - idPattern: /^[0-9]+$/, + idPattern: /^\d+$/, schema: "discovery/hubspot/tickets/.schema.json", createExample: "discovery/hubspot/tickets/.create.example.json", }, diff --git a/packages/linear/discovery/linear/.adapter.md b/packages/linear/discovery/linear/.adapter.md index b9f39566..1529cdb4 100644 --- a/packages/linear/discovery/linear/.adapter.md +++ b/packages/linear/discovery/linear/.adapter.md @@ -6,7 +6,7 @@ Read-only mounts: - `/linear/teams/.json` - Team records. - `/linear/issues/.json` - Issue records. - `/linear/users/.json` - User records. -- `/linear/comments/.json` - Comment records. +- `/linear/comments/__/meta.json` - Comment records (directory records so per-comment children can nest without a file/dir collision). Resources: @@ -14,6 +14,7 @@ Resources: |---|---|---|---|---| | `/linear/issues/.json` | `/linear/issues/.schema.json` | `/linear/issues/.create.example.json` | `^(?:[A-Za-z0-9_.~-]+(?:--\|__))?(?:[0-9a-f]{32}\|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$` | Creates a Linear issue. | | `/linear/issues/{issueId}/comments/.json` | `/linear/issues/{issueId}/comments/.schema.json` | `/linear/issues/{issueId}/comments/.create.example.json` | `^(?:[A-Za-z0-9_.~-]+(?:--\|__))?(?:[0-9a-f]{32}\|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$` | Creates a comment on a Linear issue. | +| `/linear/agent-sessions/{sessionId}/activities/.json` | `/linear/agent-sessions/{sessionId}/activities/.schema.json` | `/linear/agent-sessions/{sessionId}/activities/.create.example.json` | `^(?:activity_[A-Za-z0-9_-]+\|[0-9a-f]{32}\|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$` | Creates an activity on a Linear agent session. | ## Operations @@ -28,6 +29,7 @@ Resources: ## ID Patterns - `/linear/issues/.json`: `^(?:[A-Za-z0-9_.~-]+(?:--|__))?(?:[0-9a-f]{32}|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$`. Filenames that do not match this pattern are treated as create drafts. - `/linear/issues/{issueId}/comments/.json`: `^(?:[A-Za-z0-9_.~-]+(?:--|__))?(?:[0-9a-f]{32}|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$`. Filenames that do not match this pattern are treated as create drafts. +- `/linear/agent-sessions/{sessionId}/activities/.json`: `^(?:activity_[A-Za-z0-9_-]+|[0-9a-f]{32}|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$`. Filenames that do not match this pattern are treated as create drafts. ## Write field contracts @@ -68,5 +70,21 @@ Fields: - `parentId` (optional, string, uuid) - Parent comment UUID for threaded replies. - `doNotSubscribeToIssue` (optional, boolean) - Whether to avoid subscribing the commenter to the issue. +### Create Linear agent activity + +Resource: `/linear/agent-sessions/{sessionId}/activities/.json` +Schema: `/linear/agent-sessions/{sessionId}/activities/.schema.json` +Create example: `/linear/agent-sessions/{sessionId}/activities/.create.example.json` +Required fields: `type`. +Optional fields: `body`, `action`, `parameter`, `result`. + +Fields: + +- `type` (required, enum) - Linear agent activity content type. Allowed values: `action`, `elicitation`, `error`, `response`, `thought`. +- `body` (optional, string) - Response, thought, elicitation, or error body. +- `action` (optional, string) - Action name for action-type activities. +- `parameter` (optional, string) - Action parameter or target. +- `result` (optional, string) - Action result summary. + ## Create Examples Read the resource `.schema.json` first, then use the sibling `.create.example.json` as a minimal create document. The example intentionally omits read-only fields. diff --git a/packages/linear/discovery/linear/agent-sessions/{sessionId}/activities/.create.example.json b/packages/linear/discovery/linear/agent-sessions/{sessionId}/activities/.create.example.json new file mode 100644 index 00000000..8f9ec03d --- /dev/null +++ b/packages/linear/discovery/linear/agent-sessions/{sessionId}/activities/.create.example.json @@ -0,0 +1,4 @@ +{ + "type": "response", + "body": "Replace example agent activity body." +} diff --git a/packages/linear/discovery/linear/agent-sessions/{sessionId}/activities/.schema.json b/packages/linear/discovery/linear/agent-sessions/{sessionId}/activities/.schema.json new file mode 100644 index 00000000..7462487c --- /dev/null +++ b/packages/linear/discovery/linear/agent-sessions/{sessionId}/activities/.schema.json @@ -0,0 +1,103 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Linear agent activity", + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "enum": [ + "action", + "elicitation", + "error", + "response", + "thought" + ], + "description": "Linear agent activity content type." + }, + "body": { + "type": "string", + "description": "Response, thought, elicitation, or error body." + }, + "action": { + "type": "string", + "description": "Action name for action-type activities." + }, + "parameter": { + "type": "string", + "description": "Action parameter or target." + }, + "result": { + "type": "string", + "description": "Action result summary." + }, + "id": { + "type": "string", + "description": "Provider canonical record id.", + "readOnly": true + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "Provider creation timestamp.", + "readOnly": true + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "Provider last update timestamp.", + "readOnly": true + }, + "url": { + "type": "string", + "format": "uri", + "description": "Provider URL for the record.", + "readOnly": true + }, + "identifier": { + "type": "string", + "description": "Provider human-readable identifier or key.", + "readOnly": true + }, + "provider": { + "type": "string", + "description": "Relayfile provider name.", + "readOnly": true + }, + "objectType": { + "type": "string", + "description": "Relayfile object type.", + "readOnly": true + }, + "objectId": { + "type": "string", + "description": "Relayfile object id.", + "readOnly": true + }, + "workspaceId": { + "type": "string", + "description": "Relayfile workspace id.", + "readOnly": true + }, + "connectionId": { + "type": "string", + "description": "Relayfile connection id.", + "readOnly": true + }, + "_webhook": { + "type": "object", + "description": "Provider webhook metadata captured during sync.", + "readOnly": true, + "additionalProperties": true + }, + "_connection": { + "type": "object", + "description": "Relayfile connection metadata captured during sync.", + "readOnly": true, + "additionalProperties": true + } + }, + "additionalProperties": false, + "description": "Full resource record schema. Fields marked readOnly are synced from the provider and cannot be written by agents." +} diff --git a/packages/linear/src/__tests__/index-emission.test.ts b/packages/linear/src/__tests__/index-emission.test.ts index 2c235d2c..298c84c7 100644 --- a/packages/linear/src/__tests__/index-emission.test.ts +++ b/packages/linear/src/__tests__/index-emission.test.ts @@ -157,7 +157,7 @@ describe('linear index emission', () => { ]); assert.equal(linearIssuePath('issue-1'), '/linear/issues/issue-1.json'); - assert.equal(linearCommentPath('comment-1'), '/linear/comments/comment-1.json'); + assert.equal(linearCommentPath('comment-1'), '/linear/comments/comment-1/meta.json'); assert.equal(linearUserPath('user-1'), '/linear/users/user-1.json'); assert.equal(linearTeamPath('team-1'), '/linear/teams/team-1.json'); assert.equal(linearProjectPath('project-1'), '/linear/projects/project-1.json'); diff --git a/packages/linear/src/__tests__/linear-adapter.test.ts b/packages/linear/src/__tests__/linear-adapter.test.ts index 873cea49..46c39e55 100644 --- a/packages/linear/src/__tests__/linear-adapter.test.ts +++ b/packages/linear/src/__tests__/linear-adapter.test.ts @@ -430,7 +430,7 @@ test('path mapping stays deterministic for supported Linear VFS objects', () => const adapter = createAdapter(); assert.equal(linearIssuePath('issue 1/2'), '/linear/issues/issue%201%2F2.json'); - assert.equal(linearCommentPath('comment:42'), '/linear/comments/comment%3A42.json'); + assert.equal(linearCommentPath('comment:42'), '/linear/comments/comment%3A42/meta.json'); assert.equal(linearProjectPath('project#7'), '/linear/projects/project%237.json'); assert.equal(linearCyclePath('cycle Q2'), '/linear/cycles/cycle%20Q2.json'); assert.equal(linearTeamPath('team eng'), '/linear/teams/team%20eng.json'); @@ -439,9 +439,9 @@ test('path mapping stays deterministic for supported Linear VFS objects', () => assert.equal(linearRoadmapPath('roadmap alpha'), '/linear/roadmaps/roadmap%20alpha.json'); assert.equal(computeLinearPath('Issue', 'issue 1/2'), '/linear/issues/issue%201%2F2.json'); - assert.equal(computeLinearPath('comments', 'comment:42'), '/linear/comments/comment%3A42.json'); + assert.equal(computeLinearPath('comments', 'comment:42'), '/linear/comments/comment%3A42/meta.json'); assert.equal(computeLinearPath('Issue', 'issue_123', 'AGE-8'), '/linear/issues/AGE-8__issue_123.json'); - assert.equal(computeLinearPath('comment', 'comment_123', 'AGE-8'), '/linear/comments/AGE-8__comment_123.json'); + assert.equal(computeLinearPath('comment', 'comment_123', 'AGE-8'), '/linear/comments/AGE-8__comment_123/meta.json'); assert.equal(computeLinearPath('project', 'project#7'), '/linear/projects/project%237.json'); assert.equal(computeLinearPath('Cycles', 'cycle Q2'), '/linear/cycles/cycle%20Q2.json'); assert.equal(computeLinearPath('teams', 'team eng'), '/linear/teams/team%20eng.json'); @@ -450,7 +450,7 @@ test('path mapping stays deterministic for supported Linear VFS objects', () => assert.equal(computeLinearPath('roadmaps', 'roadmap alpha'), '/linear/roadmaps/roadmap%20alpha.json'); assert.equal(adapter.computePath('issues', 'issue 1/2'), '/linear/issues/issue%201%2F2.json'); - assert.equal(adapter.computePath('comment', 'comment:42'), '/linear/comments/comment%3A42.json'); + assert.equal(adapter.computePath('comment', 'comment:42'), '/linear/comments/comment%3A42/meta.json'); assert.equal(adapter.computePath('projects', 'project#7'), '/linear/projects/project%237.json'); assert.equal(adapter.computePath('cycle', 'cycle Q2'), '/linear/cycles/cycle%20Q2.json'); assert.equal(adapter.computePath('team', 'team eng'), '/linear/teams/team%20eng.json'); @@ -494,7 +494,7 @@ test('ingestWebhook writes identifier-aware issue and comment filenames at runti '/linear/issues/by-id/AGE-8.json', '/linear/issues/by-title/ship-mixed-case-path-handling-before-friday.json', '/linear/LAYOUT.md', - '/linear/comments/AGE-8__comment_123.json', + '/linear/comments/AGE-8__comment_123/meta.json', '/linear/LAYOUT.md', ], ); diff --git a/packages/linear/src/__tests__/name-id-convention.test.ts b/packages/linear/src/__tests__/name-id-convention.test.ts index ac6b0800..6fd6a331 100644 --- a/packages/linear/src/__tests__/name-id-convention.test.ts +++ b/packages/linear/src/__tests__/name-id-convention.test.ts @@ -35,7 +35,7 @@ test('Linear comment paths prefer the parent issue identifier over a body snippe }), ); - assert.equal(path, `/linear/comments/AGE-8__${COMMENT_ID}.json`); + assert.equal(path, `/linear/comments/AGE-8__${COMMENT_ID}/meta.json`); }); test('Linear naming collision suffixes are deterministic and derived from the id', () => { diff --git a/packages/linear/src/__tests__/path-mapper.test.ts b/packages/linear/src/__tests__/path-mapper.test.ts index 46926084..0441b1d4 100644 --- a/packages/linear/src/__tests__/path-mapper.test.ts +++ b/packages/linear/src/__tests__/path-mapper.test.ts @@ -8,6 +8,9 @@ import { linearByIdAliasPath, linearByTitleAliasPath, linearByUuidAliasPath, + linearCommentLegacyPath, + linearCommentPath, + linearCommentReadCandidatePaths, linearIssueByEditedPath, normalizeLinearObjectType, normalizeNangoLinearModel, @@ -223,4 +226,50 @@ describe('linear path-mapper', () => { assert.notEqual(issueScope, projectScope); }); }); + + describe('linearCommentPath', () => { + const commentId = '0f6f0a0c-6a44-4f6e-93f7-2c8b3a9d1e55'; + + it('is a directory record and cannot collide with child records under the comment id', () => { + const comment = linearCommentPath(commentId, 'AGE-8'); + assert.equal(comment, `/linear/comments/AGE-8__${commentId}/meta.json`); + + // A comment's children (Linear supports per-comment emoji reactions and + // threaded replies; the webhook normalizer already recognizes `reaction` + // payloads) must nest UNDER the comment's directory — never as a sibling + // that shares the comment's name with a different node type. This is the + // invariant whose violation wedges a POSIX mount: a flat leaf file + // `comments/__.json` cannot coexist with a + // `comments/__/` directory (`mkdir ... : not a directory`). + const commentDir = comment.replace(/\/meta\.json$/u, ''); + const hypotheticalReaction = `${commentDir}/reactions/tada--user-1.json`; + assert.ok( + hypotheticalReaction.startsWith(`${commentDir}/`), + 'children must nest under the comment directory', + ); + assert.notEqual( + comment, + linearCommentLegacyPath(commentId, 'AGE-8'), + 'comment stem must be a directory record, not the flat .json leaf', + ); + + // Back-compat: readers can still resolve a comment mirrored by a + // pre-migration adapter at the legacy flat path. + assert.deepEqual(linearCommentReadCandidatePaths(commentId, 'AGE-8'), [ + comment, + linearCommentLegacyPath(commentId, 'AGE-8'), + ]); + assert.equal( + linearCommentLegacyPath(commentId, 'AGE-8'), + `/linear/comments/AGE-8__${commentId}.json`, + ); + }); + + it('routes comment object types through the directory record', () => { + assert.equal( + computeLinearPath('comment', commentId, 'AGE-8'), + `/linear/comments/AGE-8__${commentId}/meta.json`, + ); + }); + }); }); diff --git a/packages/linear/src/emit-auxiliary-files.ts b/packages/linear/src/emit-auxiliary-files.ts index e677bd62..0ae3524a 100644 --- a/packages/linear/src/emit-auxiliary-files.ts +++ b/packages/linear/src/emit-auxiliary-files.ts @@ -19,10 +19,12 @@ * grouping field and `identifier` are present. Index row carries * `{ id, title, updated, identifier, state }`. * - * * **comment** — canonical `/linear/comments/__.json`, no - * subtree aliases (current cloud + adapter shape only writes the - * canonical record and the index row). Index row - * `{ id, title, updated }`. + * * **comment** — canonical `/linear/comments/__/meta.json` (a + * directory record so per-comment children like reactions or threaded + * replies can nest without a file/dir collision; pre-0.9.x mirrors hold + * the legacy flat `__.json`), no subtree aliases (current cloud + * + adapter shape only writes the canonical record and the index row). + * Index row `{ id, title, updated }`. * * * **user**, **team** — canonical `/linear/users/.json` / * `/linear/teams/.json`, index row `{ id, title, updated }`. @@ -72,6 +74,7 @@ import { linearByNameAliasPath, linearByTitleAliasPath, linearByUuidAliasPath, + linearCommentLegacyPath, linearCommentPath, linearCommentsIndexPath, linearCyclesIndexPath, @@ -564,7 +567,14 @@ async function emitComments( const fanOut = await runEmitBatch(client, workspaceId, records, async (record) => { if (isDeleteRecord(record)) { indexReconciler.remove(record.id); - return { deletes: [{ path: linearCommentPath(record.id) }] }; + // Tombstone both the directory record and the legacy flat leaf so a + // mirror populated by a pre-0.9.x adapter is cleaned up too. + return { + deletes: [ + { path: linearCommentPath(record.id) }, + { path: linearCommentLegacyPath(record.id) }, + ], + }; } const id = readNonEmptyString(record.id); if (!id) return {}; diff --git a/packages/linear/src/layout-prompt.ts b/packages/linear/src/layout-prompt.ts index b6e16761..96f6a309 100644 --- a/packages/linear/src/layout-prompt.ts +++ b/packages/linear/src/layout-prompt.ts @@ -6,6 +6,7 @@ Always run \`ls\` before constructing a path. PR 0 standardizes human-readable l \`/linear/LAYOUT.md\` is this guide. \`/linear/issues/\`, \`/linear/comments/\`, \`/linear/users/\`, \`/linear/teams/\`, \`/linear/projects/\`, \`/linear/cycles/\`, \`/linear/milestones/\`, and \`/linear/roadmaps/\` each own their canonical JSON records plus a sibling \`_index.json\`. +\`/linear/comments/__/meta.json\` is the canonical comment record (a directory record, so per-comment children such as reactions or threaded replies can nest under \`comments/__/\` without a file/directory collision). Issue lookups: \`/linear/issues/by-uuid/.json\` is the stable anchor (always emitted, keyed on the Linear UUID). \`/linear/issues/by-id/.json\` is the human-readable lookup keyed on the Linear identifier (only emitted when the issue has one). \`/linear/issues/by-title/.json\`, \`/linear/issues/by-state//.json\`, \`/linear/issues/by-assignee//.json\`, \`/linear/issues/by-creator//.json\`, \`/linear/issues/by-priority//.json\`, and \`/linear/issues/by-edited/YYYY-MM-DD/.json\` are additional lookups. The edited-date bucket is formatted as \`YYYY-MM-DD\` and uses the first available timestamp in this order: \`updatedAt\`, \`updated_at\`, \`completedAt\`, \`canceledAt\`, \`createdAt\`, then \`created_at\`. @@ -27,7 +28,7 @@ Writable resources advertise sibling schemas and create examples at \`discovery/ ## JSONL And Querying -Linear does not emit JSONL in this adapter today. Comments are individual \`.json\` records rather than \`comments.jsonl\`. +Linear does not emit JSONL in this adapter today. Comments are individual \`comments/__/meta.json\` directory records rather than \`comments.jsonl\`. Examples: diff --git a/packages/linear/src/path-mapper.ts b/packages/linear/src/path-mapper.ts index 15b9e09c..a2abf0fc 100644 --- a/packages/linear/src/path-mapper.ts +++ b/packages/linear/src/path-mapper.ts @@ -328,10 +328,46 @@ export function linearPrioritySlug(priority: number | string): string { return slugifyAlias(priority); } +/** + * Canonical comment record path. The comment is a **directory record** + * (`comments/__/meta.json`), not a flat leaf file. This is + * deliberate: a Linear comment can grow children — Linear supports per-comment + * emoji reactions (the webhook normalizer already recognizes `reaction` + * payloads) and threaded replies — which would materialize under + * `comments/__/...`. A flat leaf `comments/__.json` cannot + * coexist with that same-named directory on a POSIX mount + * (`mkdir ... : not a directory`), wedging the whole mirror. Readers should + * fall back to the legacy filename via + * {@link linearCommentReadCandidatePaths}. + */ export function linearCommentPath(commentId: string, humanReadable?: string, opts?: NameWithIdOptions): string { + return `${LINEAR_PATH_ROOT}/comments/${nameWithId(humanReadable, commentId, opts)}/meta.json`; +} + +/** + * @deprecated Pre-0.9.x emitted a flat `/linear/comments/__.json` + * leaf file, which collides with any same-named child directory on a POSIX + * mount. Use {@link linearCommentPath}. Retained for back-compat reads (and + * legacy-mirror tombstone deletes) only — see + * {@link linearCommentReadCandidatePaths}. + */ +export function linearCommentLegacyPath(commentId: string, humanReadable?: string, opts?: NameWithIdOptions): string { return `${LINEAR_PATH_ROOT}/comments/${nameWithId(humanReadable, commentId, opts)}.json`; } +/** + * Reader hint: candidate paths for a Linear comment canonical record, in order + * of preference — current (`__/meta.json`) then legacy + * (`__.json`) — so a comment mirrored by either the current or a + * pre-0.9.x adapter still reads. + */ +export function linearCommentReadCandidatePaths(commentId: string, humanReadable?: string, opts?: NameWithIdOptions): string[] { + return [ + linearCommentPath(commentId, humanReadable, opts), + linearCommentLegacyPath(commentId, humanReadable, opts), + ]; +} + export function linearCommentsIndexPath(): string { return `${LINEAR_PATH_ROOT}/comments/_index.json`; } diff --git a/scripts/writeback-discovery-data.mjs b/scripts/writeback-discovery-data.mjs index b8b7e3fa..f5188e12 100644 --- a/scripts/writeback-discovery-data.mjs +++ b/scripts/writeback-discovery-data.mjs @@ -98,6 +98,7 @@ export const adapters = [ ['/github/repos///pulls//meta.json', 'Pull request metadata.'], ['/github/repos///pulls//files/', 'Pull request file records.'], ['/github/repos///issues//meta.json', 'Issue metadata.'], + ['/github/repos///issues//comments//meta.json', 'Issue comment records (directory records so per-comment children such as reactions can nest without a file/dir collision).'], ['/github/repos///commits//metadata.json', 'Commit metadata.'], ], endpoints: [ @@ -310,7 +311,7 @@ export const adapters = [ ['/linear/teams/.json', 'Team records.'], ['/linear/issues/.json', 'Issue records.'], ['/linear/users/.json', 'User records.'], - ['/linear/comments/.json', 'Comment records.'], + ['/linear/comments/__/meta.json', 'Comment records (directory records so per-comment children can nest without a file/dir collision).'], ], endpoints: [ endpoint('/linear/issues/new.json', 'Create Linear issue', 'Creates a Linear issue.', ['teamId', 'title'], { @@ -332,6 +333,13 @@ export const adapters = [ parentId: str('Parent comment UUID for threaded replies.', 'uuid'), doNotSubscribeToIssue: bool('Whether to avoid subscribing the commenter to the issue.'), }, { body: 'Replace example comment body.' }), + endpoint('/linear/agent-sessions/{sessionId}/activities/new.json', 'Create Linear agent activity', 'Creates an activity on a Linear agent session.', ['type'], { + type: en(['action', 'elicitation', 'error', 'response', 'thought'], 'Linear agent activity content type.'), + body: str('Response, thought, elicitation, or error body.'), + action: str('Action name for action-type activities.'), + parameter: str('Action parameter or target.'), + result: str('Action result summary.'), + }, { type: 'response', body: 'Replace example agent activity body.' }), ], }, { @@ -532,6 +540,9 @@ export const adapters = [ readPaths: [['/dropbox//', 'Dropbox file content.']], endpoints: [ endpoint('/dropbox/files/new.json', 'Create Dropbox file', 'Uploads a Dropbox file.', ['path_display'], { path_display: str('Dropbox display path.'), contentBase64: str('Base64 content.'), mode: str('Upload mode.') }, { path_display: '/Team/Notes.md' }), + endpoint('/dropbox/folders/new.json', 'Create Dropbox folder', 'Creates or updates Dropbox folder metadata.', ['path_display'], { path_display: str('Dropbox display path.'), name: str('Folder name.') }, { path_display: '/Team' }), + endpoint('/dropbox/shared-folders/new.json', 'Create Dropbox shared folder marker', 'Creates or updates Dropbox shared folder metadata.', ['id'], { id: str('Dropbox shared folder id.'), name: str('Shared folder name.') }, { id: '845281924' }), + endpoint('/dropbox/shared-links/new.json', 'Create Dropbox shared link marker', 'Creates or updates Dropbox shared link metadata.', ['url'], { url: str('Dropbox shared link URL.'), name: str('Shared link name.') }, { url: 'https://www.dropbox.com/scl/fi/example/report.pdf?dl=0' }), endpoint('/dropbox/cursors/new.json', 'Create Dropbox cursor', 'Stores a list_folder cursor.', ['cursor'], { cursor: str('Dropbox cursor.'), accountId: str('Account id.') }, { cursor: 'cursor-2' }), ], }, diff --git a/scripts/writeback-discovery-normalizer.mjs b/scripts/writeback-discovery-normalizer.mjs index cea6a022..d29078a1 100644 --- a/scripts/writeback-discovery-normalizer.mjs +++ b/scripts/writeback-discovery-normalizer.mjs @@ -210,6 +210,9 @@ function resourceNameFor(adapterSlug, resourcePath) { if (adapterSlug === 'slack' && resourcePath.includes('/users/') && resourcePath.endsWith('/messages')) { return 'direct-messages'; } + if (adapterSlug === 'linear' && resourcePath.includes('/agent-sessions/') && resourcePath.endsWith('/activities')) { + return 'agent-activities'; + } const last = resourcePath.split('/').filter(Boolean).at(-1); if (!last) return adapterSlug; if (/^\{[^}]+\}\.json$/u.test(last)) { @@ -219,6 +222,14 @@ function resourceNameFor(adapterSlug, resourcePath) { } function pathPatternSourceFor(adapterSlug, resourcePath) { + if (adapterSlug === 'dropbox') { + if (resourcePath === '/dropbox/files' || resourcePath === '/dropbox/folders') { + return `^${escapeRegex(resourcePath)}/(?!_index\\.json$)(?!by-(?:id|path)/)[^/]+(?:\\.json)?$`; + } + if (resourcePath === '/dropbox/shared-folders' || resourcePath === '/dropbox/shared-links') { + return `^${escapeRegex(resourcePath)}/(?!_index\\.json$)(?:by-id/)?[^/]+(?:\\.json)?$`; + } + } if (adapterSlug === 'slack' && resourcePath === '/slack/channels/{channelId}/messages') { return '^/slack/channels/[^/]+/messages(?:/[^/]+(?:\\.json|/meta\\.json)?)?$'; } @@ -231,6 +242,12 @@ function pathPatternSourceFor(adapterSlug, resourcePath) { if (adapterSlug === 'github' && resourcePath === '/github/repos/{owner}/{repo}/pulls/{pullNumber}/merge.json') { return '^/github/repos/[^/]+/[^/]+/pulls/[1-9]\\d*(?:__[^/]+)?/merge\\.json$'; } + if (adapterSlug === 'github' && resourcePath === '/github/repos/{owner}/{repo}/issues/{issueNumber}/comments') { + // Issue comments are directory records (`comments//meta.json`); the + // legacy flat leaf (`comments/.json`) stays matchable for create + // drafts and pre-migration mirrors. + return '^/github/repos/[^/]+/[^/]+/issues/[^/]+/comments(?:/[^/]+(?:\\.json|/meta\\.json)?)?$'; + } const resourceSegments = resourcePath.split('/').filter(Boolean).map((segment) => { if (segment === '{projectPath}') { @@ -252,6 +269,9 @@ function pathPatternSourceFor(adapterSlug, resourcePath) { function idPatternFor(adapterSlug, resourcePath) { if (adapterSlug === 'linear') { + if (resourcePath.includes('/agent-sessions/') && resourcePath.endsWith('/activities')) { + return pattern('^(?:activity_[A-Za-z0-9_-]+|[0-9a-f]{32}|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$', 'i'); + } return pattern('^(?:[A-Za-z0-9_.~-]+(?:--|__))?(?:[0-9a-f]{32}|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$', 'i'); } if (adapterSlug === 'notion') { @@ -289,9 +309,19 @@ function idPatternFor(adapterSlug, resourcePath) { if (resourcePath.endsWith('/issues')) { return pattern('^[1-9]\\d*$'); } + if (resourcePath.endsWith('/comments')) { + // `meta` is the filename stem of a directory record's canonical file + // (`comments//meta.json`); the writeback handler re-derives the + // numeric comment id from the full path. Mirrors the slack messages + // resource. + return pattern('^(?:meta|\\d+)$'); + } + return pattern('^\\d+$'); + } + if (adapterSlug === 'hubspot') { return pattern('^\\d+$'); } - if (adapterSlug === 'hubspot' || adapterSlug === 'pipedrive' || adapterSlug === 'asana') { + if (adapterSlug === 'pipedrive' || adapterSlug === 'asana') { return pattern('^(?:[A-Za-z0-9_.~-]+--)?\\d+$'); } if (adapterSlug === 'jira') { diff --git a/scripts/writeback-discovery-normalizer.test.mjs b/scripts/writeback-discovery-normalizer.test.mjs index ca90b9c0..8b758104 100644 --- a/scripts/writeback-discovery-normalizer.test.mjs +++ b/scripts/writeback-discovery-normalizer.test.mjs @@ -157,8 +157,17 @@ test('normalizes existing writeback discovery adapter endpoints without changing const commentEndpoint = normalized.endpoints.find((endpoint) => endpoint.path === '/github/repos/{owner}/{repo}/issues/{issueNumber}/comments/new.json'); assert.ok(commentEndpoint); - assert.equal(commentEndpoint.resource.name, 'issue-comments'); - assert.equal(commentEndpoint.resource.schemaPath, '/github/repos/{owner}/{repo}/issues/{issueNumber}/comments/.schema.json'); + assert.deepEqual(commentEndpoint.resource, { + name: 'issue-comments', + resourcePath: '/github/repos/{owner}/{repo}/issues/{issueNumber}/comments', + schemaPath: '/github/repos/{owner}/{repo}/issues/{issueNumber}/comments/.schema.json', + examplePath: '/github/repos/{owner}/{repo}/issues/{issueNumber}/comments/.create.example.json', + description: 'Creates or updates a GitHub issue comment.', + pathPatternSource: '^/github/repos/[^/]+/[^/]+/issues/[^/]+/comments(?:/[^/]+(?:\\.json|/meta\\.json)?)?$', + pathPatternLiteral: '/^\\/github\\/repos\\/[^\\/]+\\/[^\\/]+\\/issues\\/[^\\/]+\\/comments(?:\\/[^\\/]+(?:\\.json|\\/meta\\.json)?)?$/', + idPatternLiteral: '/^(?:meta|\\d+)$/', + idPatternSource: '^(?:meta|\\d+)$', + }); }); test('normalizes GitLab slugged nested writeback paths to runtime matchers', () => {