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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ published version with a date and open a fresh empty `[Unreleased]` above it.

### Fixed

- `@relayfile/adapter-shortcut` now keeps supported story and epic actions from bundled webhook deliveries that also contain unsupported Shortcut entities, and exposes skipped event types for delivery diagnostics. **Breaking (0.2.0):** `ShortcutNormalizedWebhook` gains a required `skippedEventTypes: string[]`; consumers that construct the type by hand must supply it (`normalizeShortcutWebhook` always does).
- `@relayfile/adapter-github` pull-index convergence now fails closed when a page cannot be persisted and reports fetched versus persisted row counts separately, preventing exhausted CAS writes from appearing complete.
- `@relayfile/adapter-linear` issue creates now accept synced `team.id` and label ids directly, resolve `team.key`/`team.name` plus label names through mounted team/label indexes, and keep explicit `teamId`/`labelIds` authoritative; adapter-core now enforces the schema's at-least-one team reference.
- `@relayfile/adapter-github` now backfills label names into legacy issue `_index.json` rows from materialized issue artifacts, keeping label-filtered consumers on the index-only path.
Expand Down
2 changes: 1 addition & 1 deletion packages/shortcut/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@relayfile/adapter-shortcut",
"version": "0.1.2",
"version": "0.2.0",
"description": "Shortcut adapter package for Relayfile",
"type": "module",
"main": "dist/index.js",
Expand Down
30 changes: 29 additions & 1 deletion packages/shortcut/src/shortcut.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,9 +52,37 @@ describe("Shortcut adapter", () => {
]);
});

it("keeps a supported story create when a bundled action is unsupported", () => {
const normalized = normalizeShortcutWebhook({
actions: [
{ id: 35, entity_type: "story", action: "create" },
{ id: 9, entity_type: "label", action: "create" },
],
});

assert.deepEqual(normalized.actions.map((action) => action.eventType), ["story.create"]);
assert.deepEqual(normalized.skippedEventTypes, ["label.create"]);
});

it("keeps a supported story update after unsupported bundled actions", () => {
const normalized = normalizeShortcutWebhook({
actions: [
{ id: 8, entity_type: "pull-request", action: "update" },
{ id: 7, entity_type: "branch", action: "push" },
{ id: 35, entity_type: "story", action: "update" },
],
});

assert.deepEqual(normalized.actions.map((action) => action.eventType), ["story.update"]);
assert.deepEqual(normalized.skippedEventTypes, ["pull-request.update", "branch.push"]);
});

it("rejects malformed and unsupported webhook actions consistently", () => {
assert.throws(() => normalizeShortcutWebhook({ action: { id: 1, entity_type: "story", action: "archive" } }));
assert.throws(() => normalizeShortcutWebhook({ action: { id: 1, entity_type: "unknown", action: "update" } }));
assert.throws(
() => normalizeShortcutWebhook({ action: { id: 1, entity_type: "unknown", action: "update" } }),
/Unsupported Shortcut webhook event: unknown\.update; no supported actions; skipped event types: unknown\.update/,
);
assert.throws(() => normalizeShortcutWebhook({ actions: [{ id: 1, entity_type: "story" }] }));
});

Expand Down
41 changes: 36 additions & 5 deletions packages/shortcut/src/webhook-normalizer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,11 +38,32 @@ export interface ShortcutNormalizedWebhook {
eventId?: string;
changedAt?: string;
actions: ShortcutNormalizedWebhookAction[];
/**
* Valid Shortcut actions that do not produce a Relayfile trigger. These are
* retained so callers can log an otherwise partial webhook delivery.
*
* Required, not optional: `normalizeShortcutWebhook` always populates it, and
* a consumer reading the result must be able to rely on that. Adding it was
* released as a breaking change (0.2.0) for anyone constructing this type
* by hand.
*/
skippedEventTypes: string[];
Comment thread
coderabbitai[bot] marked this conversation as resolved.
headers: Record<string, string>;
connectionId?: string;
deliveryId?: string;
}

/**
* Creates the error used when a Shortcut webhook contains actions but none
* match the events this adapter supports.
*/
export function unsupportedShortcutWebhookEventError(unsupportedEventTypes: readonly string[]): Error {
const [firstUnsupported] = unsupportedEventTypes;
return new Error(
`Unsupported Shortcut webhook event: ${firstUnsupported}; no supported actions; skipped event types: ${unsupportedEventTypes.join(", ")}`,
);
}

export function normalizeShortcutWebhook(
rawPayload: unknown,
headers: ShortcutWebhookHeaders = {},
Expand All @@ -52,15 +73,20 @@ export function normalizeShortcutWebhook(
if (!payload) throw new Error("Shortcut webhook payload must be a JSON object");

const normalizedHeaders = normalizeHeaders(headers);
const actions = readActions(payload).map((action) => {
const skippedEventTypes: string[] = [];
const actions = readActions(payload).flatMap((action) => {
const objectType = String(action.entity_type ?? "").trim().toLowerCase();
const verb = String(action.action ?? "").trim().toLowerCase();
const objectId = String(action.id ?? "").trim();
if (!objectType || !verb || !objectId) {
throw new Error("Shortcut webhook action must include entity_type, action, and id");
}
const eventType = normalizeEventType(objectType, verb);
return {
if (!eventType) {
skippedEventTypes.push(`${objectType}.${verb}`);
return [];
}
return [{
provider: SHORTCUT_PROVIDER as typeof SHORTCUT_PROVIDER,
eventType,
action: verb,
Expand All @@ -69,14 +95,19 @@ export function normalizeShortcutWebhook(
payload: { ...payload, action: { ...action } },
...(options.connectionId ? { connectionId: options.connectionId } : {}),
...(options.deliveryId ? { deliveryId: options.deliveryId } : {}),
};
}];
});

if (actions.length === 0 && skippedEventTypes.length > 0) {
throw unsupportedShortcutWebhookEventError(skippedEventTypes);
}

return {
provider: SHORTCUT_PROVIDER,
eventId: readString(payload, "id"),
changedAt: readString(payload, "changed_at"),
actions,
skippedEventTypes,
headers: normalizedHeaders,
...(options.connectionId ? { connectionId: options.connectionId } : {}),
...(options.deliveryId ? { deliveryId: options.deliveryId } : {}),
Expand All @@ -98,14 +129,14 @@ function readActions(payload: Record<string, unknown>): ShortcutWebhookAction[]
});
}

function normalizeEventType(objectType: string, verb: string): string {
function normalizeEventType(objectType: string, verb: string): string | undefined {
const direct = `${objectType}.${verb}`;
if ((SHORTCUT_SUPPORTED_EVENTS as readonly string[]).includes(direct)) return direct;
const parentType = NESTED_PARENT_TYPES[objectType];
if (parentType && ["create", "update", "delete"].includes(verb)) {
return `${parentType}.update`;
}
throw new Error(`Unsupported Shortcut webhook event: ${direct}`);
return undefined;
}

function isWebhookAction(value: Record<string, unknown>): boolean {
Expand Down
Loading