@eunjae/il 0.0.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,17 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { allocateAliasInMap, parseAlias } from '../aliasRepo';
3
+ const emptyAliases = () => ({ T: {}, R: {} });
4
+ describe('aliasRepo', () => {
5
+ it('parses aliases with prefixes', () => {
6
+ expect(parseAlias('T01')).toEqual({ prefix: 'T', key: '01' });
7
+ expect(parseAlias('R12')).toEqual({ prefix: 'R', key: '12' });
8
+ expect(parseAlias('X99')).toBeNull();
9
+ });
10
+ it('allocates sequential aliases', () => {
11
+ const aliases = emptyAliases();
12
+ const first = allocateAliasInMap(aliases, 'T', 'T01');
13
+ expect(first.alias).toBe('T01');
14
+ const second = allocateAliasInMap(aliases, 'T', 'T02');
15
+ expect(second.alias).toBe('T02');
16
+ });
17
+ });
@@ -0,0 +1,32 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { applyTaskEdit } from '../edit';
3
+ import { buildTask } from '../taskFactory';
4
+ const createTask = () => buildTask({
5
+ type: 'regular',
6
+ title: 'Original title',
7
+ metadata: {}
8
+ });
9
+ describe('applyTaskEdit', () => {
10
+ it('updates basic fields', () => {
11
+ const task = createTask();
12
+ const updated = applyTaskEdit(task, 'title', 'Updated title');
13
+ expect(updated.title).toBe('Updated title');
14
+ });
15
+ it('updates nested metadata fields', () => {
16
+ const task = createTask();
17
+ const updated = applyTaskEdit(task, 'metadata.url', 'https://example.com');
18
+ expect(updated.metadata.url).toBe('https://example.com');
19
+ });
20
+ it('parses PR URLs when updating metadata.pr.url', () => {
21
+ const task = createTask();
22
+ const updated = applyTaskEdit(task, 'metadata.pr.url', 'https://github.com/acme/repo/pull/42');
23
+ expect(updated.metadata.pr?.url).toBe('https://github.com/acme/repo/pull/42');
24
+ expect(updated.metadata.pr?.repo?.owner).toBe('acme');
25
+ expect(updated.metadata.pr?.repo?.name).toBe('repo');
26
+ expect(updated.metadata.pr?.number).toBe(42);
27
+ });
28
+ it('rejects direct status edits', () => {
29
+ const task = createTask();
30
+ expect(() => applyTaskEdit(task, 'status', 'active')).toThrow('Use status commands');
31
+ });
32
+ });
@@ -0,0 +1,22 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { getNextStatus } from '../fsm';
3
+ const cases = [
4
+ ['backlog', 'start', 'active'],
5
+ ['backlog', 'cancel', 'cancelled'],
6
+ ['active', 'pause', 'paused'],
7
+ ['active', 'complete', 'completed'],
8
+ ['active', 'cancel', 'cancelled'],
9
+ ['paused', 'start', 'active'],
10
+ ['paused', 'complete', 'completed'],
11
+ ['paused', 'cancel', 'cancelled']
12
+ ];
13
+ describe('getNextStatus', () => {
14
+ for (const [current, action, next] of cases) {
15
+ it(`transitions ${current} via ${action}`, () => {
16
+ expect(getNextStatus(current, action)).toBe(next);
17
+ });
18
+ }
19
+ it('throws on invalid transition', () => {
20
+ expect(() => getNextStatus('completed', 'start')).toThrow('Invalid transition');
21
+ });
22
+ });
@@ -0,0 +1,25 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { generateStableRef, generateTaskId } from '../id';
3
+ const stableRefPattern = /^[TR]-[A-Z2-7]{6}$/;
4
+ describe('generateTaskId', () => {
5
+ it('prefixes regular tasks with T', () => {
6
+ const id = generateTaskId('regular');
7
+ expect(id.startsWith('T')).toBe(true);
8
+ });
9
+ it('prefixes PR review tasks with R', () => {
10
+ const id = generateTaskId('pr_review');
11
+ expect(id.startsWith('R')).toBe(true);
12
+ });
13
+ });
14
+ describe('generateStableRef', () => {
15
+ it('generates deterministic refs for a task ID', () => {
16
+ const id = 'T01J2ABCDEF4X9YQK7M3R8Z2';
17
+ expect(generateStableRef('regular', id)).toBe(generateStableRef('regular', id));
18
+ });
19
+ it('uses correct prefix and format', () => {
20
+ const id = 'R01J2ABCDEF4X9YQK7M3R8Z2';
21
+ const ref = generateStableRef('pr_review', id);
22
+ expect(ref).toMatch(stableRefPattern);
23
+ expect(ref.startsWith('R-')).toBe(true);
24
+ });
25
+ });
@@ -0,0 +1,37 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { taskSchema } from '../schema';
3
+ import { buildTask } from '../taskFactory';
4
+ const baseTask = () => buildTask({
5
+ type: 'regular',
6
+ title: 'Sample task',
7
+ metadata: {}
8
+ });
9
+ describe('taskSchema', () => {
10
+ it('accepts regular tasks without PR metadata', () => {
11
+ const task = baseTask();
12
+ expect(() => taskSchema.parse(task)).not.toThrow();
13
+ });
14
+ it('rejects PR review tasks without PR URL', () => {
15
+ const task = { ...baseTask(), type: 'pr_review', metadata: {} };
16
+ expect(() => taskSchema.parse(task)).toThrow('PR review tasks require metadata.pr.url');
17
+ });
18
+ it('accepts PR review tasks with PR metadata', () => {
19
+ const task = {
20
+ ...baseTask(),
21
+ type: 'pr_review',
22
+ metadata: {
23
+ pr: {
24
+ url: 'https://github.com/acme/repo/pull/1',
25
+ provider: 'github',
26
+ repo: {
27
+ host: 'github.com',
28
+ owner: 'acme',
29
+ name: 'repo'
30
+ },
31
+ number: 1
32
+ }
33
+ }
34
+ };
35
+ expect(() => taskSchema.parse(task)).not.toThrow();
36
+ });
37
+ });
@@ -0,0 +1,33 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { appendDayAssignment, appendLog, applyTransition, isAssignedOnDate } from '../taskOperations';
3
+ import { buildTask } from '../taskFactory';
4
+ const createTask = () => buildTask({
5
+ type: 'regular',
6
+ title: 'Work task',
7
+ metadata: {}
8
+ });
9
+ describe('taskOperations', () => {
10
+ it('appends logs with optional status', () => {
11
+ const task = createTask();
12
+ const updated = appendLog(task, 'Progress update', 'active');
13
+ expect(updated.logs).toHaveLength(1);
14
+ expect(updated.logs[0].msg).toBe('Progress update');
15
+ expect(updated.logs[0].status).toBe('active');
16
+ });
17
+ it('applies transitions with default messages', () => {
18
+ const task = createTask();
19
+ const result = applyTransition(task, 'start');
20
+ expect(result.nextStatus).toBe('active');
21
+ expect(result.task.status).toBe('active');
22
+ expect(result.task.logs.at(-1)?.msg).toBe('Started task');
23
+ });
24
+ it('tracks day assignment state', () => {
25
+ const task = createTask();
26
+ const date = '2026-01-22';
27
+ expect(isAssignedOnDate(task, date)).toBe(false);
28
+ const assigned = appendDayAssignment(task, date, 'assign');
29
+ expect(isAssignedOnDate(assigned, date)).toBe(true);
30
+ const unassigned = appendDayAssignment(assigned, date, 'unassign');
31
+ expect(isAssignedOnDate(unassigned, date)).toBe(false);
32
+ });
33
+ });
@@ -0,0 +1,28 @@
1
+ import { allocateAliasInMap, readAliases, writeAliases } from './aliasRepo';
2
+ import { listAllTasks } from './taskRepo';
3
+ export const reconcileAliases = async (workspaceRoot) => {
4
+ const aliases = await readAliases(workspaceRoot);
5
+ const tasks = await listAllTasks(workspaceRoot);
6
+ const tasksById = new Map(tasks.map((stored) => [stored.task.id, stored.task]));
7
+ const normalizePrefix = (prefix) => {
8
+ const next = {};
9
+ for (const [key, value] of Object.entries(aliases[prefix])) {
10
+ if (tasksById.has(value)) {
11
+ next[key] = value;
12
+ }
13
+ }
14
+ aliases[prefix] = next;
15
+ };
16
+ normalizePrefix('T');
17
+ normalizePrefix('R');
18
+ const existingIds = new Set([...Object.values(aliases.T), ...Object.values(aliases.R)]);
19
+ const missing = tasks
20
+ .filter((stored) => !existingIds.has(stored.task.id))
21
+ .sort((a, b) => a.task.created_at.localeCompare(b.task.created_at));
22
+ for (const stored of missing) {
23
+ const prefix = stored.task.type === 'pr_review' ? 'R' : 'T';
24
+ allocateAliasInMap(aliases, prefix, stored.task.id);
25
+ }
26
+ await writeAliases(workspaceRoot, aliases);
27
+ return { updated: true };
28
+ };
@@ -0,0 +1,58 @@
1
+ import { access } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { readJsonFile, writeJsonAtomic } from './json';
4
+ const emptyAliases = () => ({ T: {}, R: {} });
5
+ const aliasFilePath = (workspaceRoot) => path.join(workspaceRoot, 'aliases.json');
6
+ const fileExists = async (filePath) => {
7
+ try {
8
+ await access(filePath);
9
+ return true;
10
+ }
11
+ catch {
12
+ return false;
13
+ }
14
+ };
15
+ export const readAliases = async (workspaceRoot) => {
16
+ const filePath = aliasFilePath(workspaceRoot);
17
+ if (!(await fileExists(filePath))) {
18
+ return emptyAliases();
19
+ }
20
+ const data = await readJsonFile(filePath);
21
+ return {
22
+ T: data.T ?? {},
23
+ R: data.R ?? {}
24
+ };
25
+ };
26
+ export const writeAliases = async (workspaceRoot, aliases) => {
27
+ await writeJsonAtomic(aliasFilePath(workspaceRoot), aliases);
28
+ };
29
+ export const parseAlias = (alias) => {
30
+ const match = alias.match(/^([TR])(\d+)$/);
31
+ if (!match) {
32
+ return null;
33
+ }
34
+ return { prefix: match[1], key: match[2] };
35
+ };
36
+ const formatAliasKey = (value) => String(value).padStart(2, '0');
37
+ export const allocateAliasInMap = (aliases, prefix, taskId) => {
38
+ const used = new Set(Object.keys(aliases[prefix]).map((key) => Number.parseInt(key, 10)));
39
+ let next = 1;
40
+ while (used.has(next)) {
41
+ next += 1;
42
+ }
43
+ const key = formatAliasKey(next);
44
+ aliases[prefix][key] = taskId;
45
+ return { alias: `${prefix}${key}`, aliases };
46
+ };
47
+ export const allocateAlias = async (workspaceRoot, prefix, taskId) => {
48
+ const aliases = await readAliases(workspaceRoot);
49
+ return allocateAliasInMap(aliases, prefix, taskId);
50
+ };
51
+ export const resolveAlias = async (workspaceRoot, alias) => {
52
+ const parsed = parseAlias(alias);
53
+ if (!parsed) {
54
+ return null;
55
+ }
56
+ const aliases = await readAliases(workspaceRoot);
57
+ return aliases[parsed.prefix][parsed.key] ?? null;
58
+ };
@@ -0,0 +1,35 @@
1
+ import { access } from 'node:fs/promises';
2
+ import { homedir } from 'node:os';
3
+ import path from 'node:path';
4
+ import { APP_NAME } from './constants';
5
+ import { readJsonFile } from './json';
6
+ const fileExists = async (filePath) => {
7
+ try {
8
+ await access(filePath);
9
+ return true;
10
+ }
11
+ catch {
12
+ return false;
13
+ }
14
+ };
15
+ export const resolveGlobalConfigPath = () => {
16
+ const configHome = process.env.XDG_CONFIG_HOME ?? path.join(homedir(), '.config');
17
+ return path.join(configHome, APP_NAME, 'config.json');
18
+ };
19
+ export const readConfigFile = async (filePath) => {
20
+ if (!(await fileExists(filePath))) {
21
+ return {};
22
+ }
23
+ return (await readJsonFile(filePath)) ?? {};
24
+ };
25
+ export const resolveGithubToken = async (workspaceRoot) => {
26
+ if (process.env.IL_GITHUB_TOKEN) {
27
+ return process.env.IL_GITHUB_TOKEN;
28
+ }
29
+ const workspaceConfig = await readConfigFile(path.join(workspaceRoot, 'config.json'));
30
+ if (workspaceConfig.github?.token) {
31
+ return workspaceConfig.github.token;
32
+ }
33
+ const globalConfig = await readConfigFile(resolveGlobalConfigPath());
34
+ return globalConfig.github?.token ?? null;
35
+ };
@@ -0,0 +1,6 @@
1
+ export const APP_NAME = 'il';
2
+ export const APP_DIR = `.${APP_NAME}`;
3
+ export const LOCK_DIR = '.lock';
4
+ export const LOCK_FILE = 'store.lock';
5
+ export const TASKS_DIR = 'tasks';
6
+ export const STATUS_ORDER = ['backlog', 'active', 'paused', 'completed', 'cancelled'];
@@ -0,0 +1,47 @@
1
+ import { buildPrAttachment, parseGitHubPrUrl } from './pr';
2
+ import { validateTaskOrThrow } from './schema';
3
+ import { nowIso } from './time';
4
+ const parseValue = (raw) => {
5
+ try {
6
+ return JSON.parse(raw);
7
+ }
8
+ catch {
9
+ return raw;
10
+ }
11
+ };
12
+ export const applyTaskEdit = (task, dottedPath, rawValue) => {
13
+ if (dottedPath === 'status' || dottedPath.startsWith('status.')) {
14
+ throw new Error('Use status commands to change status');
15
+ }
16
+ const value = parseValue(rawValue);
17
+ const updated = structuredClone(task);
18
+ const segments = dottedPath.split('.');
19
+ let current = updated;
20
+ for (const segment of segments.slice(0, -1)) {
21
+ if (!(segment in current)) {
22
+ current[segment] = {};
23
+ }
24
+ const next = current[segment];
25
+ if (typeof next !== 'object' || next === null || Array.isArray(next)) {
26
+ throw new Error(`Cannot set ${dottedPath} on non-object path`);
27
+ }
28
+ current = next;
29
+ }
30
+ const last = segments[segments.length - 1];
31
+ current[last] = value;
32
+ if (dottedPath === 'metadata.pr.url') {
33
+ if (typeof value !== 'string') {
34
+ throw new Error('metadata.pr.url must be a string');
35
+ }
36
+ const parsed = parseGitHubPrUrl(value);
37
+ if (!parsed) {
38
+ throw new Error('Invalid PR URL');
39
+ }
40
+ updated.metadata.pr = buildPrAttachment(parsed, updated.metadata.pr?.fetched);
41
+ }
42
+ const nextTask = {
43
+ ...updated,
44
+ updated_at: nowIso()
45
+ };
46
+ return validateTaskOrThrow(nextTask);
47
+ };
@@ -0,0 +1,52 @@
1
+ export const buildAliasLookup = (aliases) => {
2
+ const map = new Map();
3
+ for (const [key, value] of Object.entries(aliases.T)) {
4
+ map.set(value, `T${key}`);
5
+ }
6
+ for (const [key, value] of Object.entries(aliases.R)) {
7
+ map.set(value, `R${key}`);
8
+ }
9
+ return map;
10
+ };
11
+ export const formatTaskListLine = (task, alias) => {
12
+ const aliasText = alias ?? '--';
13
+ return `${aliasText.padEnd(4)} ${task.ref.padEnd(9)} ${task.status.padEnd(9)} ${task.title}`;
14
+ };
15
+ export const formatTaskDetails = (task, alias) => {
16
+ const lines = [];
17
+ lines.push(`${alias ?? '--'} ${task.ref} ${task.title}`);
18
+ lines.push(`id: ${task.id}`);
19
+ lines.push(`type: ${task.type}`);
20
+ lines.push(`status: ${task.status}`);
21
+ lines.push(`created: ${task.created_at}`);
22
+ lines.push(`updated: ${task.updated_at}`);
23
+ if (task.metadata.url) {
24
+ lines.push(`url: ${task.metadata.url}`);
25
+ }
26
+ if (task.metadata.pr?.url) {
27
+ lines.push(`pr: ${task.metadata.pr.url}`);
28
+ if (task.metadata.pr.fetched) {
29
+ const fetched = task.metadata.pr.fetched;
30
+ lines.push(`pr_state: ${fetched.state}`);
31
+ lines.push(`pr_title: ${fetched.title}`);
32
+ lines.push(`pr_author: ${fetched.author.login}`);
33
+ lines.push(`pr_updated_at: ${fetched.updated_at}`);
34
+ lines.push(`pr_refreshed_at: ${fetched.at}`);
35
+ }
36
+ }
37
+ if (task.logs.length > 0) {
38
+ lines.push('logs:');
39
+ for (const log of task.logs) {
40
+ const status = log.status ? ` [${log.status}]` : '';
41
+ lines.push(`- ${log.ts}${status} ${log.msg}`);
42
+ }
43
+ }
44
+ if (task.day_assignments.length > 0) {
45
+ lines.push('day_assignments:');
46
+ for (const entry of task.day_assignments) {
47
+ const msg = entry.msg ? ` (${entry.msg})` : '';
48
+ lines.push(`- ${entry.date} ${entry.action} ${entry.ts}${msg}`);
49
+ }
50
+ }
51
+ return lines.join('\n');
52
+ };
@@ -0,0 +1,25 @@
1
+ export const transitionTable = {
2
+ backlog: {
3
+ start: 'active',
4
+ cancel: 'cancelled'
5
+ },
6
+ active: {
7
+ pause: 'paused',
8
+ complete: 'completed',
9
+ cancel: 'cancelled'
10
+ },
11
+ paused: {
12
+ start: 'active',
13
+ complete: 'completed',
14
+ cancel: 'cancelled'
15
+ },
16
+ completed: {},
17
+ cancelled: {}
18
+ };
19
+ export const getNextStatus = (current, action) => {
20
+ const next = transitionTable[current][action];
21
+ if (!next) {
22
+ throw new Error(`Invalid transition: ${current} -> ${action}`);
23
+ }
24
+ return next;
25
+ };
@@ -0,0 +1,21 @@
1
+ import { readFile, writeFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { APP_DIR } from './constants';
4
+ const lockEntry = `${APP_DIR}/.lock/`;
5
+ export const ensureLockIgnored = async (repoRoot) => {
6
+ const gitignorePath = path.join(repoRoot, '.gitignore');
7
+ let current = '';
8
+ try {
9
+ current = await readFile(gitignorePath, 'utf8');
10
+ }
11
+ catch {
12
+ current = '';
13
+ }
14
+ if (current.split('\n').some((line) => line.trim() === lockEntry)) {
15
+ return false;
16
+ }
17
+ const separator = current.endsWith('\n') || current.length === 0 ? '' : '\n';
18
+ const next = `${current}${separator}${lockEntry}\n`;
19
+ await writeFile(gitignorePath, next, 'utf8');
20
+ return true;
21
+ };
package/dist/lib/id.js ADDED
@@ -0,0 +1,30 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { ulid } from 'ulid';
3
+ const BASE32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
4
+ const base32Encode = (input) => {
5
+ let bits = 0;
6
+ let value = 0;
7
+ let output = '';
8
+ for (const byte of input) {
9
+ value = (value << 8) | byte;
10
+ bits += 8;
11
+ while (bits >= 5) {
12
+ output += BASE32_ALPHABET[(value >>> (bits - 5)) & 31];
13
+ bits -= 5;
14
+ }
15
+ }
16
+ if (bits > 0) {
17
+ output += BASE32_ALPHABET[(value << (5 - bits)) & 31];
18
+ }
19
+ return output;
20
+ };
21
+ export const generateTaskId = (taskType) => {
22
+ const prefix = taskType === 'pr_review' ? 'R' : 'T';
23
+ return `${prefix}${ulid()}`;
24
+ };
25
+ export const generateStableRef = (taskType, taskId) => {
26
+ const prefix = taskType === 'pr_review' ? 'R' : 'T';
27
+ const hash = createHash('sha256').update(taskId).digest();
28
+ const encoded = base32Encode(hash).slice(0, 6);
29
+ return `${prefix}-${encoded}`;
30
+ };
@@ -0,0 +1,10 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import writeFileAtomic from 'write-file-atomic';
3
+ export const readJsonFile = async (filePath) => {
4
+ const raw = await readFile(filePath, 'utf8');
5
+ return JSON.parse(raw);
6
+ };
7
+ export const writeJsonAtomic = async (filePath, data) => {
8
+ const payload = `${JSON.stringify(data, null, 2)}\n`;
9
+ await writeFileAtomic(filePath, payload, { encoding: 'utf8', fsync: true });
10
+ };
@@ -0,0 +1,25 @@
1
+ import { writeFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import lockfile from 'proper-lockfile';
4
+ import { LOCK_DIR, LOCK_FILE } from './constants';
5
+ import { ensureWorkspaceLayout } from './workspace';
6
+ export const withWorkspaceLock = async (workspaceRoot, fn) => {
7
+ await ensureWorkspaceLayout(workspaceRoot);
8
+ const lockPath = path.join(workspaceRoot, LOCK_DIR, LOCK_FILE);
9
+ await writeFile(lockPath, '', { flag: 'a' });
10
+ const release = await lockfile.lock(lockPath, {
11
+ stale: 60_000,
12
+ retries: {
13
+ retries: 5,
14
+ factor: 1.5,
15
+ minTimeout: 50,
16
+ maxTimeout: 1_000
17
+ }
18
+ });
19
+ try {
20
+ return await fn();
21
+ }
22
+ finally {
23
+ await release();
24
+ }
25
+ };
package/dist/lib/pr.js ADDED
@@ -0,0 +1,64 @@
1
+ import { Octokit } from 'octokit';
2
+ import { nowIso } from './time';
3
+ export const parseGitHubPrUrl = (value) => {
4
+ let url;
5
+ try {
6
+ url = new URL(value);
7
+ }
8
+ catch {
9
+ return null;
10
+ }
11
+ const parts = url.pathname.split('/').filter(Boolean);
12
+ if (parts.length < 4 || parts[2] !== 'pull') {
13
+ return null;
14
+ }
15
+ const number = Number(parts[3]);
16
+ if (!Number.isInteger(number)) {
17
+ return null;
18
+ }
19
+ return {
20
+ url: value,
21
+ provider: 'github',
22
+ repo: {
23
+ host: url.host,
24
+ owner: parts[0],
25
+ name: parts[1]
26
+ },
27
+ number
28
+ };
29
+ };
30
+ export const buildPrAttachment = (parsed, fetched) => {
31
+ return {
32
+ url: parsed.url,
33
+ provider: 'github',
34
+ repo: parsed.repo,
35
+ number: parsed.number,
36
+ fetched
37
+ };
38
+ };
39
+ export const fetchGitHubPr = async (parsed, token) => {
40
+ if (!parsed.repo || !parsed.number) {
41
+ return null;
42
+ }
43
+ if (!token) {
44
+ return null;
45
+ }
46
+ const octokit = new Octokit({ auth: token });
47
+ const response = await octokit.rest.pulls.get({
48
+ owner: parsed.repo.owner,
49
+ repo: parsed.repo.name,
50
+ pull_number: parsed.number
51
+ });
52
+ const data = response.data;
53
+ const state = data.merged ? 'merged' : data.state === 'open' ? 'open' : 'closed';
54
+ return {
55
+ at: nowIso(),
56
+ title: data.title,
57
+ author: {
58
+ login: data.user?.login ?? 'unknown'
59
+ },
60
+ state,
61
+ draft: data.draft ?? false,
62
+ updated_at: data.updated_at
63
+ };
64
+ };
@@ -0,0 +1,64 @@
1
+ import { z } from 'zod';
2
+ import { taskStatuses, taskTypes } from './types';
3
+ export const logEntrySchema = z.object({
4
+ ts: z.string(),
5
+ msg: z.string(),
6
+ status: z.enum(taskStatuses).optional()
7
+ });
8
+ export const dayAssignmentSchema = z.object({
9
+ date: z.string(),
10
+ action: z.enum(['assign', 'unassign']),
11
+ ts: z.string(),
12
+ msg: z.string().optional()
13
+ });
14
+ const prRepoSchema = z.object({
15
+ host: z.string(),
16
+ owner: z.string(),
17
+ name: z.string()
18
+ });
19
+ const prFetchedSchema = z.object({
20
+ at: z.string(),
21
+ title: z.string(),
22
+ author: z.object({
23
+ login: z.string()
24
+ }),
25
+ state: z.enum(['open', 'closed', 'merged']),
26
+ draft: z.boolean(),
27
+ updated_at: z.string()
28
+ });
29
+ export const prAttachmentSchema = z.object({
30
+ url: z.string(),
31
+ provider: z.literal('github'),
32
+ repo: prRepoSchema.optional(),
33
+ number: z.number().int().positive().optional(),
34
+ fetched: prFetchedSchema.optional()
35
+ });
36
+ export const taskMetadataSchema = z.object({
37
+ url: z.string().optional(),
38
+ pr: prAttachmentSchema.optional()
39
+ });
40
+ export const taskSchema = z
41
+ .object({
42
+ id: z.string(),
43
+ ref: z.string(),
44
+ type: z.enum(taskTypes),
45
+ title: z.string(),
46
+ status: z.enum(taskStatuses),
47
+ created_at: z.string(),
48
+ updated_at: z.string(),
49
+ metadata: taskMetadataSchema,
50
+ logs: z.array(logEntrySchema),
51
+ day_assignments: z.array(dayAssignmentSchema)
52
+ })
53
+ .superRefine((task, ctx) => {
54
+ if (task.type === 'pr_review' && !task.metadata.pr?.url) {
55
+ ctx.addIssue({
56
+ code: z.ZodIssueCode.custom,
57
+ message: 'PR review tasks require metadata.pr.url',
58
+ path: ['metadata', 'pr', 'url']
59
+ });
60
+ }
61
+ });
62
+ export const validateTaskOrThrow = (task) => {
63
+ return taskSchema.parse(task);
64
+ };
@@ -0,0 +1,17 @@
1
+ export const taskMatchesQuery = (task, query) => {
2
+ const needle = query.toLowerCase();
3
+ const contains = (value) => value ? value.toLowerCase().includes(needle) : false;
4
+ if (contains(task.title)) {
5
+ return true;
6
+ }
7
+ if (task.logs.some((log) => contains(log.msg))) {
8
+ return true;
9
+ }
10
+ if (contains(task.metadata.pr?.url)) {
11
+ return true;
12
+ }
13
+ if (contains(task.metadata.pr?.fetched?.title)) {
14
+ return true;
15
+ }
16
+ return false;
17
+ };