Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/writeback-spec-coverage.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ Contract-backed means the endpoint uses `contractEndpoint(...)`, loads its reque
| intercom | None | 0 | 3 | Inline JS schemas. |
| jira | None | 0 | 4 | Inline JS schemas. |
| linear | None | 0 | 2 | Inline JS schemas; provider source is GraphQL, not OpenAPI. |
| notion | None | 0 | 1 | Inline JS schemas. |
| notion | None | 0 | 9 | Inline JS schemas cover database page creates, page property updates, content replacement, and comments. |
| onedrive | None | 0 | 2 | Inline JS schemas. |
| pipedrive | None | 0 | 4 | Inline JS schemas. |
| postgres | None | 0 | 2 | Inline JS schemas; database/table shape is runtime-native rather than provider OpenAPI. |
Expand Down
128 changes: 100 additions & 28 deletions packages/core/src/runtime/file-native-router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,34 +152,37 @@ export function classifyWrite(
): FileNativeWritebackRoute | null {
const event = opts.fsEvent ?? "write";
const normalizedPath = normalizeWritebackPath(path);
const resource = findMatchingResource(normalizedPath, resources);
if (!resource) {
return null;
}
for (const resource of matchingResources(normalizedPath, resources)) {
const id = readWritebackId(normalizedPath, resource);
if (!id || isReservedWritebackFilename(id)) {
continue;
}

const id = readWritebackId(normalizedPath);
if (!id || isReservedWritebackFilename(id)) {
return null;
}
const canonical = testResourceId(resource.idPattern, id);
if (event === "delete") {
if (!canonical) {
continue;
}
return {
kind: "delete",
resource,
id,
canonical,
};
}

const canonical = testResourceId(resource.idPattern, id);
if (event === "delete") {
return canonical
? {
kind: "delete",
resource,
id,
canonical,
}
: null;
}
if (!canonical && resourceUsesExactFile(resource)) {
continue;
}

return {
kind: canonical ? "patch" : "create",
resource,
id,
canonical,
};
return {
kind: canonical ? "patch" : "create",
resource,
id,
canonical,
};
}
return null;
}

export function validatePayload(
Expand Down Expand Up @@ -265,7 +268,7 @@ export async function executeFileNativeWriteback(
request = await options.resolveDeleteRequest(options.path);
} else {
const content = options.content ?? "";
const payload = parseWritebackJsonObject(content);
const payload = parseWritebackPayload(content, route.resource);
const schema = await loadWritebackSchema(route.resource, options);
const validation = validatePayload(payload, schema, route.kind);
if (!validation.ok) {
Expand Down Expand Up @@ -386,6 +389,27 @@ function validateFieldValue(
}
}

function parseWritebackPayload(
content: string,
resource: AdapterResourceConfig
): Record<string, unknown> {
if (resource.path.endsWith(".md")) {
const parsed = safeParseWritebackJsonObject(content);
return parsed ?? { markdown: content };
}
return parseWritebackJsonObject(content);
}

function safeParseWritebackJsonObject(content: string): Record<string, unknown> | undefined {
let parsed: unknown;
try {
parsed = JSON.parse(content);
} catch {
return undefined;
}
return isRecord(parsed) ? parsed : undefined;
}

function parseWritebackJsonObject(content: string): Record<string, unknown> {
let parsed: unknown;
try {
Expand Down Expand Up @@ -591,9 +615,16 @@ function findMatchingResource(
path: string,
resources: readonly AdapterResourceConfig[]
): AdapterResourceConfig | undefined {
return matchingResources(path, resources)[0];
}

function matchingResources(
path: string,
resources: readonly AdapterResourceConfig[]
): AdapterResourceConfig[] {
return [...resources]
.sort((left, right) => right.path.length - left.path.length)
.find((resource) => {
.filter((resource) => {
resource.pathPattern.lastIndex = 0;
const matched = resource.pathPattern.test(path);
resource.pathPattern.lastIndex = 0;
Expand All @@ -606,7 +637,16 @@ function normalizeWritebackPath(path: string): string {
return trimmed.endsWith("/") ? trimmed.slice(0, -1) : trimmed;
}

function readWritebackId(path: string): string | undefined {
function readWritebackId(
path: string,
resource?: AdapterResourceConfig
): string | undefined {
if (resource && resourceUsesExactFile(resource)) {
const id = readExactFileResourceId(path, resource);
if (id) {
return id;
}
}
const segment = path.split("/").filter(Boolean).at(-1);
if (!segment || !segment.endsWith(".json")) {
return undefined;
Expand All @@ -618,6 +658,38 @@ function readWritebackId(path: string): string | undefined {
return decodeURIComponent(stem);
}

function resourceUsesExactFile(resource: AdapterResourceConfig): boolean {
return /\.(?:json|md)$/u.test(resource.path);
}

function readExactFileResourceId(
path: string,
resource: AdapterResourceConfig
): string | undefined {
const pathSegments = path.split("/").filter(Boolean);
const resourceSegments = resource.path.split("/").filter(Boolean);
if (pathSegments.length !== resourceSegments.length) {
return undefined;
}

for (let index = resourceSegments.length - 1; index >= 0; index -= 1) {
const resourceSegment = resourceSegments[index];
const pathSegment = pathSegments[index];
const placeholder = /\{[^}]+\}/u.exec(resourceSegment);
if (!placeholder || !pathSegment) {
continue;
}
if (resourceSegment.endsWith(".json") && pathSegment.endsWith(".json")) {
return decodeURIComponent(pathSegment.slice(0, -5));
}
if (resourceSegment.endsWith(".md") && pathSegment.endsWith(".md")) {
return decodeURIComponent(pathSegment.slice(0, -3));
}
return decodeURIComponent(pathSegment);
}
return undefined;
}

function isReservedWritebackFilename(stem: string): boolean {
return (
stem === ".schema" ||
Expand Down
33 changes: 33 additions & 0 deletions packages/github/src/__tests__/emit-auxiliary-files.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -351,10 +351,43 @@ describe('emitGitHubAuxiliaryFiles', () => {
assert.ok(writtenPaths.includes(githubByEditedAliasPath('acme', 'widgets', 'issues', '2026-05-12', 7)));
assert.ok(writtenPaths.includes(indexPath));

const canonicalBytes = client.files.get(githubIssuePath('acme', 'widgets', 7, 'Bug report'));
assert.equal(client.files.get(githubByIdAliasPath('acme', 'widgets', 'issues', 7)), canonicalBytes);
assert.equal(
client.files.get(githubNumberedByTitleAliasPath('acme', 'widgets', 'issues', 'Bug report', 7)),
canonicalBytes,
);
assert.equal(client.files.get(githubByStateAliasPath('acme', 'widgets', 'issues', 'open', 7)), canonicalBytes);
assert.equal(client.files.get(githubByEditedAliasPath('acme', 'widgets', 'issues', '2026-05-12', 7)), canonicalBytes);

// No writes leaked into the pulls index for this repo.
assert.ok(!writtenPaths.includes(githubRepoPullsIndexPath('acme', 'widgets')));
});

it('uses the newest lifecycle timestamp for PR by-edited aliases', async () => {
const client = createClient();

await emitGitHubAuxiliaryFiles(client, {
workspaceId: 'ws-1',
pullRequests: [
{
owner: 'acme',
repo: 'widgets',
number: 42,
title: 'Follow-up after merge',
state: 'closed',
merged_at: '2026-05-12T00:00:00Z',
closed_at: '2026-05-12T00:00:00Z',
updated_at: '2026-05-13T00:00:00Z',
},
],
});

const writtenPaths = client.writes.map((w) => w.path);
assert.ok(writtenPaths.includes(githubByEditedAliasPath('acme', 'widgets', 'pulls', '2026-05-13', 42)));
assert.ok(!writtenPaths.includes(githubByEditedAliasPath('acme', 'widgets', 'pulls', '2026-05-12', 42)));
});

it('writes distinct by-title aliases for duplicate issue titles and keeps cleanup scoped by number', async () => {
const client = createClient();
await emitGitHubAuxiliaryFiles(client, {
Expand Down
18 changes: 12 additions & 6 deletions packages/github/src/emit-auxiliary-files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1090,12 +1090,18 @@ function readUpdatedAt(record: Record<string, unknown>): string {
}

function readLifecycleEditedAt(record: Record<string, unknown>): string | undefined {
return (
readNonEmptyString(record.merged_at) ??
readNonEmptyString(record.closed_at) ??
readNonEmptyString(record.updated_at) ??
readNonEmptyString(record.updatedAt)
);
const candidates = [
readNonEmptyString(record.merged_at),
readNonEmptyString(record.closed_at),
readNonEmptyString(record.updated_at),
readNonEmptyString(record.updatedAt),
]
.filter((value): value is string => Boolean(value))
.map((value) => ({ value, timestamp: Date.parse(value) }))
.filter((entry) => Number.isFinite(entry.timestamp))
.sort((left, right) => right.timestamp - left.timestamp);

return candidates[0]?.value;
}

function editedDateSegment(value: string | undefined): string | undefined {
Expand Down
6 changes: 3 additions & 3 deletions packages/gitlab/discovery/gitlab/.adapter.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,11 +39,12 @@ Resource: `/gitlab/projects/{projectPath}/merge_requests/{mergeRequestIid}__{slu
Schema: `/gitlab/projects/{projectPath}/merge_requests/{mergeRequestIid}__{slug}/discussions/.schema.json`
Create example: `/gitlab/projects/{projectPath}/merge_requests/{mergeRequestIid}__{slug}/discussions/.create.example.json`
Required fields: `body`.
Optional fields: `created_at`.
Optional fields: `position`, `created_at`.

Fields:

- `body` (required, string) - Markdown note body.
- `position` (optional, object) - Optional GitLab position object for diff discussions.
- `created_at` (optional, string, date-time) - Optional timestamp for imports when supported by GitLab.

### Create GitLab issue note
Expand All @@ -52,12 +53,11 @@ Resource: `/gitlab/projects/{projectPath}/issues/{issueIid}__{slug}/comments/<id
Schema: `/gitlab/projects/{projectPath}/issues/{issueIid}__{slug}/comments/.schema.json`
Create example: `/gitlab/projects/{projectPath}/issues/{issueIid}__{slug}/comments/.create.example.json`
Required fields: `body`.
Optional fields: `position`, `created_at`.
Optional fields: `created_at`.

Fields:

- `body` (required, string) - Markdown note body.
- `position` (optional, object) - Optional GitLab position object for diff discussions.
- `created_at` (optional, string, date-time) - Optional timestamp for imports when supported by GitLab.

## Create Examples
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,20 +8,17 @@
"properties": {
"body": {
"type": "string",
"description": "Markdown note body.",
"minLength": 1,
"pattern": ".*\\S.*",
"description": "Markdown note body."
"pattern": ".*\\S.*"
},
"created_at": {
"type": "string",
"format": "date-time",
"description": "Optional timestamp for imports when supported by GitLab."
},
"id": {
"type": [
"integer",
"string"
],
"type": "string",
"description": "Provider canonical record id.",
"readOnly": true
},
Expand Down Expand Up @@ -86,6 +83,6 @@
"additionalProperties": true
}
},
"additionalProperties": true,
"additionalProperties": false,
"description": "Full resource record schema. Fields marked readOnly are synced from the provider and cannot be written by agents."
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@
"properties": {
"body": {
"type": "string",
"description": "Markdown note body.",
"minLength": 1,
"pattern": ".*\\S.*",
"description": "Markdown note body."
"pattern": ".*\\S.*"
},
"position": {
"type": "object",
Expand Down
Loading
Loading