@upgraide/ui-notes-cli 0.1.1 → 0.1.3
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/bulk-resolve.ts +138 -0
- package/commands/comment.ts +27 -0
- package/commands/context.ts +64 -0
- package/commands/pull.ts +28 -1
- package/commands/visual-diff.ts +366 -0
- package/config.ts +7 -0
- package/formatters/ai-context.ts +236 -0
- package/index.ts +101 -2
- package/package.json +4 -2
- package/resolvers/file-resolver.ts +477 -0
- package/resolvers/selector-parser.ts +80 -0
|
@@ -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
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
#!/usr/bin/env bun
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
2
|
|
|
3
3
|
import { getConfig } from './config.js';
|
|
4
4
|
import { configShow, configSet } from './commands/config.js';
|
|
@@ -6,6 +6,8 @@ import { listProjects, createProject, updateProject, deleteProject, getProject,
|
|
|
6
6
|
import { pull } from './commands/pull.js';
|
|
7
7
|
import { resolve, wontfix } from './commands/resolve.js';
|
|
8
8
|
import { login } from './commands/login.js';
|
|
9
|
+
import { comment, listComments } from './commands/comment.js';
|
|
10
|
+
import { context } from './commands/context.js';
|
|
9
11
|
|
|
10
12
|
const HELP = `uinotes - CLI for UI Notes
|
|
11
13
|
|
|
@@ -22,15 +24,30 @@ Commands:
|
|
|
22
24
|
projects delete <slug> Archive a project
|
|
23
25
|
login Login and create API key
|
|
24
26
|
pull Fetch open notes
|
|
27
|
+
context AI-optimized digest of open notes
|
|
25
28
|
resolve <id> Mark note as resolved
|
|
26
29
|
wontfix <id> Mark note as won't fix
|
|
30
|
+
comment <id> "text" Add a comment to a note
|
|
31
|
+
comments <id> List comments for a note
|
|
32
|
+
resolve-by-component <name> [msg] Bulk resolve notes by component
|
|
33
|
+
resolve-by-selector <sel> [msg] Bulk resolve notes by selector
|
|
34
|
+
visual-diff Capture & compare page screenshots
|
|
27
35
|
config View/edit configuration
|
|
28
36
|
|
|
29
37
|
Options:
|
|
30
38
|
--help, -h Show help
|
|
31
39
|
--project <name> Override project for this request
|
|
32
40
|
--archived Include archived projects in list
|
|
33
|
-
--url <pattern> URL pattern for project create (repeatable)
|
|
41
|
+
--url <pattern> URL pattern for project create (repeatable)
|
|
42
|
+
--format <fmt> Output format for context: markdown (default), xml, json
|
|
43
|
+
--component <n> Filter notes by component name (context command)
|
|
44
|
+
--status <s> Filter notes by status (default: open)
|
|
45
|
+
--type <t> Filter notes by type
|
|
46
|
+
--resolve-files Resolve notes to source files (pull, context)
|
|
47
|
+
--root-dir <dir> Source root for file resolution (default: cwd)
|
|
48
|
+
--compare Compare current screenshots against previous (visual-diff)
|
|
49
|
+
--width <px> Viewport width for screenshots (default: 1280)
|
|
50
|
+
--full-page Capture full page height (default: true)`;
|
|
34
51
|
|
|
35
52
|
function parseFlags(args: string[]): { flags: Record<string, string>; arrays: Record<string, string[]>; positional: string[] } {
|
|
36
53
|
const flags: Record<string, string> = {};
|
|
@@ -195,6 +212,21 @@ async function main() {
|
|
|
195
212
|
status: flags.status,
|
|
196
213
|
type: flags.type,
|
|
197
214
|
limit: flags.limit,
|
|
215
|
+
resolveFiles: flags['resolve-files'] === 'true',
|
|
216
|
+
rootDir: flags['root-dir'],
|
|
217
|
+
});
|
|
218
|
+
break;
|
|
219
|
+
|
|
220
|
+
case 'context':
|
|
221
|
+
await context(config, {
|
|
222
|
+
project,
|
|
223
|
+
type: flags.type,
|
|
224
|
+
status: flags.status,
|
|
225
|
+
component: flags.component,
|
|
226
|
+
format: (flags.format as 'markdown' | 'xml' | 'json') || undefined,
|
|
227
|
+
limit: flags.limit,
|
|
228
|
+
resolveFiles: flags['resolve-files'] === 'true',
|
|
229
|
+
rootDir: flags['root-dir'],
|
|
198
230
|
});
|
|
199
231
|
break;
|
|
200
232
|
|
|
@@ -218,6 +250,73 @@ async function main() {
|
|
|
218
250
|
break;
|
|
219
251
|
}
|
|
220
252
|
|
|
253
|
+
case 'comment': {
|
|
254
|
+
const id = positional[1];
|
|
255
|
+
const body = positional.slice(2).join(' ');
|
|
256
|
+
if (!id || !body) {
|
|
257
|
+
console.error('Usage: uinotes comment <note-id> "comment text"');
|
|
258
|
+
process.exit(1);
|
|
259
|
+
}
|
|
260
|
+
await comment(config, id, body, project);
|
|
261
|
+
break;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
case 'comments': {
|
|
265
|
+
const id = positional[1];
|
|
266
|
+
if (!id) {
|
|
267
|
+
console.error('Usage: uinotes comments <note-id>');
|
|
268
|
+
process.exit(1);
|
|
269
|
+
}
|
|
270
|
+
await listComments(config, id, project);
|
|
271
|
+
break;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
case 'resolve-by-component': {
|
|
275
|
+
const name = positional[1];
|
|
276
|
+
if (!name) {
|
|
277
|
+
console.error('Usage: uinotes resolve-by-component <component-name> [--message "text"]');
|
|
278
|
+
process.exit(1);
|
|
279
|
+
}
|
|
280
|
+
const { resolveByComponent } = await import('./commands/bulk-resolve.js');
|
|
281
|
+
await resolveByComponent(config, name, {
|
|
282
|
+
project,
|
|
283
|
+
message: flags.message || positional.slice(2).join(' ') || undefined,
|
|
284
|
+
dryRun: flags['dry-run'] === 'true',
|
|
285
|
+
force: flags.force === 'true',
|
|
286
|
+
status: flags.wontfix === 'true' ? 'wontfix' : 'resolved',
|
|
287
|
+
});
|
|
288
|
+
break;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
case 'visual-diff': {
|
|
292
|
+
const { visualDiff } = await import('./commands/visual-diff.js');
|
|
293
|
+
await visualDiff(config, {
|
|
294
|
+
project,
|
|
295
|
+
url: flags.url,
|
|
296
|
+
compare: flags.compare === 'true',
|
|
297
|
+
width: flags.width ? parseInt(flags.width) : undefined,
|
|
298
|
+
fullPage: flags['full-page'] !== 'false',
|
|
299
|
+
});
|
|
300
|
+
break;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
case 'resolve-by-selector': {
|
|
304
|
+
const sel = positional[1];
|
|
305
|
+
if (!sel) {
|
|
306
|
+
console.error('Usage: uinotes resolve-by-selector <selector> [--message "text"]');
|
|
307
|
+
process.exit(1);
|
|
308
|
+
}
|
|
309
|
+
const { resolveBySelector } = await import('./commands/bulk-resolve.js');
|
|
310
|
+
await resolveBySelector(config, sel, {
|
|
311
|
+
project,
|
|
312
|
+
message: flags.message || positional.slice(2).join(' ') || undefined,
|
|
313
|
+
dryRun: flags['dry-run'] === 'true',
|
|
314
|
+
force: flags.force === 'true',
|
|
315
|
+
status: flags.wontfix === 'true' ? 'wontfix' : 'resolved',
|
|
316
|
+
});
|
|
317
|
+
break;
|
|
318
|
+
}
|
|
319
|
+
|
|
221
320
|
default:
|
|
222
321
|
console.error(`Unknown command: ${command}`);
|
|
223
322
|
console.error('Run uinotes --help for usage');
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@upgraide/ui-notes-cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
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,9 @@
|
|
|
9
9
|
},
|
|
10
10
|
"files": [
|
|
11
11
|
"*.ts",
|
|
12
|
-
"commands/*.ts"
|
|
12
|
+
"commands/*.ts",
|
|
13
|
+
"formatters/*.ts",
|
|
14
|
+
"resolvers/*.ts"
|
|
13
15
|
],
|
|
14
16
|
"engines": {
|
|
15
17
|
"bun": ">=1.0.0"
|