@dahrk/linear 0.1.0 → 0.2.0
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/README.md +24 -12
- package/dist/batch-source.d.ts +63 -0
- package/dist/batch-source.d.ts.map +1 -0
- package/dist/batch-source.js +149 -0
- package/dist/batch-source.js.map +1 -0
- package/dist/comments.d.ts +36 -0
- package/dist/comments.d.ts.map +1 -0
- package/dist/comments.js +104 -0
- package/dist/comments.js.map +1 -0
- package/dist/documents.d.ts +1 -15
- package/dist/documents.d.ts.map +1 -1
- package/dist/documents.js +37 -27
- package/dist/documents.js.map +1 -1
- package/dist/format-action.d.ts +25 -0
- package/dist/format-action.d.ts.map +1 -0
- package/dist/format-action.js +250 -0
- package/dist/format-action.js.map +1 -0
- package/dist/index.d.ts +119 -15
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +231 -59
- package/dist/index.js.map +1 -1
- package/dist/issue-graph.d.ts +37 -0
- package/dist/issue-graph.d.ts.map +1 -0
- package/dist/issue-graph.js +125 -0
- package/dist/issue-graph.js.map +1 -0
- package/dist/issues.d.ts +27 -2
- package/dist/issues.d.ts.map +1 -1
- package/dist/issues.js +33 -10
- package/dist/issues.js.map +1 -1
- package/dist/labels.d.ts +48 -1
- package/dist/labels.d.ts.map +1 -1
- package/dist/labels.js +72 -24
- package/dist/labels.js.map +1 -1
- package/dist/linear-client.d.ts +51 -0
- package/dist/linear-client.d.ts.map +1 -1
- package/dist/linear-client.js +233 -34
- package/dist/linear-client.js.map +1 -1
- package/dist/oauth.d.ts +52 -12
- package/dist/oauth.d.ts.map +1 -1
- package/dist/oauth.js +91 -34
- package/dist/oauth.js.map +1 -1
- package/dist/recording-client.d.ts +20 -2
- package/dist/recording-client.d.ts.map +1 -1
- package/dist/recording-client.js +39 -1
- package/dist/recording-client.js.map +1 -1
- package/dist/responding-client.d.ts +49 -0
- package/dist/responding-client.d.ts.map +1 -0
- package/dist/responding-client.js +47 -0
- package/dist/responding-client.js.map +1 -0
- package/dist/teams.d.ts +20 -0
- package/dist/teams.d.ts.map +1 -0
- package/dist/teams.js +32 -0
- package/dist/teams.js.map +1 -0
- package/package.json +8 -10
- package/src/batch-source.ts +208 -0
- package/src/comments.ts +126 -0
- package/src/documents.ts +162 -0
- package/src/format-action.ts +279 -0
- package/src/index.ts +617 -0
- package/src/issue-graph.ts +169 -0
- package/src/issues.ts +142 -0
- package/src/labels.ts +254 -0
- package/src/linear-client.ts +448 -0
- package/src/oauth.ts +255 -0
- package/src/recording-client.ts +141 -0
- package/src/responding-client.ts +106 -0
- package/src/teams.ts +44 -0
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Snapshot a parent control issue as a deterministic child-sourced Batch (DHK-1154; ADRs 0016-0017).
|
|
3
|
+
*
|
|
4
|
+
* Assigning or @mentioning Dahrk on a PARENT issue launches its direct child work as one immutable
|
|
5
|
+
* Batch. This module takes ONE credential-resolved read of the parent's direct, nonterminal children
|
|
6
|
+
* (the control issue itself is never included - children only) and normalises it into a
|
|
7
|
+
* {@link ParentBatchSnapshot}: an ordered list of {@link BatchMember}s keyed by stable issue UUID, the
|
|
8
|
+
* explicit `blocks` edges BETWEEN members ({@link BatchBlockEdge}), and any unsettled blocker OUTSIDE
|
|
9
|
+
* the member set that must block creation (`externalBlockers`).
|
|
10
|
+
*
|
|
11
|
+
* Two rules pin the dependency model:
|
|
12
|
+
* - Containment is not a dependency. A parent/child relation NEVER produces an edge; only an explicit
|
|
13
|
+
* Linear `blocks` relation does. Children carry only their own `blocks` blockers, so containment is
|
|
14
|
+
* structurally incapable of creating one here.
|
|
15
|
+
* - "External" is relative to the SELECTED source. A blocker that is itself another selected child is
|
|
16
|
+
* an internal edge, not an external blocker, even when unsettled - that is a dependency to sequence,
|
|
17
|
+
* not a refusal. The membership test runs before the external-blocker test.
|
|
18
|
+
*
|
|
19
|
+
* The `ParentBatchSource` seam keeps the assembly pure and unit-testable (the `collectRelatedIssues`
|
|
20
|
+
* pattern from `issue-graph.ts`): the live impl wraps the SDK, tests inject a fake.
|
|
21
|
+
*/
|
|
22
|
+
import { LinearClient } from "@linear/sdk";
|
|
23
|
+
import type { BatchMember, BatchBlockEdge } from "@dahrk/contracts";
|
|
24
|
+
import { isBlockerSettled } from "./labels.js";
|
|
25
|
+
|
|
26
|
+
/** An issue that `blocks` a child, as read from the child's `inverseRelations`. `stateType` decides
|
|
27
|
+
* whether it has settled (terminal); `stateName` is carried for the decline note only, never matched. */
|
|
28
|
+
export interface RawBlocker {
|
|
29
|
+
id?: string;
|
|
30
|
+
identifier?: string;
|
|
31
|
+
stateType?: string;
|
|
32
|
+
stateName?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** A child issue as returned by Linear before normalisation, with its state and blockers resolved. */
|
|
36
|
+
export interface RawChild {
|
|
37
|
+
id?: string;
|
|
38
|
+
identifier?: string;
|
|
39
|
+
/** The child's workflow-state category (`state.type`); terminal children are dropped. */
|
|
40
|
+
stateType?: string;
|
|
41
|
+
/** Linear priority (`0` = none, `1` = urgent … `4` = low). */
|
|
42
|
+
priority?: number;
|
|
43
|
+
/** The child's rank within the parent (`subIssueSortOrder`, falling back to `sortOrder`). */
|
|
44
|
+
containerRank?: number;
|
|
45
|
+
/** The issues that `blocks` this child (its `inverseRelations` of type `blocks`). */
|
|
46
|
+
blockers: RawBlocker[];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The read seam the assembly depends on; the live impl wraps the SDK, tests inject a fake. */
|
|
50
|
+
export interface ParentBatchSource {
|
|
51
|
+
/** The parent's direct children, each with its state and its `blocks` blockers, in any order. */
|
|
52
|
+
children(parentIssueId: string): Promise<RawChild[]>;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** One unsettled blocker outside the selected source that prevents Batch creation. Shaped like the
|
|
56
|
+
* hub's `OpenBlocker` so the decline note names it (`identifier` is the key; `stateName` is display). */
|
|
57
|
+
export interface ExternalBlocker {
|
|
58
|
+
identifier: string;
|
|
59
|
+
stateName: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The normalised parent-Batch snapshot: the ordered members, the internal `blocks` edges between them,
|
|
63
|
+
* and any external blocker that must block creation. */
|
|
64
|
+
export interface ParentBatchSnapshot {
|
|
65
|
+
members: BatchMember[];
|
|
66
|
+
blocks: BatchBlockEdge[];
|
|
67
|
+
externalBlockers: ExternalBlocker[];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** How many children ride one snapshot. A parent with more direct children than this is unusual; the
|
|
71
|
+
* cap keeps the intake read bounded, mirroring the neighbours read in `issue-graph.ts`. */
|
|
72
|
+
export const MAX_BATCH_CHILDREN = 200;
|
|
73
|
+
|
|
74
|
+
/** Sort key for priority: urgent (1) first, none (0) last. Linear priority is `0=none,1=urgent..4=low`,
|
|
75
|
+
* so "order by priority" must map `0` to the end rather than the front. Pure, pinned by a test. */
|
|
76
|
+
function priorityRank(priority: number): number {
|
|
77
|
+
return priority === 0 ? Number.POSITIVE_INFINITY : priority;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Collect the parent's direct nonterminal children as a {@link ParentBatchSnapshot} from a source: drop
|
|
82
|
+
* children with no id/identifier; drop terminal (`completed`/`canceled`) children; classify each
|
|
83
|
+
* member's `blocks` blockers into internal edges (blocker is another member) or external blockers
|
|
84
|
+
* (blocker is outside the set and unsettled); order members by priority, then container rank, then
|
|
85
|
+
* identifier. Pure of any network.
|
|
86
|
+
*/
|
|
87
|
+
export async function collectParentBatchSource(
|
|
88
|
+
source: ParentBatchSource,
|
|
89
|
+
parentIssueId: string,
|
|
90
|
+
): Promise<ParentBatchSnapshot> {
|
|
91
|
+
const raw = await source.children(parentIssueId);
|
|
92
|
+
|
|
93
|
+
// Keep only real, nonterminal children. A child needs a stable id AND a human identifier; a terminal
|
|
94
|
+
// child (settled state) is finished work, not something to launch.
|
|
95
|
+
const kept: RawChild[] = [];
|
|
96
|
+
for (const c of raw) {
|
|
97
|
+
const id = c.id?.trim();
|
|
98
|
+
const identifier = c.identifier?.trim();
|
|
99
|
+
if (!id || !identifier) continue;
|
|
100
|
+
if (isBlockerSettled(c.stateType)) continue;
|
|
101
|
+
kept.push(c);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const memberIds = new Set(kept.map((c) => c.id!.trim()));
|
|
105
|
+
|
|
106
|
+
const members: BatchMember[] = kept.map((c) => ({
|
|
107
|
+
issueId: c.id!.trim(),
|
|
108
|
+
issueIdentifier: c.identifier!.trim(),
|
|
109
|
+
stateType: (c.stateType ?? "").trim() || "unknown",
|
|
110
|
+
priority: c.priority ?? 0,
|
|
111
|
+
containerRank: c.containerRank ?? 0,
|
|
112
|
+
}));
|
|
113
|
+
|
|
114
|
+
// Deterministic order: urgent priority first (none last), then container rank ascending, then the
|
|
115
|
+
// human identifier. Every downstream reader sees the same sequence, so admission is replayable.
|
|
116
|
+
members.sort(
|
|
117
|
+
(a, b) =>
|
|
118
|
+
priorityRank(a.priority) - priorityRank(b.priority) ||
|
|
119
|
+
a.containerRank - b.containerRank ||
|
|
120
|
+
a.issueIdentifier.localeCompare(b.issueIdentifier),
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
// Classify blockers in member order so edges and external blockers are deterministic. A blocker that
|
|
124
|
+
// is itself a selected member is an internal edge (a dependency to sequence); one outside the set that
|
|
125
|
+
// has not settled is an external blocker (a refusal); a settled external blocker is simply done.
|
|
126
|
+
const byId = new Map(kept.map((c) => [c.id!.trim(), c]));
|
|
127
|
+
const blocks: BatchBlockEdge[] = [];
|
|
128
|
+
const externalBlockers: ExternalBlocker[] = [];
|
|
129
|
+
const seenExternal = new Set<string>();
|
|
130
|
+
for (const m of members) {
|
|
131
|
+
const c = byId.get(m.issueId)!;
|
|
132
|
+
for (const b of c.blockers) {
|
|
133
|
+
const blockerId = b.id?.trim();
|
|
134
|
+
if (blockerId && memberIds.has(blockerId)) {
|
|
135
|
+
blocks.push({ blockerIssueId: blockerId, blockedIssueId: m.issueId });
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
if (isBlockerSettled(b.stateType)) continue;
|
|
139
|
+
const identifier = (b.identifier ?? "").trim() || "unknown";
|
|
140
|
+
if (seenExternal.has(identifier)) continue;
|
|
141
|
+
seenExternal.add(identifier);
|
|
142
|
+
externalBlockers.push({ identifier, stateName: (b.stateName ?? "").trim() || "unknown" });
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return { members, blocks, externalBlockers };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** The live `ParentBatchSource`, backed by a Linear bearer token. Uses the typed SDK: the parent's
|
|
150
|
+
* `children`, each child's `state`/`priority`/`subIssueSortOrder`/`sortOrder`, and each child's
|
|
151
|
+
* `inverseRelations` of type `blocks` resolved to `{id, identifier, state}`. Capped like the
|
|
152
|
+
* neighbours read. */
|
|
153
|
+
export function linearParentBatchSource(token: string): ParentBatchSource {
|
|
154
|
+
const client = new LinearClient({ accessToken: token });
|
|
155
|
+
return {
|
|
156
|
+
async children(parentIssueId) {
|
|
157
|
+
const parent = await client.issue(parentIssueId);
|
|
158
|
+
const conn = await parent.children({ first: MAX_BATCH_CHILDREN });
|
|
159
|
+
const children: RawChild[] = [];
|
|
160
|
+
for (const c of conn.nodes) {
|
|
161
|
+
const [state, inverse] = await Promise.all([
|
|
162
|
+
c.state,
|
|
163
|
+
// A `blocks` relation pointing AT the child means that issue is the child's blocker (the same
|
|
164
|
+
// direction `fetchOpenBlockers` reads). `relations` would return what the child blocks - the
|
|
165
|
+
// opposite question.
|
|
166
|
+
c.inverseRelations({ first: 25 }),
|
|
167
|
+
]);
|
|
168
|
+
const blockers: RawBlocker[] = [];
|
|
169
|
+
for (const rel of inverse.nodes) {
|
|
170
|
+
if (rel.type !== "blocks") continue;
|
|
171
|
+
const other = await rel.issue;
|
|
172
|
+
if (!other) continue;
|
|
173
|
+
const bState = await other.state;
|
|
174
|
+
blockers.push({
|
|
175
|
+
...(other.id ? { id: other.id } : {}),
|
|
176
|
+
...(other.identifier ? { identifier: other.identifier } : {}),
|
|
177
|
+
...(bState?.type ? { stateType: bState.type } : {}),
|
|
178
|
+
...(bState?.name ? { stateName: bState.name } : {}),
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
// `subIssueSortOrder` is the rank within the parent; fall back to `sortOrder` when absent.
|
|
182
|
+
const containerRank =
|
|
183
|
+
(c as { subIssueSortOrder?: number }).subIssueSortOrder ?? c.sortOrder ?? 0;
|
|
184
|
+
children.push({
|
|
185
|
+
...(c.id ? { id: c.id } : {}),
|
|
186
|
+
...(c.identifier ? { identifier: c.identifier } : {}),
|
|
187
|
+
...(state?.type ? { stateType: state.type } : {}),
|
|
188
|
+
...(typeof c.priority === "number" ? { priority: c.priority } : {}),
|
|
189
|
+
containerRank,
|
|
190
|
+
blockers,
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
return children;
|
|
194
|
+
},
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Take the parent-Batch snapshot for `parentIssueId` as a {@link ParentBatchSnapshot}, ready for the
|
|
200
|
+
* hub to persist. The single entry point the hub calls (mirrors `fetchRelatedIssues`). A hard failure
|
|
201
|
+
* (auth, network) propagates so the caller can log and proceed.
|
|
202
|
+
*/
|
|
203
|
+
export async function fetchParentBatchSource(
|
|
204
|
+
token: string,
|
|
205
|
+
parentIssueId: string,
|
|
206
|
+
): Promise<ParentBatchSnapshot> {
|
|
207
|
+
return collectParentBatchSource(linearParentBatchSource(token), parentIssueId);
|
|
208
|
+
}
|
package/src/comments.ts
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fetch the comment thread on a Linear issue, so the hub can snapshot it into a run and surface it to
|
|
3
|
+
* the agent. Until now nothing in the harness read comment bodies at all: an agent saw only whatever
|
|
4
|
+
* Linear happened to inline in the webhook's `promptContext`, so the conversation that actually
|
|
5
|
+
* decided the shape of a ticket was invisible to the stage doing the work.
|
|
6
|
+
*
|
|
7
|
+
* The one piece of logic that is not a plain read: the app user's OWN comments are dropped. The
|
|
8
|
+
* harness posts its stage summaries back through `commentOnIssue`, so without this filter every stage
|
|
9
|
+
* would read the previous stage's summary as though a human had written it - the run talking to itself,
|
|
10
|
+
* with the noise compounding on each continuation.
|
|
11
|
+
*
|
|
12
|
+
* The `CommentSource` seam keeps the assembly pure and unit-testable: the live source
|
|
13
|
+
* (`linearCommentSource`) speaks GraphQL, while tests inject a fake.
|
|
14
|
+
*/
|
|
15
|
+
import { LinearClient } from "@linear/sdk";
|
|
16
|
+
import type { IssueComment } from "@dahrk/contracts";
|
|
17
|
+
|
|
18
|
+
/** A comment as returned by Linear before normalisation. */
|
|
19
|
+
export interface RawComment {
|
|
20
|
+
id: string;
|
|
21
|
+
body?: string;
|
|
22
|
+
createdAt?: Date | string;
|
|
23
|
+
/** Author user id, compared against the app user id to drop the harness's own posts. */
|
|
24
|
+
authorId?: string;
|
|
25
|
+
/** Author display name, surfaced to the agent. */
|
|
26
|
+
authorName?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The read seam the assembly depends on; the live impl wraps GraphQL, tests inject a fake. */
|
|
30
|
+
export interface CommentSource {
|
|
31
|
+
/** The issue's comments, in whatever order Linear returns them. */
|
|
32
|
+
issueComments(issueId: string): Promise<RawComment[]>;
|
|
33
|
+
/** The id of the app user this token authenticates as, or null when it cannot be resolved. */
|
|
34
|
+
appUserId(): Promise<string | null>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Normalise a timestamp Linear may hand back as a Date or a string into ISO 8601. */
|
|
38
|
+
function isoOf(value: Date | string | undefined): string {
|
|
39
|
+
if (value instanceof Date) return value.toISOString();
|
|
40
|
+
if (typeof value === "string" && value.trim()) {
|
|
41
|
+
const parsed = new Date(value);
|
|
42
|
+
return Number.isNaN(parsed.getTime()) ? "" : parsed.toISOString();
|
|
43
|
+
}
|
|
44
|
+
return "";
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Collect an issue's comments from a source: drop the app user's own posts, drop empty bodies, and
|
|
49
|
+
* order oldest first so the thread reads as a conversation. Pure of any network - the source does the
|
|
50
|
+
* I/O.
|
|
51
|
+
*
|
|
52
|
+
* Comments with no resolvable timestamp sort last rather than being dropped: an unorderable comment is
|
|
53
|
+
* still content worth showing, and losing it silently would be worse than showing it out of order.
|
|
54
|
+
*/
|
|
55
|
+
export async function collectIssueComments(
|
|
56
|
+
source: CommentSource,
|
|
57
|
+
issueId: string,
|
|
58
|
+
): Promise<IssueComment[]> {
|
|
59
|
+
const [raw, appUserId] = await Promise.all([source.issueComments(issueId), source.appUserId()]);
|
|
60
|
+
const out: IssueComment[] = [];
|
|
61
|
+
for (const comment of raw) {
|
|
62
|
+
if (appUserId && comment.authorId === appUserId) continue;
|
|
63
|
+
const body = (comment.body ?? "").trim();
|
|
64
|
+
if (!body) continue;
|
|
65
|
+
out.push({
|
|
66
|
+
id: comment.id,
|
|
67
|
+
author: (comment.authorName ?? "").trim(),
|
|
68
|
+
createdAt: isoOf(comment.createdAt),
|
|
69
|
+
body,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
return out.sort((a, b) => {
|
|
73
|
+
if (!a.createdAt) return 1;
|
|
74
|
+
if (!b.createdAt) return -1;
|
|
75
|
+
return a.createdAt.localeCompare(b.createdAt);
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** The live `CommentSource`, backed by a Linear bearer token. Uses the typed SDK. */
|
|
80
|
+
export function linearCommentSource(token: string): CommentSource {
|
|
81
|
+
const client = new LinearClient({ accessToken: token });
|
|
82
|
+
return {
|
|
83
|
+
async issueComments(issueId) {
|
|
84
|
+
// Bounded, unpaginated cap, matching `linearDocumentSource.issueDocuments`: a thread longer than
|
|
85
|
+
// 100 comments silently drops the overflow. Deliberate, not a bug - the prompt inlines only the
|
|
86
|
+
// most recent few thousand characters anyway. Revisit with cursor pagination if it bites.
|
|
87
|
+
const issue = await client.issue(issueId);
|
|
88
|
+
const conn = await issue.comments({ first: 100 });
|
|
89
|
+
const nodes = await Promise.all(
|
|
90
|
+
conn.nodes.map(async (c) => {
|
|
91
|
+
// `user` is a lazy reference on the SDK's Comment; resolving it per comment is what lets us
|
|
92
|
+
// both filter the app user's posts and name the human author. A comment whose author does not
|
|
93
|
+
// resolve (a deleted user, an integration post) still rides through, unattributed.
|
|
94
|
+
const user = await c.user;
|
|
95
|
+
return {
|
|
96
|
+
id: c.id,
|
|
97
|
+
body: c.body,
|
|
98
|
+
createdAt: c.createdAt,
|
|
99
|
+
...(user?.id ? { authorId: user.id } : {}),
|
|
100
|
+
...(user?.displayName || user?.name ? { authorName: user.displayName ?? user.name } : {}),
|
|
101
|
+
} satisfies RawComment;
|
|
102
|
+
}),
|
|
103
|
+
);
|
|
104
|
+
return nodes;
|
|
105
|
+
},
|
|
106
|
+
async appUserId() {
|
|
107
|
+
try {
|
|
108
|
+
const viewer = await client.viewer;
|
|
109
|
+
return viewer?.id ?? null;
|
|
110
|
+
} catch {
|
|
111
|
+
// Failing to resolve the app user must not lose the thread. We return null and accept that the
|
|
112
|
+
// harness's own comments ride through this once, rather than dropping every comment.
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
},
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Fetch an issue's comment thread as contract `IssueComment`s, ready to snapshot into a run. The
|
|
121
|
+
* single entry point the hub calls. A hard failure (auth, network) propagates so the caller can log
|
|
122
|
+
* and proceed with none.
|
|
123
|
+
*/
|
|
124
|
+
export async function fetchIssueComments(token: string, issueId: string): Promise<IssueComment[]> {
|
|
125
|
+
return collectIssueComments(linearCommentSource(token), issueId);
|
|
126
|
+
}
|
package/src/documents.ts
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fetch the Linear Documents attached to an issue, so the hub can snapshot their content into a run
|
|
3
|
+
* and surface it to the agent (the harness was webhook-payload-only before, so a document reached the
|
|
4
|
+
* agent only as a URL, never as content). This is a read against the same per-connection Linear token
|
|
5
|
+
* the hub already uses to post back; fetching content is data assembly, not control flow.
|
|
6
|
+
*
|
|
7
|
+
* Two sources, de-duped by document id:
|
|
8
|
+
* 1. Documents directly associated with the issue (`issue.documents`).
|
|
9
|
+
* 2. Documents linked via the issue's attachments whose `url` is a Linear document URL
|
|
10
|
+
* (`https://linear.app/<ws>/document/<...>-<slug>`), resolved through `document(id:)`.
|
|
11
|
+
*
|
|
12
|
+
* The `DocumentSource` seam keeps the assembly pure and unit-testable: the live source
|
|
13
|
+
* (`linearDocumentSource`) speaks GraphQL, while tests inject a fake.
|
|
14
|
+
*/
|
|
15
|
+
import { LinearClient } from "@linear/sdk";
|
|
16
|
+
import type { AttachedDocument } from "@dahrk/contracts";
|
|
17
|
+
|
|
18
|
+
/** A document as returned by Linear before slug normalisation (slugId is Linear's URL-safe slug). */
|
|
19
|
+
export interface RawDocument {
|
|
20
|
+
id: string;
|
|
21
|
+
slugId?: string;
|
|
22
|
+
title?: string;
|
|
23
|
+
url?: string;
|
|
24
|
+
content?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The read seam the assembly depends on; the live impl wraps GraphQL, tests inject a fake. */
|
|
28
|
+
export interface DocumentSource {
|
|
29
|
+
/** Documents directly associated with the issue. */
|
|
30
|
+
issueDocuments(issueId: string): Promise<RawDocument[]>;
|
|
31
|
+
/** The issue's attachment URLs (only the `url` is needed to spot Linear document links). */
|
|
32
|
+
issueAttachmentUrls(issueId: string): Promise<string[]>;
|
|
33
|
+
/** Resolve a Linear document by the slug taken from its URL; null when it does not resolve. */
|
|
34
|
+
documentBySlug(slug: string): Promise<RawDocument | null>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The Linear-document slug from an attachment URL, or null when the URL is not a Linear document
|
|
38
|
+
* link. Linear document URLs look like `https://linear.app/<ws>/document/<title-slug>-<slugId>`;
|
|
39
|
+
* the canonical slug Linear's `document(id:)` accepts is the trailing token (the slugId), which is
|
|
40
|
+
* the substring after the final `-`. Falls back to the whole segment when there is no `-`. */
|
|
41
|
+
export function documentSlugFromUrl(url: string): string | null {
|
|
42
|
+
const m = /\/document\/([^/?#]+)/.exec(url);
|
|
43
|
+
if (!m?.[1]) return null;
|
|
44
|
+
// The URL is attacker-influenceable (anyone who can attach a link to the issue). A malformed
|
|
45
|
+
// percent-escape makes decodeURIComponent throw; swallow it and skip this link rather than let it
|
|
46
|
+
// abort the whole fetch (which would discard the issue's other documents too).
|
|
47
|
+
let segment: string;
|
|
48
|
+
try {
|
|
49
|
+
segment = decodeURIComponent(m[1]);
|
|
50
|
+
} catch {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
const dash = segment.lastIndexOf("-");
|
|
54
|
+
return dash >= 0 && dash < segment.length - 1 ? segment.slice(dash + 1) : segment;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** A filesystem- and URL-safe slug for the scratch filename: prefer Linear's slugId, else slugify the
|
|
58
|
+
* title, else the id. Bounded length so a long title cannot produce an unwieldy path. */
|
|
59
|
+
export function documentSlug(doc: RawDocument): string {
|
|
60
|
+
if (doc.slugId && doc.slugId.trim()) return doc.slugId.trim();
|
|
61
|
+
const fromTitle = (doc.title ?? "")
|
|
62
|
+
.toLowerCase()
|
|
63
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
64
|
+
.replace(/^-+|-+$/g, "")
|
|
65
|
+
.slice(0, 60);
|
|
66
|
+
return fromTitle || doc.id;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Normalise a RawDocument into the contract shape, dropping ones with no usable content. */
|
|
70
|
+
function toAttached(doc: RawDocument): AttachedDocument | null {
|
|
71
|
+
const content = doc.content ?? "";
|
|
72
|
+
if (!content.trim()) return null;
|
|
73
|
+
return {
|
|
74
|
+
id: doc.id,
|
|
75
|
+
slug: documentSlug(doc),
|
|
76
|
+
title: (doc.title ?? "").trim() || "Untitled document",
|
|
77
|
+
url: doc.url ?? "",
|
|
78
|
+
content,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Collect the issue's attached documents from a source, de-duped by id (issue documents first, then
|
|
84
|
+
* attachment-linked documents). Pure of any network: the source does the I/O. Best-effort - an
|
|
85
|
+
* attachment whose document does not resolve is skipped, never fatal.
|
|
86
|
+
*/
|
|
87
|
+
export async function collectAttachedDocuments(
|
|
88
|
+
source: DocumentSource,
|
|
89
|
+
issueId: string,
|
|
90
|
+
): Promise<AttachedDocument[]> {
|
|
91
|
+
const out: AttachedDocument[] = [];
|
|
92
|
+
const seen = new Set<string>();
|
|
93
|
+
const add = (doc: RawDocument | null): void => {
|
|
94
|
+
if (!doc || seen.has(doc.id)) return;
|
|
95
|
+
const attached = toAttached(doc);
|
|
96
|
+
if (!attached) return;
|
|
97
|
+
seen.add(doc.id);
|
|
98
|
+
out.push(attached);
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
for (const doc of await source.issueDocuments(issueId)) add(doc);
|
|
102
|
+
|
|
103
|
+
const urls = await source.issueAttachmentUrls(issueId);
|
|
104
|
+
const slugs = [...new Set(urls.map(documentSlugFromUrl).filter((s): s is string => s !== null))];
|
|
105
|
+
for (const slug of slugs) {
|
|
106
|
+
try {
|
|
107
|
+
add(await source.documentBySlug(slug));
|
|
108
|
+
} catch {
|
|
109
|
+
// best-effort: a deleted/unauthorised document link is skipped, not fatal.
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return out;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** The live `DocumentSource`, backed by a Linear bearer token. Uses the typed SDK. */
|
|
116
|
+
export function linearDocumentSource(token: string): DocumentSource {
|
|
117
|
+
const client = new LinearClient({ accessToken: token });
|
|
118
|
+
return {
|
|
119
|
+
async issueDocuments(issueId) {
|
|
120
|
+
// Bounded, unpaginated cap: an issue with more than 50 attached documents (or 100 attachments
|
|
121
|
+
// below) silently drops the overflow. That is a deliberate limit, not a bug; revisit with cursor
|
|
122
|
+
// pagination if real issues approach it.
|
|
123
|
+
const issue = await client.issue(issueId);
|
|
124
|
+
const conn = await issue.documents({ first: 50 });
|
|
125
|
+
return conn.nodes.map((d) => ({
|
|
126
|
+
id: d.id,
|
|
127
|
+
slugId: d.slugId,
|
|
128
|
+
title: d.title,
|
|
129
|
+
url: d.url,
|
|
130
|
+
content: d.content ?? undefined,
|
|
131
|
+
}));
|
|
132
|
+
},
|
|
133
|
+
async issueAttachmentUrls(issueId) {
|
|
134
|
+
const issue = await client.issue(issueId);
|
|
135
|
+
const conn = await issue.attachments({ first: 100 });
|
|
136
|
+
return conn.nodes.map((a) => a.url).filter((u) => u.length > 0);
|
|
137
|
+
},
|
|
138
|
+
async documentBySlug(slug) {
|
|
139
|
+
const doc = await client.document(slug);
|
|
140
|
+
return {
|
|
141
|
+
id: doc.id,
|
|
142
|
+
slugId: doc.slugId,
|
|
143
|
+
title: doc.title,
|
|
144
|
+
url: doc.url,
|
|
145
|
+
content: doc.content ?? undefined,
|
|
146
|
+
};
|
|
147
|
+
},
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Fetch the documents attached to a Linear issue as contract `AttachedDocument`s, ready to snapshot
|
|
153
|
+
* into a run. The single entry point the hub calls; resolves the source from the token and runs the
|
|
154
|
+
* pure assembly. Never throws on a single bad document link (best-effort), but a hard failure (auth,
|
|
155
|
+
* network) propagates so the caller can log and proceed with none.
|
|
156
|
+
*/
|
|
157
|
+
export async function fetchAttachedDocuments(
|
|
158
|
+
token: string,
|
|
159
|
+
issueId: string,
|
|
160
|
+
): Promise<AttachedDocument[]> {
|
|
161
|
+
return collectAttachedDocuments(linearDocumentSource(token), issueId);
|
|
162
|
+
}
|