@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,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fetch the metadata of the issues one hop from a run's issue - parent, children, blockers, blocked,
|
|
3
|
+
* related - so the hub can snapshot a manifest into the run and tell the agent WHAT EXISTS around the
|
|
4
|
+
* ticket it is working.
|
|
5
|
+
*
|
|
6
|
+
* This is deliberately metadata only: identifier, title, relation, state. No descriptions, no comments,
|
|
7
|
+
* no documents. That keeps the manifest cheap enough to send on every run, and it is the piece that
|
|
8
|
+
* makes on-demand context work at all - an agent that cannot see there is a spike or an epic attached
|
|
9
|
+
* will never think to open one.
|
|
10
|
+
*
|
|
11
|
+
* Distinct from `fetchOpenBlockers` in `labels.ts`, which reads the same edges for the deterministic
|
|
12
|
+
* blocked guard: that one answers "may this run proceed" and feeds control flow, this one answers
|
|
13
|
+
* "what surrounds this ticket" and is context only. They are kept apart on purpose so a change to the
|
|
14
|
+
* manifest can never alter a guard.
|
|
15
|
+
*
|
|
16
|
+
* The `IssueGraphSource` seam keeps the assembly pure and unit-testable.
|
|
17
|
+
*/
|
|
18
|
+
import { LinearClient } from "@linear/sdk";
|
|
19
|
+
import type { IssueRelation, RelatedIssue } from "@dahrk/contracts";
|
|
20
|
+
|
|
21
|
+
/** An issue as returned by Linear before normalisation, with its state already resolved. */
|
|
22
|
+
export interface RawRelatedIssue {
|
|
23
|
+
identifier?: string;
|
|
24
|
+
title?: string;
|
|
25
|
+
url?: string;
|
|
26
|
+
stateName?: string;
|
|
27
|
+
stateType?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** One edge: the neighbouring issue plus how it sits relative to the subject issue. */
|
|
31
|
+
export interface RawEdge {
|
|
32
|
+
relation: IssueRelation;
|
|
33
|
+
issue: RawRelatedIssue | null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The read seam the assembly depends on; the live impl wraps GraphQL, tests inject a fake. */
|
|
37
|
+
export interface IssueGraphSource {
|
|
38
|
+
/** Every one-hop edge from the issue, in any order. */
|
|
39
|
+
issueEdges(issueId: string): Promise<RawEdge[]>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** How many neighbours ride the manifest. Beyond this the prompt stops being a summary. */
|
|
43
|
+
export const MAX_RELATED_ISSUES = 50;
|
|
44
|
+
|
|
45
|
+
/** Stable ordering for the manifest: the structurally important edges first, so a truncated list keeps
|
|
46
|
+
* what matters. Parent and blockers change how the work should be done; `related` is a loose,
|
|
47
|
+
* human-curated edge and is the first thing worth losing. */
|
|
48
|
+
const RELATION_ORDER: Record<IssueRelation, number> = {
|
|
49
|
+
parent: 0,
|
|
50
|
+
blocker: 1,
|
|
51
|
+
child: 2,
|
|
52
|
+
blocked: 3,
|
|
53
|
+
related: 4,
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Collect the issue's one-hop neighbourhood from a source: drop edges whose issue did not resolve or
|
|
58
|
+
* carries no identifier, de-dupe by identifier (an issue can be reachable by more than one edge -
|
|
59
|
+
* keep the structurally strongest, which is the lowest `RELATION_ORDER`), sort, and cap. Pure of any
|
|
60
|
+
* network.
|
|
61
|
+
*/
|
|
62
|
+
export async function collectRelatedIssues(
|
|
63
|
+
source: IssueGraphSource,
|
|
64
|
+
issueId: string,
|
|
65
|
+
): Promise<RelatedIssue[]> {
|
|
66
|
+
const byKey = new Map<string, RelatedIssue>();
|
|
67
|
+
for (const edge of await source.issueEdges(issueId)) {
|
|
68
|
+
const issue = edge.issue;
|
|
69
|
+
const key = issue?.identifier?.trim();
|
|
70
|
+
if (!issue || !key) continue;
|
|
71
|
+
const candidate: RelatedIssue = {
|
|
72
|
+
key,
|
|
73
|
+
title: (issue.title ?? "").trim() || "Untitled issue",
|
|
74
|
+
relation: edge.relation,
|
|
75
|
+
stateName: (issue.stateName ?? "").trim() || "unknown",
|
|
76
|
+
stateType: (issue.stateType ?? "").trim() || "unknown",
|
|
77
|
+
url: issue.url ?? "",
|
|
78
|
+
};
|
|
79
|
+
const existing = byKey.get(key);
|
|
80
|
+
if (!existing || RELATION_ORDER[candidate.relation] < RELATION_ORDER[existing.relation]) {
|
|
81
|
+
byKey.set(key, candidate);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return [...byKey.values()]
|
|
85
|
+
.sort(
|
|
86
|
+
(a, b) => RELATION_ORDER[a.relation] - RELATION_ORDER[b.relation] || a.key.localeCompare(b.key),
|
|
87
|
+
)
|
|
88
|
+
.slice(0, MAX_RELATED_ISSUES);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The live `IssueGraphSource`, backed by a Linear bearer token. Uses the typed SDK. */
|
|
92
|
+
export function linearIssueGraphSource(token: string): IssueGraphSource {
|
|
93
|
+
const client = new LinearClient({ accessToken: token });
|
|
94
|
+
return {
|
|
95
|
+
async issueEdges(issueId) {
|
|
96
|
+
const issue = await client.issue(issueId);
|
|
97
|
+
|
|
98
|
+
// Resolve one neighbouring issue into the raw shape, including its state. Returns null when the
|
|
99
|
+
// issue or its state does not resolve, which the assembly then skips.
|
|
100
|
+
const shape = async (
|
|
101
|
+
node: {
|
|
102
|
+
identifier?: string;
|
|
103
|
+
title?: string;
|
|
104
|
+
url?: string;
|
|
105
|
+
state?: Promise<{ name?: string; type?: string } | undefined>;
|
|
106
|
+
} | null
|
|
107
|
+
| undefined,
|
|
108
|
+
): Promise<RawRelatedIssue | null> => {
|
|
109
|
+
if (!node) return null;
|
|
110
|
+
const state = await node.state;
|
|
111
|
+
return {
|
|
112
|
+
...(node.identifier ? { identifier: node.identifier } : {}),
|
|
113
|
+
...(node.title ? { title: node.title } : {}),
|
|
114
|
+
...(node.url ? { url: node.url } : {}),
|
|
115
|
+
...(state?.name ? { stateName: state.name } : {}),
|
|
116
|
+
...(state?.type ? { stateType: state.type } : {}),
|
|
117
|
+
};
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
// The four reads run concurrently; each is independently capped. `relations` is what this issue
|
|
121
|
+
// blocks/relates to, `inverseRelations` is what points AT this issue - the direction matters, and
|
|
122
|
+
// getting it backwards is the bug called out on `fetchOpenBlockers` (labels.ts).
|
|
123
|
+
const [parent, children, relations, inverseRelations] = await Promise.all([
|
|
124
|
+
issue.parent,
|
|
125
|
+
issue.children({ first: 25 }),
|
|
126
|
+
issue.relations({ first: 25 }),
|
|
127
|
+
issue.inverseRelations({ first: 25 }),
|
|
128
|
+
]);
|
|
129
|
+
|
|
130
|
+
const edges: RawEdge[] = [];
|
|
131
|
+
|
|
132
|
+
const parentShape = await shape(parent as Parameters<typeof shape>[0]);
|
|
133
|
+
if (parentShape) edges.push({ relation: "parent", issue: parentShape });
|
|
134
|
+
|
|
135
|
+
for (const child of children.nodes) {
|
|
136
|
+
edges.push({ relation: "child", issue: await shape(child as Parameters<typeof shape>[0]) });
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Outbound: `A blocks B` stored on A means B is blocked BY us; anything else is a plain relation.
|
|
140
|
+
for (const rel of relations.nodes) {
|
|
141
|
+
const related = await rel.relatedIssue;
|
|
142
|
+
edges.push({
|
|
143
|
+
relation: rel.type === "blocks" ? "blocked" : "related",
|
|
144
|
+
issue: await shape(related as Parameters<typeof shape>[0]),
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Inbound: a `blocks` relation pointing at us means that issue is OUR blocker.
|
|
149
|
+
for (const rel of inverseRelations.nodes) {
|
|
150
|
+
const other = await rel.issue;
|
|
151
|
+
edges.push({
|
|
152
|
+
relation: rel.type === "blocks" ? "blocker" : "related",
|
|
153
|
+
issue: await shape(other as Parameters<typeof shape>[0]),
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return edges;
|
|
158
|
+
},
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Fetch the issue's one-hop neighbourhood as contract `RelatedIssue`s, ready to snapshot into a run.
|
|
164
|
+
* The single entry point the hub calls. A hard failure (auth, network) propagates so the caller can
|
|
165
|
+
* log and proceed with none.
|
|
166
|
+
*/
|
|
167
|
+
export async function fetchRelatedIssues(token: string, issueId: string): Promise<RelatedIssue[]> {
|
|
168
|
+
return collectRelatedIssues(linearIssueGraphSource(token), issueId);
|
|
169
|
+
}
|
package/src/issues.ts
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auto-create a Linear triage issue when a run fails (failure-notification path). Mirrors the
|
|
3
|
+
* `LabelApi` seam in ./labels.ts: a small `TriageApi` interface so the hub stays pure and testable,
|
|
4
|
+
* plus a live adapter (`linearTriageApi`) that wraps `@linear/sdk`. It uses the hub's OWN triage
|
|
5
|
+
* token (an internal ops workspace), never a customer connection's token.
|
|
6
|
+
*/
|
|
7
|
+
import { IssueRelationType, LinearClient } from "@linear/sdk";
|
|
8
|
+
|
|
9
|
+
export interface TriageApi {
|
|
10
|
+
/** Create an issue in `teamKey` (optionally under `projectName`); returns its identifier + url.
|
|
11
|
+
* Created with NO `stateId`, so the issue lands in the team's Triage view when triage is enabled
|
|
12
|
+
* (the intake/review surface), rather than a workflow's default backlog state. */
|
|
13
|
+
createIssue(input: {
|
|
14
|
+
teamKey: string;
|
|
15
|
+
projectName?: string;
|
|
16
|
+
title: string;
|
|
17
|
+
description: string;
|
|
18
|
+
}): Promise<{ identifier: string; url: string }>;
|
|
19
|
+
/** Associate a Customer Request (CustomerNeed) with an issue (SL-359), linking customer feedback to
|
|
20
|
+
* the logged issue. Off-by-default: only called when the source carries customer identity (a
|
|
21
|
+
* `customerId` or external id). Returns the created need's id. */
|
|
22
|
+
createCustomerNeed(input: {
|
|
23
|
+
issueId: string;
|
|
24
|
+
customerId?: string;
|
|
25
|
+
customerExternalId?: string;
|
|
26
|
+
body?: string;
|
|
27
|
+
}): Promise<{ id: string }>;
|
|
28
|
+
/** Relate one issue to another (SL-379), so a triage-filed fix issue links back to the originating
|
|
29
|
+
* bug. Off-by-default: only called when the target run carries an originating Linear issue. `type`
|
|
30
|
+
* defaults to `related` (a non-directional link); `blocks` records the fix-blocks-bug direction.
|
|
31
|
+
* `issueId`/`relatedIssueId` accept a UUID or an identifier (e.g. `TEST-49`). */
|
|
32
|
+
relateIssues(input: {
|
|
33
|
+
issueId: string;
|
|
34
|
+
relatedIssueId: string;
|
|
35
|
+
type?: "related" | "blocks";
|
|
36
|
+
}): Promise<void>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Pick the right `@linear/sdk` auth field for a triage token. A personal API key (`lin_api_…`) must
|
|
40
|
+
* go in the raw `Authorization` header (the SDK's `apiKey`); an OAuth access token uses `Bearer` (the
|
|
41
|
+
* SDK's `accessToken`). Sending an API key as a Bearer token fails with "Remove the Bearer prefix…"
|
|
42
|
+
* (SL-399), which is exactly what silently broke triage logging. Exported for testing. */
|
|
43
|
+
export function linearClientAuth(token: string): { apiKey: string } | { accessToken: string } {
|
|
44
|
+
return token.startsWith("lin_api_") ? { apiKey: token } : { accessToken: token };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The live `TriageApi` backed by a Linear token (personal API key or OAuth access token). Resolves
|
|
48
|
+
* team by key and optional project by name via server-side `filter` (verified on @linear/sdk 86.0.0,
|
|
49
|
+
* DHK-579: `TeamFilter.key`/`ProjectFilter.name` return exactly the expected node - the earlier
|
|
50
|
+
* paginate-then-match workaround is no longer needed). */
|
|
51
|
+
export function linearTriageApi(token: string): TriageApi {
|
|
52
|
+
const client = new LinearClient(linearClientAuth(token));
|
|
53
|
+
return {
|
|
54
|
+
async createIssue(input) {
|
|
55
|
+
const team = (await client.teams({ filter: { key: { eq: input.teamKey } } })).nodes[0];
|
|
56
|
+
if (!team) throw new Error(`triage team not found: ${input.teamKey}`);
|
|
57
|
+
|
|
58
|
+
let projectId: string | undefined;
|
|
59
|
+
if (input.projectName) {
|
|
60
|
+
projectId = (await client.projects({ filter: { name: { eq: input.projectName } } })).nodes[0]?.id;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const payload = await client.createIssue({
|
|
64
|
+
teamId: team.id,
|
|
65
|
+
title: input.title,
|
|
66
|
+
description: input.description,
|
|
67
|
+
...(projectId ? { projectId } : {}),
|
|
68
|
+
});
|
|
69
|
+
const issue = await payload.issue;
|
|
70
|
+
return { identifier: issue?.identifier ?? "", url: issue?.url ?? "" };
|
|
71
|
+
},
|
|
72
|
+
|
|
73
|
+
async createCustomerNeed(input) {
|
|
74
|
+
// `issueId` accepts a UUID or an issue identifier (e.g. `TEST-49`); exactly one of
|
|
75
|
+
// `customerId`/`customerExternalId` identifies the customer (the SDK forbids both at once).
|
|
76
|
+
const payload = await client.createCustomerNeed({
|
|
77
|
+
issueId: input.issueId,
|
|
78
|
+
...(input.customerId ? { customerId: input.customerId } : {}),
|
|
79
|
+
...(input.customerExternalId ? { customerExternalId: input.customerExternalId } : {}),
|
|
80
|
+
...(input.body ? { body: input.body } : {}),
|
|
81
|
+
});
|
|
82
|
+
return { id: payload.needId ?? "" };
|
|
83
|
+
},
|
|
84
|
+
|
|
85
|
+
async relateIssues(input) {
|
|
86
|
+
await client.createIssueRelation({
|
|
87
|
+
issueId: input.issueId,
|
|
88
|
+
relatedIssueId: input.relatedIssueId,
|
|
89
|
+
type: input.type === "blocks" ? IssueRelationType.Blocks : IssueRelationType.Related,
|
|
90
|
+
});
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** The result of a Capture create/update: the issue's internal id plus its human identifier and url. */
|
|
96
|
+
export interface CaptureIssueResult {
|
|
97
|
+
issueId: string;
|
|
98
|
+
identifier: string;
|
|
99
|
+
url: string;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** The Capture Linear seam (DHK-1187): create an issue in a team (or under a project), or update an
|
|
103
|
+
* existing one. Unlike {@link TriageApi}, it returns the issue's internal `issueId` too (a Capture
|
|
104
|
+
* receipt needs it) and can update. Backed by a CUSTOMER connection token, never the hub triage token. */
|
|
105
|
+
export interface CaptureLinearApi {
|
|
106
|
+
createIssue(input: {
|
|
107
|
+
teamKey: string;
|
|
108
|
+
projectName?: string;
|
|
109
|
+
title: string;
|
|
110
|
+
description: string;
|
|
111
|
+
}): Promise<CaptureIssueResult>;
|
|
112
|
+
updateIssue(input: { issueId: string; title: string; description: string }): Promise<CaptureIssueResult>;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** The live {@link CaptureLinearApi} backed by a tenant connection token (API key or OAuth token). */
|
|
116
|
+
export function linearCaptureApi(token: string): CaptureLinearApi {
|
|
117
|
+
const client = new LinearClient(linearClientAuth(token));
|
|
118
|
+
return {
|
|
119
|
+
async createIssue(input) {
|
|
120
|
+
const team = (await client.teams({ filter: { key: { eq: input.teamKey } } })).nodes[0];
|
|
121
|
+
if (!team) throw new Error(`capture team not found: ${input.teamKey}`);
|
|
122
|
+
let projectId: string | undefined;
|
|
123
|
+
if (input.projectName) {
|
|
124
|
+
projectId = (await client.projects({ filter: { name: { eq: input.projectName } } })).nodes[0]?.id;
|
|
125
|
+
}
|
|
126
|
+
const payload = await client.createIssue({
|
|
127
|
+
teamId: team.id,
|
|
128
|
+
title: input.title,
|
|
129
|
+
description: input.description,
|
|
130
|
+
...(projectId ? { projectId } : {}),
|
|
131
|
+
});
|
|
132
|
+
const issue = await payload.issue;
|
|
133
|
+
return { issueId: issue?.id ?? "", identifier: issue?.identifier ?? "", url: issue?.url ?? "" };
|
|
134
|
+
},
|
|
135
|
+
|
|
136
|
+
async updateIssue(input) {
|
|
137
|
+
const payload = await client.updateIssue(input.issueId, { title: input.title, description: input.description });
|
|
138
|
+
const issue = await payload.issue;
|
|
139
|
+
return { issueId: issue?.id ?? input.issueId, identifier: issue?.identifier ?? "", url: issue?.url ?? "" };
|
|
140
|
+
},
|
|
141
|
+
};
|
|
142
|
+
}
|
package/src/labels.ts
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Trigger-label auto-provisioning (build spec section 14; plan item 7). A repository declares its
|
|
3
|
+
* workflows as `.dahrk/workflows/*.yaml`, each with a `select.label`; for those labels to be
|
|
4
|
+
* assignable in Linear they must exist in the workspace. Rather than make operators create them by
|
|
5
|
+
* hand, the onboarding path reads the workflow label set and creates any that are missing - once,
|
|
6
|
+
* idempotently - optionally grouped under a single "Dahrk workflow" label group.
|
|
7
|
+
*
|
|
8
|
+
* The `LabelApi` seam keeps this pure and unit-testable: the live adapter (`linearLabelApi`) wraps
|
|
9
|
+
* `@linear/sdk`, while tests inject a fake. Provisioning is read-decoupled from execution - it uses
|
|
10
|
+
* the hub's own credential, not a node's.
|
|
11
|
+
*/
|
|
12
|
+
import { LinearClient } from "@linear/sdk";
|
|
13
|
+
|
|
14
|
+
export interface LabelApi {
|
|
15
|
+
/** Existing workspace labels as a name -> id map. */
|
|
16
|
+
listLabels(): Promise<Map<string, string>>;
|
|
17
|
+
/** Create a label (or label group) and return its id. */
|
|
18
|
+
createLabel(input: {
|
|
19
|
+
name: string;
|
|
20
|
+
color?: string;
|
|
21
|
+
description?: string;
|
|
22
|
+
parentId?: string;
|
|
23
|
+
isGroup?: boolean;
|
|
24
|
+
}): Promise<string>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface ProvisionOptions {
|
|
28
|
+
/** Colour applied to created labels (hex, e.g. "#5e6ad2"). */
|
|
29
|
+
color?: string;
|
|
30
|
+
/** When set, missing labels are created under a group label of this name (created if absent). */
|
|
31
|
+
groupName?: string;
|
|
32
|
+
/** Description set on the group label when it is created. */
|
|
33
|
+
groupDescription?: string;
|
|
34
|
+
/** Build the description for each created child label. */
|
|
35
|
+
describe?: (name: string) => string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Idempotently create any missing labels via the `LabelApi` seam. Pre-existing labels (by name) are
|
|
40
|
+
* left untouched; the group, if named, is created once and reused. Returns the names actually created,
|
|
41
|
+
* so callers can log what changed. The seam is namespace-agnostic - pass `linearLabelApi` to provision
|
|
42
|
+
* issue labels or `projectLabelApi` to provision project labels.
|
|
43
|
+
*/
|
|
44
|
+
export async function provisionLabels(
|
|
45
|
+
api: LabelApi,
|
|
46
|
+
names: Iterable<string>,
|
|
47
|
+
opts: ProvisionOptions = {},
|
|
48
|
+
): Promise<string[]> {
|
|
49
|
+
const existing = await api.listLabels();
|
|
50
|
+
|
|
51
|
+
let parentId: string | undefined;
|
|
52
|
+
if (opts.groupName) {
|
|
53
|
+
parentId =
|
|
54
|
+
existing.get(opts.groupName) ??
|
|
55
|
+
(await api.createLabel({
|
|
56
|
+
name: opts.groupName,
|
|
57
|
+
isGroup: true,
|
|
58
|
+
...(opts.groupDescription ? { description: opts.groupDescription } : {}),
|
|
59
|
+
}));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const created: string[] = [];
|
|
63
|
+
for (const name of new Set(names)) {
|
|
64
|
+
if (existing.has(name)) continue;
|
|
65
|
+
await api.createLabel({
|
|
66
|
+
name,
|
|
67
|
+
...(opts.color ? { color: opts.color } : {}),
|
|
68
|
+
...(opts.describe ? { description: opts.describe(name) } : {}),
|
|
69
|
+
...(parentId ? { parentId } : {}),
|
|
70
|
+
});
|
|
71
|
+
created.push(name);
|
|
72
|
+
}
|
|
73
|
+
return created;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Idempotently create any missing workflow trigger labels (build spec section 14). Thin wrapper over
|
|
78
|
+
* `provisionLabels` with the workflow-trigger wording, preserving the onboarding behaviour.
|
|
79
|
+
*/
|
|
80
|
+
export async function provisionWorkflowLabels(
|
|
81
|
+
api: LabelApi,
|
|
82
|
+
names: Iterable<string>,
|
|
83
|
+
opts: ProvisionOptions = {},
|
|
84
|
+
): Promise<string[]> {
|
|
85
|
+
return provisionLabels(api, names, {
|
|
86
|
+
...opts,
|
|
87
|
+
...(opts.groupName ? { groupDescription: "Dahrk workflow trigger labels" } : {}),
|
|
88
|
+
describe: (name) => `Dahrk workflow trigger: ${name}`,
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** The canonical repo-selector label group: a parent group named `repo` whose children name each
|
|
93
|
+
* repository, rendered `repo › <name>` in Linear. This is the form the routing tier recognises (issue
|
|
94
|
+
* labels override, project labels default); the colon/slash string forms are back-compat only. */
|
|
95
|
+
export const REPO_LABEL_GROUP = "repo";
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Idempotently create the `repo` label group and a child per repository name, so a `repo › <name>`
|
|
99
|
+
* label is assignable in Linear. Works for either namespace: pass `linearLabelApi(token)` to create the
|
|
100
|
+
* issue-label override group, or `projectLabelApi(token)` to create the project-label default group.
|
|
101
|
+
*/
|
|
102
|
+
export async function provisionRepoLabels(
|
|
103
|
+
api: LabelApi,
|
|
104
|
+
repoNames: Iterable<string>,
|
|
105
|
+
opts: { color?: string } = {},
|
|
106
|
+
): Promise<string[]> {
|
|
107
|
+
return provisionLabels(api, repoNames, {
|
|
108
|
+
...(opts.color ? { color: opts.color } : {}),
|
|
109
|
+
groupName: REPO_LABEL_GROUP,
|
|
110
|
+
groupDescription: "Dahrk repository selector labels",
|
|
111
|
+
describe: (name) => `Dahrk repository selector: ${name}`,
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Fetch the child names of the issue's **project** `repo` label group (project labels are a separate
|
|
117
|
+
* Linear namespace from issue labels, `Project.labels`). Returns the names of project labels whose
|
|
118
|
+
* parent group is `repo` - e.g. `["dahrk-web"]` - so the hub can bind every issue in that project to
|
|
119
|
+
* a repo via the project-label routing tier. Empty when the issue has no project, no project labels, or
|
|
120
|
+
* no `repo` group. A read against the connection token; data assembly, not control flow, so the result
|
|
121
|
+
* feeds deterministic routing and is snapshotted into the run.
|
|
122
|
+
*/
|
|
123
|
+
export async function fetchIssueProjectRepoLabels(token: string, issueId: string): Promise<string[]> {
|
|
124
|
+
const client = new LinearClient({ accessToken: token });
|
|
125
|
+
const issue = await client.issue(issueId);
|
|
126
|
+
const project = await issue.project;
|
|
127
|
+
if (!project) return [];
|
|
128
|
+
const conn = await project.labels({ first: 100, filter: { parent: { name: { eqIgnoreCase: REPO_LABEL_GROUP } } } });
|
|
129
|
+
return conn.nodes.map((n) => n.name.trim());
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Count an issue's child issues (DHK-412), so the hub's epic guard can decline a parent-of-children.
|
|
134
|
+
* The webhook payload carries an issue's `<parent-issue>` but never a listing of its children, so
|
|
135
|
+
* "has children" must be resolved with a read against the connection token. Returns 0 when the issue
|
|
136
|
+
* has none (or the field is empty). Capped at `first:1` - the guard only needs "any", not an exact
|
|
137
|
+
* total - so it stays a cheap intake read. Data assembly, not control flow: the count feeds the
|
|
138
|
+
* deterministic, no-LLM epic guard.
|
|
139
|
+
*/
|
|
140
|
+
export async function fetchIssueChildCount(token: string, issueId: string): Promise<number> {
|
|
141
|
+
const client = new LinearClient({ accessToken: token });
|
|
142
|
+
const issue = await client.issue(issueId);
|
|
143
|
+
const conn = await issue.children({ first: 1 });
|
|
144
|
+
return conn.nodes.length;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** One issue that blocks another and has not settled yet. `identifier` is the human key (e.g.
|
|
148
|
+
* `DHK-777`) the decline note names; `stateName` is the team's own label for the state it sits in,
|
|
149
|
+
* carried for the note only - never matched on (see `fetchOpenBlockers`). */
|
|
150
|
+
export interface OpenBlocker {
|
|
151
|
+
identifier: string;
|
|
152
|
+
stateName: string;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Has a blocker finished, so it no longer blocks?
|
|
157
|
+
*
|
|
158
|
+
* Decided on the workflow state's `type` - Linear's own fixed vocabulary (`backlog`, `unstarted`,
|
|
159
|
+
* `started`, `completed`, `canceled`) - and NEVER on its name. Teams rename states freely: "Done"
|
|
160
|
+
* becomes "Shipped", "Released", "Merged". A name match would stop guarding for every one of those
|
|
161
|
+
* teams with nothing to notice, because the guard would simply never fire again.
|
|
162
|
+
*
|
|
163
|
+
* An absent/unknown type is treated as NOT settled: a blocker we cannot prove is finished is one we
|
|
164
|
+
* must assume is not. Pure, so the rule is pinned by a test rather than by a live Linear read.
|
|
165
|
+
*/
|
|
166
|
+
export function isBlockerSettled(stateType: string | undefined): boolean {
|
|
167
|
+
return stateType === "completed" || stateType === "canceled";
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* The issues blocking `issueId` that are not finished yet, so the hub's blocked guard can decline a
|
|
172
|
+
* run that cannot possibly succeed. The webhook payload carries no relation data at all, so this must
|
|
173
|
+
* be resolved with a read against the connection token, exactly as `fetchIssueChildCount` resolves the
|
|
174
|
+
* epic signal. Returns an empty list when nothing blocks the issue, or when every blocker has settled.
|
|
175
|
+
*
|
|
176
|
+
* The direction matters: `A blocks B` is stored on A, so B reads it through `inverseRelations`. Reading
|
|
177
|
+
* `relations` instead would return what this issue blocks - the opposite question, and one that would
|
|
178
|
+
* silently never guard anything.
|
|
179
|
+
*
|
|
180
|
+
* "Settled" is decided on the state's `type` (`completed` / `canceled`), NEVER on its name. Teams rename
|
|
181
|
+
* workflow states freely - "Done" becomes "Shipped", "Released", "Merged" - and a name match would stop
|
|
182
|
+
* guarding for those teams without any error to notice. The type is Linear's own fixed vocabulary.
|
|
183
|
+
*
|
|
184
|
+
* Capped, like the child-count read: a ticket with more open blockers than the cap is still blocked, and
|
|
185
|
+
* the note naming the first few is enough to act on. Data assembly, not control flow: the list feeds the
|
|
186
|
+
* deterministic, no-LLM blocked guard.
|
|
187
|
+
*/
|
|
188
|
+
export async function fetchOpenBlockers(token: string, issueId: string): Promise<OpenBlocker[]> {
|
|
189
|
+
const client = new LinearClient({ accessToken: token });
|
|
190
|
+
const issue = await client.issue(issueId);
|
|
191
|
+
const conn = await issue.inverseRelations({ first: 25 });
|
|
192
|
+
const open: OpenBlocker[] = [];
|
|
193
|
+
for (const relation of conn.nodes) {
|
|
194
|
+
if (relation.type !== "blocks") continue;
|
|
195
|
+
const blocker = await relation.issue;
|
|
196
|
+
if (!blocker) continue;
|
|
197
|
+
const state = await blocker.state;
|
|
198
|
+
if (isBlockerSettled(state?.type)) continue;
|
|
199
|
+
open.push({ identifier: blocker.identifier, stateName: state?.name ?? "unknown" });
|
|
200
|
+
}
|
|
201
|
+
return open;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** The live `LabelApi` backed by a Linear bearer token (workspace-scoped labels). */
|
|
205
|
+
export function linearLabelApi(token: string): LabelApi {
|
|
206
|
+
const client = new LinearClient({ accessToken: token });
|
|
207
|
+
return {
|
|
208
|
+
async listLabels() {
|
|
209
|
+
const conn = await client.issueLabels();
|
|
210
|
+
while (conn.pageInfo.hasNextPage) await conn.fetchNext();
|
|
211
|
+
const map = new Map<string, string>();
|
|
212
|
+
for (const label of conn.nodes) map.set(label.name, label.id);
|
|
213
|
+
return map;
|
|
214
|
+
},
|
|
215
|
+
async createLabel(input) {
|
|
216
|
+
const payload = await client.createIssueLabel({
|
|
217
|
+
name: input.name,
|
|
218
|
+
...(input.color ? { color: input.color } : {}),
|
|
219
|
+
...(input.description ? { description: input.description } : {}),
|
|
220
|
+
...(input.parentId ? { parentId: input.parentId } : {}),
|
|
221
|
+
...(input.isGroup ? { isGroup: true } : {}),
|
|
222
|
+
});
|
|
223
|
+
const label = await payload.issueLabel;
|
|
224
|
+
return label?.id ?? "";
|
|
225
|
+
},
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** The live `LabelApi` backed by a Linear bearer token, for **project** labels (a separate namespace
|
|
230
|
+
* from issue labels). Lets `provisionLabels`/`provisionRepoLabels` create the project-label `repo`
|
|
231
|
+
* group that drives the default routing layer. */
|
|
232
|
+
export function projectLabelApi(token: string): LabelApi {
|
|
233
|
+
const client = new LinearClient({ accessToken: token });
|
|
234
|
+
return {
|
|
235
|
+
async listLabels() {
|
|
236
|
+
const conn = await client.projectLabels();
|
|
237
|
+
while (conn.pageInfo.hasNextPage) await conn.fetchNext();
|
|
238
|
+
const map = new Map<string, string>();
|
|
239
|
+
for (const label of conn.nodes) map.set(label.name, label.id);
|
|
240
|
+
return map;
|
|
241
|
+
},
|
|
242
|
+
async createLabel(input) {
|
|
243
|
+
const payload = await client.createProjectLabel({
|
|
244
|
+
name: input.name,
|
|
245
|
+
...(input.color ? { color: input.color } : {}),
|
|
246
|
+
...(input.description ? { description: input.description } : {}),
|
|
247
|
+
...(input.parentId ? { parentId: input.parentId } : {}),
|
|
248
|
+
...(input.isGroup ? { isGroup: true } : {}),
|
|
249
|
+
});
|
|
250
|
+
const label = await payload.projectLabel;
|
|
251
|
+
return label?.id ?? "";
|
|
252
|
+
},
|
|
253
|
+
};
|
|
254
|
+
}
|