improved a lot of things
This commit is contained in:
@@ -0,0 +1,84 @@
|
||||
import { z } from 'zod';
|
||||
import type { OriginIngester, IngestionResult } from '../types.js';
|
||||
|
||||
// ── GitHub Webhook payload ────────────────────────────────────────────────────
|
||||
// Handles pull_request events (opened, synchronize, reopened).
|
||||
// Ref: https://docs.github.com/en/webhooks/webhook-events-and-payloads#pull_request
|
||||
|
||||
const GitHubUserSchema = z.object({
|
||||
login: z.string(),
|
||||
html_url: z.string().url(),
|
||||
});
|
||||
|
||||
const GitHubRepositorySchema = z.object({
|
||||
id: z.number(),
|
||||
full_name: z.string(),
|
||||
html_url: z.string().url(),
|
||||
default_branch: z.string(),
|
||||
});
|
||||
|
||||
const GitHubPullRequestPayloadSchema = z.object({
|
||||
action: z.enum(['opened', 'synchronize', 'reopened', 'closed']),
|
||||
number: z.number().int(),
|
||||
pull_request: z.object({
|
||||
id: z.number(),
|
||||
number: z.number().int(),
|
||||
title: z.string(),
|
||||
html_url: z.string().url(),
|
||||
state: z.enum(['open', 'closed']),
|
||||
head: z.object({
|
||||
sha: z.string().length(40),
|
||||
ref: z.string(),
|
||||
label: z.string(),
|
||||
}),
|
||||
base: z.object({
|
||||
sha: z.string().length(40),
|
||||
ref: z.string(),
|
||||
}),
|
||||
user: GitHubUserSchema,
|
||||
body: z.string().nullable().optional(),
|
||||
draft: z.boolean().optional(),
|
||||
}),
|
||||
repository: GitHubRepositorySchema,
|
||||
sender: GitHubUserSchema,
|
||||
});
|
||||
|
||||
export type GitHubPullRequestPayload = z.infer<typeof GitHubPullRequestPayloadSchema>;
|
||||
|
||||
// ── Connector ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export const githubIngester: OriginIngester<GitHubPullRequestPayload> = {
|
||||
parsePayload(raw) {
|
||||
return GitHubPullRequestPayloadSchema.parse(raw);
|
||||
},
|
||||
|
||||
ingest(payload): IngestionResult {
|
||||
const { pull_request: pr, repository } = payload;
|
||||
|
||||
// The render URL points to the head commit's deployed preview if available.
|
||||
// Conventionally: https://<pr-number>.<preview-domain> — caller overrides as needed.
|
||||
const renderUrl = pr.html_url;
|
||||
|
||||
return {
|
||||
origin: {
|
||||
type: 'GIT_COMMIT',
|
||||
source_ref: pr.head.sha,
|
||||
source_metadata_jsonb: {
|
||||
pr_number: pr.number,
|
||||
pr_title: pr.title,
|
||||
pr_url: pr.html_url,
|
||||
head_sha: pr.head.sha,
|
||||
head_ref: pr.head.ref,
|
||||
base_sha: pr.base.sha,
|
||||
base_ref: pr.base.ref,
|
||||
repo: repository.full_name,
|
||||
author: pr.user.login,
|
||||
...(pr.body !== undefined && pr.body !== null ? { body: pr.body } : {}),
|
||||
...(pr.draft !== undefined ? { draft: pr.draft } : {}),
|
||||
},
|
||||
},
|
||||
artboardTitle: `PR #${pr.number}: ${pr.title}`,
|
||||
renderUrl,
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,109 @@
|
||||
import { z } from 'zod';
|
||||
import type { OriginIngester, IngestionResult } from '../types.js';
|
||||
|
||||
// ── Intercom Webhook payload ──────────────────────────────────────────────────
|
||||
// Handles conversation.user.created events where users report UI issues.
|
||||
// Intercom sends annotated screenshots as file_url attachments.
|
||||
// Ref: https://developers.intercom.com/docs/references/webhooks/conversation/
|
||||
|
||||
const IntercomAttachmentSchema = z.object({
|
||||
type: z.literal('upload'),
|
||||
name: z.string(),
|
||||
url: z.string().url(),
|
||||
content_type: z.string().optional(),
|
||||
filesize: z.number().optional(),
|
||||
width: z.number().optional(),
|
||||
height: z.number().optional(),
|
||||
});
|
||||
|
||||
const IntercomUserSchema = z.object({
|
||||
type: z.enum(['user', 'lead']),
|
||||
id: z.string(),
|
||||
email: z.string().email().optional(),
|
||||
name: z.string().optional(),
|
||||
});
|
||||
|
||||
const IntercomConversationPartSchema = z.object({
|
||||
type: z.literal('conversation_part'),
|
||||
body: z.string().nullable().optional(),
|
||||
attachments: z.array(IntercomAttachmentSchema).optional(),
|
||||
author: z.object({
|
||||
type: z.string(),
|
||||
id: z.string(),
|
||||
email: z.string().optional(),
|
||||
name: z.string().optional(),
|
||||
}).optional(),
|
||||
});
|
||||
|
||||
const IntercomWebhookPayloadSchema = z.object({
|
||||
type: z.literal('notification_event'),
|
||||
topic: z.string(),
|
||||
data: z.object({
|
||||
type: z.literal('notification_event_data'),
|
||||
item: z.object({
|
||||
type: z.literal('conversation'),
|
||||
id: z.string(),
|
||||
created_at: z.number(),
|
||||
source: z.object({
|
||||
type: z.string(),
|
||||
subject: z.string().optional(),
|
||||
body: z.string().nullable().optional(),
|
||||
attachments: z.array(IntercomAttachmentSchema).optional(),
|
||||
author: IntercomUserSchema.optional(),
|
||||
}),
|
||||
conversation_parts: z.object({
|
||||
conversation_parts: z.array(IntercomConversationPartSchema).optional(),
|
||||
}).optional(),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
export type IntercomWebhookPayload = z.infer<typeof IntercomWebhookPayloadSchema>;
|
||||
|
||||
// ── Connector ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export const intercomIngester: OriginIngester<IntercomWebhookPayload> = {
|
||||
parsePayload(raw) {
|
||||
return IntercomWebhookPayloadSchema.parse(raw);
|
||||
},
|
||||
|
||||
ingest(payload): IngestionResult {
|
||||
const { item } = payload.data;
|
||||
const { source } = item;
|
||||
|
||||
// Prefer annotated screenshot from the source message
|
||||
const imageAttachment = source.attachments?.find(a =>
|
||||
a.content_type?.startsWith('image/')
|
||||
);
|
||||
|
||||
const authorName = source.author?.name ?? source.author?.email ?? 'Unknown user';
|
||||
const subject = source.subject ?? `User report from ${authorName}`;
|
||||
|
||||
return {
|
||||
origin: {
|
||||
type: 'URL',
|
||||
source_ref: item.id,
|
||||
source_metadata_jsonb: {
|
||||
conversation_id: item.id,
|
||||
topic: payload.topic,
|
||||
subject,
|
||||
body: source.body ?? '',
|
||||
created_at: item.created_at,
|
||||
author: {
|
||||
...(source.author?.id !== undefined ? { id: source.author.id } : {}),
|
||||
...(source.author?.email !== undefined ? { email: source.author.email } : {}),
|
||||
...(source.author?.name !== undefined ? { name: source.author.name } : {}),
|
||||
},
|
||||
...(imageAttachment !== undefined ? {
|
||||
screenshot_url: imageAttachment.url,
|
||||
screenshot_name: imageAttachment.name,
|
||||
...(imageAttachment.width !== undefined ? { width: imageAttachment.width } : {}),
|
||||
...(imageAttachment.height !== undefined ? { height: imageAttachment.height } : {}),
|
||||
} : {}),
|
||||
},
|
||||
},
|
||||
artboardTitle: `Intercom: ${subject}`,
|
||||
...(imageAttachment !== undefined ? { renderUrl: imageAttachment.url } : {}),
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,65 @@
|
||||
import { z } from 'zod';
|
||||
import type { OriginIngester, IngestionResult } from '../types.js';
|
||||
|
||||
// ── Linear webhook payload ────────────────────────────────────────────────────
|
||||
// Fired on Issue create/update events.
|
||||
|
||||
const LinearAttachmentSchema = z.object({
|
||||
url: z.string().url().optional(),
|
||||
title: z.string().optional(),
|
||||
});
|
||||
|
||||
const LinearIssuePayloadSchema = z.object({
|
||||
action: z.enum(['create', 'update', 'remove']),
|
||||
type: z.literal('Issue'),
|
||||
data: z.object({
|
||||
id: z.string(),
|
||||
identifier: z.string(),
|
||||
title: z.string(),
|
||||
description: z.string().optional(),
|
||||
url: z.string().url(),
|
||||
priority: z.number().int().min(0).max(4).optional(),
|
||||
state: z.object({ name: z.string() }).optional(),
|
||||
assignee: z.object({ name: z.string(), email: z.string() }).optional(),
|
||||
attachments: z.array(LinearAttachmentSchema).optional(),
|
||||
team: z.object({ id: z.string(), name: z.string() }),
|
||||
}),
|
||||
updatedFrom: z.record(z.unknown()).optional(),
|
||||
});
|
||||
|
||||
export type LinearIssuePayload = z.infer<typeof LinearIssuePayloadSchema>;
|
||||
|
||||
// ── Connector ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export const linearIngester: OriginIngester<LinearIssuePayload> = {
|
||||
parsePayload(raw) {
|
||||
return LinearIssuePayloadSchema.parse(raw);
|
||||
},
|
||||
|
||||
ingest(payload): IngestionResult {
|
||||
const { data } = payload;
|
||||
|
||||
const attachment = data.attachments?.find(a => a.url !== undefined);
|
||||
const renderUrl = attachment?.url ?? data.url;
|
||||
|
||||
return {
|
||||
origin: {
|
||||
type: 'LINEAR_ISSUE',
|
||||
source_ref: data.identifier,
|
||||
source_metadata_jsonb: {
|
||||
id: data.id,
|
||||
identifier: data.identifier,
|
||||
title: data.title,
|
||||
url: data.url,
|
||||
...(data.description !== undefined ? { description: data.description } : {}),
|
||||
...(data.priority !== undefined ? { priority: data.priority } : {}),
|
||||
...(data.state !== undefined ? { state: data.state.name } : {}),
|
||||
...(data.assignee !== undefined ? { assignee: data.assignee.name } : {}),
|
||||
team: data.team.name,
|
||||
},
|
||||
},
|
||||
artboardTitle: `${data.identifier}: ${data.title}`,
|
||||
renderUrl,
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,74 @@
|
||||
import { z } from 'zod';
|
||||
import type { OriginIngester, IngestionResult } from '../types.js';
|
||||
|
||||
// ── Slack Event API payload ───────────────────────────────────────────────────
|
||||
// Sent when a message is posted to a channel the app is subscribed to.
|
||||
// Ref: https://api.slack.com/events/message
|
||||
|
||||
const SlackFileSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string().optional(),
|
||||
mimetype: z.string().optional(),
|
||||
url_private: z.string().optional(),
|
||||
permalink: z.string().optional(),
|
||||
});
|
||||
|
||||
const SlackMessageEventSchema = z.object({
|
||||
type: z.literal('event_callback'),
|
||||
event_id: z.string(),
|
||||
team_id: z.string(),
|
||||
event: z.object({
|
||||
type: z.literal('message'),
|
||||
channel: z.string(),
|
||||
channel_name: z.string().optional(),
|
||||
user: z.string(),
|
||||
text: z.string(),
|
||||
ts: z.string(),
|
||||
thread_ts: z.string().optional(),
|
||||
files: z.array(SlackFileSchema).optional(),
|
||||
}),
|
||||
authorizations: z.array(z.object({ user_id: z.string() })).optional(),
|
||||
});
|
||||
|
||||
export type SlackMessagePayload = z.infer<typeof SlackMessageEventSchema>;
|
||||
|
||||
// ── Connector ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export const slackIngester: OriginIngester<SlackMessagePayload> = {
|
||||
parsePayload(raw) {
|
||||
return SlackMessageEventSchema.parse(raw);
|
||||
},
|
||||
|
||||
ingest(payload): IngestionResult {
|
||||
const { event, team_id } = payload;
|
||||
|
||||
// Extract first image attachment as the render target
|
||||
const imageFile = event.files?.find(f =>
|
||||
f.mimetype?.startsWith('image/') && f.url_private !== undefined
|
||||
);
|
||||
const renderUrl = imageFile?.url_private ?? imageFile?.permalink;
|
||||
|
||||
const channelLabel = event.channel_name ?? event.channel;
|
||||
const unixTs = parseFloat(event.ts);
|
||||
const date = new Date(unixTs * 1000).toISOString().slice(0, 10);
|
||||
|
||||
return {
|
||||
origin: {
|
||||
type: 'SLACK_MESSAGE',
|
||||
source_ref: `${event.channel}:${event.ts}`,
|
||||
source_metadata_jsonb: {
|
||||
team_id,
|
||||
channel: event.channel,
|
||||
channel_name: channelLabel,
|
||||
user: event.user,
|
||||
text: event.text,
|
||||
ts: event.ts,
|
||||
...(event.thread_ts !== undefined ? { thread_ts: event.thread_ts } : {}),
|
||||
...(imageFile !== undefined ? { image_file_id: imageFile.id } : {}),
|
||||
},
|
||||
},
|
||||
artboardTitle: `Slack: #${channelLabel} (${date})`,
|
||||
...(renderUrl !== undefined ? { renderUrl } : {}),
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -1 +1,14 @@
|
||||
export {};
|
||||
export type { IngestionResult, OriginIngester, WebhookEnvelope } from './types.js';
|
||||
export { WebhookEnvelopeSchema } from './types.js';
|
||||
|
||||
export type { LinearIssuePayload } from './connectors/linear.js';
|
||||
export { linearIngester } from './connectors/linear.js';
|
||||
|
||||
export type { SlackMessagePayload } from './connectors/slack.js';
|
||||
export { slackIngester } from './connectors/slack.js';
|
||||
|
||||
export type { GitHubPullRequestPayload } from './connectors/github.js';
|
||||
export { githubIngester } from './connectors/github.js';
|
||||
|
||||
export type { IntercomWebhookPayload } from './connectors/intercom.js';
|
||||
export { intercomIngester } from './connectors/intercom.js';
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { z } from 'zod';
|
||||
import type { InsertOrigin } from '@originmain/origin-graph';
|
||||
|
||||
// ── Core ingester interface ───────────────────────────────────────────────────
|
||||
|
||||
export interface IngestionResult {
|
||||
origin: InsertOrigin;
|
||||
/** Human-readable label used as the artboard title */
|
||||
artboardTitle: string;
|
||||
/** URL that the Live Artboard renderer should load, if applicable */
|
||||
renderUrl?: string;
|
||||
}
|
||||
|
||||
export interface OriginIngester<TPayload> {
|
||||
/** Validates and parses the raw webhook payload. Throws ZodError on invalid input. */
|
||||
parsePayload(raw: unknown): TPayload;
|
||||
/** Converts a validated payload into an IngestionResult. */
|
||||
ingest(payload: TPayload): IngestionResult;
|
||||
}
|
||||
|
||||
// ── Shared helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
export const WebhookEnvelopeSchema = z.object({
|
||||
timestamp: z.string().optional(),
|
||||
signature: z.string().optional(),
|
||||
});
|
||||
|
||||
export type WebhookEnvelope = z.infer<typeof WebhookEnvelopeSchema>;
|
||||
Reference in New Issue
Block a user