@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,136 @@
|
|
|
1
|
+
import { Resource } from "@useairfoil/connector-kit";
|
|
2
|
+
|
|
3
|
+
import type { LinearClientService } from "../client/client";
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
InitiativeLabelObjectSchema,
|
|
7
|
+
InitiativeLabelSchema,
|
|
8
|
+
InitiativeObjectSchema,
|
|
9
|
+
InitiativeRelationObjectSchema,
|
|
10
|
+
InitiativeRelationSchema,
|
|
11
|
+
InitiativeSchema,
|
|
12
|
+
InitiativeToProjectObjectSchema,
|
|
13
|
+
InitiativeToProjectSchema,
|
|
14
|
+
InitiativeUpdateObjectSchema,
|
|
15
|
+
InitiativeUpdateSchema,
|
|
16
|
+
} from "../schemas/initiatives";
|
|
17
|
+
import {
|
|
18
|
+
backfillList,
|
|
19
|
+
changesList,
|
|
20
|
+
checkList,
|
|
21
|
+
listSources,
|
|
22
|
+
listSpec,
|
|
23
|
+
rowsOf,
|
|
24
|
+
webhookList,
|
|
25
|
+
} from "./pages";
|
|
26
|
+
|
|
27
|
+
const initiativeSpec = listSpec({
|
|
28
|
+
connection: "initiatives",
|
|
29
|
+
single: "initiative",
|
|
30
|
+
fields: `id created_at: createdAt updated_at: updatedAt archived_at: archivedAt
|
|
31
|
+
name description content url icon color status health
|
|
32
|
+
health_updated_at: healthUpdatedAt priority owner_id: owner { id } creator_id: creator { id }
|
|
33
|
+
parent_initiative_id: parentInitiative { id } target_date: targetDate
|
|
34
|
+
started_at: startedAt completed_at: completedAt canceled_at: canceledAt
|
|
35
|
+
label_ids: labelIds visibility trashed`,
|
|
36
|
+
item: InitiativeObjectSchema,
|
|
37
|
+
updatedAt: (initiative) => initiative.updated_at,
|
|
38
|
+
rows: rowsOf((initiative) => ({
|
|
39
|
+
...initiative,
|
|
40
|
+
version: initiative.updated_at,
|
|
41
|
+
_deleted: false,
|
|
42
|
+
})),
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
export const makeInitiatives = (client: LinearClientService) =>
|
|
46
|
+
Resource.entity({
|
|
47
|
+
name: "initiatives",
|
|
48
|
+
rowSchema: InitiativeSchema,
|
|
49
|
+
version: "version",
|
|
50
|
+
|
|
51
|
+
check: checkList(client, initiativeSpec),
|
|
52
|
+
backfill: backfillList(client, initiativeSpec),
|
|
53
|
+
changes: changesList(client, initiativeSpec),
|
|
54
|
+
webhook: webhookList(client, initiativeSpec),
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const initiativeUpdateSpec = listSpec({
|
|
58
|
+
connection: "initiativeUpdates",
|
|
59
|
+
single: "initiativeUpdate",
|
|
60
|
+
fields: `id created_at: createdAt updated_at: updatedAt archived_at: archivedAt
|
|
61
|
+
initiative_id: initiative { id } user_id: user { id } body health url
|
|
62
|
+
edited_at: editedAt comment_count: commentCount`,
|
|
63
|
+
item: InitiativeUpdateObjectSchema,
|
|
64
|
+
updatedAt: (update) => update.updated_at,
|
|
65
|
+
rows: rowsOf((update) => ({ ...update, version: update.updated_at, _deleted: false })),
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
export const makeInitiativeUpdates = (client: LinearClientService) =>
|
|
69
|
+
Resource.entity({
|
|
70
|
+
name: "initiative_updates",
|
|
71
|
+
rowSchema: InitiativeUpdateSchema,
|
|
72
|
+
version: "version",
|
|
73
|
+
|
|
74
|
+
check: checkList(client, initiativeUpdateSpec),
|
|
75
|
+
backfill: backfillList(client, initiativeUpdateSpec),
|
|
76
|
+
changes: changesList(client, initiativeUpdateSpec),
|
|
77
|
+
webhook: webhookList(client, initiativeUpdateSpec),
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
const initiativeLabelSpec = listSpec({
|
|
81
|
+
connection: "initiativeLabels",
|
|
82
|
+
fields: `id created_at: createdAt updated_at: updatedAt archived_at: archivedAt
|
|
83
|
+
name description color is_group: isGroup parent_id: parent { id }
|
|
84
|
+
creator_id: creator { id } retired_at: retiredAt`,
|
|
85
|
+
item: InitiativeLabelObjectSchema,
|
|
86
|
+
updatedAt: (label) => label.updated_at,
|
|
87
|
+
rows: rowsOf((label) => ({ ...label, version: label.updated_at })),
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
export const makeInitiativeLabels = (client: LinearClientService) =>
|
|
91
|
+
Resource.entity({
|
|
92
|
+
name: "initiative_labels",
|
|
93
|
+
rowSchema: InitiativeLabelSchema,
|
|
94
|
+
version: "version",
|
|
95
|
+
...listSources(client, initiativeLabelSpec),
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
const initiativeRelationSpec = listSpec({
|
|
99
|
+
connection: "initiativeRelations",
|
|
100
|
+
fields: `id created_at: createdAt updated_at: updatedAt archived_at: archivedAt
|
|
101
|
+
initiative_id: initiative { id } related_initiative_id: relatedInitiative { id }
|
|
102
|
+
sort_order: sortOrder user_id: user { id }`,
|
|
103
|
+
item: InitiativeRelationObjectSchema,
|
|
104
|
+
updatedAt: (relation) => relation.updated_at,
|
|
105
|
+
rows: rowsOf((relation) => ({ ...relation, version: relation.updated_at })),
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
export const makeInitiativeRelations = (client: LinearClientService) =>
|
|
109
|
+
Resource.entity({
|
|
110
|
+
name: "initiative_relations",
|
|
111
|
+
rowSchema: InitiativeRelationSchema,
|
|
112
|
+
version: "version",
|
|
113
|
+
|
|
114
|
+
check: checkList(client, initiativeRelationSpec),
|
|
115
|
+
backfill: backfillList(client, initiativeRelationSpec),
|
|
116
|
+
changes: changesList(client, initiativeRelationSpec),
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
const initiativeToProjectSpec = listSpec({
|
|
120
|
+
connection: "initiativeToProjects",
|
|
121
|
+
fields: `id created_at: createdAt updated_at: updatedAt archived_at: archivedAt initiative_id: initiative { id } project_id: project { id } sort_order: sortOrder`,
|
|
122
|
+
item: InitiativeToProjectObjectSchema,
|
|
123
|
+
updatedAt: (link) => link.updated_at,
|
|
124
|
+
rows: rowsOf((link) => ({ ...link, version: link.updated_at })),
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
export const makeInitiativeToProjects = (client: LinearClientService) =>
|
|
128
|
+
Resource.entity({
|
|
129
|
+
name: "initiative_to_projects",
|
|
130
|
+
rowSchema: InitiativeToProjectSchema,
|
|
131
|
+
version: "version",
|
|
132
|
+
|
|
133
|
+
check: checkList(client, initiativeToProjectSpec),
|
|
134
|
+
backfill: backfillList(client, initiativeToProjectSpec),
|
|
135
|
+
changes: changesList(client, initiativeToProjectSpec),
|
|
136
|
+
});
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { type ConnectorError, Resource } from "@useairfoil/connector-kit";
|
|
2
|
+
import { Effect, Option, Schema, Stream } from "effect";
|
|
3
|
+
|
|
4
|
+
import type { LinearClientService } from "../client/client";
|
|
5
|
+
|
|
6
|
+
import { type IssueHistory, IssueHistoryObjectSchema, IssueHistorySchema } from "../schemas/issues";
|
|
7
|
+
import { Timestamp } from "../schemas/shared";
|
|
8
|
+
import { backfillList, changesList, checkList, listSpec } from "./pages";
|
|
9
|
+
|
|
10
|
+
const historyFields = `
|
|
11
|
+
id created_at: createdAt updated_at: updatedAt archived_at: archivedAt actor_id: actorId
|
|
12
|
+
from_state_id: fromStateId to_state_id: toStateId
|
|
13
|
+
from_assignee_id: fromAssigneeId to_assignee_id: toAssigneeId
|
|
14
|
+
from_cycle_id: fromCycleId to_cycle_id: toCycleId
|
|
15
|
+
from_project_id: fromProjectId to_project_id: toProjectId
|
|
16
|
+
from_parent_id: fromParentId to_parent_id: toParentId from_team_id: fromTeamId to_team_id: toTeamId
|
|
17
|
+
from_priority: fromPriority to_priority: toPriority
|
|
18
|
+
from_estimate: fromEstimate to_estimate: toEstimate
|
|
19
|
+
from_due_date: fromDueDate to_due_date: toDueDate from_title: fromTitle to_title: toTitle
|
|
20
|
+
added_label_ids: addedLabelIds removed_label_ids: removedLabelIds
|
|
21
|
+
attachment_id: attachmentId customer_need_id: customerNeedId
|
|
22
|
+
to_converted_project_id: toConvertedProjectId updated_description: updatedDescription
|
|
23
|
+
archived trashed auto_archived: autoArchived auto_closed: autoClosed`;
|
|
24
|
+
|
|
25
|
+
const HistoryPageSchema = Schema.Struct({
|
|
26
|
+
nodes: Schema.Array(IssueHistoryObjectSchema),
|
|
27
|
+
pageInfo: Schema.Struct({ hasNextPage: Schema.Boolean, endCursor: Schema.NullOr(Schema.String) }),
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
type HistoryPage = typeof HistoryPageSchema.Type;
|
|
31
|
+
|
|
32
|
+
const IssueWithHistorySchema = Schema.Struct({
|
|
33
|
+
id: Schema.String,
|
|
34
|
+
updated_at: Timestamp,
|
|
35
|
+
history: HistoryPageSchema,
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const nextCursor = ({ pageInfo }: HistoryPage) =>
|
|
39
|
+
pageInfo.hasNextPage ? Option.fromNullishOr(pageInfo.endCursor) : Option.none();
|
|
40
|
+
|
|
41
|
+
/** Returns the history after the first page, without repeating that page. */
|
|
42
|
+
const readMore = (
|
|
43
|
+
client: LinearClientService,
|
|
44
|
+
issueId: string,
|
|
45
|
+
first: HistoryPage,
|
|
46
|
+
): Effect.Effect<HistoryPage["nodes"], ConnectorError> =>
|
|
47
|
+
Option.match(nextCursor(first), {
|
|
48
|
+
onNone: () => Effect.succeed([]),
|
|
49
|
+
onSome: (cursor) =>
|
|
50
|
+
Stream.paginate(cursor, (after) =>
|
|
51
|
+
client
|
|
52
|
+
.query(
|
|
53
|
+
Schema.Struct({ issue: Schema.Struct({ history: HistoryPageSchema }) }),
|
|
54
|
+
`query IssueHistory($id: String!, $after: String) {
|
|
55
|
+
issue(id: $id) { history(first: 250, after: $after) { nodes { ${historyFields} } pageInfo { hasNextPage endCursor } } }
|
|
56
|
+
}`,
|
|
57
|
+
{ id: issueId, after },
|
|
58
|
+
)
|
|
59
|
+
.pipe(
|
|
60
|
+
Effect.map(({ issue }) => [issue.history.nodes, nextCursor(issue.history)] as const),
|
|
61
|
+
),
|
|
62
|
+
).pipe(Stream.runCollect),
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
/** Linear has no top-level history query. */
|
|
66
|
+
const historySpec = (client: LinearClientService) =>
|
|
67
|
+
listSpec({
|
|
68
|
+
connection: "issues",
|
|
69
|
+
fields: `id updated_at: updatedAt history(first: 50) { nodes { ${historyFields} } pageInfo { hasNextPage endCursor } }`,
|
|
70
|
+
item: IssueWithHistorySchema,
|
|
71
|
+
// Each issue reads up to 50 history entries, so pages stay under the cost limit.
|
|
72
|
+
first: 25,
|
|
73
|
+
updatedAt: (issue) => issue.updated_at,
|
|
74
|
+
rows: (issues): Effect.Effect<ReadonlyArray<IssueHistory>, ConnectorError> =>
|
|
75
|
+
Effect.forEach(issues, (issue) =>
|
|
76
|
+
readMore(client, issue.id, issue.history).pipe(
|
|
77
|
+
Effect.map((more) =>
|
|
78
|
+
[...issue.history.nodes, ...more].map((entry) => ({
|
|
79
|
+
...entry,
|
|
80
|
+
issue_id: issue.id,
|
|
81
|
+
version: entry.updated_at,
|
|
82
|
+
})),
|
|
83
|
+
),
|
|
84
|
+
),
|
|
85
|
+
).pipe(Effect.map((rows) => rows.flat())),
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
export const makeIssueHistory = (client: LinearClientService) => {
|
|
89
|
+
const spec = historySpec(client);
|
|
90
|
+
return Resource.entity({
|
|
91
|
+
name: "issue_history",
|
|
92
|
+
rowSchema: IssueHistorySchema,
|
|
93
|
+
version: "version",
|
|
94
|
+
|
|
95
|
+
check: checkList(client, spec),
|
|
96
|
+
backfill: backfillList(client, spec),
|
|
97
|
+
changes: changesList(client, spec),
|
|
98
|
+
webhook: {
|
|
99
|
+
schema: Schema.Struct({ data: Schema.Struct({ id: Schema.String }) }),
|
|
100
|
+
handler: ({ payload }) =>
|
|
101
|
+
client
|
|
102
|
+
.find(
|
|
103
|
+
Schema.Struct({ issue: IssueWithHistorySchema }),
|
|
104
|
+
`query IssueHistory($id: String!) {
|
|
105
|
+
issue(id: $id) { ${spec.fields} }
|
|
106
|
+
}`,
|
|
107
|
+
{ id: payload.data.id },
|
|
108
|
+
)
|
|
109
|
+
.pipe(
|
|
110
|
+
Effect.flatMap(
|
|
111
|
+
Option.match({
|
|
112
|
+
onNone: () => Effect.succeed([]),
|
|
113
|
+
onSome: ({ issue }) => spec.rows([issue]),
|
|
114
|
+
}),
|
|
115
|
+
),
|
|
116
|
+
),
|
|
117
|
+
},
|
|
118
|
+
});
|
|
119
|
+
};
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { Resource } from "@useairfoil/connector-kit";
|
|
2
|
+
|
|
3
|
+
import type { LinearClientService } from "../client/client";
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
AttachmentObjectSchema,
|
|
7
|
+
AttachmentSchema,
|
|
8
|
+
IssueLabelObjectSchema,
|
|
9
|
+
IssueLabelSchema,
|
|
10
|
+
IssueObjectSchema,
|
|
11
|
+
IssueRelationObjectSchema,
|
|
12
|
+
IssueRelationSchema,
|
|
13
|
+
IssueSchema,
|
|
14
|
+
} from "../schemas/issues";
|
|
15
|
+
import {
|
|
16
|
+
backfillList,
|
|
17
|
+
changesList,
|
|
18
|
+
checkList,
|
|
19
|
+
listSources,
|
|
20
|
+
listSpec,
|
|
21
|
+
rowsOf,
|
|
22
|
+
webhookList,
|
|
23
|
+
} from "./pages";
|
|
24
|
+
|
|
25
|
+
const issueFields = `
|
|
26
|
+
id created_at: createdAt updated_at: updatedAt archived_at: archivedAt
|
|
27
|
+
identifier number title description url branch_name: branchName
|
|
28
|
+
priority priority_label: priorityLabel estimate due_date: dueDate
|
|
29
|
+
creator_id: creator { id } assignee_id: assignee { id } delegate_id: delegate { id }
|
|
30
|
+
snoozed_by_id: snoozedBy { id } state_id: state { id } team_id: team { id }
|
|
31
|
+
project_id: project { id } project_milestone_id: projectMilestone { id }
|
|
32
|
+
cycle_id: cycle { id } parent_id: parent { id } source_comment_id: sourceComment { id }
|
|
33
|
+
external_user_creator_id: externalUserCreator { id }
|
|
34
|
+
label_ids: labelIds previous_identifiers: previousIdentifiers
|
|
35
|
+
started_at: startedAt completed_at: completedAt canceled_at: canceledAt
|
|
36
|
+
started_triage_at: startedTriageAt triaged_at: triagedAt auto_closed_at: autoClosedAt
|
|
37
|
+
auto_archived_at: autoArchivedAt added_to_cycle_at: addedToCycleAt
|
|
38
|
+
added_to_project_at: addedToProjectAt added_to_team_at: addedToTeamAt
|
|
39
|
+
snoozed_until_at: snoozedUntilAt sla_type: slaType sla_started_at: slaStartedAt
|
|
40
|
+
sla_breaches_at: slaBreachesAt customer_ticket_count: customerTicketCount
|
|
41
|
+
integration_source_type: integrationSourceType trashed`;
|
|
42
|
+
|
|
43
|
+
const issueSpec = listSpec({
|
|
44
|
+
connection: "issues",
|
|
45
|
+
single: "issue",
|
|
46
|
+
fields: issueFields,
|
|
47
|
+
item: IssueObjectSchema,
|
|
48
|
+
updatedAt: (issue) => issue.updated_at,
|
|
49
|
+
rows: rowsOf((issue) => ({ ...issue, version: issue.updated_at, _deleted: false })),
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
export const makeIssues = (client: LinearClientService) =>
|
|
53
|
+
Resource.entity({
|
|
54
|
+
name: "issues",
|
|
55
|
+
rowSchema: IssueSchema,
|
|
56
|
+
version: "version",
|
|
57
|
+
|
|
58
|
+
check: checkList(client, issueSpec),
|
|
59
|
+
backfill: backfillList(client, issueSpec),
|
|
60
|
+
changes: changesList(client, issueSpec),
|
|
61
|
+
webhook: webhookList(client, issueSpec),
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
const issueRelationSpec = listSpec({
|
|
65
|
+
connection: "issueRelations",
|
|
66
|
+
fields: `id created_at: createdAt updated_at: updatedAt archived_at: archivedAt
|
|
67
|
+
issue_id: issue { id } related_issue_id: relatedIssue { id } type`,
|
|
68
|
+
item: IssueRelationObjectSchema,
|
|
69
|
+
updatedAt: (relation) => relation.updated_at,
|
|
70
|
+
rows: rowsOf((relation) => ({ ...relation, version: relation.updated_at })),
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
export const makeIssueRelations = (client: LinearClientService) =>
|
|
74
|
+
Resource.entity({
|
|
75
|
+
name: "issue_relations",
|
|
76
|
+
rowSchema: IssueRelationSchema,
|
|
77
|
+
version: "version",
|
|
78
|
+
|
|
79
|
+
check: checkList(client, issueRelationSpec),
|
|
80
|
+
backfill: backfillList(client, issueRelationSpec),
|
|
81
|
+
changes: changesList(client, issueRelationSpec),
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
const attachmentSpec = listSpec({
|
|
85
|
+
connection: "attachments",
|
|
86
|
+
single: "attachment",
|
|
87
|
+
fields: `id created_at: createdAt updated_at: updatedAt archived_at: archivedAt
|
|
88
|
+
issue_id: issue { id } creator_id: creator { id }
|
|
89
|
+
external_user_creator_id: externalUserCreator { id }
|
|
90
|
+
title subtitle url source_type: sourceType`,
|
|
91
|
+
item: AttachmentObjectSchema,
|
|
92
|
+
updatedAt: (attachment) => attachment.updated_at,
|
|
93
|
+
rows: rowsOf((attachment) => ({
|
|
94
|
+
...attachment,
|
|
95
|
+
version: attachment.updated_at,
|
|
96
|
+
_deleted: false,
|
|
97
|
+
})),
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
export const makeAttachments = (client: LinearClientService) =>
|
|
101
|
+
Resource.entity({
|
|
102
|
+
name: "attachments",
|
|
103
|
+
rowSchema: AttachmentSchema,
|
|
104
|
+
version: "version",
|
|
105
|
+
|
|
106
|
+
check: checkList(client, attachmentSpec),
|
|
107
|
+
backfill: backfillList(client, attachmentSpec),
|
|
108
|
+
changes: changesList(client, attachmentSpec),
|
|
109
|
+
webhook: webhookList(client, attachmentSpec),
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
const issueLabelSpec = listSpec({
|
|
113
|
+
connection: "issueLabels",
|
|
114
|
+
single: "issueLabel",
|
|
115
|
+
fields: `id created_at: createdAt updated_at: updatedAt archived_at: archivedAt
|
|
116
|
+
name description color is_group: isGroup parent_id: parent { id } team_id: team { id }
|
|
117
|
+
creator_id: creator { id } retired_at: retiredAt`,
|
|
118
|
+
item: IssueLabelObjectSchema,
|
|
119
|
+
updatedAt: (label) => label.updated_at,
|
|
120
|
+
rows: rowsOf((label) => ({ ...label, version: label.updated_at, _deleted: false })),
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
export const makeIssueLabels = (client: LinearClientService) =>
|
|
124
|
+
Resource.entity({
|
|
125
|
+
name: "issue_labels",
|
|
126
|
+
rowSchema: IssueLabelSchema,
|
|
127
|
+
version: "version",
|
|
128
|
+
|
|
129
|
+
...listSources(client, issueLabelSpec),
|
|
130
|
+
webhook: webhookList(client, issueLabelSpec),
|
|
131
|
+
});
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
import { ConnectorError, Cursor, Fetch } from "@useairfoil/connector-kit";
|
|
2
|
+
import {
|
|
3
|
+
Array as Arr,
|
|
4
|
+
DateTime,
|
|
5
|
+
Duration,
|
|
6
|
+
Effect,
|
|
7
|
+
Option,
|
|
8
|
+
Schema,
|
|
9
|
+
SchemaTransformation,
|
|
10
|
+
Stream,
|
|
11
|
+
String as Str,
|
|
12
|
+
} from "effect";
|
|
13
|
+
|
|
14
|
+
import type { LinearClientService } from "../client/client";
|
|
15
|
+
|
|
16
|
+
const changesInterval = Duration.minutes(5);
|
|
17
|
+
const hourly = Duration.hours(1);
|
|
18
|
+
// A run stops after this many pages and the next one starts right away.
|
|
19
|
+
const maxPagesPerRun = 20;
|
|
20
|
+
// Updates can show up in a list late, so each changes read starts a bit early.
|
|
21
|
+
const changesOverlap = Duration.minutes(1);
|
|
22
|
+
|
|
23
|
+
type ListSpec<Item, Row> = {
|
|
24
|
+
/** List query, such as `issues`. */
|
|
25
|
+
readonly connection: string;
|
|
26
|
+
/** Single-item query for webhooks, such as `issue`. */
|
|
27
|
+
readonly single?: string;
|
|
28
|
+
/** GraphQL fields, aliased to match the schema. */
|
|
29
|
+
readonly fields: string;
|
|
30
|
+
readonly item: Schema.Decoder<Item>;
|
|
31
|
+
/** Page size. Lower it for queries with nested lists. */
|
|
32
|
+
readonly first?: number;
|
|
33
|
+
readonly updatedAt: (item: Item) => Date;
|
|
34
|
+
/** Builds rows and may fetch more nested pages. */
|
|
35
|
+
readonly rows: (items: ReadonlyArray<Item>) => Effect.Effect<ReadonlyArray<Row>, ConnectorError>;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/** Keeps item types inferred in the callbacks. */
|
|
39
|
+
export const listSpec = <Item, Row>(spec: ListSpec<Item, Row>) => spec;
|
|
40
|
+
|
|
41
|
+
export const rowsOf =
|
|
42
|
+
<Item, Row>(toRow: (item: Item) => Row) =>
|
|
43
|
+
(items: ReadonlyArray<Item>) =>
|
|
44
|
+
Effect.succeed(items.map(toRow));
|
|
45
|
+
|
|
46
|
+
type Page<Item> = {
|
|
47
|
+
readonly items: ReadonlyArray<Item>;
|
|
48
|
+
readonly next: Option.Option<string>;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const PageInfoSchema = Schema.Struct({
|
|
52
|
+
hasNextPage: Schema.Boolean,
|
|
53
|
+
endCursor: Schema.NullOr(Schema.String),
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const readPage = <Item, Row>(
|
|
57
|
+
client: LinearClientService,
|
|
58
|
+
spec: ListSpec<Item, Row>,
|
|
59
|
+
options: {
|
|
60
|
+
readonly orderBy: "createdAt" | "updatedAt";
|
|
61
|
+
readonly after: Option.Option<string>;
|
|
62
|
+
readonly first?: number;
|
|
63
|
+
},
|
|
64
|
+
): Effect.Effect<Page<Item>, ConnectorError> =>
|
|
65
|
+
client
|
|
66
|
+
.query(
|
|
67
|
+
Schema.Struct({
|
|
68
|
+
page: Schema.Struct({ nodes: Schema.Array(spec.item), pageInfo: PageInfoSchema }),
|
|
69
|
+
}).pipe(Schema.encodeKeys({ page: spec.connection })),
|
|
70
|
+
`query ${Str.capitalize(spec.connection)}($first: Int!, $after: String, $orderBy: PaginationOrderBy) {
|
|
71
|
+
${spec.connection}(first: $first, after: $after, orderBy: $orderBy, includeArchived: true) {
|
|
72
|
+
nodes { ${spec.fields} }
|
|
73
|
+
pageInfo { hasNextPage endCursor }
|
|
74
|
+
}
|
|
75
|
+
}`,
|
|
76
|
+
{
|
|
77
|
+
first: options.first ?? spec.first ?? 250,
|
|
78
|
+
after: Option.getOrNull(options.after),
|
|
79
|
+
orderBy: options.orderBy,
|
|
80
|
+
},
|
|
81
|
+
)
|
|
82
|
+
.pipe(
|
|
83
|
+
Effect.map(({ page }) => ({
|
|
84
|
+
items: page.nodes,
|
|
85
|
+
next: page.pageInfo.hasNextPage
|
|
86
|
+
? Option.fromNullishOr(page.pageInfo.endCursor)
|
|
87
|
+
: Option.none(),
|
|
88
|
+
})),
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
const listAll = <Item, Row>(
|
|
92
|
+
client: LinearClientService,
|
|
93
|
+
spec: ListSpec<Item, Row>,
|
|
94
|
+
): Effect.Effect<ReadonlyArray<Item>, ConnectorError> =>
|
|
95
|
+
Stream.paginate(Option.none<string>(), (after) =>
|
|
96
|
+
readPage(client, spec, { orderBy: "createdAt", after }).pipe(
|
|
97
|
+
Effect.map(({ items, next }) => [items, Option.map(next, Option.some)] as const),
|
|
98
|
+
),
|
|
99
|
+
).pipe(Stream.runCollect);
|
|
100
|
+
|
|
101
|
+
export const checkList = <Item, Row>(client: LinearClientService, spec: ListSpec<Item, Row>) =>
|
|
102
|
+
readPage(client, spec, { orderBy: "createdAt", after: Option.none(), first: 1 }).pipe(
|
|
103
|
+
Effect.flatMap(({ items }) => spec.rows(items)),
|
|
104
|
+
Effect.asVoid,
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
/** Small lists are read in full every hour. */
|
|
108
|
+
export const listSources = <Item, Row extends object>(
|
|
109
|
+
client: LinearClientService,
|
|
110
|
+
spec: ListSpec<Item, Row>,
|
|
111
|
+
) => {
|
|
112
|
+
const read = listAll(client, spec).pipe(Effect.flatMap(spec.rows));
|
|
113
|
+
return {
|
|
114
|
+
check: checkList(client, spec),
|
|
115
|
+
backfill: Fetch.page({
|
|
116
|
+
pageCursor: Cursor.string(),
|
|
117
|
+
cutoff: Cursor.isoDateTime(),
|
|
118
|
+
fetch: () => read.pipe(Effect.map((rows) => ({ rows, hasMore: false }))),
|
|
119
|
+
}),
|
|
120
|
+
changes: Fetch.changes({
|
|
121
|
+
cursor: Cursor.string(),
|
|
122
|
+
interval: hourly,
|
|
123
|
+
fetch: () =>
|
|
124
|
+
Effect.all([read, DateTime.now]).pipe(
|
|
125
|
+
Effect.map(([rows, now]) => ({ rows, cursor: DateTime.formatIso(now) })),
|
|
126
|
+
),
|
|
127
|
+
}),
|
|
128
|
+
};
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
/** Changes pick up items created during backfill. */
|
|
132
|
+
export const backfillList = <Item, Row extends object>(
|
|
133
|
+
client: LinearClientService,
|
|
134
|
+
spec: ListSpec<Item, Row>,
|
|
135
|
+
) =>
|
|
136
|
+
Fetch.page({
|
|
137
|
+
pageCursor: Cursor.string(),
|
|
138
|
+
cutoff: Cursor.isoDateTime(),
|
|
139
|
+
fetch: ({ pageCursor }) =>
|
|
140
|
+
Effect.gen(function* () {
|
|
141
|
+
const page = yield* readPage(client, spec, {
|
|
142
|
+
orderBy: "createdAt",
|
|
143
|
+
after: Option.fromNullishOr(typeof pageCursor === "string" ? pageCursor : undefined),
|
|
144
|
+
});
|
|
145
|
+
return {
|
|
146
|
+
rows: yield* spec.rows(page.items),
|
|
147
|
+
hasMore: Option.isSome(page.next),
|
|
148
|
+
...Option.match(page.next, {
|
|
149
|
+
onNone: () => ({}),
|
|
150
|
+
onSome: (nextPageCursor) => ({ nextPageCursor }),
|
|
151
|
+
}),
|
|
152
|
+
};
|
|
153
|
+
}),
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* `since` is when the last run started. `pending` is set while a run is split:
|
|
158
|
+
* `until` is the start of that run, and `after` the page to go on from.
|
|
159
|
+
*/
|
|
160
|
+
const ChangesCursorSchema = Schema.Struct({
|
|
161
|
+
since: Schema.DateTimeUtcFromString,
|
|
162
|
+
pending: Schema.optional(
|
|
163
|
+
Schema.Struct({ until: Schema.DateTimeUtcFromString, after: Schema.String }),
|
|
164
|
+
),
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
type ChangesCursor = Schema.Schema.Type<typeof ChangesCursorSchema>;
|
|
168
|
+
|
|
169
|
+
const ChangesCursorCodec = Schema.Union([
|
|
170
|
+
Schema.fromJsonString(ChangesCursorSchema),
|
|
171
|
+
// The first cursor is the backfill cutoff, a plain ISO time.
|
|
172
|
+
Schema.DateTimeUtcFromString.pipe(
|
|
173
|
+
Schema.decodeTo(
|
|
174
|
+
Schema.toType(ChangesCursorSchema),
|
|
175
|
+
SchemaTransformation.transform({
|
|
176
|
+
decode: (since): ChangesCursor => ({ since }),
|
|
177
|
+
encode: (cursor) => cursor.since,
|
|
178
|
+
}),
|
|
179
|
+
),
|
|
180
|
+
),
|
|
181
|
+
]);
|
|
182
|
+
|
|
183
|
+
const cursorError = (cause: Schema.SchemaError) =>
|
|
184
|
+
new ConnectorError({ message: "Invalid Linear changes cursor", cause });
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Newest first: an edit during paging is read again on the next run.
|
|
188
|
+
*/
|
|
189
|
+
export const changesList = <Item, Row extends object>(
|
|
190
|
+
client: LinearClientService,
|
|
191
|
+
spec: ListSpec<Item, Row>,
|
|
192
|
+
) =>
|
|
193
|
+
Fetch.changes({
|
|
194
|
+
cursor: Cursor.string(),
|
|
195
|
+
interval: changesInterval,
|
|
196
|
+
fetch: ({ cursor }) =>
|
|
197
|
+
Effect.gen(function* () {
|
|
198
|
+
const state = yield* Schema.decodeUnknownEffect(ChangesCursorCodec)(String(cursor)).pipe(
|
|
199
|
+
Effect.mapError(cursorError),
|
|
200
|
+
);
|
|
201
|
+
const until = state.pending?.until ?? (yield* DateTime.now);
|
|
202
|
+
const from = DateTime.toDateUtc(DateTime.subtractDuration(state.since, changesOverlap));
|
|
203
|
+
const isRecent = (item: Item) => spec.updatedAt(item).getTime() >= from.getTime();
|
|
204
|
+
|
|
205
|
+
const pages = yield* Stream.paginate(Option.fromNullishOr(state.pending?.after), (after) =>
|
|
206
|
+
Effect.gen(function* () {
|
|
207
|
+
const page = yield* readPage(client, spec, { orderBy: "updatedAt", after });
|
|
208
|
+
const recent = page.items.filter(isRecent);
|
|
209
|
+
const next = recent.length === page.items.length ? page.next : Option.none();
|
|
210
|
+
const rows = yield* spec.rows(recent);
|
|
211
|
+
return [[{ rows, next }], Option.map(next, Option.some)] as const;
|
|
212
|
+
}),
|
|
213
|
+
).pipe(Stream.take(maxPagesPerRun), Stream.runCollect);
|
|
214
|
+
|
|
215
|
+
const rows = pages.flatMap((page) => page.rows);
|
|
216
|
+
const after = Option.flatMap(Arr.last(pages), (page) => page.next);
|
|
217
|
+
const next: ChangesCursor =
|
|
218
|
+
pages.length === maxPagesPerRun && Option.isSome(after)
|
|
219
|
+
? { since: state.since, pending: { until, after: after.value } }
|
|
220
|
+
: { since: until };
|
|
221
|
+
return {
|
|
222
|
+
rows,
|
|
223
|
+
cursor: yield* Schema.encodeEffect(Schema.fromJsonString(ChangesCursorSchema))(next).pipe(
|
|
224
|
+
Effect.mapError(cursorError),
|
|
225
|
+
),
|
|
226
|
+
hasMore: next.pending !== undefined,
|
|
227
|
+
};
|
|
228
|
+
}),
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
const WebhookPayloadSchema = Schema.Struct({
|
|
232
|
+
data: Schema.Struct({ id: Schema.String }),
|
|
233
|
+
webhookTimestamp: Schema.Number,
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
type WebhookPayload = Schema.Schema.Type<typeof WebhookPayloadSchema>;
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Archive and trash also send `remove`, without changing `updatedAt`.
|
|
240
|
+
* Read the item again; delete only if it's gone. Use the webhook time as the version.
|
|
241
|
+
*/
|
|
242
|
+
export const webhookList = <Item, Row extends object>(
|
|
243
|
+
client: LinearClientService,
|
|
244
|
+
spec: ListSpec<Item, Row>,
|
|
245
|
+
) => ({
|
|
246
|
+
schema: WebhookPayloadSchema,
|
|
247
|
+
handler: ({ payload }: { readonly payload: WebhookPayload }) =>
|
|
248
|
+
Effect.gen(function* () {
|
|
249
|
+
const version = new Date(payload.webhookTimestamp);
|
|
250
|
+
const single = spec.single ?? spec.connection;
|
|
251
|
+
const found = yield* client.find(
|
|
252
|
+
Schema.Struct({ item: spec.item }).pipe(Schema.encodeKeys({ item: single })),
|
|
253
|
+
`query ${Str.capitalize(single)}($id: String!) {
|
|
254
|
+
${single}(id: $id) { ${spec.fields} }
|
|
255
|
+
}`,
|
|
256
|
+
{ id: payload.data.id },
|
|
257
|
+
);
|
|
258
|
+
return yield* Option.match(found, {
|
|
259
|
+
onNone: () => Effect.succeed([{ id: payload.data.id, version, _deleted: true as const }]),
|
|
260
|
+
onSome: ({ item }) =>
|
|
261
|
+
spec.rows([item]).pipe(Effect.map((rows) => rows.map((row) => ({ ...row, version })))),
|
|
262
|
+
});
|
|
263
|
+
}),
|
|
264
|
+
});
|