taskode 0.4.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.
@@ -0,0 +1,229 @@
1
+ const LINEAR_URL = 'https://api.linear.app/graphql';
2
+
3
+ export class LinearTracker {
4
+ constructor({ config, store }) {
5
+ this.config = config;
6
+ this.store = store;
7
+
8
+ if (!config.tracker.apiKey) {
9
+ throw new Error('LINEAR tracker requires tracker.api_key or LINEAR_API_KEY');
10
+ }
11
+ }
12
+
13
+ async fetchCandidates() {
14
+ const data = await this.callLinear(
15
+ `
16
+ query CandidateIssues($projectSlug: String, $states: [String!]) {
17
+ issues(filter: { state: { name: { in: $states } }, project: { slugId: { eq: $projectSlug } } }, first: 50) {
18
+ nodes { id identifier title description priority url state { name } labels { nodes { name } } }
19
+ }
20
+ }
21
+ `,
22
+ {
23
+ projectSlug: this.config.tracker.projectSlug,
24
+ states: this.config.tracker.activeStates
25
+ }
26
+ );
27
+
28
+ return this.syncIssues((data.issues?.nodes || []).map(mapIssue));
29
+ }
30
+
31
+ async fetchByIds(ids) {
32
+ if (!ids.length) return [];
33
+
34
+ const data = await this.callLinear(
35
+ `
36
+ query IssuesById($ids: [String!]) {
37
+ issues(filter: { id: { in: $ids } }, first: 50) {
38
+ nodes { id identifier title description priority url state { name } labels { nodes { name } } }
39
+ }
40
+ }
41
+ `,
42
+ { ids }
43
+ );
44
+
45
+ return this.syncIssues((data.issues?.nodes || []).map(mapIssue));
46
+ }
47
+
48
+ async writeComment(issueId, body) {
49
+ await this.callLinear(
50
+ `
51
+ mutation CreateComment($issueId: String!, $body: String!) {
52
+ commentCreate(input: { issueId: $issueId, body: $body }) { success }
53
+ }
54
+ `,
55
+ { issueId, body }
56
+ );
57
+ }
58
+
59
+ async transitionState(issueId, stateName) {
60
+ const stateId = await this.findWorkflowStateId(stateName);
61
+ if (!stateId) return;
62
+
63
+ await this.callLinear(
64
+ `
65
+ mutation UpdateIssueState($issueId: String!, $stateId: String!) {
66
+ issueUpdate(id: $issueId, input: { stateId: $stateId }) { success }
67
+ }
68
+ `,
69
+ { issueId, stateId }
70
+ );
71
+ }
72
+
73
+ async linkPullRequest(issueId, prUrl) {
74
+ await this.writeComment(issueId, `PR: ${prUrl}`);
75
+ }
76
+
77
+ async executeDynamicTool(name, argumentsValue) {
78
+ if (name !== 'linear_graphql') {
79
+ return toolFailure(`Unsupported dynamic tool: ${name}`);
80
+ }
81
+
82
+ try {
83
+ const { query, variables } = normalizeGraphqlArguments(argumentsValue);
84
+ const data = await this.callLinear(query, variables);
85
+ return toolSuccess(data);
86
+ } catch (error) {
87
+ return toolFailure(error.message);
88
+ }
89
+ }
90
+
91
+ async findWorkflowStateId(stateName) {
92
+ const data = await this.callLinear(
93
+ `
94
+ query WorkflowStates($projectSlug: String) {
95
+ projects(filter: { slugId: { eq: $projectSlug } }, first: 1) {
96
+ nodes {
97
+ team { states { nodes { id name } } }
98
+ }
99
+ }
100
+ }
101
+ `,
102
+ { projectSlug: this.config.tracker.projectSlug }
103
+ );
104
+
105
+ const states = data.projects?.nodes?.[0]?.team?.states?.nodes || [];
106
+ return states.find((state) => state.name === stateName)?.id || null;
107
+ }
108
+
109
+ async callLinear(query, variables) {
110
+ const response = await fetch(LINEAR_URL, {
111
+ method: 'POST',
112
+ headers: {
113
+ 'Content-Type': 'application/json',
114
+ Authorization: this.config.tracker.apiKey
115
+ },
116
+ body: JSON.stringify({ query, variables })
117
+ });
118
+
119
+ if (!response.ok) {
120
+ throw new Error(`Linear API failed: ${response.status}`);
121
+ }
122
+
123
+ const payload = await response.json();
124
+ if (payload.errors?.length) {
125
+ throw new Error(`Linear errors: ${payload.errors.map((entry) => entry.message).join('; ')}`);
126
+ }
127
+ return payload.data;
128
+ }
129
+
130
+ syncIssues(issues) {
131
+ for (const issue of issues) {
132
+ this.store.upsertTask({
133
+ id: issue.identifier,
134
+ title: issue.title,
135
+ description: issue.description,
136
+ type: 'issue',
137
+ status: mergeSyncedStatus(this.store.getTask(issue.identifier)?.status, normalizeTrackerState(issue.state)),
138
+ priority: numberToPriority(issue.priority),
139
+ externalId: issue.id,
140
+ trackerKind: 'linear',
141
+ trackerState: issue.state,
142
+ url: issue.url,
143
+ labels: issue.labels,
144
+ dependencies: issue.blocked_by
145
+ });
146
+ }
147
+
148
+ return issues;
149
+ }
150
+ }
151
+
152
+ function mapIssue(issue) {
153
+ return {
154
+ id: issue.id,
155
+ identifier: issue.identifier,
156
+ title: issue.title,
157
+ description: issue.description,
158
+ priority: issue.priority ?? null,
159
+ state: issue.state?.name || 'unknown',
160
+ url: issue.url,
161
+ labels: (issue.labels?.nodes || []).map((label) => (label.name || '').toLowerCase()),
162
+ blocked_by: []
163
+ };
164
+ }
165
+
166
+ function normalizeTrackerState(state) {
167
+ const value = String(state || '').toLowerCase();
168
+ if (value.includes('review')) return 'review';
169
+ if (value.includes('progress')) return 'in_progress';
170
+ if (['done', 'closed', 'cancelled', 'duplicate'].some((token) => value.includes(token))) return 'done';
171
+ return 'todo';
172
+ }
173
+
174
+ function mergeSyncedStatus(currentStatus, remoteStatus) {
175
+ if (['review', 'done', 'failed'].includes(currentStatus)) return currentStatus;
176
+ return remoteStatus;
177
+ }
178
+
179
+ function numberToPriority(priority) {
180
+ if (priority === 1 || priority === 2) return 'high';
181
+ if (priority === 3) return 'medium';
182
+ return 'low';
183
+ }
184
+
185
+ function normalizeGraphqlArguments(argumentsValue) {
186
+ if (typeof argumentsValue === 'string') {
187
+ const query = argumentsValue.trim();
188
+ if (!query) {
189
+ throw new Error('linear_graphql requires a non-empty query string');
190
+ }
191
+ return { query, variables: {} };
192
+ }
193
+
194
+ if (!argumentsValue || typeof argumentsValue !== 'object' || Array.isArray(argumentsValue)) {
195
+ throw new Error('linear_graphql expects a query string or an object with query and variables');
196
+ }
197
+
198
+ const query = String(argumentsValue.query || '').trim();
199
+ if (!query) {
200
+ throw new Error('linear_graphql requires a non-empty query string');
201
+ }
202
+
203
+ const variables = argumentsValue.variables ?? {};
204
+ if (variables === null) {
205
+ return { query, variables: {} };
206
+ }
207
+ if (typeof variables !== 'object' || Array.isArray(variables)) {
208
+ throw new Error('linear_graphql.variables must be an object');
209
+ }
210
+
211
+ return { query, variables };
212
+ }
213
+
214
+ function toolSuccess(data) {
215
+ const output = JSON.stringify(data, null, 2);
216
+ return {
217
+ success: true,
218
+ output,
219
+ contentItems: [{ type: 'inputText', text: output }]
220
+ };
221
+ }
222
+
223
+ function toolFailure(message) {
224
+ return {
225
+ success: false,
226
+ output: message,
227
+ contentItems: [{ type: 'inputText', text: message }]
228
+ };
229
+ }
@@ -0,0 +1,75 @@
1
+ export class LocalTracker {
2
+ constructor({ store, config }) {
3
+ this.store = store;
4
+ this.config = config;
5
+ }
6
+
7
+ async fetchCandidates() {
8
+ return this.store.listTasks()
9
+ .filter((task) => task.trackerKind === 'local')
10
+ .filter((task) => ['todo', 'in_progress', 'blocked'].includes(task.status))
11
+ .map((task) => mapTask(task, this.store));
12
+ }
13
+
14
+ async fetchByIds(ids) {
15
+ return ids
16
+ .map((id) => this.store.getTask(id) || this.store.getTaskByExternalId(id))
17
+ .filter(Boolean)
18
+ .map((task) => mapTask(task, this.store));
19
+ }
20
+
21
+ async writeComment(issueId, body) {
22
+ this.store.appendAudit({
23
+ action: 'tracker_comment',
24
+ actor: 'local-tracker',
25
+ metadata: { issueId, body }
26
+ });
27
+ }
28
+
29
+ async transitionState(issueId, stateName) {
30
+ const mapped = mapStateName(stateName);
31
+ this.store.updateTaskByIdentifier(issueId, { status: mapped, trackerState: stateName });
32
+ }
33
+
34
+ async linkPullRequest(issueId, prUrl) {
35
+ this.store.appendAudit({
36
+ action: 'tracker_pr_linked',
37
+ actor: 'local-tracker',
38
+ metadata: { issueId, prUrl }
39
+ });
40
+ }
41
+ }
42
+
43
+ function mapTask(task, store) {
44
+ const blockedBy = (task.dependencies || []).filter((dependencyId) => {
45
+ const dependency = store.getTask(dependencyId);
46
+ return dependency && dependency.status !== 'done';
47
+ });
48
+
49
+ return {
50
+ id: task.id,
51
+ identifier: task.id,
52
+ title: task.title,
53
+ description: task.description,
54
+ priority: priorityToNumber(task.priority),
55
+ state: task.status,
56
+ url: task.url,
57
+ labels: task.labels || [task.type],
58
+ blocked_by: blockedBy
59
+ };
60
+ }
61
+
62
+ function priorityToNumber(priority) {
63
+ if (priority === 'high') return 1;
64
+ if (priority === 'medium') return 2;
65
+ return 3;
66
+ }
67
+
68
+ function mapStateName(stateName) {
69
+ const value = String(stateName || '').toLowerCase();
70
+ if (value.includes('review')) return 'review';
71
+ if (value.includes('progress')) return 'in_progress';
72
+ if (value.includes('done') || value.includes('closed')) return 'done';
73
+ if (value.includes('cancel')) return 'cancelled';
74
+ return 'todo';
75
+ }
@@ -0,0 +1,77 @@
1
+ import crypto from 'node:crypto';
2
+ import fs from 'node:fs';
3
+ import { loadWorkflow, replaceConfigContents, resolveRuntimeConfig } from './workflow.js';
4
+
5
+ export class WorkflowStore {
6
+ constructor({ workflowPath, cliOptions = {}, onReloadError = null }) {
7
+ this.workflowPath = workflowPath;
8
+ this.cliOptions = { ...cliOptions };
9
+ this.onReloadError = typeof onReloadError === 'function' ? onReloadError : null;
10
+ this.state = this.loadFreshState();
11
+ }
12
+
13
+ getConfig() {
14
+ return this.state.config;
15
+ }
16
+
17
+ getWorkflow() {
18
+ return this.state.workflow;
19
+ }
20
+
21
+ getMetadata() {
22
+ return {
23
+ workflowPath: this.workflowPath,
24
+ lastLoadedAt: this.state.loadedAt,
25
+ lastError: this.state.lastError
26
+ };
27
+ }
28
+
29
+ refresh({ force = false } = {}) {
30
+ try {
31
+ const nextStamp = readWorkflowStamp(this.workflowPath);
32
+ if (!force && stampsEqual(this.state.stamp, nextStamp)) {
33
+ return { changed: false, config: this.state.config, error: null };
34
+ }
35
+
36
+ const workflow = loadWorkflow(this.workflowPath);
37
+ const nextConfig = resolveRuntimeConfig(workflow, this.cliOptions);
38
+ replaceConfigContents(this.state.config, nextConfig);
39
+ this.state.workflow = workflow;
40
+ this.state.stamp = nextStamp;
41
+ this.state.loadedAt = new Date().toISOString();
42
+ this.state.lastError = null;
43
+ return { changed: true, config: this.state.config, error: null };
44
+ } catch (error) {
45
+ this.state.lastError = { at: new Date().toISOString(), message: error.message };
46
+ if (this.onReloadError) {
47
+ this.onReloadError(error);
48
+ }
49
+ return { changed: false, config: this.state.config, error };
50
+ }
51
+ }
52
+
53
+ loadFreshState() {
54
+ const workflow = loadWorkflow(this.workflowPath);
55
+ return {
56
+ workflow,
57
+ config: resolveRuntimeConfig(workflow, this.cliOptions),
58
+ stamp: readWorkflowStamp(this.workflowPath),
59
+ loadedAt: new Date().toISOString(),
60
+ lastError: null
61
+ };
62
+ }
63
+ }
64
+
65
+ function readWorkflowStamp(workflowPath) {
66
+ const stat = fs.statSync(workflowPath);
67
+ const content = fs.readFileSync(workflowPath);
68
+ return {
69
+ mtimeMs: stat.mtimeMs,
70
+ size: stat.size,
71
+ hash: crypto.createHash('sha1').update(content).digest('hex')
72
+ };
73
+ }
74
+
75
+ function stampsEqual(left, right) {
76
+ return left?.mtimeMs === right?.mtimeMs && left?.size === right?.size && left?.hash === right?.hash;
77
+ }