@upgraide/ui-notes-cli 0.1.2 → 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/commands/batch.ts +116 -0
- package/commands/changelog.ts +150 -0
- package/commands/explain.ts +67 -0
- package/commands/schema.ts +33 -0
- package/formatters/ai-context.ts +236 -0
- package/index.ts +55 -1
- package/package.json +5 -2
- package/resolvers/file-resolver.ts +477 -0
- package/resolvers/selector-parser.ts +80 -0
- package/schemas/comments.json +14 -0
- package/schemas/notes.json +24 -0
- package/schemas/projects.json +15 -0
- package/schemas/webhooks.json +16 -0
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import type { Config } from '../config.js';
|
|
2
|
+
import { apiFetch } from '../api.js';
|
|
3
|
+
import { readFileSync } from 'fs';
|
|
4
|
+
|
|
5
|
+
interface BatchOp {
|
|
6
|
+
op: 'resolve' | 'wontfix' | 'comment' | 'reopen';
|
|
7
|
+
id: string;
|
|
8
|
+
message?: string;
|
|
9
|
+
text?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface BatchOptions {
|
|
13
|
+
project?: string;
|
|
14
|
+
concurrency?: number;
|
|
15
|
+
dryRun?: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function batch(config: Config, source: string, opts: BatchOptions): Promise<void> {
|
|
19
|
+
let input: string;
|
|
20
|
+
if (source === '-') {
|
|
21
|
+
input = await readStdin();
|
|
22
|
+
} else {
|
|
23
|
+
input = readFileSync(source, 'utf-8');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const lines = input.trim().split('\n').filter(l => l.trim());
|
|
27
|
+
const ops: BatchOp[] = [];
|
|
28
|
+
|
|
29
|
+
for (let i = 0; i < lines.length; i++) {
|
|
30
|
+
try {
|
|
31
|
+
ops.push(JSON.parse(lines[i]));
|
|
32
|
+
} catch {
|
|
33
|
+
console.error(`Line ${i + 1}: invalid JSON — skipped`);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (ops.length === 0) {
|
|
38
|
+
console.log('No operations to execute.');
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (opts.dryRun) {
|
|
43
|
+
console.log(`Dry run: ${ops.length} operations`);
|
|
44
|
+
for (let i = 0; i < ops.length; i++) {
|
|
45
|
+
console.log(`[${i + 1}/${ops.length}] ${ops[i].op} ${ops[i].id}`);
|
|
46
|
+
}
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const concurrency = opts.concurrency || 5;
|
|
51
|
+
let succeeded = 0;
|
|
52
|
+
let failed = 0;
|
|
53
|
+
|
|
54
|
+
for (let i = 0; i < ops.length; i += concurrency) {
|
|
55
|
+
const chunk = ops.slice(i, i + concurrency);
|
|
56
|
+
await Promise.allSettled(
|
|
57
|
+
chunk.map(async (op, idx) => {
|
|
58
|
+
const globalIdx = i + idx;
|
|
59
|
+
try {
|
|
60
|
+
await executeOp(config, op, opts.project);
|
|
61
|
+
succeeded++;
|
|
62
|
+
console.log(`[${globalIdx + 1}/${ops.length}] ${op.op} ${op.id} ✓`);
|
|
63
|
+
} catch (err: any) {
|
|
64
|
+
failed++;
|
|
65
|
+
console.log(`[${globalIdx + 1}/${ops.length}] ${op.op} ${op.id} ✗ ${err.message}`);
|
|
66
|
+
}
|
|
67
|
+
})
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
console.log(`Batch complete: ${succeeded}/${ops.length} succeeded${failed > 0 ? `, ${failed} failed` : ''}`);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function executeOp(config: Config, op: BatchOp, project?: string): Promise<void> {
|
|
75
|
+
switch (op.op) {
|
|
76
|
+
case 'resolve':
|
|
77
|
+
await apiFetch(config, `/notes/${op.id}`, {
|
|
78
|
+
method: 'PATCH',
|
|
79
|
+
body: { status: 'resolved', resolution: op.message },
|
|
80
|
+
project,
|
|
81
|
+
});
|
|
82
|
+
break;
|
|
83
|
+
case 'wontfix':
|
|
84
|
+
await apiFetch(config, `/notes/${op.id}`, {
|
|
85
|
+
method: 'PATCH',
|
|
86
|
+
body: { status: 'wontfix', resolution: op.message },
|
|
87
|
+
project,
|
|
88
|
+
});
|
|
89
|
+
break;
|
|
90
|
+
case 'comment':
|
|
91
|
+
if (!op.text) throw new Error('Missing "text" field for comment operation');
|
|
92
|
+
await apiFetch(config, `/notes/${op.id}/comments`, {
|
|
93
|
+
method: 'POST',
|
|
94
|
+
body: { body: op.text },
|
|
95
|
+
project,
|
|
96
|
+
});
|
|
97
|
+
break;
|
|
98
|
+
case 'reopen':
|
|
99
|
+
await apiFetch(config, `/notes/${op.id}`, {
|
|
100
|
+
method: 'PATCH',
|
|
101
|
+
body: { status: 'open' },
|
|
102
|
+
project,
|
|
103
|
+
});
|
|
104
|
+
break;
|
|
105
|
+
default:
|
|
106
|
+
throw new Error(`Unknown operation: ${(op as any).op}`);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function readStdin(): Promise<string> {
|
|
111
|
+
const chunks: Buffer[] = [];
|
|
112
|
+
for await (const chunk of process.stdin) {
|
|
113
|
+
chunks.push(Buffer.from(chunk));
|
|
114
|
+
}
|
|
115
|
+
return Buffer.concat(chunks).toString('utf-8');
|
|
116
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import type { Config } from '../config.js';
|
|
2
|
+
import { apiFetch } from '../api.js';
|
|
3
|
+
|
|
4
|
+
interface ChangelogOptions {
|
|
5
|
+
project?: string;
|
|
6
|
+
since?: string;
|
|
7
|
+
format?: 'markdown' | 'json' | 'conventional';
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
interface Note {
|
|
11
|
+
id: string;
|
|
12
|
+
type: string;
|
|
13
|
+
body: string;
|
|
14
|
+
component: string | null;
|
|
15
|
+
resolved_at: string | null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const TYPE_ORDER = ['bug', 'ux', 'feature', 'question'];
|
|
19
|
+
|
|
20
|
+
const TYPE_LABELS: Record<string, string> = {
|
|
21
|
+
bug: 'Bugs',
|
|
22
|
+
ux: 'UX',
|
|
23
|
+
feature: 'Features',
|
|
24
|
+
question: 'Questions',
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const TYPE_CONVENTIONAL: Record<string, string> = {
|
|
28
|
+
bug: 'fix',
|
|
29
|
+
ux: 'style',
|
|
30
|
+
feature: 'feat',
|
|
31
|
+
question: 'docs',
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
function isISODate(value: string): boolean {
|
|
35
|
+
return /^\d{4}-\d{2}-\d{2}/.test(value);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function resolveTimestamp(since: string): string {
|
|
39
|
+
if (isISODate(since)) {
|
|
40
|
+
return since;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Treat as git ref, resolve to ISO timestamp
|
|
44
|
+
const result = Bun.spawnSync(['git', 'log', '-1', '--format=%cI', since]);
|
|
45
|
+
const output = result.stdout.toString().trim();
|
|
46
|
+
|
|
47
|
+
if (result.exitCode !== 0 || !output) {
|
|
48
|
+
throw new Error(`Could not resolve git ref "${since}". Make sure it exists.`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return output;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function formatMarkdown(notes: Note[]): string {
|
|
55
|
+
const grouped: Record<string, Note[]> = {};
|
|
56
|
+
|
|
57
|
+
for (const note of notes) {
|
|
58
|
+
if (!grouped[note.type]) grouped[note.type] = [];
|
|
59
|
+
grouped[note.type].push(note);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const lines: string[] = ['## UI Notes Resolved', ''];
|
|
63
|
+
|
|
64
|
+
for (const type of TYPE_ORDER) {
|
|
65
|
+
const group = grouped[type];
|
|
66
|
+
if (!group || group.length === 0) continue;
|
|
67
|
+
|
|
68
|
+
lines.push(`### ${TYPE_LABELS[type] || type}`);
|
|
69
|
+
for (const note of group) {
|
|
70
|
+
const shortId = note.id.slice(0, 8);
|
|
71
|
+
const body = note.body.split('\n')[0].trim();
|
|
72
|
+
lines.push(`- ${body} (#${shortId})`);
|
|
73
|
+
}
|
|
74
|
+
lines.push('');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return lines.join('\n');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function formatConventional(notes: Note[]): string {
|
|
81
|
+
const lines: string[] = [];
|
|
82
|
+
|
|
83
|
+
for (const type of TYPE_ORDER) {
|
|
84
|
+
const group = notes.filter((n) => n.type === type);
|
|
85
|
+
for (const note of group) {
|
|
86
|
+
const prefix = TYPE_CONVENTIONAL[note.type] || note.type;
|
|
87
|
+
const scope = note.component || 'ui';
|
|
88
|
+
const body = note.body.split('\n')[0].trim();
|
|
89
|
+
const shortId = note.id.slice(0, 8);
|
|
90
|
+
lines.push(`${prefix}(${scope}): ${body} (note:${shortId})`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return lines.join('\n');
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function formatJSON(notes: Note[]): string {
|
|
98
|
+
const items = notes.map((n) => ({
|
|
99
|
+
id: n.id,
|
|
100
|
+
type: n.type,
|
|
101
|
+
body: n.body,
|
|
102
|
+
component: n.component,
|
|
103
|
+
resolved_at: n.resolved_at,
|
|
104
|
+
}));
|
|
105
|
+
return JSON.stringify(items, null, 2);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export async function changelog(config: Config, opts: ChangelogOptions): Promise<void> {
|
|
109
|
+
const since = opts.since;
|
|
110
|
+
if (!since) {
|
|
111
|
+
console.error('--since is required. Provide a git ref (e.g., HEAD~5, v1.2.0) or ISO date (e.g., 2026-02-01).');
|
|
112
|
+
process.exit(1);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const timestamp = resolveTimestamp(since);
|
|
116
|
+
const format = opts.format || 'markdown';
|
|
117
|
+
|
|
118
|
+
const params = new URLSearchParams();
|
|
119
|
+
params.set('status', 'all');
|
|
120
|
+
params.set('resolved_after', timestamp);
|
|
121
|
+
params.set('limit', '500');
|
|
122
|
+
|
|
123
|
+
const path = `/notes?${params.toString()}`;
|
|
124
|
+
const response = (await apiFetch(config, path, { project: opts.project })) as {
|
|
125
|
+
ok: boolean;
|
|
126
|
+
data: { notes: Note[] };
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
const notes = response.data.notes
|
|
130
|
+
.filter((n) => n.resolved_at)
|
|
131
|
+
.sort((a, b) => (a.resolved_at! > b.resolved_at! ? 1 : -1));
|
|
132
|
+
|
|
133
|
+
if (notes.length === 0) {
|
|
134
|
+
console.log('No resolved notes found since ' + timestamp);
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
switch (format) {
|
|
139
|
+
case 'json':
|
|
140
|
+
console.log(formatJSON(notes));
|
|
141
|
+
break;
|
|
142
|
+
case 'conventional':
|
|
143
|
+
console.log(formatConventional(notes));
|
|
144
|
+
break;
|
|
145
|
+
case 'markdown':
|
|
146
|
+
default:
|
|
147
|
+
console.log(formatMarkdown(notes));
|
|
148
|
+
break;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { Config } from '../config.js';
|
|
2
|
+
import { apiFetch } from '../api.js';
|
|
3
|
+
|
|
4
|
+
interface ExplainOptions {
|
|
5
|
+
format?: 'markdown' | 'json';
|
|
6
|
+
project?: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export async function explain(config: Config, noteId: string, opts: ExplainOptions): Promise<void> {
|
|
10
|
+
const response = await apiFetch(config, `/notes/${noteId}/explain`, {
|
|
11
|
+
project: opts.project,
|
|
12
|
+
}) as any;
|
|
13
|
+
|
|
14
|
+
const { note, comments, history, visual_diffs } = response.data;
|
|
15
|
+
|
|
16
|
+
if (opts.format === 'json') {
|
|
17
|
+
console.log(JSON.stringify(response.data, null, 2));
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Default: markdown format
|
|
22
|
+
const lines: string[] = [];
|
|
23
|
+
lines.push(`# Note ${note.id} — ${note.type} (${note.status})`);
|
|
24
|
+
lines.push('');
|
|
25
|
+
if (note.component) lines.push(`**Component**: ${note.component}`);
|
|
26
|
+
if (note.selector) lines.push(`**Selector**: \`${note.selector}\``);
|
|
27
|
+
lines.push(`**URL**: ${note.url}`);
|
|
28
|
+
if (note.author) lines.push(`**Author**: ${note.author}`);
|
|
29
|
+
lines.push(`**Created**: ${note.created_at}`);
|
|
30
|
+
if (note.resolved_at) lines.push(`**Resolved**: ${note.resolved_at}`);
|
|
31
|
+
if (note.screenshot_url) lines.push(`**Screenshot**: ${note.screenshot_url}`);
|
|
32
|
+
lines.push('');
|
|
33
|
+
lines.push(`> ${note.body}`);
|
|
34
|
+
|
|
35
|
+
if (comments.length > 0) {
|
|
36
|
+
lines.push('');
|
|
37
|
+
lines.push(`## Comments (${comments.length})`);
|
|
38
|
+
lines.push('');
|
|
39
|
+
for (const c of comments) {
|
|
40
|
+
lines.push(`**${c.author}** (${c.created_at}):`);
|
|
41
|
+
lines.push(`> ${c.body}`);
|
|
42
|
+
lines.push('');
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (history.length > 0) {
|
|
47
|
+
lines.push('## History');
|
|
48
|
+
lines.push('');
|
|
49
|
+
for (const h of history) {
|
|
50
|
+
lines.push(`- ${h.changed_at}: ${h.field} ${h.old_value ?? '(none)'} → ${h.new_value ?? '(none)'}${h.changed_by ? ` (${h.changed_by})` : ''}`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (visual_diffs.length > 0) {
|
|
55
|
+
lines.push('');
|
|
56
|
+
lines.push(`## Visual Diffs (${visual_diffs.length})`);
|
|
57
|
+
lines.push('');
|
|
58
|
+
for (const vd of visual_diffs) {
|
|
59
|
+
lines.push(`- ${vd.created_at}: diff ${vd.diff_percentage != null ? `${vd.diff_percentage}%` : 'N/A'}`);
|
|
60
|
+
if (vd.before_url) lines.push(` Before: ${vd.before_url}`);
|
|
61
|
+
if (vd.after_url) lines.push(` After: ${vd.after_url}`);
|
|
62
|
+
if (vd.diff_url) lines.push(` Diff: ${vd.diff_url}`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
console.log(lines.join('\n'));
|
|
67
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { Config } from '../config.js';
|
|
2
|
+
import { readFileSync } from 'fs';
|
|
3
|
+
import { join } from 'path';
|
|
4
|
+
|
|
5
|
+
const RESOURCES = ['notes', 'comments', 'projects', 'webhooks'];
|
|
6
|
+
|
|
7
|
+
export async function schema(_config: Config, resource?: string): Promise<void> {
|
|
8
|
+
if (!resource) {
|
|
9
|
+
console.log('Available schemas:');
|
|
10
|
+
for (const r of RESOURCES) {
|
|
11
|
+
console.log(` ${r}`);
|
|
12
|
+
}
|
|
13
|
+
console.log('\nUsage: uinotes schema --resource <name>');
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
if (!RESOURCES.includes(resource)) {
|
|
18
|
+
console.error(`Unknown resource: ${resource}`);
|
|
19
|
+
console.error(`Available: ${RESOURCES.join(', ')}`);
|
|
20
|
+
process.exit(1);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const schemaDir = join(import.meta.dir, '..', 'schemas');
|
|
24
|
+
const schemaPath = join(schemaDir, `${resource}.json`);
|
|
25
|
+
|
|
26
|
+
try {
|
|
27
|
+
const content = readFileSync(schemaPath, 'utf-8');
|
|
28
|
+
console.log(content);
|
|
29
|
+
} catch {
|
|
30
|
+
console.error(`Schema file not found: ${schemaPath}`);
|
|
31
|
+
process.exit(1);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import type { ResolvedNote, FileMatch } from '../resolvers/file-resolver.js';
|
|
2
|
+
|
|
3
|
+
interface Note {
|
|
4
|
+
id: string;
|
|
5
|
+
type: string;
|
|
6
|
+
body: string;
|
|
7
|
+
url: string;
|
|
8
|
+
component: string | null;
|
|
9
|
+
selector: string | null;
|
|
10
|
+
tag: string | null;
|
|
11
|
+
text_content: string | null;
|
|
12
|
+
created_at: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface FormatOptions {
|
|
16
|
+
format: 'markdown' | 'xml' | 'json';
|
|
17
|
+
componentFilter?: string;
|
|
18
|
+
projectName: string;
|
|
19
|
+
resolvedNotes?: Map<string, ResolvedNote>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function formatNotesForAI(notes: Note[], opts: FormatOptions): string {
|
|
23
|
+
// Filter by component if specified
|
|
24
|
+
let filtered = notes;
|
|
25
|
+
if (opts.componentFilter) {
|
|
26
|
+
filtered = notes.filter(n =>
|
|
27
|
+
n.component?.toLowerCase().includes(opts.componentFilter!.toLowerCase())
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Group by component
|
|
32
|
+
const grouped = new Map<string, Note[]>();
|
|
33
|
+
for (const note of filtered) {
|
|
34
|
+
const key = note.component || '_unresolved';
|
|
35
|
+
if (!grouped.has(key)) grouped.set(key, []);
|
|
36
|
+
grouped.get(key)!.push(note);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const date = new Date().toISOString().split('T')[0];
|
|
40
|
+
|
|
41
|
+
if (opts.format === 'xml') {
|
|
42
|
+
return formatAsXML(grouped, filtered.length, opts.projectName, date, opts.resolvedNotes);
|
|
43
|
+
}
|
|
44
|
+
if (opts.format === 'json') {
|
|
45
|
+
return formatAsJSON(grouped, filtered.length, opts.projectName, date, opts.resolvedNotes);
|
|
46
|
+
}
|
|
47
|
+
return formatAsMarkdown(grouped, filtered.length, opts.projectName, date, opts.resolvedNotes);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function formatFileMatch(file: FileMatch): string {
|
|
51
|
+
const loc = file.lineNumber ? `${file.filePath}:${file.lineNumber}` : file.filePath;
|
|
52
|
+
return `\`${loc}\` (${file.confidence} confidence, matched on ${file.matchedOn})`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function formatAsMarkdown(
|
|
56
|
+
grouped: Map<string, Note[]>,
|
|
57
|
+
total: number,
|
|
58
|
+
projectName: string,
|
|
59
|
+
date: string,
|
|
60
|
+
resolvedNotes?: Map<string, ResolvedNote>,
|
|
61
|
+
): string {
|
|
62
|
+
const lines: string[] = [];
|
|
63
|
+
|
|
64
|
+
lines.push(`# UI Notes: ${projectName}`);
|
|
65
|
+
|
|
66
|
+
if (total === 0) {
|
|
67
|
+
lines.push('> No open notes.');
|
|
68
|
+
return lines.join('\n') + '\n';
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const resolveInfo = resolvedNotes ? ' | File resolution: enabled' : '';
|
|
72
|
+
lines.push(`> ${total} open note${total === 1 ? '' : 's'} as of ${date}${resolveInfo}`);
|
|
73
|
+
lines.push('');
|
|
74
|
+
|
|
75
|
+
for (const [component, notes] of grouped) {
|
|
76
|
+
if (component === '_unresolved') {
|
|
77
|
+
lines.push(`## _Unresolved (${notes.length} note${notes.length === 1 ? '' : 's'})`);
|
|
78
|
+
} else {
|
|
79
|
+
lines.push(`## Component: ${component} (${notes.length} note${notes.length === 1 ? '' : 's'})`);
|
|
80
|
+
}
|
|
81
|
+
lines.push('');
|
|
82
|
+
|
|
83
|
+
for (const note of notes) {
|
|
84
|
+
const typeLabel = (note.type || 'note').toUpperCase();
|
|
85
|
+
const firstLine = note.body?.split('\n')[0] || '(no body)';
|
|
86
|
+
lines.push(`### [${typeLabel}] ${firstLine}`);
|
|
87
|
+
|
|
88
|
+
// File resolution output
|
|
89
|
+
if (resolvedNotes) {
|
|
90
|
+
const resolved = resolvedNotes.get(note.id);
|
|
91
|
+
if (resolved && resolved.files.length > 0) {
|
|
92
|
+
for (const file of resolved.files) {
|
|
93
|
+
lines.push(`- **File**: ${formatFileMatch(file)}`);
|
|
94
|
+
}
|
|
95
|
+
} else {
|
|
96
|
+
lines.push(`- **File**: _(no match found)_`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (note.selector) {
|
|
101
|
+
lines.push(`- **Selector**: \`${note.selector}\``);
|
|
102
|
+
}
|
|
103
|
+
if (note.url) {
|
|
104
|
+
lines.push(`- **URL**: ${note.url}`);
|
|
105
|
+
}
|
|
106
|
+
if (note.tag || note.text_content) {
|
|
107
|
+
const parts: string[] = [];
|
|
108
|
+
if (note.tag) parts.push(`<${note.tag}>`);
|
|
109
|
+
if (note.text_content) parts.push(`"${note.text_content}"`);
|
|
110
|
+
lines.push(`- **Element**: ${parts.join(' ')}`);
|
|
111
|
+
}
|
|
112
|
+
if (note.created_at) {
|
|
113
|
+
const reported = note.created_at.split('T')[0];
|
|
114
|
+
lines.push(`- **Reported**: ${reported}`);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Full body as blockquote
|
|
118
|
+
if (note.body) {
|
|
119
|
+
const bodyLines = note.body.split('\n').map(l => `> ${l}`);
|
|
120
|
+
lines.push(bodyLines.join('\n'));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
lines.push('');
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return lines.join('\n');
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function formatAsXML(
|
|
131
|
+
grouped: Map<string, Note[]>,
|
|
132
|
+
total: number,
|
|
133
|
+
projectName: string,
|
|
134
|
+
date: string,
|
|
135
|
+
resolvedNotes?: Map<string, ResolvedNote>,
|
|
136
|
+
): string {
|
|
137
|
+
const lines: string[] = [];
|
|
138
|
+
|
|
139
|
+
lines.push(`<ui-notes project="${escapeXml(projectName)}" count="${total}" date="${date}">`);
|
|
140
|
+
|
|
141
|
+
for (const [component, notes] of grouped) {
|
|
142
|
+
const compName = component === '_unresolved' ? '_unresolved' : component;
|
|
143
|
+
lines.push(` <component name="${escapeXml(compName)}" count="${notes.length}">`);
|
|
144
|
+
|
|
145
|
+
for (const note of notes) {
|
|
146
|
+
const typeAttr = (note.type || 'note').toLowerCase();
|
|
147
|
+
lines.push(` <note type="${escapeXml(typeAttr)}" id="${escapeXml(note.id)}">`);
|
|
148
|
+
lines.push(` <body>${escapeXml(note.body || '')}</body>`);
|
|
149
|
+
|
|
150
|
+
// File resolution output
|
|
151
|
+
if (resolvedNotes) {
|
|
152
|
+
const resolved = resolvedNotes.get(note.id);
|
|
153
|
+
if (resolved && resolved.files.length > 0) {
|
|
154
|
+
lines.push(` <resolved-files>`);
|
|
155
|
+
for (const file of resolved.files) {
|
|
156
|
+
const loc = file.lineNumber ? `${file.filePath}:${file.lineNumber}` : file.filePath;
|
|
157
|
+
lines.push(` <file path="${escapeXml(loc)}" confidence="${file.confidence}" match-type="${file.matchType}" matched-on="${escapeXml(file.matchedOn)}" />`);
|
|
158
|
+
}
|
|
159
|
+
lines.push(` </resolved-files>`);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (note.selector) {
|
|
164
|
+
lines.push(` <selector>${escapeXml(note.selector)}</selector>`);
|
|
165
|
+
}
|
|
166
|
+
if (note.url) {
|
|
167
|
+
lines.push(` <url>${escapeXml(note.url)}</url>`);
|
|
168
|
+
}
|
|
169
|
+
if (note.tag) {
|
|
170
|
+
lines.push(` <tag>${escapeXml(note.tag)}</tag>`);
|
|
171
|
+
}
|
|
172
|
+
if (note.text_content) {
|
|
173
|
+
lines.push(` <text-content>${escapeXml(note.text_content)}</text-content>`);
|
|
174
|
+
}
|
|
175
|
+
if (note.created_at) {
|
|
176
|
+
lines.push(` <created>${note.created_at.split('T')[0]}</created>`);
|
|
177
|
+
}
|
|
178
|
+
lines.push(` </note>`);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
lines.push(` </component>`);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
lines.push(`</ui-notes>`);
|
|
185
|
+
return lines.join('\n') + '\n';
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function formatAsJSON(
|
|
189
|
+
grouped: Map<string, Note[]>,
|
|
190
|
+
total: number,
|
|
191
|
+
projectName: string,
|
|
192
|
+
date: string,
|
|
193
|
+
resolvedNotes?: Map<string, ResolvedNote>,
|
|
194
|
+
): string {
|
|
195
|
+
const components: Record<string, unknown[]> = {};
|
|
196
|
+
|
|
197
|
+
for (const [component, notes] of grouped) {
|
|
198
|
+
components[component] = notes.map(n => {
|
|
199
|
+
const entry: Record<string, unknown> = {
|
|
200
|
+
id: n.id,
|
|
201
|
+
type: n.type,
|
|
202
|
+
body: n.body,
|
|
203
|
+
selector: n.selector || undefined,
|
|
204
|
+
url: n.url || undefined,
|
|
205
|
+
tag: n.tag || undefined,
|
|
206
|
+
text_content: n.text_content || undefined,
|
|
207
|
+
created_at: n.created_at,
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
if (resolvedNotes) {
|
|
211
|
+
const resolved = resolvedNotes.get(n.id);
|
|
212
|
+
entry.resolved_files = resolved?.files || [];
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return entry;
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const output = {
|
|
220
|
+
project: projectName,
|
|
221
|
+
total,
|
|
222
|
+
date,
|
|
223
|
+
components,
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
return JSON.stringify(output, null, 2) + '\n';
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function escapeXml(str: string): string {
|
|
230
|
+
return str
|
|
231
|
+
.replace(/&/g, '&')
|
|
232
|
+
.replace(/</g, '<')
|
|
233
|
+
.replace(/>/g, '>')
|
|
234
|
+
.replace(/"/g, '"')
|
|
235
|
+
.replace(/'/g, ''');
|
|
236
|
+
}
|
package/index.ts
CHANGED
|
@@ -25,13 +25,17 @@ Commands:
|
|
|
25
25
|
login Login and create API key
|
|
26
26
|
pull Fetch open notes
|
|
27
27
|
context AI-optimized digest of open notes
|
|
28
|
+
explain <id> Full context for a single note
|
|
28
29
|
resolve <id> Mark note as resolved
|
|
29
30
|
wontfix <id> Mark note as won't fix
|
|
30
31
|
comment <id> "text" Add a comment to a note
|
|
31
32
|
comments <id> List comments for a note
|
|
32
33
|
resolve-by-component <name> [msg] Bulk resolve notes by component
|
|
33
34
|
resolve-by-selector <sel> [msg] Bulk resolve notes by selector
|
|
35
|
+
changelog Generate changelog from resolved notes
|
|
34
36
|
visual-diff Capture & compare page screenshots
|
|
37
|
+
batch <file|-> Execute NDJSON batch operations
|
|
38
|
+
schema List or print API resource schemas
|
|
35
39
|
config View/edit configuration
|
|
36
40
|
|
|
37
41
|
Options:
|
|
@@ -47,7 +51,11 @@ Options:
|
|
|
47
51
|
--root-dir <dir> Source root for file resolution (default: cwd)
|
|
48
52
|
--compare Compare current screenshots against previous (visual-diff)
|
|
49
53
|
--width <px> Viewport width for screenshots (default: 1280)
|
|
50
|
-
--full-page Capture full page height (default: true)
|
|
54
|
+
--full-page Capture full page height (default: true)
|
|
55
|
+
--since <ref> Git ref or ISO date for changelog start
|
|
56
|
+
--resource <name> Resource name for schema command
|
|
57
|
+
--concurrency <n> Concurrent batch operations (default: 5)
|
|
58
|
+
--dry-run Preview batch operations without executing`;
|
|
51
59
|
|
|
52
60
|
function parseFlags(args: string[]): { flags: Record<string, string>; arrays: Record<string, string[]>; positional: string[] } {
|
|
53
61
|
const flags: Record<string, string> = {};
|
|
@@ -116,6 +124,13 @@ async function main() {
|
|
|
116
124
|
return;
|
|
117
125
|
}
|
|
118
126
|
|
|
127
|
+
// Schema command is local-only, no API key needed
|
|
128
|
+
if (command === 'schema') {
|
|
129
|
+
const { schema } = await import('./commands/schema.js');
|
|
130
|
+
await schema(getConfig(), flags.resource);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
|
|
119
134
|
const config = getConfig();
|
|
120
135
|
if (!config.apiKey) {
|
|
121
136
|
console.error('No API key configured. Run: uinotes config set apiKey <your-key>');
|
|
@@ -230,6 +245,20 @@ async function main() {
|
|
|
230
245
|
});
|
|
231
246
|
break;
|
|
232
247
|
|
|
248
|
+
case 'explain': {
|
|
249
|
+
const id = positional[1];
|
|
250
|
+
if (!id) {
|
|
251
|
+
console.error('Usage: uinotes explain <note-id> [--format markdown|json]');
|
|
252
|
+
process.exit(1);
|
|
253
|
+
}
|
|
254
|
+
const { explain } = await import('./commands/explain.js');
|
|
255
|
+
await explain(config, id, {
|
|
256
|
+
project,
|
|
257
|
+
format: (flags.format as 'markdown' | 'json') || undefined,
|
|
258
|
+
});
|
|
259
|
+
break;
|
|
260
|
+
}
|
|
261
|
+
|
|
233
262
|
case 'resolve': {
|
|
234
263
|
const id = positional[1];
|
|
235
264
|
if (!id) {
|
|
@@ -288,6 +317,16 @@ async function main() {
|
|
|
288
317
|
break;
|
|
289
318
|
}
|
|
290
319
|
|
|
320
|
+
case 'changelog': {
|
|
321
|
+
const { changelog } = await import('./commands/changelog.js');
|
|
322
|
+
await changelog(config, {
|
|
323
|
+
project,
|
|
324
|
+
since: flags.since,
|
|
325
|
+
format: (flags.format as 'markdown' | 'json' | 'conventional') || undefined,
|
|
326
|
+
});
|
|
327
|
+
break;
|
|
328
|
+
}
|
|
329
|
+
|
|
291
330
|
case 'visual-diff': {
|
|
292
331
|
const { visualDiff } = await import('./commands/visual-diff.js');
|
|
293
332
|
await visualDiff(config, {
|
|
@@ -300,6 +339,21 @@ async function main() {
|
|
|
300
339
|
break;
|
|
301
340
|
}
|
|
302
341
|
|
|
342
|
+
case 'batch': {
|
|
343
|
+
const source = positional[1];
|
|
344
|
+
if (!source) {
|
|
345
|
+
console.error('Usage: uinotes batch <file|-> [--concurrency 5] [--dry-run]');
|
|
346
|
+
process.exit(1);
|
|
347
|
+
}
|
|
348
|
+
const { batch } = await import('./commands/batch.js');
|
|
349
|
+
await batch(config, source, {
|
|
350
|
+
project,
|
|
351
|
+
concurrency: flags.concurrency ? parseInt(flags.concurrency) : undefined,
|
|
352
|
+
dryRun: flags['dry-run'] === 'true',
|
|
353
|
+
});
|
|
354
|
+
break;
|
|
355
|
+
}
|
|
356
|
+
|
|
303
357
|
case 'resolve-by-selector': {
|
|
304
358
|
const sel = positional[1];
|
|
305
359
|
if (!sel) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@upgraide/ui-notes-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "CLI for UI Notes - pull and manage UI feedback notes",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"keywords": ["cli", "ui", "notes", "feedback", "ui-notes"],
|
|
@@ -9,7 +9,10 @@
|
|
|
9
9
|
},
|
|
10
10
|
"files": [
|
|
11
11
|
"*.ts",
|
|
12
|
-
"commands/*.ts"
|
|
12
|
+
"commands/*.ts",
|
|
13
|
+
"formatters/*.ts",
|
|
14
|
+
"resolvers/*.ts",
|
|
15
|
+
"schemas/*.json"
|
|
13
16
|
],
|
|
14
17
|
"engines": {
|
|
15
18
|
"bun": ">=1.0.0"
|
|
@@ -0,0 +1,477 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File resolver: maps UI note metadata (component names, CSS selectors) to source files.
|
|
3
|
+
*
|
|
4
|
+
* Resolution strategies in priority order:
|
|
5
|
+
* 1. data-testid / data-component attribute match (very high confidence)
|
|
6
|
+
* 2. Component name -> filename match (high confidence)
|
|
7
|
+
* 3. Component name -> export/function declaration (high confidence)
|
|
8
|
+
* 4. Semantic class name search (non-Tailwind) (high confidence)
|
|
9
|
+
* 5. ID attribute search (high confidence)
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { readdir, readFile, stat } from 'fs/promises';
|
|
13
|
+
import { join, relative, basename, extname } from 'path';
|
|
14
|
+
import { existsSync } from 'fs';
|
|
15
|
+
import {
|
|
16
|
+
parseSelector,
|
|
17
|
+
filterSemanticClasses,
|
|
18
|
+
type SelectorTokens,
|
|
19
|
+
} from './selector-parser.js';
|
|
20
|
+
|
|
21
|
+
export interface FileMatch {
|
|
22
|
+
filePath: string;
|
|
23
|
+
lineNumber: number | null;
|
|
24
|
+
matchType: 'data-attr' | 'component' | 'selector' | 'id' | 'tag';
|
|
25
|
+
confidence: 'high' | 'medium' | 'low';
|
|
26
|
+
matchedOn: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface ResolvedNote {
|
|
30
|
+
noteId: string;
|
|
31
|
+
files: FileMatch[];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface ResolveOptions {
|
|
35
|
+
rootDir: string;
|
|
36
|
+
extensions?: string[];
|
|
37
|
+
excludeDirs?: string[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const DEFAULT_EXTENSIONS = ['.tsx', '.jsx', '.ts', '.js', '.vue', '.svelte'];
|
|
41
|
+
const DEFAULT_EXCLUDE_DIRS = [
|
|
42
|
+
'node_modules',
|
|
43
|
+
'.next',
|
|
44
|
+
'dist',
|
|
45
|
+
'build',
|
|
46
|
+
'.git',
|
|
47
|
+
'coverage',
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
interface NoteForResolution {
|
|
51
|
+
id: string;
|
|
52
|
+
component?: string | null;
|
|
53
|
+
selector?: string | null;
|
|
54
|
+
tag?: string | null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Check whether the project uses Tailwind CSS by looking for config files.
|
|
59
|
+
*/
|
|
60
|
+
function detectTailwind(rootDir: string): boolean {
|
|
61
|
+
const configFiles = [
|
|
62
|
+
'tailwind.config.js',
|
|
63
|
+
'tailwind.config.ts',
|
|
64
|
+
'tailwind.config.mjs',
|
|
65
|
+
'tailwind.config.cjs',
|
|
66
|
+
];
|
|
67
|
+
return configFiles.some((f) => existsSync(join(rootDir, f)));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Recursively collect source files under rootDir, respecting extension and exclusion filters.
|
|
72
|
+
*/
|
|
73
|
+
async function collectSourceFiles(
|
|
74
|
+
dir: string,
|
|
75
|
+
extensions: string[],
|
|
76
|
+
excludeDirs: string[],
|
|
77
|
+
): Promise<string[]> {
|
|
78
|
+
const results: string[] = [];
|
|
79
|
+
|
|
80
|
+
let entries;
|
|
81
|
+
try {
|
|
82
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
83
|
+
} catch {
|
|
84
|
+
return results;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
for (const entry of entries) {
|
|
88
|
+
const fullPath = join(dir, entry.name);
|
|
89
|
+
|
|
90
|
+
if (entry.isDirectory()) {
|
|
91
|
+
if (!excludeDirs.includes(entry.name)) {
|
|
92
|
+
const sub = await collectSourceFiles(fullPath, extensions, excludeDirs);
|
|
93
|
+
results.push(...sub);
|
|
94
|
+
}
|
|
95
|
+
} else if (entry.isFile()) {
|
|
96
|
+
const ext = extname(entry.name);
|
|
97
|
+
if (extensions.includes(ext)) {
|
|
98
|
+
results.push(fullPath);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return results;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Escape special regex characters in a string.
|
|
108
|
+
*/
|
|
109
|
+
function escapeRegex(str: string): string {
|
|
110
|
+
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Find the 1-based line number of the first occurrence of `pattern` in `content`.
|
|
115
|
+
*/
|
|
116
|
+
function findLineNumber(content: string, pattern: RegExp): number | null {
|
|
117
|
+
const match = content.match(pattern);
|
|
118
|
+
if (!match || match.index === undefined) return null;
|
|
119
|
+
return content.slice(0, match.index).split('\n').length;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ── Strategy: data-attribute match ───────────────────────────────────────
|
|
123
|
+
|
|
124
|
+
async function resolveByDataAttribute(
|
|
125
|
+
attrs: SelectorTokens['dataAttributes'],
|
|
126
|
+
sourceFiles: string[],
|
|
127
|
+
rootDir: string,
|
|
128
|
+
fileContents: Map<string, string>,
|
|
129
|
+
): Promise<FileMatch[]> {
|
|
130
|
+
const matches: FileMatch[] = [];
|
|
131
|
+
|
|
132
|
+
for (const attr of attrs) {
|
|
133
|
+
if (!attr.value) continue;
|
|
134
|
+
const searchPattern = new RegExp(
|
|
135
|
+
`${escapeRegex(attr.name)}\\s*=\\s*["']${escapeRegex(attr.value)}["']`,
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
for (const filePath of sourceFiles) {
|
|
139
|
+
const content = await getFileContent(filePath, fileContents);
|
|
140
|
+
if (!content) continue;
|
|
141
|
+
|
|
142
|
+
const lineNumber = findLineNumber(content, searchPattern);
|
|
143
|
+
if (lineNumber !== null) {
|
|
144
|
+
matches.push({
|
|
145
|
+
filePath: relative(rootDir, filePath),
|
|
146
|
+
lineNumber,
|
|
147
|
+
matchType: 'data-attr',
|
|
148
|
+
confidence: 'high',
|
|
149
|
+
matchedOn: `${attr.name}="${attr.value}"`,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// data-testid is very high confidence; return immediately if found
|
|
155
|
+
if (matches.length > 0) return matches;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return matches;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ── Strategy: Component name -> filename match ───────────────────────────
|
|
162
|
+
|
|
163
|
+
async function resolveByComponentFilename(
|
|
164
|
+
componentName: string,
|
|
165
|
+
sourceFiles: string[],
|
|
166
|
+
rootDir: string,
|
|
167
|
+
fileContents: Map<string, string>,
|
|
168
|
+
): Promise<FileMatch[]> {
|
|
169
|
+
const matches: FileMatch[] = [];
|
|
170
|
+
const normalizedName = componentName.toLowerCase();
|
|
171
|
+
|
|
172
|
+
for (const filePath of sourceFiles) {
|
|
173
|
+
const fileName = basename(filePath)
|
|
174
|
+
.replace(/\.[^.]+$/, '')
|
|
175
|
+
.toLowerCase();
|
|
176
|
+
|
|
177
|
+
if (fileName === normalizedName) {
|
|
178
|
+
// Try to find the exact component declaration for a line number
|
|
179
|
+
const content = await getFileContent(filePath, fileContents);
|
|
180
|
+
let lineNumber: number | null = null;
|
|
181
|
+
if (content) {
|
|
182
|
+
lineNumber = findComponentDeclarationLine(content, componentName);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
matches.push({
|
|
186
|
+
filePath: relative(rootDir, filePath),
|
|
187
|
+
lineNumber,
|
|
188
|
+
matchType: 'component',
|
|
189
|
+
confidence: 'high',
|
|
190
|
+
matchedOn: componentName,
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return matches;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// ── Strategy: Component name -> export/function search ───────────────────
|
|
199
|
+
|
|
200
|
+
async function resolveByComponentDeclaration(
|
|
201
|
+
componentName: string,
|
|
202
|
+
sourceFiles: string[],
|
|
203
|
+
rootDir: string,
|
|
204
|
+
fileContents: Map<string, string>,
|
|
205
|
+
): Promise<FileMatch[]> {
|
|
206
|
+
const matches: FileMatch[] = [];
|
|
207
|
+
const escaped = escapeRegex(componentName);
|
|
208
|
+
|
|
209
|
+
const patterns = [
|
|
210
|
+
new RegExp(`(?:export\\s+)?function\\s+${escaped}\\b`),
|
|
211
|
+
new RegExp(`(?:export\\s+)?const\\s+${escaped}\\s*[=:]`),
|
|
212
|
+
new RegExp(`(?:export\\s+)?class\\s+${escaped}\\b`),
|
|
213
|
+
new RegExp(`export\\s+default\\s+${escaped}\\b`),
|
|
214
|
+
new RegExp(`displayName\\s*=\\s*['"]${escaped}['"]`),
|
|
215
|
+
];
|
|
216
|
+
|
|
217
|
+
for (const filePath of sourceFiles) {
|
|
218
|
+
const content = await getFileContent(filePath, fileContents);
|
|
219
|
+
if (!content) continue;
|
|
220
|
+
|
|
221
|
+
for (const pattern of patterns) {
|
|
222
|
+
const lineNumber = findLineNumber(content, pattern);
|
|
223
|
+
if (lineNumber !== null) {
|
|
224
|
+
matches.push({
|
|
225
|
+
filePath: relative(rootDir, filePath),
|
|
226
|
+
lineNumber,
|
|
227
|
+
matchType: 'component',
|
|
228
|
+
confidence: 'high',
|
|
229
|
+
matchedOn: componentName,
|
|
230
|
+
});
|
|
231
|
+
break; // One match per file is enough
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return matches;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// ── Strategy: Semantic class name search ─────────────────────────────────
|
|
240
|
+
|
|
241
|
+
async function resolveBySemanticClasses(
|
|
242
|
+
classNames: string[],
|
|
243
|
+
sourceFiles: string[],
|
|
244
|
+
rootDir: string,
|
|
245
|
+
fileContents: Map<string, string>,
|
|
246
|
+
): Promise<FileMatch[]> {
|
|
247
|
+
const matches: FileMatch[] = [];
|
|
248
|
+
|
|
249
|
+
for (const cls of classNames) {
|
|
250
|
+
const pattern = new RegExp(escapeRegex(cls));
|
|
251
|
+
|
|
252
|
+
for (const filePath of sourceFiles) {
|
|
253
|
+
const content = await getFileContent(filePath, fileContents);
|
|
254
|
+
if (!content) continue;
|
|
255
|
+
|
|
256
|
+
const lineNumber = findLineNumber(content, pattern);
|
|
257
|
+
if (lineNumber !== null) {
|
|
258
|
+
matches.push({
|
|
259
|
+
filePath: relative(rootDir, filePath),
|
|
260
|
+
lineNumber,
|
|
261
|
+
matchType: 'selector',
|
|
262
|
+
confidence: 'high',
|
|
263
|
+
matchedOn: `.${cls}`,
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
return matches;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// ── Strategy: ID attribute search ────────────────────────────────────────
|
|
273
|
+
|
|
274
|
+
async function resolveByIds(
|
|
275
|
+
ids: string[],
|
|
276
|
+
sourceFiles: string[],
|
|
277
|
+
rootDir: string,
|
|
278
|
+
fileContents: Map<string, string>,
|
|
279
|
+
): Promise<FileMatch[]> {
|
|
280
|
+
const matches: FileMatch[] = [];
|
|
281
|
+
|
|
282
|
+
for (const id of ids) {
|
|
283
|
+
const pattern = new RegExp(`id\\s*=\\s*["']${escapeRegex(id)}["']`);
|
|
284
|
+
|
|
285
|
+
for (const filePath of sourceFiles) {
|
|
286
|
+
const content = await getFileContent(filePath, fileContents);
|
|
287
|
+
if (!content) continue;
|
|
288
|
+
|
|
289
|
+
const lineNumber = findLineNumber(content, pattern);
|
|
290
|
+
if (lineNumber !== null) {
|
|
291
|
+
matches.push({
|
|
292
|
+
filePath: relative(rootDir, filePath),
|
|
293
|
+
lineNumber,
|
|
294
|
+
matchType: 'id',
|
|
295
|
+
confidence: 'high',
|
|
296
|
+
matchedOn: `#${id}`,
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
return matches;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// ── Helpers ──────────────────────────────────────────────────────────────
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Read file content with caching within a single invocation.
|
|
309
|
+
*/
|
|
310
|
+
async function getFileContent(
|
|
311
|
+
filePath: string,
|
|
312
|
+
cache: Map<string, string>,
|
|
313
|
+
): Promise<string | null> {
|
|
314
|
+
if (cache.has(filePath)) return cache.get(filePath)!;
|
|
315
|
+
try {
|
|
316
|
+
const content = await readFile(filePath, 'utf-8');
|
|
317
|
+
cache.set(filePath, content);
|
|
318
|
+
return content;
|
|
319
|
+
} catch {
|
|
320
|
+
return null;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Find the line number of a component function/const declaration.
|
|
326
|
+
*/
|
|
327
|
+
function findComponentDeclarationLine(
|
|
328
|
+
content: string,
|
|
329
|
+
componentName: string,
|
|
330
|
+
): number | null {
|
|
331
|
+
const escaped = escapeRegex(componentName);
|
|
332
|
+
const patterns = [
|
|
333
|
+
new RegExp(`(?:export\\s+)?function\\s+${escaped}\\b`),
|
|
334
|
+
new RegExp(`(?:export\\s+)?const\\s+${escaped}\\s*[=:]`),
|
|
335
|
+
new RegExp(`(?:export\\s+)?class\\s+${escaped}\\b`),
|
|
336
|
+
new RegExp(`export\\s+default\\s+${escaped}\\b`),
|
|
337
|
+
];
|
|
338
|
+
for (const p of patterns) {
|
|
339
|
+
const line = findLineNumber(content, p);
|
|
340
|
+
if (line !== null) return line;
|
|
341
|
+
}
|
|
342
|
+
return null;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Deduplicate file matches, keeping the highest-confidence match per file.
|
|
347
|
+
*/
|
|
348
|
+
function deduplicateMatches(matches: FileMatch[]): FileMatch[] {
|
|
349
|
+
const byFile = new Map<string, FileMatch>();
|
|
350
|
+
const confidenceRank = { high: 3, medium: 2, low: 1 };
|
|
351
|
+
|
|
352
|
+
for (const m of matches) {
|
|
353
|
+
const existing = byFile.get(m.filePath);
|
|
354
|
+
if (
|
|
355
|
+
!existing ||
|
|
356
|
+
confidenceRank[m.confidence] > confidenceRank[existing.confidence]
|
|
357
|
+
) {
|
|
358
|
+
byFile.set(m.filePath, m);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
return Array.from(byFile.values());
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// ── Main entry point ─────────────────────────────────────────────────────
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Resolve a single note to source files.
|
|
369
|
+
*/
|
|
370
|
+
export async function resolveNoteToFiles(
|
|
371
|
+
note: NoteForResolution,
|
|
372
|
+
opts: ResolveOptions,
|
|
373
|
+
): Promise<ResolvedNote> {
|
|
374
|
+
const extensions = opts.extensions || DEFAULT_EXTENSIONS;
|
|
375
|
+
const excludeDirs = opts.excludeDirs || DEFAULT_EXCLUDE_DIRS;
|
|
376
|
+
const rootDir = opts.rootDir;
|
|
377
|
+
|
|
378
|
+
const sourceFiles = await collectSourceFiles(rootDir, extensions, excludeDirs);
|
|
379
|
+
const fileContents = new Map<string, string>();
|
|
380
|
+
const hasTailwind = detectTailwind(rootDir);
|
|
381
|
+
|
|
382
|
+
if (sourceFiles.length === 0) {
|
|
383
|
+
console.error(
|
|
384
|
+
`Warning: No source files found in ${rootDir}. Check --root-dir or resolve.rootDir in config.`,
|
|
385
|
+
);
|
|
386
|
+
return { noteId: note.id, files: [] };
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
let allMatches: FileMatch[] = [];
|
|
390
|
+
|
|
391
|
+
// Parse selector tokens if we have a selector
|
|
392
|
+
let tokens: SelectorTokens | null = null;
|
|
393
|
+
if (note.selector) {
|
|
394
|
+
tokens = parseSelector(note.selector);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// Strategy 1: data-attribute match (very high confidence, short-circuits)
|
|
398
|
+
if (tokens && tokens.dataAttributes.length > 0) {
|
|
399
|
+
const dataMatches = await resolveByDataAttribute(
|
|
400
|
+
tokens.dataAttributes,
|
|
401
|
+
sourceFiles,
|
|
402
|
+
rootDir,
|
|
403
|
+
fileContents,
|
|
404
|
+
);
|
|
405
|
+
if (dataMatches.length > 0) {
|
|
406
|
+
return { noteId: note.id, files: deduplicateMatches(dataMatches) };
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// Strategy 2: Component name -> filename match
|
|
411
|
+
if (note.component) {
|
|
412
|
+
const filenameMatches = await resolveByComponentFilename(
|
|
413
|
+
note.component,
|
|
414
|
+
sourceFiles,
|
|
415
|
+
rootDir,
|
|
416
|
+
fileContents,
|
|
417
|
+
);
|
|
418
|
+
allMatches.push(...filenameMatches);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// Strategy 3: Component name -> export/function search (only if no filename match)
|
|
422
|
+
if (note.component && allMatches.length === 0) {
|
|
423
|
+
const declMatches = await resolveByComponentDeclaration(
|
|
424
|
+
note.component,
|
|
425
|
+
sourceFiles,
|
|
426
|
+
rootDir,
|
|
427
|
+
fileContents,
|
|
428
|
+
);
|
|
429
|
+
allMatches.push(...declMatches);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// If we already have high-confidence component matches, return early
|
|
433
|
+
if (allMatches.length > 0) {
|
|
434
|
+
return { noteId: note.id, files: deduplicateMatches(allMatches) };
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// Strategy 4: Semantic class name search
|
|
438
|
+
if (tokens && tokens.classNames.length > 0) {
|
|
439
|
+
const semanticClasses = filterSemanticClasses(tokens.classNames);
|
|
440
|
+
if (semanticClasses.length > 0) {
|
|
441
|
+
const classMatches = await resolveBySemanticClasses(
|
|
442
|
+
semanticClasses,
|
|
443
|
+
sourceFiles,
|
|
444
|
+
rootDir,
|
|
445
|
+
fileContents,
|
|
446
|
+
);
|
|
447
|
+
allMatches.push(...classMatches);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// Strategy 5: ID attribute search
|
|
452
|
+
if (tokens && tokens.ids.length > 0) {
|
|
453
|
+
const idMatches = await resolveByIds(
|
|
454
|
+
tokens.ids,
|
|
455
|
+
sourceFiles,
|
|
456
|
+
rootDir,
|
|
457
|
+
fileContents,
|
|
458
|
+
);
|
|
459
|
+
allMatches.push(...idMatches);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
return { noteId: note.id, files: deduplicateMatches(allMatches) };
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* Resolve multiple notes in batch, sharing the file cache.
|
|
467
|
+
*/
|
|
468
|
+
export async function resolveNotesToFiles(
|
|
469
|
+
notes: NoteForResolution[],
|
|
470
|
+
opts: ResolveOptions,
|
|
471
|
+
): Promise<ResolvedNote[]> {
|
|
472
|
+
const results: ResolvedNote[] = [];
|
|
473
|
+
for (const note of notes) {
|
|
474
|
+
results.push(await resolveNoteToFiles(note, opts));
|
|
475
|
+
}
|
|
476
|
+
return results;
|
|
477
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CSS selector parser and Tailwind utility class filter.
|
|
3
|
+
* Extracts searchable tokens from CSS selectors captured by the UI Notes React package.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export interface SelectorTokens {
|
|
7
|
+
classNames: string[];
|
|
8
|
+
ids: string[];
|
|
9
|
+
tags: string[];
|
|
10
|
+
dataAttributes: Array<{ name: string; value: string | null }>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Regex matching common Tailwind CSS utility class prefixes.
|
|
15
|
+
* Classes matching this pattern carry no semantic meaning for file resolution.
|
|
16
|
+
*/
|
|
17
|
+
const TAILWIND_UTILITY_RE =
|
|
18
|
+
/^(flex|grid|block|inline|hidden|visible|static|fixed|absolute|relative|sticky|p[xytblr]?-|m[xytblr]?-|text-|bg-|border-|rounded|w-|h-|min-w-|max-w-|gap-|space-|font-|leading-|tracking-|shadow-|opacity-|z-|overflow-|cursor-|transition-|duration-|ease-|animate-|ring-|outline-|placeholder-|divide-|sr-only|not-sr-only|items-|justify-|self-|place-|order-|col-|row-|float-|clear-|object-|aspect-|container|prose|truncate|break-|whitespace-|decoration-|underline|overline|line-through|no-underline|uppercase|lowercase|capitalize|normal-case|italic|not-italic|tabular-nums|oldstyle-nums|lining-nums|proportional-nums|ordinal|slashed-zero|diagonal-fractions|stacked-fractions|list-|align-|table-|border-collapse|border-separate|origin-|scale-|rotate-|translate-|skew-|transform|accent-|appearance-|caret-|scroll-|snap-|touch-|select-|will-change-|fill-|stroke-)/;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Check if a class name looks like a Tailwind utility class.
|
|
22
|
+
*/
|
|
23
|
+
export function isTailwindUtility(className: string): boolean {
|
|
24
|
+
return TAILWIND_UTILITY_RE.test(className);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Filter out Tailwind utility classes, returning only semantic class names.
|
|
29
|
+
*/
|
|
30
|
+
export function filterSemanticClasses(classNames: string[]): string[] {
|
|
31
|
+
return classNames.filter((c) => !isTailwindUtility(c));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Parse a CSS selector string into searchable tokens.
|
|
36
|
+
*
|
|
37
|
+
* Handles selectors like:
|
|
38
|
+
* [data-testid="sidebar"]
|
|
39
|
+
* #checkout-form
|
|
40
|
+
* nav.sidebar-nav > ul.nav-list
|
|
41
|
+
* div.flex > button.submit-btn
|
|
42
|
+
*/
|
|
43
|
+
export function parseSelector(selector: string): SelectorTokens {
|
|
44
|
+
const classNames: string[] = [];
|
|
45
|
+
const ids: string[] = [];
|
|
46
|
+
const tags: string[] = [];
|
|
47
|
+
const dataAttributes: Array<{ name: string; value: string | null }> = [];
|
|
48
|
+
|
|
49
|
+
// Extract data-* attribute selectors: [data-testid="value"] or [data-component="value"]
|
|
50
|
+
const dataMatches = selector.matchAll(
|
|
51
|
+
/\[(data-[\w-]+)(?:="([^"]*)")?\]/g,
|
|
52
|
+
);
|
|
53
|
+
for (const m of dataMatches) {
|
|
54
|
+
dataAttributes.push({ name: m[1], value: m[2] ?? null });
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Extract .class-names (handling CSS-escaped chars like \.)
|
|
58
|
+
const classMatches = selector.matchAll(/\.([a-zA-Z_][\w-]*)/g);
|
|
59
|
+
for (const m of classMatches) {
|
|
60
|
+
classNames.push(m[1]);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Extract #ids
|
|
64
|
+
const idMatches = selector.matchAll(/#([a-zA-Z_][\w-]*)/g);
|
|
65
|
+
for (const m of idMatches) {
|
|
66
|
+
ids.push(m[1]);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Extract tag names: bare words at start of selector or after combinators (>, +, ~, space)
|
|
70
|
+
const tagMatches = selector.matchAll(/(?:^|[\s>+~])([a-z][a-z0-9]*)\b/gi);
|
|
71
|
+
for (const m of tagMatches) {
|
|
72
|
+
const tag = m[1].toLowerCase();
|
|
73
|
+
// Exclude common non-tag tokens
|
|
74
|
+
if (tag !== 'data' && tag !== 'not') {
|
|
75
|
+
tags.push(tag);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return { classNames, ids, tags, dataAttributes };
|
|
80
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"title": "Comment",
|
|
4
|
+
"description": "A comment on a note",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"properties": {
|
|
7
|
+
"id": { "type": "string", "description": "Unique comment identifier" },
|
|
8
|
+
"note_id": { "type": "string", "description": "ID of the parent note" },
|
|
9
|
+
"author": { "type": "string", "description": "Comment author" },
|
|
10
|
+
"body": { "type": "string", "description": "Comment text" },
|
|
11
|
+
"created_at": { "type": "string", "format": "date-time", "description": "Creation timestamp" }
|
|
12
|
+
},
|
|
13
|
+
"required": ["id", "note_id", "author", "body", "created_at"]
|
|
14
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"title": "Note",
|
|
4
|
+
"description": "A UI feedback note attached to a page element",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"properties": {
|
|
7
|
+
"id": { "type": "string", "description": "Unique note identifier" },
|
|
8
|
+
"project": { "type": "string", "description": "Project slug this note belongs to" },
|
|
9
|
+
"type": { "type": "string", "enum": ["bug", "ux", "feature", "question"], "description": "Type of feedback" },
|
|
10
|
+
"status": { "type": "string", "enum": ["open", "resolved", "wontfix"], "description": "Current status" },
|
|
11
|
+
"body": { "type": "string", "description": "Note content/description" },
|
|
12
|
+
"url": { "type": "string", "description": "Page URL where the note was created" },
|
|
13
|
+
"component": { "type": ["string", "null"], "description": "Component name from data attributes" },
|
|
14
|
+
"text_content": { "type": ["string", "null"], "description": "Text content of the annotated element" },
|
|
15
|
+
"selector": { "type": ["string", "null"], "description": "CSS selector for the annotated element" },
|
|
16
|
+
"tag": { "type": ["string", "null"], "description": "HTML tag of the annotated element" },
|
|
17
|
+
"author": { "type": ["string", "null"], "description": "Author email or name" },
|
|
18
|
+
"screenshot_url": { "type": ["string", "null"], "description": "URL to the screenshot capture" },
|
|
19
|
+
"resolution": { "type": ["string", "null"], "description": "Resolution message when resolved" },
|
|
20
|
+
"resolved_at": { "type": ["string", "null"], "format": "date-time", "description": "When the note was resolved" },
|
|
21
|
+
"created_at": { "type": "string", "format": "date-time", "description": "Creation timestamp" }
|
|
22
|
+
},
|
|
23
|
+
"required": ["id", "project", "type", "status", "body", "url", "created_at"]
|
|
24
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"title": "Project",
|
|
4
|
+
"description": "A UI Notes project that groups notes",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"properties": {
|
|
7
|
+
"id": { "type": "string", "description": "Unique project identifier" },
|
|
8
|
+
"slug": { "type": "string", "description": "URL-friendly project identifier" },
|
|
9
|
+
"name": { "type": "string", "description": "Display name" },
|
|
10
|
+
"url_patterns": { "type": "array", "items": { "type": "string" }, "description": "URL glob patterns for auto-matching" },
|
|
11
|
+
"archived": { "type": "boolean", "description": "Whether the project is archived" },
|
|
12
|
+
"created_at": { "type": "string", "format": "date-time", "description": "Creation timestamp" }
|
|
13
|
+
},
|
|
14
|
+
"required": ["id", "slug", "name", "created_at"]
|
|
15
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"title": "Webhook",
|
|
4
|
+
"description": "A webhook endpoint for receiving note events",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"properties": {
|
|
7
|
+
"id": { "type": "string", "description": "Unique webhook identifier" },
|
|
8
|
+
"project_id": { "type": "string", "description": "Project this webhook belongs to" },
|
|
9
|
+
"url": { "type": "string", "description": "Webhook delivery URL" },
|
|
10
|
+
"secret": { "type": ["string", "null"], "description": "HMAC signing secret" },
|
|
11
|
+
"events": { "type": "array", "items": { "type": "string" }, "description": "Events to subscribe to" },
|
|
12
|
+
"active": { "type": "boolean", "description": "Whether the webhook is active" },
|
|
13
|
+
"created_at": { "type": "string", "format": "date-time", "description": "Creation timestamp" }
|
|
14
|
+
},
|
|
15
|
+
"required": ["id", "project_id", "url", "events", "active", "created_at"]
|
|
16
|
+
}
|