baton-issue-tracker 1.3.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.
@@ -0,0 +1,72 @@
1
+ export const Status = Object.freeze({
2
+ OPEN: "Open",
3
+ IN_PROGRESS: "In-Progress",
4
+ IN_REVIEW: "In-Review",
5
+ CLOSED: "Closed"
6
+ });
7
+
8
+ export const Priority = Object.freeze({
9
+ LOW: "Low",
10
+ MEDIUM: "Medium",
11
+ HIGH: "High",
12
+ });
13
+
14
+ export class Issue {
15
+ constructor({
16
+ // User fields
17
+ title = "Issue #",
18
+ status = Status.OPEN,
19
+ priority = Priority.LOW,
20
+ tokenLimit = null,
21
+ description = null,
22
+ lastUpdated = new Date().toISOString(),
23
+ assignees = null,
24
+ // Auto-generated fields
25
+ id = 0,
26
+ createdAt = new Date().toISOString(),
27
+ attemptNum = 0,
28
+ } = {}) {
29
+ this.title = title;
30
+ this.status = status;
31
+ this.priority = priority;
32
+ this.tokenLimit = tokenLimit;
33
+ this.description = description;
34
+ this.lastUpdated = lastUpdated;
35
+ this.assignees = assignees;
36
+ this.id = id;
37
+ this.createdAt = createdAt;
38
+ this.attemptNum = attemptNum;
39
+ }
40
+
41
+
42
+ /**
43
+ * Validates the issue fields based on project business rules.
44
+ * @returns {{isValid: boolean, errors: string[]}} boolean and an array of errors
45
+ */
46
+ validate(){
47
+ const errors = [];
48
+
49
+ if (!this.title || typeof this.title !== "string" || this.title.trim() === ""){
50
+ errors.push("Title cannot be empty");
51
+ }
52
+
53
+ if (!Object.values(Status).includes(this.status)) {
54
+ errors.push(`Invalid status "${this.status}". Must be one of: ${Object.values(Status).join(", ")}`)
55
+ }
56
+
57
+ if (!Object.values(Priority).includes(this.priority)) {
58
+ errors.push(`Invalid priority "${this.priority}". Must be one of: ${Object.values(Priority).join(", ")}`)
59
+ }
60
+
61
+ if (this.tokenLimit != null){
62
+ const tokenLim = Number(this.tokenLimit);
63
+ if (isNaN(tokenLim) || tokenLim <= 0){
64
+ errors.push("tokenLimit must be positive");
65
+ }
66
+ }
67
+
68
+ return {isValid: errors.length == 0, errors: errors}
69
+ }
70
+ }
71
+
72
+
@@ -0,0 +1,53 @@
1
+ import { check, int, sqliteTable, text } from "drizzle-orm/sqlite-core";
2
+ import { sql } from "drizzle-orm";
3
+ import { Status, Priority } from "./issue.js";
4
+ import { Action } from "./activityLog.js";
5
+
6
+ export const issuesTable = sqliteTable("issues", {
7
+ id: int().primaryKey({ autoIncrement: true }),
8
+ createdAt: text("created_at").notNull().default(sql`CURRENT_TIMESTAMP`),
9
+ lastUpdated: text("last_updated").notNull().default(sql`CURRENT_TIMESTAMP`),
10
+ attemptNum: int("attempt_num").notNull().default(0),
11
+ title: text().notNull().default("PENDING"),
12
+ status: text({ enum: Object.values(Status) }).notNull().default(Status.OPEN),
13
+ priority: text({ enum: Object.values(Priority) }).default(Priority.LOW),
14
+ tokenLimit: int("token_limit"),
15
+ description: text(),
16
+ assignees: text({mode: "json"}).default(sql`'[]'`),
17
+ });
18
+
19
+ export const activityTable = sqliteTable(
20
+ "activity",
21
+ {
22
+ logId: int("log_id").primaryKey({ autoIncrement: true }),
23
+ issueId: int("issue_id"),
24
+ createdAt: text("created_at").notNull().default(sql`CURRENT_TIMESTAMP`),
25
+ action: text({ enum: Object.values(Action) }).notNull(),
26
+ details: text(),
27
+ },
28
+ (table) => [
29
+ check(
30
+ "activity_action_check",
31
+ sql`${table.action} IN (${sql.raw(
32
+ Object.values(Action)
33
+ .map((value) => `'${value}'`)
34
+ .join(", "),
35
+ )})`,
36
+ ),
37
+ ],
38
+ );
39
+
40
+ // helper for parsing command line arguments
41
+ export const issueSchema = {
42
+ id: { flag: '--id', type: 'number' },
43
+ attemptNum: { flag: '--attempt', type: 'number' },
44
+ title: { flag: '--title', type: 'string' },
45
+ status: { flag: '--status', type: 'enum', values: Object.values(Status) },
46
+ tokenLimit: { flag: '--token-limit', type: 'number' },
47
+ priority: { flag: '--priority', type: 'enum', values: Object.values(Priority) },
48
+ description:{ flag: '--description', type: 'string' },
49
+ //assignees: { flag: '--assignees', type: 'json'},
50
+ // Pagination options
51
+ limit: { flag: '--limit', type: 'number' },
52
+ offset: { flag: '--offset', type: 'number' }
53
+ };
@@ -0,0 +1,476 @@
1
+ import { getDB } from '../db/index.js';
2
+ import { eq, and, or, like, sql } from "drizzle-orm";
3
+ import {issuesTable, activityTable} from "../models/schema.js";
4
+ import {
5
+ Issue,
6
+ Status,
7
+ Priority,
8
+ } from "../models/issue.js";
9
+ import { ActivityLog, Action } from '../models/activityLog.js';
10
+
11
+ /**
12
+ * Internal helper to log actions.
13
+ * @private
14
+ * @param {object} db - The database instance.
15
+ * @param {number} issueId - The ID of the issue.
16
+ * @param {string} action - The action type.
17
+ * @param {string|null} [details=null] - Optional details.
18
+ */
19
+ function logActivity(db, issueId, action, details = null) {
20
+ db.insert(activityTable)
21
+ .values({ issueId, action, details })
22
+ .run();
23
+ }
24
+
25
+ /**
26
+ * Convert a raw database row to an Issue instance.
27
+ * @private
28
+ * @param {object|null} row - The raw database row.
29
+ * @returns {Issue|null}
30
+ */
31
+ function rowToIssue(row) {
32
+ return row ? new Issue(row) : null;
33
+ }
34
+
35
+ /**
36
+ * Convert a raw database row to an ActivityLog instance.
37
+ * @private
38
+ * @param {object|null} row - The raw database row.
39
+ * @returns {ActivityLog|null}
40
+ */
41
+ function rowToLog(row) {
42
+ return row ? new ActivityLog(row) : null;
43
+ }
44
+
45
+ /**
46
+ * Fetch a raw issue row by ID, throwing if not found.
47
+ * @private
48
+ * @param {object} db - The database instance.
49
+ * @param {number} id - The ID of the issue.
50
+ * @returns {object} The raw database row.
51
+ * @throws {Error} If no issue with the given ID exists.
52
+ */
53
+ function findById(db, id) {
54
+ const row = db.select()
55
+ .from(issuesTable)
56
+ .where(eq(issuesTable.id, id))
57
+ .get();
58
+ if (!row) throw new Error(`Issue #${id} not found`);
59
+ return row;
60
+ }
61
+
62
+ /**
63
+ * @typedef {Object} CreateIssueFields
64
+ * @property {string} [title] - The title of the issue.
65
+ * @property {string} [priority] - The priority level.
66
+ * @property {number} [tokenLimit] - The maximum token limit for the issue.
67
+ * @property {string} [description] - The detailed description of the issue.
68
+ */
69
+
70
+ /**
71
+ * Create a new issue.
72
+ * Title defaults to "Issue #<id>" via SQL trigger if not provided.
73
+ * @param {CreateIssueFields} fields - The fields to initialize the issue with.
74
+ * @returns {Issue}
75
+ */
76
+ export function createIssue({
77
+ title,
78
+ priority,
79
+ tokenLimit,
80
+ description,
81
+ } = {}) {
82
+
83
+ const db = getDB();
84
+ const result = db.insert(issuesTable)
85
+ .values({
86
+ title: title?.trim() || "PENDING",
87
+ priority: priority ?? Priority.LOW,
88
+ tokenLimit: tokenLimit ?? null,
89
+ description: description ?? null,
90
+ })
91
+ .returning({ id: issuesTable.id })
92
+ .get();
93
+
94
+ // re-fetch the issue to get auto-generated fields back
95
+ const issue = rowToIssue(findById(db, result.id));
96
+ logActivity(db, issue.id, Action.CREATION, `"${issue.title}" was created.`);
97
+
98
+ return issue;
99
+ }
100
+
101
+ /**
102
+ * Get a single issue by id. Logs a read event.
103
+ * @param {number} id
104
+ * @returns {Issue}
105
+ */
106
+ export function getIssue(id) {
107
+ const db = getDB();
108
+ const issue = rowToIssue(findById(db, id));
109
+ logActivity(db, id, Action.READ, `Issue #${id} was accessed.`);
110
+ return issue;
111
+ }
112
+
113
+ /**
114
+ * @typedef {Object} ListIssuesOptions
115
+ * @property {string} [status] - Filter issues by their current status.
116
+ * @property {string} [priority] - Filter issues by their priority level.
117
+ * @property {number} [limit] - Maximum number of issues to return.
118
+ * @property {number} [offset] - Pagination offset.
119
+ */
120
+
121
+ /**
122
+ * List issues with optional filters. Does not log activity.
123
+ * @param {ListIssuesOptions} options - Filtering and pagination options.
124
+ * @returns {Issue[]}
125
+ */
126
+ export async function listIssues({ status, priority, limit, offset } = {}) {
127
+ const db = getDB();
128
+ const filters = [];
129
+
130
+
131
+ if (status) {
132
+ filters.push(sql`${issuesTable.status} COLLATE NOCASE = ${status}`);
133
+ }
134
+
135
+ if (priority) {
136
+ filters.push(sql`${issuesTable.priority} COLLATE NOCASE = ${priority}`);
137
+ }
138
+
139
+ // set defaults if limit or offset is NULL
140
+ const limitVal = limit ?? 50;
141
+ const offsetVal = offset ?? 0;
142
+
143
+ return db.select()
144
+ .from(issuesTable)
145
+ .where(filters.length > 0 ? and(...filters) : undefined)
146
+ .limit(limitVal)
147
+ .offset(offsetVal)
148
+ .all();
149
+ }
150
+
151
+ /**
152
+ * Search issues by title or description. Does not log activity.
153
+ * @param {string} query
154
+ * @returns {Issue[]}
155
+ */
156
+ export function searchIssues(query) {
157
+ const db = getDB();
158
+
159
+ if (!query || query.trim() == "") {
160
+ return [];
161
+ }
162
+
163
+ const searchTerm = `%${query.toLowerCase().trim()}%`;
164
+
165
+ return db.select()
166
+ .from(issuesTable)
167
+ .where(
168
+ or(
169
+ like(issuesTable.title, searchTerm),
170
+ like(issuesTable.description, searchTerm)
171
+ )
172
+ )
173
+ .all();
174
+ }
175
+
176
+ /**
177
+ * @typedef {Object} UpdateIssueFields
178
+ * @property {string} [title] - The updated title.
179
+ * @property {string} [description] - The updated description.
180
+ * @property {number} [tokenLimit] - The updated token limit.
181
+ * @property {string} [status] - The updated status.
182
+ * @property {string} [priority] - The updated priority.
183
+ */
184
+
185
+ /**
186
+ * Update editable fields: title, description, tokenLimit, status, priority.
187
+ * Logs an edit event.
188
+ * @param {number} id
189
+ * @param {Issue} oldIssue - The current data
190
+ * @param {UpdateIssueFields} fields - The fields to update.
191
+ * @returns {Issue}
192
+ */
193
+ export function updateIssue(id, oldIssue, { title, description, tokenLimit, status, priority } = {}) {
194
+ const db = getDB();
195
+ const updates = {};
196
+
197
+ if (title !== undefined) updates.title = title;
198
+ if (description !== undefined) updates.description = description;
199
+ if (tokenLimit !== undefined) updates.tokenLimit = tokenLimit;
200
+
201
+ // Normalize status argument
202
+ if (status !== undefined) {
203
+ const statusValues = Object.values(Status);
204
+ const toUpdate = statusValues.find(v => v.trim().toLowerCase() === status.trim().toLowerCase());
205
+ updates.status = toUpdate || status;
206
+ }
207
+
208
+ // Normalize priority argument
209
+ if (priority !== undefined) {
210
+ const priorityValues = Object.values(Priority);
211
+ const toUpdate = priorityValues.find(v => v.toLowerCase().trim() === priority.trim().toLowerCase());
212
+ updates.priority = toUpdate || priority;
213
+ }
214
+
215
+ // Validate the new data
216
+ const proposedIssue = new Issue({ ...oldIssue, ...updates });
217
+ const { isValid, errors } = proposedIssue.validate();
218
+
219
+ if (!isValid) {
220
+ throw new Error(`Validation failed: ${errors.join(", ")}`);
221
+ }
222
+
223
+ if (Object.keys(updates).length > 0) {
224
+ db.update(issuesTable)
225
+ .set(updates)
226
+ .where(eq(issuesTable.id, id))
227
+ .run();
228
+ }
229
+
230
+ logActivity(db, id, Action.EDIT, `Issue #${id} was updated.`);
231
+ return getIssue(id);
232
+ }
233
+
234
+ /**
235
+ * Change the status of an issue from in-review to closed
236
+ * Logs a closed event.
237
+ * @param {number} id
238
+ * @returns {Issue}
239
+ */
240
+ export function approveIssue(id) {
241
+ const db = getDB();
242
+ db.update(issuesTable).set({ status: Status.CLOSED }).where(eq(issuesTable.id, id)).run();
243
+ logActivity(db, id, Action.STATE_CHANGE, `Issue #${id} has been closed`);
244
+ return getIssue(id);
245
+ }
246
+
247
+ /**
248
+ * Change the status of an issue from in-review to in-progress
249
+ * Logs a reject event and reason.
250
+ * @param {number} id
251
+ * @param {string} reason
252
+ * @returns {Issue}
253
+ */
254
+ export function rejectIssue(id, reason) {
255
+ const db = getDB();
256
+ db.update(issuesTable).set({ status: Status.IN_PROGRESS }).where(eq(issuesTable.id, id)).run();
257
+ logActivity(db, id, Action.REJECT, `Issue #${id} has been rejected due to "${reason}"`);
258
+ return getIssue(id);
259
+ }
260
+
261
+ /**
262
+ * Change the status of an issue to in-review.
263
+ * Logs a state_change_event.
264
+ * @param {number} id
265
+ * @returns {Issue}
266
+ */
267
+ export function submitForReview(id) {
268
+ const db = getDB();
269
+ db.update(issuesTable).set({ status: Status.IN_REVIEW }).where(eq(issuesTable.id, id)).run();
270
+ logActivity(db, id, Action.STATE_CHANGE, `Issue #${id} was submitted for review.`);
271
+ return getIssue(id);
272
+ }
273
+
274
+ /**
275
+ * Change the status of an issue (Open / Closed).
276
+ * Logs a state_change event.
277
+ * @param {number} id
278
+ * @param {string} status
279
+ * @returns {Issue}
280
+ */
281
+ export function setStatus(id, status) {
282
+ const db = getDB();
283
+ db.update(issuesTable).set({ status }).where(eq(issuesTable.id, id)).run();
284
+ logActivity(db, id, Action.STATE_CHANGE, `Issue #${id} status changed to ${status}.`);
285
+ return getIssue(id);
286
+ }
287
+
288
+ /**
289
+ * Change the priority of an issue (Low / Medium / High).
290
+ * Logs a priority_change event.
291
+ * @param {number} id
292
+ * @param {string} priority
293
+ * @returns {Issue}
294
+ */
295
+ export function setPriority(id, priority) {
296
+ const db = getDB();
297
+ db.update(issuesTable).set({ priority }).where(eq(issuesTable.id, id)).run();
298
+ logActivity(db, id, Action.PRIORITY_CHANGE, `Issue #${id} priority changed to ${priority}.`);
299
+ return getIssue(id);
300
+ }
301
+
302
+ /**
303
+ * Increment the attempt counter for an issue.
304
+ * Logs an edit event.
305
+ * @param {number} id
306
+ * @returns {Issue}
307
+ */
308
+ export function incrementAttempt(id) {
309
+ const db = getDB();
310
+ db.update(issuesTable)
311
+ .set({ attemptNum: sql`${issuesTable.attemptNum} + 1` })
312
+ .where(eq(issuesTable.id, id))
313
+ .run();
314
+ logActivity(db, id, Action.EDIT, `Attempt count increased for Issue #${id}.`);
315
+ return getIssue(id);
316
+ }
317
+
318
+ /**
319
+ * Delete an issue. Activity log entry is written before deletion
320
+ * to preserve the audit trail per schema spec.
321
+ * @param {number} id
322
+ * @returns {boolean}
323
+ */
324
+ export function deleteIssue(id) {
325
+ const db = getDB();
326
+ const existing = findById(db, id);
327
+
328
+ logActivity(db, id, Action.DELETION, `"${existing.title}" was deleted.`);
329
+ db.delete(issuesTable).where(eq(issuesTable.id, id)).run();
330
+ return true;
331
+ }
332
+
333
+ /**
334
+ * Get the full activity history for a specific issue.
335
+ * @param {number} issueId
336
+ * @returns {ActivityLog[]}
337
+ */
338
+ export function getActivityLog(issueId) {
339
+ const db = getDB();
340
+ return db.select().from(activityTable).where(eq(activityTable.issueId, issueId)).all();
341
+ }
342
+
343
+ /**
344
+ * Get the most recent activity across all issues.
345
+ * @param {RecentActivityOptions} options - Options for retrieving recent activity.
346
+ * @returns {ActivityLog[]}
347
+ */
348
+ export function getRecentActivity({ limit = 20 } = {}) {
349
+ const db = getDB();
350
+ return db.select().from(activityTable).orderBy(sql`${activityTable.logId} DESC`).limit(limit).all();
351
+ }
352
+ // =============================================================================
353
+ // Tracker operations (CLI: init / next / status / loop)
354
+ // To be edited later if needed.
355
+ // =============================================================================
356
+
357
+ /**
358
+ * True when both `issues` and `activity` tables exist (matches initDB schema).
359
+ * @returns {boolean}
360
+ */
361
+ export function isTrackerReady() {
362
+ const db = getDB();
363
+ try {
364
+ // use raw SQL query here, can't use Drizzle
365
+ const row = db.get(sql`
366
+ SELECT COUNT(*) AS table_count
367
+ FROM sqlite_master
368
+ WHERE type = 'table' AND name IN ('issues', 'activity')
369
+ `);
370
+ return (row?.table_count ?? 0) === 2;
371
+ } catch (error) {
372
+ return false;
373
+ }
374
+ }
375
+
376
+ /**
377
+ * Issue counts by status for `baton status` (single round-trip).
378
+ * @returns {{ total: number, open: number, inProgress: number, closed: number }}
379
+ */
380
+ export function getIssueStats() {
381
+ const db = getDB();
382
+ const row = db.get(sql`
383
+ SELECT
384
+ COUNT(*) AS total,
385
+ COALESCE(SUM(CASE WHEN status = 'Open' THEN 1 ELSE 0 END), 0) AS open_count,
386
+ COALESCE(SUM(CASE WHEN status = 'In-Progress' THEN 1 ELSE 0 END), 0) AS in_progress_count,
387
+ COALESCE(SUM(CASE WHEN status = 'In-Review' THEN 1 ELSE 0 END), 0) AS in_review_count,
388
+ COALESCE(SUM(CASE WHEN status = 'Closed' THEN 1 ELSE 0 END), 0) AS closed_count
389
+ FROM issues
390
+ `);
391
+
392
+ return {
393
+ total: Number(row?.total ?? 0),
394
+ open: Number(row?.open_count ?? 0),
395
+ inProgress: Number(row?.in_progress_count ?? 0),
396
+ inReview: Number(row?.in_review_count ?? 0),
397
+ closed: Number(row?.closed_count ?? 0),
398
+ };
399
+ }
400
+
401
+ /**
402
+ * All issues ordered by id.
403
+ * @returns {object[]}
404
+ */
405
+ export function getAllIssues() {
406
+ const db = getDB();
407
+ return db.select().from(issuesTable).orderBy(issuesTable.id).all();
408
+ }
409
+
410
+ /**
411
+ * Highest-priority open issue, then lowest id (same ordering as previous JS sort).
412
+ * @returns {object|null}
413
+ */
414
+ export function selectNextIssue() {
415
+ const db = getDB();
416
+ return db.select()
417
+ .from(issuesTable)
418
+ .where(eq(issuesTable.status, Status.OPEN))
419
+ .orderBy(
420
+ sql`CASE priority WHEN 'High' THEN 0 WHEN 'Medium' THEN 1 WHEN 'Low' THEN 2 ELSE 3 END`,
421
+ issuesTable.id
422
+ )
423
+ .limit(1)
424
+ .get() ?? null;
425
+ }
426
+
427
+ /**
428
+ * Mark an issue in-progress, increment attempts, and log activity (atomic).
429
+ * Uses existing `findById` / `logActivity` from above; does not modify lines 1–187.
430
+ * @param {number} issueId
431
+ * @returns {object}
432
+ */
433
+ export function workOnIssue(issueId) {
434
+ const db = getDB();
435
+ const issue = findById(db, issueId);
436
+
437
+ if (issue.status === Status.CLOSED) {
438
+ throw new Error(`Issue #${issueId} is closed and cannot be worked on.`);
439
+ }
440
+
441
+ db.transaction((tx) => {
442
+ logActivity(tx, issueId, Action.READ, `Agent accessed issue #${issueId}`);
443
+
444
+ tx.update(issuesTable)
445
+ .set({
446
+ status: Status.IN_PROGRESS,
447
+ attemptNum: sql`${issuesTable.attemptNum} + 1`
448
+ })
449
+ .where(eq(issuesTable.id, issueId))
450
+ .run();
451
+
452
+ logActivity(
453
+ tx,
454
+ issueId,
455
+ Action.STATE_CHANGE,
456
+ `Status changed from ${issue.status} to ${Status.IN_PROGRESS}`,
457
+ );
458
+
459
+ logActivity(
460
+ tx,
461
+ issueId,
462
+ Action.EDIT,
463
+ `Agent attempt #${issue.attemptNum + 1} on issue #${issueId}`,
464
+ );
465
+ });
466
+
467
+ return findById(db, issueId);
468
+ }
469
+
470
+ /**
471
+ * Remove all issues (`baton init --force`). Activity rows are kept for audit.
472
+ */
473
+ export function clearAllIssues() {
474
+ const db = getDB();
475
+ db.delete(issuesTable).run();
476
+ }
package/source/temp.md ADDED
@@ -0,0 +1 @@
1
+