@useairfoil/producer-linear 0.0.1 → 0.0.3
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.
- package/LICENSE.txt +201 -0
- package/README.md +71 -0
- package/dist/index.d.mts +4027 -0
- package/dist/index.mjs +3 -0
- package/dist/main.d.mts +1 -0
- package/dist/main.mjs +1305 -0
- package/dist/main.mjs.map +1 -0
- package/dist/manifest.d.mts +114 -0
- package/dist/manifest.mjs +161 -0
- package/dist/manifest.mjs.map +1 -0
- package/dist/rolldown-runtime-D7D4PA-g.mjs +13 -0
- package/dist/src-BbzySPR7.mjs +1688 -0
- package/dist/src-BbzySPR7.mjs.map +1 -0
- package/package.json +46 -8
- package/src/client/client.ts +200 -0
- package/src/connector.ts +151 -0
- package/src/create-table.ts +10 -0
- package/src/demo/index.ts +137 -0
- package/src/demo/initiatives.ts +116 -0
- package/src/demo/issues.ts +320 -0
- package/src/demo/projects.ts +215 -0
- package/src/demo/setup.ts +151 -0
- package/src/demo/shared.ts +122 -0
- package/src/demo/teams.ts +173 -0
- package/src/index.ts +83 -0
- package/src/main.ts +28 -0
- package/src/manifest.ts +58 -0
- package/src/resources/comments.ts +32 -0
- package/src/resources/customers.ts +105 -0
- package/src/resources/documents.ts +31 -0
- package/src/resources/initiatives.ts +136 -0
- package/src/resources/issue-history.ts +119 -0
- package/src/resources/issues.ts +131 -0
- package/src/resources/pages.ts +264 -0
- package/src/resources/projects.ts +161 -0
- package/src/resources/releases.ts +114 -0
- package/src/resources/teams.ts +140 -0
- package/src/sandbox.ts +35 -0
- package/src/schemas/comments.ts +52 -0
- package/src/schemas/customers.ts +96 -0
- package/src/schemas/documents.ts +40 -0
- package/src/schemas/initiatives.ts +155 -0
- package/src/schemas/issues.ts +262 -0
- package/src/schemas/projects.ts +210 -0
- package/src/schemas/releases.ts +132 -0
- package/src/schemas/shared.ts +68 -0
- package/src/schemas/teams.ts +177 -0
- package/src/start.ts +47 -0
- package/src/tables.ts +83 -0
- package/src/webhook/route.ts +50 -0
- package/src/webhook/signature.ts +28 -0
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { Iceberg } from "@useairfoil/connector-kit";
|
|
2
|
+
import { Schema, SchemaTransformation, Struct } from "effect";
|
|
3
|
+
|
|
4
|
+
import { Long, OptionalRef, OptionalString, OptionalTimestamp, Ref, common } from "./shared";
|
|
5
|
+
|
|
6
|
+
export const ReleaseSchema = Schema.Struct({
|
|
7
|
+
...common("Release"),
|
|
8
|
+
name: Schema.String.pipe(Schema.annotateKey({ description: "Release name." })),
|
|
9
|
+
description: OptionalString.pipe(Schema.annotateKey({ description: "Description." })),
|
|
10
|
+
// `version` is the row version, so the release's own version is renamed.
|
|
11
|
+
release_version: OptionalString.pipe(
|
|
12
|
+
Schema.annotateKey({ description: "Version, such as 1.2.0." }),
|
|
13
|
+
),
|
|
14
|
+
url: Schema.String.pipe(Schema.annotateKey({ description: "Release URL." })),
|
|
15
|
+
commit_sha: OptionalString.pipe(Schema.annotateKey({ description: "Commit SHA." })),
|
|
16
|
+
pipeline_id: Ref.pipe(Schema.annotateKey({ description: "Release pipeline." })),
|
|
17
|
+
stage_id: Ref.pipe(Schema.annotateKey({ description: "Release stage." })),
|
|
18
|
+
creator_id: OptionalRef.pipe(Schema.annotateKey({ description: "User who made the release." })),
|
|
19
|
+
start_date: OptionalString.pipe(
|
|
20
|
+
Schema.annotateKey({ description: "Start date, as YYYY-MM-DD." }),
|
|
21
|
+
),
|
|
22
|
+
target_date: OptionalString.pipe(
|
|
23
|
+
Schema.annotateKey({ description: "Target date, as YYYY-MM-DD." }),
|
|
24
|
+
),
|
|
25
|
+
started_at: OptionalTimestamp.pipe(
|
|
26
|
+
Schema.annotateKey({ description: "Time the release started." }),
|
|
27
|
+
),
|
|
28
|
+
completed_at: OptionalTimestamp.pipe(
|
|
29
|
+
Schema.annotateKey({ description: "Time the release was completed." }),
|
|
30
|
+
),
|
|
31
|
+
canceled_at: OptionalTimestamp.pipe(
|
|
32
|
+
Schema.annotateKey({ description: "Time the release was canceled." }),
|
|
33
|
+
),
|
|
34
|
+
trashed: Schema.NullOr(Schema.Boolean).pipe(
|
|
35
|
+
Schema.annotateKey({ description: "Whether the release is in the trash." }),
|
|
36
|
+
),
|
|
37
|
+
})
|
|
38
|
+
.annotate({ description: "Linear releases. Needs the Business plan." })
|
|
39
|
+
.pipe(Iceberg.identifier("id"));
|
|
40
|
+
|
|
41
|
+
export type Release = Schema.Schema.Type<typeof ReleaseSchema>;
|
|
42
|
+
|
|
43
|
+
export const ReleaseObjectSchema = ReleaseSchema.mapFields((fields) =>
|
|
44
|
+
Struct.omit(fields, ["version"]),
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
export const ReleaseNoteSchema = Schema.Struct({
|
|
48
|
+
...common("Release note"),
|
|
49
|
+
title: OptionalString.pipe(Schema.annotateKey({ description: "Release note title." })),
|
|
50
|
+
content: Schema.NullOr(Schema.Struct({ content: OptionalString })).pipe(
|
|
51
|
+
Schema.decodeTo(
|
|
52
|
+
OptionalString,
|
|
53
|
+
SchemaTransformation.transform({
|
|
54
|
+
decode: (document) => document?.content ?? null,
|
|
55
|
+
encode: (content) => (content === null ? null : { content }),
|
|
56
|
+
}),
|
|
57
|
+
),
|
|
58
|
+
Schema.annotateKey({ description: "Release note body in markdown." }),
|
|
59
|
+
),
|
|
60
|
+
url: Schema.String.pipe(Schema.annotateKey({ description: "Release note URL." })),
|
|
61
|
+
pipeline_id: Ref.pipe(Schema.annotateKey({ description: "Release pipeline." })),
|
|
62
|
+
release_ids: Schema.Array(Ref).pipe(
|
|
63
|
+
Schema.annotateKey({ description: "Releases covered by the note." }),
|
|
64
|
+
),
|
|
65
|
+
})
|
|
66
|
+
.annotate({ description: "Linear release notes. Needs the Business plan." })
|
|
67
|
+
.pipe(Iceberg.identifier("id"));
|
|
68
|
+
|
|
69
|
+
export type ReleaseNote = Schema.Schema.Type<typeof ReleaseNoteSchema>;
|
|
70
|
+
|
|
71
|
+
export const ReleaseNoteObjectSchema = ReleaseNoteSchema.mapFields((fields) =>
|
|
72
|
+
Struct.omit(fields, ["version"]),
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
export const ReleasePipelineSchema = Schema.Struct({
|
|
76
|
+
...common("Pipeline"),
|
|
77
|
+
name: Schema.String.pipe(Schema.annotateKey({ description: "Pipeline name." })),
|
|
78
|
+
type: Schema.String.pipe(Schema.annotateKey({ description: "Pipeline type." })),
|
|
79
|
+
is_production: Schema.Boolean.pipe(
|
|
80
|
+
Schema.annotateKey({ description: "Whether it releases to production." }),
|
|
81
|
+
),
|
|
82
|
+
url: Schema.String.pipe(Schema.annotateKey({ description: "Pipeline URL." })),
|
|
83
|
+
include_path_patterns: Schema.Array(Schema.String).pipe(
|
|
84
|
+
Schema.annotateKey({ description: "File paths that count toward the pipeline." }),
|
|
85
|
+
),
|
|
86
|
+
approximate_release_count: Long.pipe(
|
|
87
|
+
Schema.annotateKey({ description: "About how many releases it has." }),
|
|
88
|
+
),
|
|
89
|
+
trashed: Schema.NullOr(Schema.Boolean).pipe(
|
|
90
|
+
Schema.annotateKey({ description: "Whether the pipeline is in the trash." }),
|
|
91
|
+
),
|
|
92
|
+
})
|
|
93
|
+
.annotate({ description: "Linear release pipelines. Needs the Business plan." })
|
|
94
|
+
.pipe(Iceberg.identifier("id"));
|
|
95
|
+
|
|
96
|
+
export type ReleasePipeline = Schema.Schema.Type<typeof ReleasePipelineSchema>;
|
|
97
|
+
|
|
98
|
+
export const ReleasePipelineObjectSchema = ReleasePipelineSchema.mapFields((fields) =>
|
|
99
|
+
Struct.omit(fields, ["version"]),
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
export const ReleaseStageSchema = Schema.Struct({
|
|
103
|
+
...common("Stage"),
|
|
104
|
+
name: Schema.String.pipe(Schema.annotateKey({ description: "Stage name." })),
|
|
105
|
+
type: Schema.String.pipe(Schema.annotateKey({ description: "Stage type." })),
|
|
106
|
+
color: Schema.String.pipe(Schema.annotateKey({ description: "Color, as hex." })),
|
|
107
|
+
position: Schema.Finite.pipe(Schema.annotateKey({ description: "Order in the pipeline." })),
|
|
108
|
+
frozen: Schema.Boolean.pipe(Schema.annotateKey({ description: "Whether the stage is frozen." })),
|
|
109
|
+
pipeline_id: Ref.pipe(Schema.annotateKey({ description: "Release pipeline." })),
|
|
110
|
+
})
|
|
111
|
+
.annotate({ description: "Linear release stages. Needs the Business plan." })
|
|
112
|
+
.pipe(Iceberg.identifier("id"));
|
|
113
|
+
|
|
114
|
+
export type ReleaseStage = Schema.Schema.Type<typeof ReleaseStageSchema>;
|
|
115
|
+
|
|
116
|
+
export const ReleaseStageObjectSchema = ReleaseStageSchema.mapFields((fields) =>
|
|
117
|
+
Struct.omit(fields, ["version"]),
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
export const IssueToReleaseSchema = Schema.Struct({
|
|
121
|
+
...common("Link"),
|
|
122
|
+
issue_id: Ref.pipe(Schema.annotateKey({ description: "Issue." })),
|
|
123
|
+
release_id: Ref.pipe(Schema.annotateKey({ description: "Release." })),
|
|
124
|
+
})
|
|
125
|
+
.annotate({ description: "Links between Linear issues and releases. Needs the Business plan." })
|
|
126
|
+
.pipe(Iceberg.identifier("id"));
|
|
127
|
+
|
|
128
|
+
export type IssueToRelease = Schema.Schema.Type<typeof IssueToReleaseSchema>;
|
|
129
|
+
|
|
130
|
+
export const IssueToReleaseObjectSchema = IssueToReleaseSchema.mapFields((fields) =>
|
|
131
|
+
Struct.omit(fields, ["version"]),
|
|
132
|
+
);
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { Schema, SchemaTransformation } from "effect";
|
|
2
|
+
|
|
3
|
+
export const Timestamp = Schema.DateFromString;
|
|
4
|
+
|
|
5
|
+
/** Decodes a GraphQL Int as bigint for an Iceberg long. */
|
|
6
|
+
export const Long = Schema.Int.pipe(
|
|
7
|
+
Schema.decodeTo(
|
|
8
|
+
Schema.BigInt,
|
|
9
|
+
SchemaTransformation.transform({
|
|
10
|
+
decode: (value) => BigInt(value),
|
|
11
|
+
encode: (value) => Number(value),
|
|
12
|
+
}),
|
|
13
|
+
),
|
|
14
|
+
);
|
|
15
|
+
|
|
16
|
+
/** Decodes a linked object such as `assignee { id }` as its ID. */
|
|
17
|
+
export const Ref = Schema.Struct({ id: Schema.String }).pipe(
|
|
18
|
+
Schema.decodeTo(
|
|
19
|
+
Schema.String,
|
|
20
|
+
SchemaTransformation.transform({
|
|
21
|
+
decode: (ref) => ref.id,
|
|
22
|
+
encode: (id) => ({ id }),
|
|
23
|
+
}),
|
|
24
|
+
),
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
export const OptionalRef = Schema.NullOr(Ref);
|
|
28
|
+
|
|
29
|
+
export const OptionalTimestamp = Schema.NullOr(Timestamp);
|
|
30
|
+
|
|
31
|
+
export const OptionalString = Schema.NullOr(Schema.String);
|
|
32
|
+
|
|
33
|
+
const RefConnection = Schema.Struct({ nodes: Schema.Array(Schema.Struct({ id: Schema.String })) });
|
|
34
|
+
|
|
35
|
+
/** Decodes connection nodes as IDs. The query must fetch any extra pages. */
|
|
36
|
+
export const RefList = RefConnection.pipe(
|
|
37
|
+
Schema.decodeTo(
|
|
38
|
+
Schema.Array(Schema.String),
|
|
39
|
+
SchemaTransformation.transform<ReadonlyArray<string>, typeof RefConnection.Type>({
|
|
40
|
+
decode: (connection) => connection.nodes.map((node) => node.id),
|
|
41
|
+
encode: (ids) => ({ nodes: ids.map((id) => ({ id })) }),
|
|
42
|
+
}),
|
|
43
|
+
),
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
/** Rows use updated_at as version; lifecycle webhooks use the webhook time. */
|
|
47
|
+
export const common = (object: string) => ({
|
|
48
|
+
id: Schema.String.pipe(Schema.annotateKey({ description: `${object} ID.` })),
|
|
49
|
+
version: Schema.Date.pipe(
|
|
50
|
+
Schema.annotateKey({ description: `Time Linear last changed the ${object.toLowerCase()}.` }),
|
|
51
|
+
),
|
|
52
|
+
created_at: Timestamp.pipe(
|
|
53
|
+
Schema.annotateKey({ description: `Time the ${object.toLowerCase()} was created.` }),
|
|
54
|
+
),
|
|
55
|
+
updated_at: Timestamp.pipe(
|
|
56
|
+
Schema.annotateKey({ description: `Time the ${object.toLowerCase()} was last updated.` }),
|
|
57
|
+
),
|
|
58
|
+
archived_at: Schema.NullOr(Timestamp).pipe(
|
|
59
|
+
Schema.annotateKey({
|
|
60
|
+
description: `Time the ${object.toLowerCase()} was archived or trashed.`,
|
|
61
|
+
}),
|
|
62
|
+
),
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
export const deleted = (object: string) =>
|
|
66
|
+
Schema.optional(Schema.Boolean).pipe(
|
|
67
|
+
Schema.annotateKey({ description: `Whether the ${object} was deleted in Linear.` }),
|
|
68
|
+
);
|
|
@@ -0,0 +1,177 @@
|
|
|
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
|
+
} from "./shared";
|
|
13
|
+
|
|
14
|
+
export const TeamSchema = Schema.Struct({
|
|
15
|
+
...common("Team"),
|
|
16
|
+
name: Schema.String.pipe(Schema.annotateKey({ description: "Team name." })),
|
|
17
|
+
key: Schema.String.pipe(
|
|
18
|
+
Schema.annotateKey({ description: "Team key, used in issue IDs such as ENG-123." }),
|
|
19
|
+
),
|
|
20
|
+
display_name: Schema.String.pipe(Schema.annotateKey({ description: "Name shown in Linear." })),
|
|
21
|
+
description: OptionalString.pipe(Schema.annotateKey({ description: "Team description." })),
|
|
22
|
+
visibility: Schema.String.pipe(Schema.annotateKey({ description: "Who can see the team." })),
|
|
23
|
+
parent_id: OptionalRef.pipe(Schema.annotateKey({ description: "Parent team." })),
|
|
24
|
+
timezone: Schema.String.pipe(Schema.annotateKey({ description: "Team timezone." })),
|
|
25
|
+
cycles_enabled: Schema.Boolean.pipe(
|
|
26
|
+
Schema.annotateKey({ description: "Whether the team uses cycles." }),
|
|
27
|
+
),
|
|
28
|
+
cycle_duration: Schema.Finite.pipe(Schema.annotateKey({ description: "Cycle length in weeks." })),
|
|
29
|
+
cycle_cooldown_time: Schema.Finite.pipe(
|
|
30
|
+
Schema.annotateKey({ description: "Weeks between cycles." }),
|
|
31
|
+
),
|
|
32
|
+
cycle_start_day: Schema.Finite.pipe(
|
|
33
|
+
Schema.annotateKey({ description: "Day cycles start, 0 for Sunday." }),
|
|
34
|
+
),
|
|
35
|
+
triage_enabled: Schema.Boolean.pipe(
|
|
36
|
+
Schema.annotateKey({ description: "Whether the team uses triage." }),
|
|
37
|
+
),
|
|
38
|
+
issue_estimation_type: Schema.String.pipe(Schema.annotateKey({ description: "Estimate scale." })),
|
|
39
|
+
default_issue_estimate: Schema.Finite.pipe(
|
|
40
|
+
Schema.annotateKey({ description: "Default estimate." }),
|
|
41
|
+
),
|
|
42
|
+
auto_archive_period: Schema.Finite.pipe(
|
|
43
|
+
Schema.annotateKey({ description: "Months before closed issues are archived." }),
|
|
44
|
+
),
|
|
45
|
+
auto_close_period: Schema.NullOr(Schema.Finite).pipe(
|
|
46
|
+
Schema.annotateKey({ description: "Months before stale issues are closed." }),
|
|
47
|
+
),
|
|
48
|
+
retired_at: OptionalTimestamp.pipe(
|
|
49
|
+
Schema.annotateKey({ description: "Time the team was retired." }),
|
|
50
|
+
),
|
|
51
|
+
})
|
|
52
|
+
.annotate({ description: "Linear teams." })
|
|
53
|
+
.pipe(Iceberg.identifier("id"));
|
|
54
|
+
|
|
55
|
+
export type Team = Schema.Schema.Type<typeof TeamSchema>;
|
|
56
|
+
|
|
57
|
+
export const TeamObjectSchema = TeamSchema.mapFields((fields) => Struct.omit(fields, ["version"]));
|
|
58
|
+
|
|
59
|
+
export const TeamMembershipSchema = Schema.Struct({
|
|
60
|
+
...common("Membership"),
|
|
61
|
+
team_id: Ref.pipe(Schema.annotateKey({ description: "Team." })),
|
|
62
|
+
user_id: Ref.pipe(Schema.annotateKey({ description: "User." })),
|
|
63
|
+
owner: Schema.Boolean.pipe(
|
|
64
|
+
Schema.annotateKey({ description: "Whether the user owns the team." }),
|
|
65
|
+
),
|
|
66
|
+
})
|
|
67
|
+
.annotate({ description: "Linear team members. Removed members aren't seen." })
|
|
68
|
+
.pipe(Iceberg.identifier("id"));
|
|
69
|
+
|
|
70
|
+
export type TeamMembership = Schema.Schema.Type<typeof TeamMembershipSchema>;
|
|
71
|
+
|
|
72
|
+
export const TeamMembershipObjectSchema = TeamMembershipSchema.mapFields((fields) =>
|
|
73
|
+
Struct.omit(fields, ["version"]),
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
export const WorkflowStateSchema = Schema.Struct({
|
|
77
|
+
...common("Workflow state"),
|
|
78
|
+
name: Schema.String.pipe(Schema.annotateKey({ description: "State name." })),
|
|
79
|
+
type: Schema.String.pipe(
|
|
80
|
+
Schema.annotateKey({
|
|
81
|
+
description: "State type: triage, backlog, unstarted, started, completed, or canceled.",
|
|
82
|
+
}),
|
|
83
|
+
),
|
|
84
|
+
color: Schema.String.pipe(Schema.annotateKey({ description: "State color, as hex." })),
|
|
85
|
+
description: OptionalString.pipe(Schema.annotateKey({ description: "State description." })),
|
|
86
|
+
position: Schema.Finite.pipe(Schema.annotateKey({ description: "Order within the team." })),
|
|
87
|
+
team_id: Ref.pipe(Schema.annotateKey({ description: "Team." })),
|
|
88
|
+
})
|
|
89
|
+
.annotate({ description: "Linear workflow states. Names the IDs in `issues.state_id`." })
|
|
90
|
+
.pipe(Iceberg.identifier("id"));
|
|
91
|
+
|
|
92
|
+
export type WorkflowState = Schema.Schema.Type<typeof WorkflowStateSchema>;
|
|
93
|
+
|
|
94
|
+
export const WorkflowStateObjectSchema = WorkflowStateSchema.mapFields((fields) =>
|
|
95
|
+
Struct.omit(fields, ["version"]),
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
export const UserSchema = Schema.Struct({
|
|
99
|
+
...common("User"),
|
|
100
|
+
_deleted: deleted("user"),
|
|
101
|
+
name: Schema.String.pipe(Schema.annotateKey({ description: "Full name." })),
|
|
102
|
+
display_name: Schema.String.pipe(Schema.annotateKey({ description: "Display name." })),
|
|
103
|
+
email: Schema.String.pipe(Schema.annotateKey({ description: "Email address." })),
|
|
104
|
+
avatar_url: OptionalString.pipe(Schema.annotateKey({ description: "Avatar image URL." })),
|
|
105
|
+
title: OptionalString.pipe(Schema.annotateKey({ description: "Job title." })),
|
|
106
|
+
timezone: OptionalString.pipe(Schema.annotateKey({ description: "Timezone." })),
|
|
107
|
+
active: Schema.Boolean.pipe(Schema.annotateKey({ description: "Whether the user is active." })),
|
|
108
|
+
admin: Schema.Boolean.pipe(Schema.annotateKey({ description: "Whether the user is an admin." })),
|
|
109
|
+
owner: Schema.Boolean.pipe(
|
|
110
|
+
Schema.annotateKey({ description: "Whether the user owns the workspace." }),
|
|
111
|
+
),
|
|
112
|
+
guest: Schema.Boolean.pipe(Schema.annotateKey({ description: "Whether the user is a guest." })),
|
|
113
|
+
app: Schema.Boolean.pipe(
|
|
114
|
+
Schema.annotateKey({ description: "Whether the user is an app or agent." }),
|
|
115
|
+
),
|
|
116
|
+
last_seen: OptionalTimestamp.pipe(
|
|
117
|
+
Schema.annotateKey({ description: "Time the user was last online." }),
|
|
118
|
+
),
|
|
119
|
+
url: Schema.String.pipe(Schema.annotateKey({ description: "Profile URL." })),
|
|
120
|
+
})
|
|
121
|
+
.annotate({ description: "Linear users." })
|
|
122
|
+
.pipe(Iceberg.identifier("id"));
|
|
123
|
+
|
|
124
|
+
export type User = Schema.Schema.Type<typeof UserSchema>;
|
|
125
|
+
|
|
126
|
+
export const UserObjectSchema = UserSchema.mapFields((fields) =>
|
|
127
|
+
Struct.omit(fields, ["version", "_deleted"]),
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
export const ExternalUserSchema = Schema.Struct({
|
|
131
|
+
...common("External user"),
|
|
132
|
+
name: Schema.String.pipe(Schema.annotateKey({ description: "Name." })),
|
|
133
|
+
display_name: Schema.String.pipe(Schema.annotateKey({ description: "Display name." })),
|
|
134
|
+
email: OptionalString.pipe(Schema.annotateKey({ description: "Email address." })),
|
|
135
|
+
avatar_url: OptionalString.pipe(Schema.annotateKey({ description: "Avatar image URL." })),
|
|
136
|
+
last_seen: OptionalTimestamp.pipe(
|
|
137
|
+
Schema.annotateKey({ description: "Time the user was last seen." }),
|
|
138
|
+
),
|
|
139
|
+
})
|
|
140
|
+
.annotate({
|
|
141
|
+
description: "People from integrations, such as Slack, who create issues and comments.",
|
|
142
|
+
})
|
|
143
|
+
.pipe(Iceberg.identifier("id"));
|
|
144
|
+
|
|
145
|
+
export type ExternalUser = Schema.Schema.Type<typeof ExternalUserSchema>;
|
|
146
|
+
|
|
147
|
+
export const ExternalUserObjectSchema = ExternalUserSchema.mapFields((fields) =>
|
|
148
|
+
Struct.omit(fields, ["version"]),
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
export const CycleSchema = Schema.Struct({
|
|
152
|
+
...common("Cycle"),
|
|
153
|
+
_deleted: deleted("cycle"),
|
|
154
|
+
number: Schema.Finite.pipe(Schema.annotateKey({ description: "Cycle number within its team." })),
|
|
155
|
+
name: OptionalString.pipe(Schema.annotateKey({ description: "Cycle name." })),
|
|
156
|
+
description: OptionalString.pipe(Schema.annotateKey({ description: "Cycle description." })),
|
|
157
|
+
team_id: Ref.pipe(Schema.annotateKey({ description: "Team." })),
|
|
158
|
+
starts_at: Timestamp.pipe(Schema.annotateKey({ description: "Time the cycle starts." })),
|
|
159
|
+
ends_at: Timestamp.pipe(Schema.annotateKey({ description: "Time the cycle ends." })),
|
|
160
|
+
completed_at: OptionalTimestamp.pipe(
|
|
161
|
+
Schema.annotateKey({ description: "Time the cycle was completed." }),
|
|
162
|
+
),
|
|
163
|
+
auto_archived_at: OptionalTimestamp.pipe(
|
|
164
|
+
Schema.annotateKey({ description: "Time the cycle was archived automatically." }),
|
|
165
|
+
),
|
|
166
|
+
progress: Schema.Finite.pipe(
|
|
167
|
+
Schema.annotateKey({ description: "Share of the scope completed, from 0 to 1." }),
|
|
168
|
+
),
|
|
169
|
+
})
|
|
170
|
+
.annotate({ description: "Linear cycles." })
|
|
171
|
+
.pipe(Iceberg.identifier("id"));
|
|
172
|
+
|
|
173
|
+
export type Cycle = Schema.Schema.Type<typeof CycleSchema>;
|
|
174
|
+
|
|
175
|
+
export const CycleObjectSchema = CycleSchema.mapFields((fields) =>
|
|
176
|
+
Struct.omit(fields, ["version", "_deleted"]),
|
|
177
|
+
);
|
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.layerArrowFlightConfig(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 connector"));
|
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
|
+
});
|