@useairfoil/producer-linear 0.0.0-stage → 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/README.md +70 -2
  2. package/dist/index.d.mts +4027 -0
  3. package/dist/index.mjs +3 -0
  4. package/dist/main.d.mts +1 -0
  5. package/dist/main.mjs +1303 -0
  6. package/dist/main.mjs.map +1 -0
  7. package/dist/manifest.d.mts +114 -0
  8. package/dist/manifest.mjs +161 -0
  9. package/dist/manifest.mjs.map +1 -0
  10. package/dist/rolldown-runtime-D7D4PA-g.mjs +13 -0
  11. package/dist/src-Ci3_ltan.mjs +1691 -0
  12. package/dist/src-Ci3_ltan.mjs.map +1 -0
  13. package/package.json +48 -4
  14. package/src/client/client.ts +200 -0
  15. package/src/connector.ts +151 -0
  16. package/src/create-table.ts +5 -0
  17. package/src/demo/index.ts +137 -0
  18. package/src/demo/initiatives.ts +116 -0
  19. package/src/demo/issues.ts +320 -0
  20. package/src/demo/projects.ts +215 -0
  21. package/src/demo/setup.ts +151 -0
  22. package/src/demo/shared.ts +122 -0
  23. package/src/demo/teams.ts +173 -0
  24. package/src/index.ts +83 -0
  25. package/src/main.ts +22 -0
  26. package/src/manifest.ts +58 -0
  27. package/src/resources/comments.ts +32 -0
  28. package/src/resources/customers.ts +105 -0
  29. package/src/resources/documents.ts +31 -0
  30. package/src/resources/initiatives.ts +136 -0
  31. package/src/resources/issue-history.ts +119 -0
  32. package/src/resources/issues.ts +131 -0
  33. package/src/resources/pages.ts +264 -0
  34. package/src/resources/projects.ts +161 -0
  35. package/src/resources/releases.ts +114 -0
  36. package/src/resources/teams.ts +140 -0
  37. package/src/sandbox.ts +35 -0
  38. package/src/schemas/comments.ts +32 -0
  39. package/src/schemas/customers.ts +80 -0
  40. package/src/schemas/documents.ts +32 -0
  41. package/src/schemas/initiatives.ts +119 -0
  42. package/src/schemas/issues.ts +180 -0
  43. package/src/schemas/projects.ts +154 -0
  44. package/src/schemas/releases.ts +119 -0
  45. package/src/schemas/shared.ts +66 -0
  46. package/src/schemas/teams.ts +144 -0
  47. package/src/start.ts +47 -0
  48. package/src/tables.ts +83 -0
  49. package/src/webhook/route.ts +50 -0
  50. package/src/webhook/signature.ts +28 -0
@@ -0,0 +1,144 @@
1
+ import { Iceberg } from "@useairfoil/connector-kit";
2
+ import { Schema, Struct } from "effect";
3
+
4
+ import {
5
+ OptionalRef,
6
+ OptionalString,
7
+ OptionalTimestamp,
8
+ Ref,
9
+ Timestamp,
10
+ common,
11
+ deleted,
12
+ field,
13
+ } from "./shared";
14
+
15
+ export const TeamSchema = Schema.Struct({
16
+ ...common("Team"),
17
+ name: Schema.String.pipe(field(10, "Team name.")),
18
+ key: Schema.String.pipe(field(11, "Team key, used in issue IDs such as ENG-123.")),
19
+ display_name: Schema.String.pipe(field(12, "Name shown in Linear.")),
20
+ description: OptionalString.pipe(field(13, "Team description.")),
21
+ visibility: Schema.String.pipe(field(14, "Who can see the team.")),
22
+ parent_id: OptionalRef.pipe(field(15, "Parent team.")),
23
+ timezone: Schema.String.pipe(field(16, "Team timezone.")),
24
+ cycles_enabled: Schema.Boolean.pipe(field(17, "Whether the team uses cycles.")),
25
+ cycle_duration: Schema.Finite.pipe(field(18, "Cycle length in weeks.")),
26
+ cycle_cooldown_time: Schema.Finite.pipe(field(19, "Weeks between cycles.")),
27
+ cycle_start_day: Schema.Finite.pipe(field(20, "Day cycles start, 0 for Sunday.")),
28
+ triage_enabled: Schema.Boolean.pipe(field(21, "Whether the team uses triage.")),
29
+ issue_estimation_type: Schema.String.pipe(field(22, "Estimate scale.")),
30
+ default_issue_estimate: Schema.Finite.pipe(field(23, "Default estimate.")),
31
+ auto_archive_period: Schema.Finite.pipe(field(24, "Months before closed issues are archived.")),
32
+ auto_close_period: Schema.NullOr(Schema.Finite).pipe(
33
+ field(25, "Months before stale issues are closed."),
34
+ ),
35
+ retired_at: OptionalTimestamp.pipe(field(26, "Time the team was retired.")),
36
+ })
37
+ .annotate({ description: "Linear teams." })
38
+ .pipe(Iceberg.identifier("id"));
39
+
40
+ export type Team = Schema.Schema.Type<typeof TeamSchema>;
41
+
42
+ export const TeamObjectSchema = TeamSchema.mapFields((fields) => Struct.omit(fields, ["version"]));
43
+
44
+ export const TeamMembershipSchema = Schema.Struct({
45
+ ...common("Membership"),
46
+ team_id: Ref.pipe(field(10, "Team.")),
47
+ user_id: Ref.pipe(field(11, "User.")),
48
+ owner: Schema.Boolean.pipe(field(12, "Whether the user owns the team.")),
49
+ })
50
+ .annotate({ description: "Linear team members. Removed members aren't seen." })
51
+ .pipe(Iceberg.identifier("id"));
52
+
53
+ export type TeamMembership = Schema.Schema.Type<typeof TeamMembershipSchema>;
54
+
55
+ export const TeamMembershipObjectSchema = TeamMembershipSchema.mapFields((fields) =>
56
+ Struct.omit(fields, ["version"]),
57
+ );
58
+
59
+ export const WorkflowStateSchema = Schema.Struct({
60
+ ...common("Workflow state"),
61
+ name: Schema.String.pipe(field(10, "State name.")),
62
+ type: Schema.String.pipe(
63
+ field(11, "State type: triage, backlog, unstarted, started, completed, or canceled."),
64
+ ),
65
+ color: Schema.String.pipe(field(12, "State color, as hex.")),
66
+ description: OptionalString.pipe(field(13, "State description.")),
67
+ position: Schema.Finite.pipe(field(14, "Order within the team.")),
68
+ team_id: Ref.pipe(field(15, "Team.")),
69
+ })
70
+ .annotate({ description: "Linear workflow states. Names the IDs in `issues.state_id`." })
71
+ .pipe(Iceberg.identifier("id"));
72
+
73
+ export type WorkflowState = Schema.Schema.Type<typeof WorkflowStateSchema>;
74
+
75
+ export const WorkflowStateObjectSchema = WorkflowStateSchema.mapFields((fields) =>
76
+ Struct.omit(fields, ["version"]),
77
+ );
78
+
79
+ export const UserSchema = Schema.Struct({
80
+ ...common("User"),
81
+ _deleted: deleted("user"),
82
+ name: Schema.String.pipe(field(10, "Full name.")),
83
+ display_name: Schema.String.pipe(field(11, "Display name.")),
84
+ email: Schema.String.pipe(field(12, "Email address.")),
85
+ avatar_url: OptionalString.pipe(field(13, "Avatar image URL.")),
86
+ title: OptionalString.pipe(field(14, "Job title.")),
87
+ timezone: OptionalString.pipe(field(15, "Timezone.")),
88
+ active: Schema.Boolean.pipe(field(16, "Whether the user is active.")),
89
+ admin: Schema.Boolean.pipe(field(17, "Whether the user is an admin.")),
90
+ owner: Schema.Boolean.pipe(field(18, "Whether the user owns the workspace.")),
91
+ guest: Schema.Boolean.pipe(field(19, "Whether the user is a guest.")),
92
+ app: Schema.Boolean.pipe(field(20, "Whether the user is an app or agent.")),
93
+ last_seen: OptionalTimestamp.pipe(field(21, "Time the user was last online.")),
94
+ url: Schema.String.pipe(field(22, "Profile URL.")),
95
+ })
96
+ .annotate({ description: "Linear users." })
97
+ .pipe(Iceberg.identifier("id"));
98
+
99
+ export type User = Schema.Schema.Type<typeof UserSchema>;
100
+
101
+ export const UserObjectSchema = UserSchema.mapFields((fields) =>
102
+ Struct.omit(fields, ["version", "_deleted"]),
103
+ );
104
+
105
+ export const ExternalUserSchema = Schema.Struct({
106
+ ...common("External user"),
107
+ name: Schema.String.pipe(field(10, "Name.")),
108
+ display_name: Schema.String.pipe(field(11, "Display name.")),
109
+ email: OptionalString.pipe(field(12, "Email address.")),
110
+ avatar_url: OptionalString.pipe(field(13, "Avatar image URL.")),
111
+ last_seen: OptionalTimestamp.pipe(field(14, "Time the user was last seen.")),
112
+ })
113
+ .annotate({
114
+ description: "People from integrations, such as Slack, who create issues and comments.",
115
+ })
116
+ .pipe(Iceberg.identifier("id"));
117
+
118
+ export type ExternalUser = Schema.Schema.Type<typeof ExternalUserSchema>;
119
+
120
+ export const ExternalUserObjectSchema = ExternalUserSchema.mapFields((fields) =>
121
+ Struct.omit(fields, ["version"]),
122
+ );
123
+
124
+ export const CycleSchema = Schema.Struct({
125
+ ...common("Cycle"),
126
+ _deleted: deleted("cycle"),
127
+ number: Schema.Finite.pipe(field(10, "Cycle number within its team.")),
128
+ name: OptionalString.pipe(field(11, "Cycle name.")),
129
+ description: OptionalString.pipe(field(12, "Cycle description.")),
130
+ team_id: Ref.pipe(field(13, "Team.")),
131
+ starts_at: Timestamp.pipe(field(14, "Time the cycle starts.")),
132
+ ends_at: Timestamp.pipe(field(15, "Time the cycle ends.")),
133
+ completed_at: OptionalTimestamp.pipe(field(16, "Time the cycle was completed.")),
134
+ auto_archived_at: OptionalTimestamp.pipe(field(17, "Time the cycle was archived automatically.")),
135
+ progress: Schema.Finite.pipe(field(18, "Share of the scope completed, from 0 to 1.")),
136
+ })
137
+ .annotate({ description: "Linear cycles." })
138
+ .pipe(Iceberg.identifier("id"));
139
+
140
+ export type Cycle = Schema.Schema.Type<typeof CycleSchema>;
141
+
142
+ export const CycleObjectSchema = CycleSchema.mapFields((fields) =>
143
+ Struct.omit(fields, ["version", "_deleted"]),
144
+ );
package/src/start.ts ADDED
@@ -0,0 +1,47 @@
1
+ import { PgClient } from "@effect/sql-pg";
2
+ import {
3
+ ConnectorApp,
4
+ Ingestor,
5
+ RuntimeConfig,
6
+ StateStore,
7
+ Telemetry,
8
+ } from "@useairfoil/connector-kit";
9
+ import { Config, Effect, Layer, Logger, Schema } from "effect";
10
+ import { Command } from "effect/cli";
11
+
12
+ import { LinearConnector } from "./index";
13
+
14
+ const ConnectorLayer = LinearConnector.layerConfig(LinearConnector.LinearConfigDef.config);
15
+ const TelemetryLayer = Telemetry.layerOtlp();
16
+ const PostgresLayer = PgClient.layerConfig({
17
+ url: Config.schema(
18
+ Schema.Redacted(Schema.NonEmptyString),
19
+ RuntimeConfig.PlatformRuntimeKey.postgresConnectionString,
20
+ ),
21
+ maxConnections: Config.succeed(2),
22
+ connectTimeout: Config.succeed("5 seconds"),
23
+ applicationName: Config.succeed("airfoil-producer-linear"),
24
+ });
25
+ const StateStoreLayer = StateStore.layerSql().pipe(Layer.provide(PostgresLayer));
26
+
27
+ export const startCommand = Command.make("start", {}, () =>
28
+ Effect.gen(function* () {
29
+ const port = yield* RuntimeConfig.httpPort;
30
+ const entrypoint = yield* LinearConnector.LinearConnector;
31
+
32
+ return yield* ConnectorApp.start(entrypoint, { port }).pipe(
33
+ Effect.provide(Ingestor.layerWingsConfig(entrypoint)),
34
+ );
35
+ }).pipe(
36
+ Effect.annotateLogs({ component: "linear" }),
37
+ Effect.provide(
38
+ Layer.mergeAll(
39
+ StateStoreLayer,
40
+ ConnectorLayer,
41
+ Logger.layer([Logger.consolePretty()]),
42
+ TelemetryLayer,
43
+ ),
44
+ ),
45
+ Effect.provide(RuntimeConfig.layerHosted()),
46
+ ),
47
+ ).pipe(Command.withDescription("Run the production connector against Wings"));
package/src/tables.ts ADDED
@@ -0,0 +1,83 @@
1
+ import type { Schema } from "effect";
2
+
3
+ import { CommentSchema } from "./schemas/comments";
4
+ import {
5
+ CustomerNeedSchema,
6
+ CustomerSchema,
7
+ CustomerStatusSchema,
8
+ CustomerTierSchema,
9
+ } from "./schemas/customers";
10
+ import { DocumentSchema } from "./schemas/documents";
11
+ import {
12
+ InitiativeLabelSchema,
13
+ InitiativeRelationSchema,
14
+ InitiativeSchema,
15
+ InitiativeToProjectSchema,
16
+ InitiativeUpdateSchema,
17
+ } from "./schemas/initiatives";
18
+ import {
19
+ AttachmentSchema,
20
+ IssueHistorySchema,
21
+ IssueLabelSchema,
22
+ IssueRelationSchema,
23
+ IssueSchema,
24
+ } from "./schemas/issues";
25
+ import {
26
+ ProjectLabelSchema,
27
+ ProjectMilestoneSchema,
28
+ ProjectRelationSchema,
29
+ ProjectSchema,
30
+ ProjectStatusSchema,
31
+ ProjectUpdateSchema,
32
+ } from "./schemas/projects";
33
+ import {
34
+ IssueToReleaseSchema,
35
+ ReleaseNoteSchema,
36
+ ReleasePipelineSchema,
37
+ ReleaseSchema,
38
+ ReleaseStageSchema,
39
+ } from "./schemas/releases";
40
+ import {
41
+ CycleSchema,
42
+ ExternalUserSchema,
43
+ TeamMembershipSchema,
44
+ TeamSchema,
45
+ UserSchema,
46
+ WorkflowStateSchema,
47
+ } from "./schemas/teams";
48
+
49
+ export const tableSchemas: Readonly<Record<string, Schema.Top>> = {
50
+ issues: IssueSchema,
51
+ issue_history: IssueHistorySchema,
52
+ comments: CommentSchema,
53
+ issue_relations: IssueRelationSchema,
54
+ attachments: AttachmentSchema,
55
+ issue_labels: IssueLabelSchema,
56
+ workflow_states: WorkflowStateSchema,
57
+ teams: TeamSchema,
58
+ team_memberships: TeamMembershipSchema,
59
+ users: UserSchema,
60
+ external_users: ExternalUserSchema,
61
+ cycles: CycleSchema,
62
+ projects: ProjectSchema,
63
+ project_milestones: ProjectMilestoneSchema,
64
+ project_updates: ProjectUpdateSchema,
65
+ project_statuses: ProjectStatusSchema,
66
+ project_labels: ProjectLabelSchema,
67
+ project_relations: ProjectRelationSchema,
68
+ initiatives: InitiativeSchema,
69
+ initiative_updates: InitiativeUpdateSchema,
70
+ initiative_labels: InitiativeLabelSchema,
71
+ initiative_relations: InitiativeRelationSchema,
72
+ initiative_to_projects: InitiativeToProjectSchema,
73
+ documents: DocumentSchema,
74
+ customers: CustomerSchema,
75
+ customer_needs: CustomerNeedSchema,
76
+ customer_statuses: CustomerStatusSchema,
77
+ customer_tiers: CustomerTierSchema,
78
+ releases: ReleaseSchema,
79
+ release_notes: ReleaseNoteSchema,
80
+ release_pipelines: ReleasePipelineSchema,
81
+ release_stages: ReleaseStageSchema,
82
+ issue_to_releases: IssueToReleaseSchema,
83
+ };
@@ -0,0 +1,50 @@
1
+ import { type ResourceDefinition, Webhook } from "@useairfoil/connector-kit";
2
+ import { Effect, Result, Schema } from "effect";
3
+ import { HttpServerResponse } from "effect/http";
4
+
5
+ import { verifySignature } from "./signature";
6
+
7
+ export const webhookPath = "/webhooks/linear";
8
+
9
+ const WebhookEventSchema = Schema.Struct({
10
+ type: Schema.String,
11
+ action: Schema.String,
12
+ webhookTimestamp: Schema.Number,
13
+ data: Schema.Struct({ id: Schema.String }),
14
+ });
15
+
16
+ export const makeWebhookRoute = (options: {
17
+ readonly secret: string;
18
+ /** Resources by the event's `type`, such as `Issue`. */
19
+ readonly targets: ReadonlyMap<string, ResourceDefinition>;
20
+ readonly issueHistory: ResourceDefinition;
21
+ }) =>
22
+ Webhook.route({
23
+ path: webhookPath,
24
+ ackMode: "after-enqueue",
25
+ schema: WebhookEventSchema,
26
+ handler: ({ request, rawBody, payload, to }) =>
27
+ Effect.gen(function* () {
28
+ const verified = yield* verifySignature({
29
+ rawBody,
30
+ header: request.headers["linear-signature"],
31
+ secret: options.secret,
32
+ timestamp: payload.webhookTimestamp,
33
+ }).pipe(Effect.result);
34
+ if (Result.isFailure(verified)) {
35
+ return HttpServerResponse.jsonUnsafe(
36
+ { ok: false, error: verified.failure.message },
37
+ { status: 401 },
38
+ );
39
+ }
40
+
41
+ // Polling reads creates and updates.
42
+ const resource = options.targets.get(payload.type);
43
+ if (payload.action === "remove" || payload.action === "restore") {
44
+ if (resource !== undefined) yield* to(resource, payload);
45
+ if (payload.type === "Issue") yield* to(options.issueHistory, payload);
46
+ }
47
+
48
+ return HttpServerResponse.jsonUnsafe({ ok: true });
49
+ }),
50
+ });
@@ -0,0 +1,28 @@
1
+ import { ConnectorError } from "@useairfoil/connector-kit";
2
+ import { DateTime, Effect } from "effect";
3
+ import { createHmac, timingSafeEqual } from "node:crypto";
4
+
5
+ // Linear suggests rejecting deliveries more than a minute old, to stop replays.
6
+ const maxAgeMillis = 60_000;
7
+
8
+ export const verifySignature = (options: {
9
+ readonly rawBody: Uint8Array;
10
+ readonly header: string | undefined;
11
+ readonly secret: string;
12
+ /** `webhookTimestamp` from the body, in milliseconds. */
13
+ readonly timestamp: number | undefined;
14
+ }): Effect.Effect<void, ConnectorError> =>
15
+ Effect.gen(function* () {
16
+ const expected = createHmac("sha256", options.secret).update(options.rawBody).digest();
17
+ const provided = Buffer.from(options.header ?? "", "hex");
18
+ if (provided.length !== expected.length || !timingSafeEqual(provided, expected)) {
19
+ return yield* new ConnectorError({ message: "Invalid Linear webhook signature" });
20
+ }
21
+ const now = yield* DateTime.nowAsDate;
22
+ if (
23
+ options.timestamp === undefined ||
24
+ Math.abs(now.getTime() - options.timestamp) > maxAgeMillis
25
+ ) {
26
+ return yield* new ConnectorError({ message: "Linear webhook is too old" });
27
+ }
28
+ });