@dahrk/linear 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/README.md +24 -12
  2. package/dist/comments.d.ts +36 -0
  3. package/dist/comments.d.ts.map +1 -0
  4. package/dist/comments.js +104 -0
  5. package/dist/comments.js.map +1 -0
  6. package/dist/documents.d.ts +1 -15
  7. package/dist/documents.d.ts.map +1 -1
  8. package/dist/documents.js +37 -27
  9. package/dist/documents.js.map +1 -1
  10. package/dist/format-action.d.ts +25 -0
  11. package/dist/format-action.d.ts.map +1 -0
  12. package/dist/format-action.js +250 -0
  13. package/dist/format-action.js.map +1 -0
  14. package/dist/index.d.ts +113 -11
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +182 -58
  17. package/dist/index.js.map +1 -1
  18. package/dist/issue-graph.d.ts +37 -0
  19. package/dist/issue-graph.d.ts.map +1 -0
  20. package/dist/issue-graph.js +125 -0
  21. package/dist/issue-graph.js.map +1 -0
  22. package/dist/issues.d.ts +3 -2
  23. package/dist/issues.d.ts.map +1 -1
  24. package/dist/issues.js +5 -10
  25. package/dist/issues.js.map +1 -1
  26. package/dist/labels.d.ts +47 -0
  27. package/dist/labels.d.ts.map +1 -1
  28. package/dist/labels.js +71 -23
  29. package/dist/labels.js.map +1 -1
  30. package/dist/linear-client.d.ts +51 -0
  31. package/dist/linear-client.d.ts.map +1 -1
  32. package/dist/linear-client.js +234 -34
  33. package/dist/linear-client.js.map +1 -1
  34. package/dist/oauth.d.ts +11 -10
  35. package/dist/oauth.d.ts.map +1 -1
  36. package/dist/oauth.js +35 -32
  37. package/dist/oauth.js.map +1 -1
  38. package/dist/recording-client.d.ts +20 -2
  39. package/dist/recording-client.d.ts.map +1 -1
  40. package/dist/recording-client.js +39 -1
  41. package/dist/recording-client.js.map +1 -1
  42. package/dist/responding-client.d.ts +49 -0
  43. package/dist/responding-client.d.ts.map +1 -0
  44. package/dist/responding-client.js +47 -0
  45. package/dist/responding-client.js.map +1 -0
  46. package/dist/teams.d.ts +20 -0
  47. package/dist/teams.d.ts.map +1 -0
  48. package/dist/teams.js +32 -0
  49. package/dist/teams.js.map +1 -0
  50. package/package.json +8 -10
  51. package/src/comments.ts +126 -0
  52. package/src/documents.ts +162 -0
  53. package/src/format-action.ts +279 -0
  54. package/src/index.ts +556 -0
  55. package/src/issue-graph.ts +169 -0
  56. package/src/issues.ts +93 -0
  57. package/src/labels.ts +254 -0
  58. package/src/linear-client.ts +449 -0
  59. package/src/oauth.ts +194 -0
  60. package/src/recording-client.ts +141 -0
  61. package/src/responding-client.ts +106 -0
  62. package/src/teams.ts +44 -0
@@ -0,0 +1,141 @@
1
+ /**
2
+ * A credential-free AgentSessionClient that records every call instead of touching
3
+ * Linear. It is the offline-acceptance counterpart to the mock Runner: the hub harness
4
+ * and unit tests inject it and assert on the ordered call log, so the whole control
5
+ * surface (plan, activities, elicitation, auth, issue-start, PR attach, state,
6
+ * externalUrls) is verified with no Linear credentials. The real `@linear/sdk` client is
7
+ * the live path (./linear-client.ts).
8
+ */
9
+ import type {
10
+ Activity,
11
+ AgentSessionClient,
12
+ AuthRequest,
13
+ DocumentInput,
14
+ ElicitOption,
15
+ ExternalUrl,
16
+ IssueEngagement,
17
+ PlanItem,
18
+ PrAttachment,
19
+ RepoCandidate,
20
+ SessionState,
21
+ } from "./index.js";
22
+
23
+ export type RecordedCall =
24
+ | { call: "postActivity"; sessionId: string; activity: Activity }
25
+ | { call: "setPlan"; sessionId: string; items: PlanItem[] }
26
+ | { call: "raiseElicitation"; sessionId: string; prompt: string; options?: ElicitOption[] }
27
+ | { call: "suggestRepositories"; issueId: string; candidates: RepoCandidate[] }
28
+ | { call: "addIssueLabel"; issueId: string; name: string }
29
+ | { call: "requestAuth"; sessionId: string; prompt: string; auth: AuthRequest }
30
+ | { call: "startIssue"; sessionId: string }
31
+ | { call: "moveIssueToReview"; sessionId: string }
32
+ | { call: "commentOnIssue"; sessionId: string; body: string }
33
+ | { call: "attachPr"; sessionId: string; pr: PrAttachment }
34
+ | { call: "createDocument"; sessionId: string; doc: DocumentInput }
35
+ | { call: "setExternalUrls"; sessionId: string; urls: ExternalUrl[] }
36
+ | { call: "setState"; sessionId: string; state: SessionState }
37
+ | { call: "readIssueEngagement"; issueIds: string[] }
38
+ | { call: "createSessionOnIssue"; issueId: string }
39
+ | { call: "createSessionOnComment"; commentId: string };
40
+
41
+ export interface RecordingClient extends AgentSessionClient {
42
+ /** Every call made, in order. */
43
+ readonly calls: RecordedCall[];
44
+ }
45
+
46
+ /** Drive the read-only ground-truth methods so a test can simulate a disengaged issue. */
47
+ export interface RecordingClientOptions {
48
+ /** The app user id `appUserId()` returns (default "app-user"). */
49
+ appUserId?: string;
50
+ /** Per-issue engagement `readIssueEngagement` returns. Any requested id NOT present here defaults
51
+ * to a fully-engaged issue delegated + assigned to `appUserId` (so an unconfigured sweep never
52
+ * false-cancels). Set an id to `null` to simulate a hard-deleted issue (omitted from the result). */
53
+ engagement?: Record<string, IssueEngagement | null>;
54
+ }
55
+
56
+ export function createRecordingClient(opts: RecordingClientOptions = {}): RecordingClient {
57
+ const calls: RecordedCall[] = [];
58
+ const appUserId = opts.appUserId ?? "app-user";
59
+ const engagement = opts.engagement ?? {};
60
+ return {
61
+ calls,
62
+ async postActivity(sessionId, activity) {
63
+ calls.push({ call: "postActivity", sessionId, activity });
64
+ },
65
+ async setPlan(sessionId, items) {
66
+ calls.push({ call: "setPlan", sessionId, items });
67
+ },
68
+ async raiseElicitation(sessionId, prompt, options) {
69
+ calls.push({ call: "raiseElicitation", sessionId, prompt, ...(options ? { options } : {}) });
70
+ },
71
+ async suggestRepositories(issueId, candidates) {
72
+ calls.push({ call: "suggestRepositories", issueId, candidates });
73
+ // No opinion offline: the caller then falls back to registry order (exercised by the unit tests
74
+ // of the pure ordering function, which take real suggestions).
75
+ return [];
76
+ },
77
+ async addIssueLabel(issueId, name) {
78
+ calls.push({ call: "addIssueLabel", issueId, name });
79
+ },
80
+ async requestAuth(sessionId, prompt, auth) {
81
+ calls.push({ call: "requestAuth", sessionId, prompt, auth });
82
+ },
83
+ async startIssue(sessionId) {
84
+ calls.push({ call: "startIssue", sessionId });
85
+ // Offline stub: report a successful move so the reporter's no-op surfacing stays quiet.
86
+ return { moved: true, stateName: "In Progress" };
87
+ },
88
+ async moveIssueToReview(sessionId) {
89
+ calls.push({ call: "moveIssueToReview", sessionId });
90
+ // Offline stub: report a successful move so the reporter's no-op surfacing stays quiet.
91
+ return { moved: true, stateName: "In Review" };
92
+ },
93
+ async commentOnIssue(sessionId, body) {
94
+ calls.push({ call: "commentOnIssue", sessionId, body });
95
+ },
96
+ async attachPr(sessionId, pr) {
97
+ calls.push({ call: "attachPr", sessionId, pr });
98
+ },
99
+ async createDocument(sessionId, doc) {
100
+ calls.push({ call: "createDocument", sessionId, doc });
101
+ // Deterministic fake id/url so offline runs and tests have a stable document reference.
102
+ const slug = doc.title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
103
+ return { id: `doc-${slug}`, url: `https://linear.app/doc/${slug}` };
104
+ },
105
+ async setExternalUrls(sessionId, urls) {
106
+ calls.push({ call: "setExternalUrls", sessionId, urls });
107
+ },
108
+ async setState(sessionId, state) {
109
+ calls.push({ call: "setState", sessionId, state });
110
+ },
111
+ async readIssueEngagement(issueIds) {
112
+ calls.push({ call: "readIssueEngagement", issueIds });
113
+ const out = new Map<string, IssueEngagement>();
114
+ for (const id of issueIds) {
115
+ if (!id) continue;
116
+ if (Object.prototype.hasOwnProperty.call(engagement, id)) {
117
+ const e = engagement[id];
118
+ // null => hard-deleted: leave it out of the result entirely.
119
+ if (e) out.set(id, e);
120
+ } else {
121
+ // Default: fully engaged (delegated + assigned to the app user), so a sweep against an
122
+ // unconfigured recording client reads every issue as still-engaged.
123
+ out.set(id, { present: true, archived: false, stateType: "started", delegateId: appUserId, assigneeId: appUserId });
124
+ }
125
+ }
126
+ return out;
127
+ },
128
+ async appUserId() {
129
+ return appUserId;
130
+ },
131
+ async createSessionOnIssue(issueId) {
132
+ calls.push({ call: "createSessionOnIssue", issueId });
133
+ // Deterministic synthetic session id so offline runs and tests have a stable reference.
134
+ return `sess-${issueId}`;
135
+ },
136
+ async createSessionOnComment(commentId) {
137
+ calls.push({ call: "createSessionOnComment", commentId });
138
+ return `sess-${commentId}`;
139
+ },
140
+ };
141
+ }
@@ -0,0 +1,106 @@
1
+ /**
2
+ * An actively-responding, credential-free AgentSessionClient for the `pnpm dev` offline
3
+ * environment. It is a superset of the recording client (./recording-client.ts): it keeps
4
+ * the ordered `calls` log so it is a drop-in wherever the recording client is injected, but
5
+ * it additionally
6
+ *
7
+ * 1. maintains a small, queryable per-session projection of what the hub last pushed
8
+ * (latest state, latest plan, appended activities, set external URLs), so a dev can
9
+ * observe a run without touching Linear, and
10
+ * 2. exposes a pure, pre-declared gate-decision policy (`decide`) a dev CLI reads to know
11
+ * which answer to fire for a stage.
12
+ *
13
+ * The gate policy is a value the dev supplies up front; it NEVER chooses the next stage
14
+ * (that would cross the determinism boundary). Gate answers remain human-drive: the CLI
15
+ * uses `decide` only to pick which `prompted` webhook to send.
16
+ */
17
+ import type {
18
+ Activity,
19
+ ElicitOption,
20
+ ExternalUrl,
21
+ PlanItem,
22
+ SessionState,
23
+ } from "./index.js";
24
+ import {
25
+ createRecordingClient,
26
+ type RecordingClient,
27
+ type RecordingClientOptions,
28
+ } from "./recording-client.js";
29
+
30
+ /** A pre-declared gate answer for a stage. Never a control-flow decision (see file header). */
31
+ export type GateDecision = "allow" | "deny";
32
+
33
+ /** What the hub last pushed for one session, observable offline without touching Linear. */
34
+ export interface SessionProjection {
35
+ /** The latest session state the hub mirrored, or undefined if none was set yet. */
36
+ state?: SessionState;
37
+ /** The latest plan (agent-plan checklist); replaced wholesale on each `setPlan`. */
38
+ plan: PlanItem[];
39
+ /** Every activity posted to the session, in order. */
40
+ activities: Activity[];
41
+ /** The latest external-URL set; replaced wholesale on each `setExternalUrls`. */
42
+ externalUrls: ExternalUrl[];
43
+ /** The latest elicitation raised on the session, if any. */
44
+ elicitation?: { prompt: string; options?: ElicitOption[] };
45
+ }
46
+
47
+ export interface RespondingClientOptions extends RecordingClientOptions {
48
+ /** Per-stage gate answers the dev pre-declares. A stage absent here defaults to "allow". */
49
+ gateDecisions?: Record<string, GateDecision>;
50
+ }
51
+
52
+ export interface RespondingClient extends RecordingClient {
53
+ /** The queryable projection of what the hub last pushed for a session, or undefined if the
54
+ * hub has not touched that session. */
55
+ session(sessionId: string): SessionProjection | undefined;
56
+ /** The dev's pre-declared gate answer for a stage (default "allow"). Pure. */
57
+ decide(stageId: string): GateDecision;
58
+ }
59
+
60
+ export function createRespondingLinearClient(opts: RespondingClientOptions = {}): RespondingClient {
61
+ // Reuse the recording client wholesale for the ordered call log and the seeded read-side
62
+ // methods (engagement, appUserId, createDocument), then wrap the mutating methods to also
63
+ // update a per-session projection.
64
+ const base = createRecordingClient(opts);
65
+ const gateDecisions = opts.gateDecisions ?? {};
66
+ const projections = new Map<string, SessionProjection>();
67
+
68
+ const projectionFor = (sessionId: string): SessionProjection => {
69
+ let p = projections.get(sessionId);
70
+ if (!p) {
71
+ p = { plan: [], activities: [], externalUrls: [] };
72
+ projections.set(sessionId, p);
73
+ }
74
+ return p;
75
+ };
76
+
77
+ return {
78
+ ...base,
79
+ async setPlan(sessionId, items) {
80
+ await base.setPlan(sessionId, items);
81
+ projectionFor(sessionId).plan = items;
82
+ },
83
+ async postActivity(sessionId, activity) {
84
+ await base.postActivity(sessionId, activity);
85
+ projectionFor(sessionId).activities.push(activity);
86
+ },
87
+ async raiseElicitation(sessionId, prompt, options) {
88
+ await base.raiseElicitation(sessionId, prompt, options);
89
+ projectionFor(sessionId).elicitation = { prompt, ...(options ? { options } : {}) };
90
+ },
91
+ async setExternalUrls(sessionId, urls) {
92
+ await base.setExternalUrls(sessionId, urls);
93
+ projectionFor(sessionId).externalUrls = urls;
94
+ },
95
+ async setState(sessionId, state) {
96
+ await base.setState(sessionId, state);
97
+ projectionFor(sessionId).state = state;
98
+ },
99
+ session(sessionId) {
100
+ return projections.get(sessionId);
101
+ },
102
+ decide(stageId) {
103
+ return gateDecisions[stageId] ?? "allow";
104
+ },
105
+ };
106
+ }
package/src/teams.ts ADDED
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Workspace-team listing (DHK-777). A repository routes issues to itself partly by team key
3
+ * (`Repository.routing.teamKeys`); the portal offers the real keys instead of free text by reading
4
+ * the linked workspace's teams. The `TeamsApi` seam keeps this pure and unit-testable: the live
5
+ * adapter (`linearTeamsApi`) wraps `@linear/sdk`, while tests inject a fake - mirroring `LabelApi`.
6
+ */
7
+ import { LinearClient } from "@linear/sdk";
8
+
9
+ /** A workspace team, reduced to just what the picker needs. */
10
+ export interface Team {
11
+ id: string;
12
+ key: string;
13
+ name: string;
14
+ }
15
+
16
+ /** The injectable seam: list the workspace's teams. */
17
+ export interface TeamsApi {
18
+ listTeams(): Promise<Team[]>;
19
+ }
20
+
21
+ /**
22
+ * List the workspace's teams via the `TeamsApi` seam, normalised to `{id,key,name}` and sorted by
23
+ * key so the picker renders in a stable, deterministic order. A thin deterministic transform - no
24
+ * control flow, no network - so it feeds the portal picker directly.
25
+ */
26
+ export async function listWorkspaceTeams(api: TeamsApi): Promise<Team[]> {
27
+ const teams = await api.listTeams();
28
+ return teams
29
+ .map((t) => ({ id: t.id, key: t.key, name: t.name }))
30
+ .sort((a, b) => a.key.localeCompare(b.key));
31
+ }
32
+
33
+ /** The live `TeamsApi` backed by a Linear bearer token (workspace-scoped teams). Paginates
34
+ * `client.teams()` exactly as `linearLabelApi` paginates `issueLabels()`. */
35
+ export function linearTeamsApi(token: string): TeamsApi {
36
+ const client = new LinearClient({ accessToken: token });
37
+ return {
38
+ async listTeams() {
39
+ const conn = await client.teams();
40
+ while (conn.pageInfo.hasNextPage) await conn.fetchNext();
41
+ return conn.nodes.map((t) => ({ id: t.id, key: t.key, name: t.name }));
42
+ },
43
+ };
44
+ }