@foundation0/api 1.0.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/agents.ts +492 -0
- package/dist/git.js +4 -0
- package/git.ts +31 -0
- package/mcp/cli.mjs +37 -0
- package/mcp/cli.ts +43 -0
- package/mcp/client.ts +149 -0
- package/mcp/index.ts +15 -0
- package/mcp/server.ts +461 -0
- package/package.json +40 -0
- package/projects.ts +2690 -0
- package/taskgraph-parser.ts +217 -0
package/projects.ts
ADDED
|
@@ -0,0 +1,2690 @@
|
|
|
1
|
+
import fs from 'node:fs/promises'
|
|
2
|
+
import fsSync from 'node:fs'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import crypto from 'node:crypto'
|
|
5
|
+
import { setActiveLink } from './agents'
|
|
6
|
+
import {
|
|
7
|
+
parseTaskGraphFromMarkdown as parseTaskGraphFromMarkdownScript,
|
|
8
|
+
type TaskGraphParseResult,
|
|
9
|
+
type TaskGraphTask,
|
|
10
|
+
} from './taskgraph-parser'
|
|
11
|
+
import {
|
|
12
|
+
createGitServiceApi,
|
|
13
|
+
resolveProjectRepoIdentity,
|
|
14
|
+
type GitServiceApi,
|
|
15
|
+
type GitServiceApiExecutionResult,
|
|
16
|
+
type GitServiceApiMethod,
|
|
17
|
+
syncIssueDependencies as syncIssueDependenciesViaApi,
|
|
18
|
+
} from './git'
|
|
19
|
+
|
|
20
|
+
type ProjectGenerateOptions = {
|
|
21
|
+
input?: string
|
|
22
|
+
out?: string
|
|
23
|
+
dryRun: boolean
|
|
24
|
+
manifest: boolean
|
|
25
|
+
prune: boolean
|
|
26
|
+
verbose: boolean
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type ProjectGenerateResult = {
|
|
30
|
+
projectName: string
|
|
31
|
+
inputPath: string
|
|
32
|
+
outRoot: string
|
|
33
|
+
files: number
|
|
34
|
+
created: number
|
|
35
|
+
updated: number
|
|
36
|
+
unchanged: number
|
|
37
|
+
dryRun: boolean
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
type MonolithFile = {
|
|
41
|
+
relPath: string
|
|
42
|
+
content: string
|
|
43
|
+
sha256: string
|
|
44
|
+
bytes: number
|
|
45
|
+
meta?: {
|
|
46
|
+
spec_id?: string
|
|
47
|
+
title?: string
|
|
48
|
+
source?: string
|
|
49
|
+
source_version?: string
|
|
50
|
+
source_anchor?: string
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
type ProjectSyncTaskResult = {
|
|
55
|
+
projectName: string
|
|
56
|
+
tracksPath: string
|
|
57
|
+
owner: string
|
|
58
|
+
repo: string
|
|
59
|
+
totalTasks: number
|
|
60
|
+
created: number
|
|
61
|
+
updated: number
|
|
62
|
+
skipped: number
|
|
63
|
+
dryRun: boolean
|
|
64
|
+
truncated: boolean
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
type ProjectClearIssuesOptions = {
|
|
68
|
+
owner?: string
|
|
69
|
+
repo?: string
|
|
70
|
+
state: 'open' | 'closed' | 'all'
|
|
71
|
+
dryRun: boolean
|
|
72
|
+
force: boolean
|
|
73
|
+
verbose: boolean
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
type ProjectClearIssuesResult = {
|
|
77
|
+
projectName: string
|
|
78
|
+
owner: string
|
|
79
|
+
repo: string
|
|
80
|
+
state: 'open' | 'closed' | 'all'
|
|
81
|
+
total: number
|
|
82
|
+
deleted: number
|
|
83
|
+
dryRun: boolean
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
type ProjectSyncTaskOptions = {
|
|
87
|
+
tracks?: string
|
|
88
|
+
owner?: string
|
|
89
|
+
repo?: string
|
|
90
|
+
labels: string[]
|
|
91
|
+
prefix: string
|
|
92
|
+
dryRun: boolean
|
|
93
|
+
max?: number
|
|
94
|
+
maxBodyLength?: number
|
|
95
|
+
requestTimeoutMs?: number
|
|
96
|
+
throttleMs?: number
|
|
97
|
+
skipDependencies?: boolean
|
|
98
|
+
verbose?: boolean
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
type GitTaskIssueState = 'open' | 'closed' | 'all'
|
|
102
|
+
|
|
103
|
+
type ProjectGitTaskQueryOptions = {
|
|
104
|
+
owner?: string
|
|
105
|
+
repo?: string
|
|
106
|
+
state?: GitTaskIssueState
|
|
107
|
+
taskOnly?: boolean
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
type ProjectWriteGitTaskOptions = {
|
|
111
|
+
owner?: string
|
|
112
|
+
repo?: string
|
|
113
|
+
createIfMissing?: boolean
|
|
114
|
+
title?: string
|
|
115
|
+
body?: string
|
|
116
|
+
labels?: string[]
|
|
117
|
+
state?: 'open' | 'closed'
|
|
118
|
+
taskDependencies?: string[]
|
|
119
|
+
taskSignature?: string
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export type ProjectGitTaskRecord = {
|
|
123
|
+
number: number
|
|
124
|
+
title: string
|
|
125
|
+
taskId: string | null
|
|
126
|
+
taskSignature: string | null
|
|
127
|
+
body: string
|
|
128
|
+
state: string
|
|
129
|
+
labels: string[]
|
|
130
|
+
taskDependenciesFromPayload: string[]
|
|
131
|
+
taskDependenciesFromBody: string[]
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export type ProjectFetchGitTasksResult = {
|
|
135
|
+
projectName: string
|
|
136
|
+
owner: string
|
|
137
|
+
repo: string
|
|
138
|
+
state: GitTaskIssueState
|
|
139
|
+
taskOnly: boolean
|
|
140
|
+
total: number
|
|
141
|
+
tasks: ProjectGitTaskRecord[]
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export type ProjectReadGitTaskResult = {
|
|
145
|
+
projectName: string
|
|
146
|
+
owner: string
|
|
147
|
+
repo: string
|
|
148
|
+
issueNumber: number
|
|
149
|
+
issue: ProjectGitTaskRecord
|
|
150
|
+
foundBy: 'number' | 'taskId'
|
|
151
|
+
searchScope: GitTaskIssueState
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export type ProjectWriteGitTaskResult = {
|
|
155
|
+
projectName: string
|
|
156
|
+
owner: string
|
|
157
|
+
repo: string
|
|
158
|
+
action: 'created' | 'updated'
|
|
159
|
+
issueNumber: number
|
|
160
|
+
issue: ProjectGitTaskRecord
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const CLI_NAME = 'f0'
|
|
164
|
+
const VERSION_RE = 'v?\\d+(?:\\.\\d+){2,}'
|
|
165
|
+
const VERSION_RE_CORE = `(?:${VERSION_RE})`
|
|
166
|
+
const ACTIVE_EXT_PRIORITY = ['.md', '.json', '.yaml', '.yml', '.ts', '.js', '.txt']
|
|
167
|
+
const PROJECT_DOC_SUBDIRS = ['docs', 'spec'] as const
|
|
168
|
+
const PROJECT_ARCHIVE_DIR_NAME = '_archive'
|
|
169
|
+
const ACTIVE_DOC_EXTENSION_MARKER_RE = /^(.+)\.active\.([^.]+)$/i
|
|
170
|
+
const VERSIONED_DOC_RE = new RegExp(`^(.+)\\.(${VERSION_RE_CORE})\\.([^.]+)$`)
|
|
171
|
+
const TASK_HASH_LENGTH = 8
|
|
172
|
+
const ISSUE_LIST_PAGE_SIZE = 100
|
|
173
|
+
const resolveGiteaHost = (): string => {
|
|
174
|
+
const host = process.env.GITEA_HOST ?? process.env.F0_GITEA_HOST
|
|
175
|
+
if (!host) {
|
|
176
|
+
throw new Error('No Gitea host configured. Set GITEA_HOST or F0_GITEA_HOST.')
|
|
177
|
+
}
|
|
178
|
+
return host
|
|
179
|
+
}
|
|
180
|
+
const TASK_ID_RE = /^TASK-[0-9]{3}[A-Za-z0-9]*$/
|
|
181
|
+
const TASK_TITLE_RE = /^\[([^\]]+)\]\s+(TASK-[0-9]{3}[A-Za-z0-9]*)\s*(?:\s*\[([0-9a-fA-F]{8})\])?/
|
|
182
|
+
const DEPENDENCY_TOKEN_RE = /TASK-[0-9]{3}[A-Za-z0-9]*/g
|
|
183
|
+
const DEFAULT_TRACKS_CANDIDATES = [
|
|
184
|
+
path.join('spec', 'tracks.md'),
|
|
185
|
+
path.join('spec', '07_roadmap', 'tracks.md'),
|
|
186
|
+
] as const
|
|
187
|
+
const PROJECTS_DIRECTORY = 'projects'
|
|
188
|
+
|
|
189
|
+
type ProjectDocsCandidateKind = 'active' | 'plain' | 'versioned'
|
|
190
|
+
|
|
191
|
+
type ProjectDocsCandidate = {
|
|
192
|
+
sourcePath: string
|
|
193
|
+
kind: ProjectDocsCandidateKind
|
|
194
|
+
version: string | null
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function resolveProjectsRoot(processRoot: string): string {
|
|
198
|
+
return path.join(processRoot, PROJECTS_DIRECTORY)
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export function usage(): string {
|
|
202
|
+
return `Usage:\n ${CLI_NAME} projects <project-name> --generate-spec\n ${CLI_NAME} projects <project-name> <file-path> --set-active\n ${CLI_NAME} projects --list\n ${CLI_NAME} projects <project-name> sync-tasks\n ${CLI_NAME} projects <project-name> clear-issues\n\n` +
|
|
203
|
+
`Examples:\n ${CLI_NAME} projects index --generate-spec\n ${CLI_NAME} projects index /implementation-plan.v0.0.1 --set-active\n ${CLI_NAME} projects index /implementation-plan --set-active --latest\n ${CLI_NAME} projects index sync-tasks\n ${CLI_NAME} projects index sync-tasks --verbose\n ${CLI_NAME} projects index sync-tasks --request-timeout-ms 60000 --throttle-ms 250\n ${CLI_NAME} projects index sync-tasks --skip-dependencies\n ${CLI_NAME} projects index clear-issues --dry-run\n ${CLI_NAME} projects index clear-issues --force\n ${CLI_NAME} projects index clear-issues --state all --force\n ${CLI_NAME} projects --list\n\n` +
|
|
204
|
+
`file-path is relative to the project root (leading slash required).\n` +
|
|
205
|
+
`Sync-tasks flags: --verbose --max --max-body --request-timeout-ms --throttle-ms --skip-dependencies --owner --repo --label --prefix --tracks.\n` +
|
|
206
|
+
`Use --verbose with clear-issues to print each deletion.\n` +
|
|
207
|
+
`Use --latest to resolve /file-name to the latest version.\n` +
|
|
208
|
+
`The active file created is [file].active.<ext>.\n`
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export function resolveProjectRoot(projectName: string, processRoot: string = process.cwd()): string {
|
|
212
|
+
if (!projectName || projectName.trim().length === 0) {
|
|
213
|
+
throw new Error('project-name is required.')
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const normalized = projectName.trim().replace(/[\\/]+/g, path.sep)
|
|
217
|
+
if (normalized === '.' || normalized === '..' || normalized.includes(path.sep + '..') || normalized.includes('..')) {
|
|
218
|
+
throw new Error('project-name may not include path traversal.')
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const projectRoot = path.join(resolveProjectsRoot(processRoot), normalized)
|
|
222
|
+
if (!existsDir(projectRoot)) {
|
|
223
|
+
throw new Error(`Project folder not found: ${projectRoot}`)
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
return projectRoot
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export function listProjects(processRoot: string = process.cwd()): string[] {
|
|
230
|
+
const projectsRoot = resolveProjectsRoot(processRoot)
|
|
231
|
+
if (!existsDir(projectsRoot)) {
|
|
232
|
+
return []
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
return fsSync
|
|
236
|
+
.readdirSync(projectsRoot, { withFileTypes: true })
|
|
237
|
+
.filter((entry) => entry.isDirectory())
|
|
238
|
+
.filter((entry) => {
|
|
239
|
+
const docsPath = path.join(projectsRoot, entry.name, 'docs')
|
|
240
|
+
return existsDir(docsPath)
|
|
241
|
+
})
|
|
242
|
+
.map((entry) => entry.name)
|
|
243
|
+
.sort((a, b) => a.localeCompare(b))
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
export function listProjectDocs(projectName: string, processRoot: string = process.cwd()): string[] {
|
|
247
|
+
const projectRoot = resolveProjectRoot(projectName, processRoot)
|
|
248
|
+
const candidates = buildProjectDocsCatalog(projectRoot)
|
|
249
|
+
return Array.from(candidates.keys()).sort((a, b) => a.localeCompare(b))
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export async function readProjectDoc(
|
|
253
|
+
projectName: string,
|
|
254
|
+
requestPath: string,
|
|
255
|
+
processRoot: string = process.cwd()
|
|
256
|
+
): Promise<string> {
|
|
257
|
+
const projectRoot = resolveProjectRoot(projectName, processRoot)
|
|
258
|
+
const catalog = buildProjectDocsCatalog(projectRoot)
|
|
259
|
+
const normalized = normalizeProjectDocRequest(requestPath)
|
|
260
|
+
const sourcePath = resolveProjectDocPath(catalog, normalized)
|
|
261
|
+
|
|
262
|
+
if (!sourcePath) {
|
|
263
|
+
throw new Error(`Doc file not found: ${requestPath}`)
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const content = await readTextIfExists(sourcePath)
|
|
267
|
+
if (content === null) {
|
|
268
|
+
throw new Error(`Doc file not found: ${requestPath}`)
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
return content
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
type ProjectVersionedFileSpec = {
|
|
275
|
+
parts: string[]
|
|
276
|
+
base: string
|
|
277
|
+
version: string | null
|
|
278
|
+
ext: string | null
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function buildProjectDocsCatalog(projectRoot: string): Map<string, ProjectDocsCandidate> {
|
|
282
|
+
const catalog = new Map<string, ProjectDocsCandidate>()
|
|
283
|
+
|
|
284
|
+
for (const section of PROJECT_DOC_SUBDIRS) {
|
|
285
|
+
const sectionRoot = path.join(projectRoot, section)
|
|
286
|
+
if (!existsDir(sectionRoot)) {
|
|
287
|
+
continue
|
|
288
|
+
}
|
|
289
|
+
collectProjectDocs(sectionRoot, section, '', catalog)
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
return catalog
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function collectProjectDocs(
|
|
296
|
+
baseDir: string,
|
|
297
|
+
section: string,
|
|
298
|
+
relativeDir: string,
|
|
299
|
+
catalog: Map<string, ProjectDocsCandidate>
|
|
300
|
+
): void {
|
|
301
|
+
const entries = fsSync.readdirSync(baseDir, { withFileTypes: true })
|
|
302
|
+
|
|
303
|
+
for (const entry of entries) {
|
|
304
|
+
if (entry.name.toLowerCase() === PROJECT_ARCHIVE_DIR_NAME) {
|
|
305
|
+
continue
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const childAbsolute = path.join(baseDir, entry.name)
|
|
309
|
+
if (entry.isDirectory()) {
|
|
310
|
+
const nextRelative = relativeDir ? `${relativeDir}/${entry.name}` : entry.name
|
|
311
|
+
collectProjectDocs(childAbsolute, section, nextRelative, catalog)
|
|
312
|
+
continue
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
if (!entry.isFile() && !entry.isSymbolicLink()) {
|
|
316
|
+
continue
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const isActiveCandidate = entry.isSymbolicLink()
|
|
320
|
+
const simpleName = simplifyDocsFileName(entry.name, isActiveCandidate)
|
|
321
|
+
if (!simpleName) {
|
|
322
|
+
continue
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const logicalPath = relativeDir
|
|
326
|
+
? `${section}/${relativeDir}/${simpleName}`
|
|
327
|
+
: `${section}/${simpleName}`
|
|
328
|
+
|
|
329
|
+
const candidate = buildDocsCandidate(entry.name, childAbsolute, isActiveCandidate)
|
|
330
|
+
const existing = catalog.get(logicalPath)
|
|
331
|
+
if (!existing || shouldReplaceDocsCandidate(existing, candidate)) {
|
|
332
|
+
catalog.set(logicalPath, candidate)
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function buildDocsCandidate(fileName: string, sourcePath: string, isSymlink: boolean): ProjectDocsCandidate {
|
|
338
|
+
const activeMatch = isSymlink ? fileName.match(ACTIVE_DOC_EXTENSION_MARKER_RE) : null
|
|
339
|
+
if (activeMatch) {
|
|
340
|
+
return {
|
|
341
|
+
sourcePath,
|
|
342
|
+
kind: 'active',
|
|
343
|
+
version: null,
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const versionedMatch = fileName.match(VERSIONED_DOC_RE)
|
|
348
|
+
if (versionedMatch) {
|
|
349
|
+
return {
|
|
350
|
+
sourcePath,
|
|
351
|
+
kind: 'versioned',
|
|
352
|
+
version: versionedMatch[2],
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
return {
|
|
357
|
+
sourcePath,
|
|
358
|
+
kind: 'plain',
|
|
359
|
+
version: null,
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function simplifyDocsFileName(fileName: string, isSymlink: boolean): string | null {
|
|
364
|
+
const activeMatch = isSymlink ? fileName.match(ACTIVE_DOC_EXTENSION_MARKER_RE) : null
|
|
365
|
+
if (activeMatch) {
|
|
366
|
+
return `${activeMatch[1]}.${activeMatch[2]}`
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const versionedMatch = fileName.match(VERSIONED_DOC_RE)
|
|
370
|
+
if (versionedMatch) {
|
|
371
|
+
return `${versionedMatch[1]}.${versionedMatch[3]}`
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
return fileName
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function docsCandidatePriority(kind: ProjectDocsCandidateKind): number {
|
|
378
|
+
if (kind === 'active') return 0
|
|
379
|
+
if (kind === 'plain') return 1
|
|
380
|
+
return 2
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function shouldReplaceDocsCandidate(
|
|
384
|
+
current: ProjectDocsCandidate,
|
|
385
|
+
incoming: ProjectDocsCandidate
|
|
386
|
+
): boolean {
|
|
387
|
+
const currentPriority = docsCandidatePriority(current.kind)
|
|
388
|
+
const incomingPriority = docsCandidatePriority(incoming.kind)
|
|
389
|
+
|
|
390
|
+
if (incomingPriority < currentPriority) return true
|
|
391
|
+
if (incomingPriority > currentPriority) return false
|
|
392
|
+
|
|
393
|
+
if (incoming.kind === 'versioned' && current.kind === 'versioned') {
|
|
394
|
+
if (!incoming.version || !current.version) {
|
|
395
|
+
return incoming.sourcePath.localeCompare(current.sourcePath) > 0
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
const versionCompare = compareVersions(current.version, incoming.version)
|
|
399
|
+
if (versionCompare < 0) return true
|
|
400
|
+
if (versionCompare === 0) {
|
|
401
|
+
return incoming.sourcePath.localeCompare(current.sourcePath) > 0
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
return false
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function normalizeProjectDocRequest(requestPath: string): string {
|
|
409
|
+
const normalized = requestPath
|
|
410
|
+
.replace(/\\/g, '/')
|
|
411
|
+
.trim()
|
|
412
|
+
|
|
413
|
+
if (!normalized) {
|
|
414
|
+
throw new Error('doc-path is required.')
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
const trimmed = normalized.startsWith('/') ? normalized.slice(1) : normalized
|
|
418
|
+
|
|
419
|
+
const parts = trimmed.split('/').filter(Boolean)
|
|
420
|
+
if (parts.length === 0) {
|
|
421
|
+
throw new Error('doc-path is required.')
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
if (parts.some((part) => part === '.' || part === '..')) {
|
|
425
|
+
throw new Error(`Invalid doc-path: ${requestPath}`)
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
return trimmed
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function resolveProjectDocPath(
|
|
432
|
+
catalog: Map<string, ProjectDocsCandidate>,
|
|
433
|
+
requestPath: string
|
|
434
|
+
): string | null {
|
|
435
|
+
const exact = catalog.get(requestPath)
|
|
436
|
+
if (exact) {
|
|
437
|
+
return exact.sourcePath
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
const matches = Array.from(catalog).filter(([
|
|
441
|
+
candidatePath,
|
|
442
|
+
]) => candidatePath === requestPath || candidatePath.endsWith(`/${requestPath}`))
|
|
443
|
+
|
|
444
|
+
if (matches.length === 1) {
|
|
445
|
+
return matches[0]?.[1]?.sourcePath ?? null
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
if (matches.length > 1) {
|
|
449
|
+
throw new Error(`Ambiguous doc-path: ${requestPath}`)
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
return null
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
export interface ProjectSetActiveResult {
|
|
456
|
+
projectName: string
|
|
457
|
+
sourceFile: string
|
|
458
|
+
activeFile: string
|
|
459
|
+
matchedFile: string
|
|
460
|
+
mode: 'symlink'
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
type PlanResolveOptions = {
|
|
464
|
+
requireActive?: boolean
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
export function resolveImplementationPlan(
|
|
468
|
+
projectRoot: string,
|
|
469
|
+
inputFile?: string,
|
|
470
|
+
options: PlanResolveOptions = {}
|
|
471
|
+
): string {
|
|
472
|
+
const docsRoot = path.join(projectRoot, 'docs')
|
|
473
|
+
if (!existsDir(docsRoot)) {
|
|
474
|
+
throw new Error(`docs directory not found for project: ${projectRoot}`)
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
if (inputFile) {
|
|
478
|
+
const explicit = path.isAbsolute(inputFile) ? inputFile : path.join(docsRoot, inputFile)
|
|
479
|
+
const resolved = path.resolve(explicit)
|
|
480
|
+
if (!isSubPath(docsRoot, resolved)) {
|
|
481
|
+
throw new Error(`Input plan path must be within docs directory: ${inputFile}`)
|
|
482
|
+
}
|
|
483
|
+
if (!existsFile(explicit)) {
|
|
484
|
+
throw new Error(`Input plan file not found: ${explicit}`)
|
|
485
|
+
}
|
|
486
|
+
return resolved
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
const files = fsSync.readdirSync(docsRoot, { withFileTypes: true }).map((entry) => entry.name)
|
|
490
|
+
|
|
491
|
+
const active = files.find((file) => /^implementation[-_]?plan\.active\.[^.]+$/i.test(file))
|
|
492
|
+
if (active) {
|
|
493
|
+
return path.join(docsRoot, active)
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
if (options.requireActive) {
|
|
497
|
+
throw new Error(
|
|
498
|
+
'No active implementation plan file found: expected docs/implementation-plan.active.md (or .json/.txt/etc).'
|
|
499
|
+
)
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
const versioned = files.filter((file) => new RegExp(`^implementation[-_]plan\\.${VERSION_RE}\\.[^.]+$`, 'i').test(file))
|
|
503
|
+
if (versioned.length === 1) {
|
|
504
|
+
return path.join(docsRoot, versioned[0])
|
|
505
|
+
}
|
|
506
|
+
if (versioned.length > 1) {
|
|
507
|
+
throw new Error(
|
|
508
|
+
`Multiple versioned implementation plan files found: ${versioned.join(', ')}. Please use --input <path>.`
|
|
509
|
+
)
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
throw new Error('No implementation plan file found. Expected docs/implementation-plan.active.md or docs/implementation-plan.<ver>.md')
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
export async function generateSpec(
|
|
516
|
+
projectName: string,
|
|
517
|
+
options: Partial<ProjectGenerateOptions> = {},
|
|
518
|
+
processRoot: string = process.cwd()
|
|
519
|
+
): Promise<ProjectGenerateResult> {
|
|
520
|
+
const projectRoot = resolveProjectRoot(projectName, processRoot)
|
|
521
|
+
const opts: ProjectGenerateOptions = {
|
|
522
|
+
input: options.input,
|
|
523
|
+
out: options.out,
|
|
524
|
+
dryRun: options.dryRun ?? false,
|
|
525
|
+
manifest: options.manifest ?? true,
|
|
526
|
+
prune: options.prune ?? false,
|
|
527
|
+
verbose: options.verbose ?? false,
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
const inputAbs = path.resolve(
|
|
531
|
+
resolveImplementationPlan(projectRoot, opts.input, { requireActive: !opts.input })
|
|
532
|
+
)
|
|
533
|
+
const outRoot = opts.out ? path.resolve(projectRoot, opts.out) : projectRoot
|
|
534
|
+
|
|
535
|
+
const inputContents = await readTextIfExists(inputAbs)
|
|
536
|
+
if (inputContents === null) {
|
|
537
|
+
throw new Error(`Input monolith file not found: ${inputAbs}`)
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
if (inputContents.trim().length === 0) {
|
|
541
|
+
throw new Error(`Input monolith is empty: ${inputAbs}`)
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
const files = parseMonolith(inputContents)
|
|
545
|
+
files.sort((a, b) => a.relPath.localeCompare(b.relPath))
|
|
546
|
+
|
|
547
|
+
const manifestRel = 'spec/.spec-manifest.json'
|
|
548
|
+
const manifestAbs = path.resolve(outRoot, manifestRel)
|
|
549
|
+
|
|
550
|
+
if (opts.prune && !opts.dryRun) {
|
|
551
|
+
const previous = await loadPreviousManifest(manifestAbs)
|
|
552
|
+
if (previous?.files) {
|
|
553
|
+
const now = new Set(files.map((file) => file.relPath))
|
|
554
|
+
const toDelete = previous.files
|
|
555
|
+
.map((entry) => entry?.path)
|
|
556
|
+
.filter((value): value is string => Boolean(value))
|
|
557
|
+
.map((entryPath) => normalizeRelPath(entryPath))
|
|
558
|
+
.filter((entryPath) => !now.has(entryPath))
|
|
559
|
+
|
|
560
|
+
for (const relPath of toDelete) {
|
|
561
|
+
const absolute = path.resolve(outRoot, relPath)
|
|
562
|
+
assertWithinOutRoot(outRoot, absolute)
|
|
563
|
+
try {
|
|
564
|
+
await fs.rm(absolute, { force: true })
|
|
565
|
+
if (opts.verbose) {
|
|
566
|
+
// eslint-disable-next-line no-console
|
|
567
|
+
console.log(`pruned ${relPath}`)
|
|
568
|
+
}
|
|
569
|
+
} catch {
|
|
570
|
+
// ignore
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
let created = 0
|
|
577
|
+
let updated = 0
|
|
578
|
+
let unchanged = 0
|
|
579
|
+
|
|
580
|
+
for (const file of files) {
|
|
581
|
+
const outPath = path.resolve(outRoot, file.relPath)
|
|
582
|
+
assertWithinOutRoot(outRoot, outPath)
|
|
583
|
+
|
|
584
|
+
if (opts.dryRun) {
|
|
585
|
+
// eslint-disable-next-line no-console
|
|
586
|
+
console.log(`[dry-run] would write ${file.relPath} (${file.bytes} bytes)`)
|
|
587
|
+
continue
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
await fs.mkdir(path.dirname(outPath), { recursive: true })
|
|
591
|
+
const status = await writeIfChanged(outPath, file.content)
|
|
592
|
+
if (status === 'created') created += 1
|
|
593
|
+
if (status === 'updated') updated += 1
|
|
594
|
+
if (status === 'unchanged') unchanged += 1
|
|
595
|
+
|
|
596
|
+
if (opts.verbose) {
|
|
597
|
+
// eslint-disable-next-line no-console
|
|
598
|
+
console.log(`${status.padEnd(9)} ${file.relPath}`)
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
if (opts.manifest && !opts.dryRun) {
|
|
603
|
+
const manifest = {
|
|
604
|
+
generated_at: new Date().toISOString(),
|
|
605
|
+
source_monolith: path.relative(process.cwd(), inputAbs) || inputAbs,
|
|
606
|
+
out_root: path.relative(process.cwd(), outRoot) || outRoot,
|
|
607
|
+
file_count: files.length,
|
|
608
|
+
total_bytes: files.reduce((sum, file) => sum + file.bytes, 0),
|
|
609
|
+
files: files.map((file) => ({
|
|
610
|
+
path: file.relPath,
|
|
611
|
+
sha256: file.sha256,
|
|
612
|
+
bytes: file.bytes,
|
|
613
|
+
spec_id: file.meta?.spec_id ?? null,
|
|
614
|
+
title: file.meta?.title ?? null,
|
|
615
|
+
source_anchor: file.meta?.source_anchor ?? null,
|
|
616
|
+
})),
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
await fs.mkdir(path.dirname(manifestAbs), { recursive: true })
|
|
620
|
+
await fs.writeFile(manifestAbs, JSON.stringify(manifest, null, 2) + '\n')
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
return {
|
|
624
|
+
projectName,
|
|
625
|
+
inputPath: inputAbs,
|
|
626
|
+
outRoot,
|
|
627
|
+
files: files.length,
|
|
628
|
+
created,
|
|
629
|
+
updated,
|
|
630
|
+
unchanged,
|
|
631
|
+
dryRun: opts.dryRun,
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
function parseGenerateArgs(argv: string[]): {
|
|
636
|
+
command: 'generate-spec' | 'list'
|
|
637
|
+
setActive: boolean
|
|
638
|
+
target?: string
|
|
639
|
+
latest: boolean
|
|
640
|
+
options: Partial<ProjectGenerateOptions>
|
|
641
|
+
requestedGenerateSpec: boolean
|
|
642
|
+
unknownFlags: string[]
|
|
643
|
+
} {
|
|
644
|
+
const result: {
|
|
645
|
+
command: 'generate-spec' | 'list'
|
|
646
|
+
setActive: boolean
|
|
647
|
+
target?: string
|
|
648
|
+
latest: boolean
|
|
649
|
+
options: Partial<ProjectGenerateOptions>
|
|
650
|
+
requestedGenerateSpec: boolean
|
|
651
|
+
unknownFlags: string[]
|
|
652
|
+
} = {
|
|
653
|
+
options: {},
|
|
654
|
+
requestedGenerateSpec: false,
|
|
655
|
+
unknownFlags: [],
|
|
656
|
+
command: 'generate-spec',
|
|
657
|
+
setActive: false,
|
|
658
|
+
latest: false,
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
662
|
+
const arg = argv[i]
|
|
663
|
+
|
|
664
|
+
if (!arg.startsWith('--')) {
|
|
665
|
+
if (!result.target) {
|
|
666
|
+
result.target = arg
|
|
667
|
+
} else {
|
|
668
|
+
result.unknownFlags.push(arg)
|
|
669
|
+
}
|
|
670
|
+
continue
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
if (arg === '--generate-spec') {
|
|
674
|
+
result.command = 'generate-spec'
|
|
675
|
+
result.requestedGenerateSpec = true
|
|
676
|
+
continue
|
|
677
|
+
}
|
|
678
|
+
if (arg === '--set-active') {
|
|
679
|
+
result.setActive = true
|
|
680
|
+
continue
|
|
681
|
+
}
|
|
682
|
+
if (arg === '--latest') {
|
|
683
|
+
result.latest = true
|
|
684
|
+
continue
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
if (arg === '--list') {
|
|
688
|
+
result.command = 'list'
|
|
689
|
+
continue
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
if (arg === '--input') {
|
|
693
|
+
const value = argv[i + 1]
|
|
694
|
+
if (!value) {
|
|
695
|
+
throw new Error('Missing value for --input')
|
|
696
|
+
}
|
|
697
|
+
result.options.input = value
|
|
698
|
+
i += 1
|
|
699
|
+
continue
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
if (arg === '--out') {
|
|
703
|
+
const value = argv[i + 1]
|
|
704
|
+
if (!value) {
|
|
705
|
+
throw new Error('Missing value for --out')
|
|
706
|
+
}
|
|
707
|
+
result.options.out = value
|
|
708
|
+
i += 1
|
|
709
|
+
continue
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
if (arg === '--dry-run') {
|
|
713
|
+
result.options.dryRun = true
|
|
714
|
+
continue
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
if (arg === '--prune') {
|
|
718
|
+
result.options.prune = true
|
|
719
|
+
continue
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
if (arg === '--manifest') {
|
|
723
|
+
result.options.manifest = true
|
|
724
|
+
continue
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
if (arg === '--no-manifest') {
|
|
728
|
+
result.options.manifest = false
|
|
729
|
+
continue
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
if (arg === '--verbose') {
|
|
733
|
+
result.options.verbose = true
|
|
734
|
+
continue
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
if (arg === '--') {
|
|
738
|
+
continue
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
result.unknownFlags.push(arg)
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
return result
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
function parseSyncTasksArgs(argv: string[]): {
|
|
748
|
+
options: Omit<ProjectSyncTaskOptions, 'tracks'>
|
|
749
|
+
tracks?: string
|
|
750
|
+
unknownFlags: string[]
|
|
751
|
+
} {
|
|
752
|
+
const options: Omit<ProjectSyncTaskOptions, 'tracks'> = {
|
|
753
|
+
labels: [],
|
|
754
|
+
prefix: '[TASK]',
|
|
755
|
+
dryRun: false,
|
|
756
|
+
requestTimeoutMs: 60_000,
|
|
757
|
+
throttleMs: 0,
|
|
758
|
+
skipDependencies: false,
|
|
759
|
+
verbose: false,
|
|
760
|
+
}
|
|
761
|
+
const result: {
|
|
762
|
+
options: Omit<ProjectSyncTaskOptions, 'tracks'>
|
|
763
|
+
tracks?: string
|
|
764
|
+
unknownFlags: string[]
|
|
765
|
+
} = {
|
|
766
|
+
options,
|
|
767
|
+
unknownFlags: [],
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
771
|
+
const arg = argv[i]
|
|
772
|
+
|
|
773
|
+
if (!arg.startsWith('--')) {
|
|
774
|
+
if (!result.tracks && !arg.startsWith('sync-tasks')) {
|
|
775
|
+
result.tracks = arg
|
|
776
|
+
} else {
|
|
777
|
+
result.unknownFlags.push(arg)
|
|
778
|
+
}
|
|
779
|
+
continue
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
if (arg === '--dry-run') {
|
|
783
|
+
options.dryRun = true
|
|
784
|
+
continue
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
if (arg === '--owner') {
|
|
788
|
+
const value = argv[i + 1]
|
|
789
|
+
if (!value) {
|
|
790
|
+
throw new Error('Missing value for --owner')
|
|
791
|
+
}
|
|
792
|
+
options.owner = value
|
|
793
|
+
i += 1
|
|
794
|
+
continue
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
if (arg === '--repo') {
|
|
798
|
+
const value = argv[i + 1]
|
|
799
|
+
if (!value) {
|
|
800
|
+
throw new Error('Missing value for --repo')
|
|
801
|
+
}
|
|
802
|
+
options.repo = value
|
|
803
|
+
i += 1
|
|
804
|
+
continue
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
if (arg === '--label') {
|
|
808
|
+
const value = argv[i + 1]
|
|
809
|
+
if (!value) {
|
|
810
|
+
throw new Error('Missing value for --label')
|
|
811
|
+
}
|
|
812
|
+
options.labels.push(value)
|
|
813
|
+
i += 1
|
|
814
|
+
continue
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
if (arg === '--prefix') {
|
|
818
|
+
const value = argv[i + 1]
|
|
819
|
+
if (!value) {
|
|
820
|
+
throw new Error('Missing value for --prefix')
|
|
821
|
+
}
|
|
822
|
+
options.prefix = value
|
|
823
|
+
i += 1
|
|
824
|
+
continue
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
if (arg === '--max-body') {
|
|
828
|
+
const value = Number(argv[i + 1])
|
|
829
|
+
if (!Number.isFinite(value) || value <= 0 || Math.floor(value) !== value) {
|
|
830
|
+
throw new Error(`Invalid --max-body value: ${argv[i + 1]}`)
|
|
831
|
+
}
|
|
832
|
+
options.maxBodyLength = value
|
|
833
|
+
i += 1
|
|
834
|
+
continue
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
if (arg === '--max') {
|
|
838
|
+
const value = Number(argv[i + 1])
|
|
839
|
+
if (!Number.isFinite(value) || value <= 0 || Math.floor(value) !== value) {
|
|
840
|
+
throw new Error(`Invalid --max value: ${argv[i + 1]}`)
|
|
841
|
+
}
|
|
842
|
+
options.max = value
|
|
843
|
+
i += 1
|
|
844
|
+
continue
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
if (arg === '--request-timeout-ms') {
|
|
848
|
+
const value = Number(argv[i + 1])
|
|
849
|
+
if (!Number.isFinite(value) || value <= 0 || Math.floor(value) !== value) {
|
|
850
|
+
throw new Error(`Invalid --request-timeout-ms value: ${argv[i + 1]}`)
|
|
851
|
+
}
|
|
852
|
+
options.requestTimeoutMs = value
|
|
853
|
+
i += 1
|
|
854
|
+
continue
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
if (arg === '--throttle-ms') {
|
|
858
|
+
const value = Number(argv[i + 1])
|
|
859
|
+
if (!Number.isFinite(value) || value < 0 || Math.floor(value) !== value) {
|
|
860
|
+
throw new Error(`Invalid --throttle-ms value: ${argv[i + 1]}`)
|
|
861
|
+
}
|
|
862
|
+
options.throttleMs = value
|
|
863
|
+
i += 1
|
|
864
|
+
continue
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
if (arg === '--skip-dependencies') {
|
|
868
|
+
options.skipDependencies = true
|
|
869
|
+
continue
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
if (arg === '--tracks') {
|
|
873
|
+
const value = argv[i + 1]
|
|
874
|
+
if (!value) {
|
|
875
|
+
throw new Error('Missing value for --tracks')
|
|
876
|
+
}
|
|
877
|
+
result.tracks = value
|
|
878
|
+
i += 1
|
|
879
|
+
continue
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
if (arg === '--verbose') {
|
|
883
|
+
options.verbose = true
|
|
884
|
+
continue
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
result.unknownFlags.push(arg)
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
return result
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
function parseClearIssuesArgs(argv: string[]): {
|
|
894
|
+
options: ProjectClearIssuesOptions
|
|
895
|
+
unknownFlags: string[]
|
|
896
|
+
} {
|
|
897
|
+
const options: ProjectClearIssuesOptions = {
|
|
898
|
+
state: 'all',
|
|
899
|
+
dryRun: false,
|
|
900
|
+
force: false,
|
|
901
|
+
verbose: false,
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
const result: {
|
|
905
|
+
options: ProjectClearIssuesOptions
|
|
906
|
+
unknownFlags: string[]
|
|
907
|
+
} = {
|
|
908
|
+
options,
|
|
909
|
+
unknownFlags: [],
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
913
|
+
const arg = argv[i]
|
|
914
|
+
|
|
915
|
+
if (arg === '--dry-run') {
|
|
916
|
+
options.dryRun = true
|
|
917
|
+
continue
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
if (arg === '--force') {
|
|
921
|
+
options.force = true
|
|
922
|
+
continue
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
if (arg === '--verbose') {
|
|
926
|
+
options.verbose = true
|
|
927
|
+
continue
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
if (arg === '--state') {
|
|
931
|
+
const value = argv[i + 1]
|
|
932
|
+
if (!value) {
|
|
933
|
+
throw new Error('Missing value for --state')
|
|
934
|
+
}
|
|
935
|
+
if (value !== 'open' && value !== 'closed' && value !== 'all') {
|
|
936
|
+
throw new Error(`Invalid value for --state: ${value}`)
|
|
937
|
+
}
|
|
938
|
+
options.state = value
|
|
939
|
+
i += 1
|
|
940
|
+
continue
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
if (arg === '--owner') {
|
|
944
|
+
const value = argv[i + 1]
|
|
945
|
+
if (!value) {
|
|
946
|
+
throw new Error('Missing value for --owner')
|
|
947
|
+
}
|
|
948
|
+
options.owner = value
|
|
949
|
+
i += 1
|
|
950
|
+
continue
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
if (arg === '--repo') {
|
|
954
|
+
const value = argv[i + 1]
|
|
955
|
+
if (!value) {
|
|
956
|
+
throw new Error('Missing value for --repo')
|
|
957
|
+
}
|
|
958
|
+
options.repo = value
|
|
959
|
+
i += 1
|
|
960
|
+
continue
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
if (arg.startsWith('--')) {
|
|
964
|
+
result.unknownFlags.push(arg)
|
|
965
|
+
continue
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
result.unknownFlags.push(arg)
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
return result
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
function resolveGitIdentityFromProject(projectRoot: string): { owner: string; repo: string } {
|
|
975
|
+
const resolved = resolveProjectRepoIdentity(projectRoot)
|
|
976
|
+
if (!resolved) {
|
|
977
|
+
throw new Error(
|
|
978
|
+
`Could not resolve project git identity from ${path.join(projectRoot, 'code', '.git', 'config')} or ${path.join(projectRoot, '.git', 'config')}`
|
|
979
|
+
)
|
|
980
|
+
}
|
|
981
|
+
return resolved
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
function resolveTracksPath(projectRoot: string, input?: string): string {
|
|
985
|
+
if (input) {
|
|
986
|
+
if (path.isAbsolute(input)) {
|
|
987
|
+
if (!existsFile(input)) {
|
|
988
|
+
throw new Error(`tracks file not found: ${input}`)
|
|
989
|
+
}
|
|
990
|
+
return input
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
const relative = input.startsWith('/') || input.startsWith('\\') ? input.slice(1) : input
|
|
994
|
+
const candidate = path.join(projectRoot, relative)
|
|
995
|
+
if (!existsFile(candidate)) {
|
|
996
|
+
throw new Error(`tracks file not found: ${input}`)
|
|
997
|
+
}
|
|
998
|
+
return candidate
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
for (const candidate of DEFAULT_TRACKS_CANDIDATES) {
|
|
1002
|
+
const absolute = path.join(projectRoot, candidate)
|
|
1003
|
+
if (existsFile(absolute)) {
|
|
1004
|
+
return absolute
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
throw new Error(
|
|
1009
|
+
`Could not locate tracks file. Checked: ${DEFAULT_TRACKS_CANDIDATES.map((candidate) => path.join(projectRoot, candidate)).join(', ')}`
|
|
1010
|
+
)
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
function normalizeTaskDependencies(value: string): string[] {
|
|
1014
|
+
const normalized = value
|
|
1015
|
+
.split(',')
|
|
1016
|
+
.map((raw) => raw.trim())
|
|
1017
|
+
.filter(Boolean)
|
|
1018
|
+
.filter((raw) => TASK_ID_RE.test(raw))
|
|
1019
|
+
const seen = new Set<string>()
|
|
1020
|
+
const deduped: string[] = []
|
|
1021
|
+
|
|
1022
|
+
for (const item of normalized) {
|
|
1023
|
+
const canonical = item.toUpperCase()
|
|
1024
|
+
if (seen.has(canonical)) {
|
|
1025
|
+
continue
|
|
1026
|
+
}
|
|
1027
|
+
seen.add(canonical)
|
|
1028
|
+
deduped.push(canonical)
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
return deduped
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
function parseDependencyCell(value: string): string[] {
|
|
1035
|
+
const found = value.match(DEPENDENCY_TOKEN_RE)
|
|
1036
|
+
if (!found) {
|
|
1037
|
+
return []
|
|
1038
|
+
}
|
|
1039
|
+
return normalizeTaskDependencies(found.join(','))
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
function parseTaskGraphFromMarkdownForProjects(
|
|
1043
|
+
markdown: string,
|
|
1044
|
+
log?: (message: string) => void
|
|
1045
|
+
): TaskGraphParseResult {
|
|
1046
|
+
try {
|
|
1047
|
+
log?.('Parsing tracks using script taskgraph parser.')
|
|
1048
|
+
return parseTaskGraphFromMarkdownScript(markdown)
|
|
1049
|
+
} catch {
|
|
1050
|
+
log?.('Script taskgraph parser did not match; trying table fallback parser.')
|
|
1051
|
+
const rows = parseTaskGraphFromTableRows(markdown)
|
|
1052
|
+
if (rows.length === 0) {
|
|
1053
|
+
throw new Error('No parseable taskgraph found in markdown.')
|
|
1054
|
+
}
|
|
1055
|
+
return buildTaskGraphFromRows(rows)
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
function parseTaskGraphFromTableRows(markdown: string): Array<{ id: string; desc: string; deps: string[]; lineNo: number }> {
|
|
1060
|
+
const rows: Array<{ id: string; desc: string; deps: string[]; lineNo: number }> = []
|
|
1061
|
+
const lines = markdown.replace(/\r/g, '').split('\n')
|
|
1062
|
+
let tableHasHeader = false
|
|
1063
|
+
let depIndex = -1
|
|
1064
|
+
|
|
1065
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
1066
|
+
const line = lines[i].trim()
|
|
1067
|
+
if (!line.startsWith('|') || !line.endsWith('|')) {
|
|
1068
|
+
if (tableHasHeader && line.length === 0) {
|
|
1069
|
+
tableHasHeader = false
|
|
1070
|
+
depIndex = -1
|
|
1071
|
+
}
|
|
1072
|
+
continue
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
const cells = line
|
|
1076
|
+
.slice(1, -1)
|
|
1077
|
+
.split('|')
|
|
1078
|
+
.map((value) => value.trim())
|
|
1079
|
+
.filter((value) => value.length > 0)
|
|
1080
|
+
|
|
1081
|
+
if (cells.length < 3) {
|
|
1082
|
+
continue
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
const isSeparator = cells.every((cell) => /^:?[-\s]+:?$/.test(cell))
|
|
1086
|
+
if (isSeparator) {
|
|
1087
|
+
continue
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
const hasTaskHeader = cells[0].toUpperCase().includes('TASK') && cells[0].toUpperCase().includes('ID')
|
|
1091
|
+
const hasDescriptionHeader = cells[1].toUpperCase().includes('DESCRIPTION')
|
|
1092
|
+
|
|
1093
|
+
if (hasTaskHeader && hasDescriptionHeader) {
|
|
1094
|
+
tableHasHeader = true
|
|
1095
|
+
const depsHeaderIndex = cells.findIndex((cell) => cell.toUpperCase().includes('DEPEND'))
|
|
1096
|
+
depIndex = depsHeaderIndex >= 0 ? depsHeaderIndex : -1
|
|
1097
|
+
continue
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
if (!tableHasHeader) {
|
|
1101
|
+
continue
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
const taskId = cells[0].trim().toUpperCase()
|
|
1105
|
+
if (!TASK_ID_RE.test(taskId)) {
|
|
1106
|
+
continue
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
const desc = cells[1]?.trim() ?? ''
|
|
1110
|
+
const depCell = depIndex >= 0 ? cells[depIndex] ?? '' : ''
|
|
1111
|
+
const dependencies = parseDependencyCell(depCell)
|
|
1112
|
+
|
|
1113
|
+
rows.push({ id: taskId, desc, deps: dependencies, lineNo: i + 1 })
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
return rows
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
function buildTaskGraphFromRows(rows: Array<{ id: string; desc: string; deps: string[]; lineNo: number }>): TaskGraphParseResult {
|
|
1120
|
+
const byId = new Map<string, TaskGraphTask>()
|
|
1121
|
+
|
|
1122
|
+
for (const row of rows) {
|
|
1123
|
+
if (!row.desc) {
|
|
1124
|
+
throw new Error(`Empty description in tracks table at line ${row.lineNo}`)
|
|
1125
|
+
}
|
|
1126
|
+
if (TASK_ID_RE.test(row.id) === false) {
|
|
1127
|
+
continue
|
|
1128
|
+
}
|
|
1129
|
+
const normalizedDeps = row.deps.filter((dependency) => dependency !== row.id)
|
|
1130
|
+
if (normalizedDeps.length !== row.deps.length) {
|
|
1131
|
+
throw new Error(`Task '${row.id}' cannot depend on itself`)
|
|
1132
|
+
}
|
|
1133
|
+
if (new Set(normalizedDeps).size !== normalizedDeps.length) {
|
|
1134
|
+
throw new Error(`Duplicate dependencies for '${row.id}' at line ${row.lineNo}`)
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
const task: TaskGraphTask = {
|
|
1138
|
+
id: row.id as TaskGraphTask['id'],
|
|
1139
|
+
desc: row.desc,
|
|
1140
|
+
deps: normalizedDeps as TaskGraphTask['deps'],
|
|
1141
|
+
}
|
|
1142
|
+
byId.set(task.id, task)
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
const tasks = [...byId.values()]
|
|
1146
|
+
|
|
1147
|
+
const inDeg = new Map<string, number>()
|
|
1148
|
+
const adj = new Map<string, string[]>()
|
|
1149
|
+
for (const task of tasks) {
|
|
1150
|
+
const effectiveDeps = task.deps.filter((dependency) => byId.has(dependency))
|
|
1151
|
+
inDeg.set(task.id, effectiveDeps.length)
|
|
1152
|
+
adj.set(task.id, [])
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
for (const task of tasks) {
|
|
1156
|
+
for (const dep of task.deps.filter((dependency) => byId.has(dependency))) {
|
|
1157
|
+
adj.get(dep)?.push(task.id)
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
const ready = [...inDeg.entries()]
|
|
1162
|
+
.filter(([, degree]) => degree === 0)
|
|
1163
|
+
.map(([id]) => id)
|
|
1164
|
+
.sort()
|
|
1165
|
+
|
|
1166
|
+
const topo: string[] = []
|
|
1167
|
+
while (ready.length > 0) {
|
|
1168
|
+
const id = ready.shift()
|
|
1169
|
+
if (!id) {
|
|
1170
|
+
break
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
topo.push(id)
|
|
1174
|
+
for (const downstream of adj.get(id) ?? []) {
|
|
1175
|
+
const degree = (inDeg.get(downstream) ?? 0) - 1
|
|
1176
|
+
inDeg.set(downstream, degree)
|
|
1177
|
+
if (degree === 0) {
|
|
1178
|
+
ready.push(downstream)
|
|
1179
|
+
ready.sort()
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
if (topo.length !== tasks.length) {
|
|
1185
|
+
const remaining = [...inDeg.entries()].filter(([, degree]) => degree > 0).map(([id]) => id).sort()
|
|
1186
|
+
throw new Error(`Dependency cycle detected among: ${remaining.join(',')}`)
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
const byIdObject: Record<string, TaskGraphTask> = {}
|
|
1190
|
+
for (const [id, task] of byId.entries()) {
|
|
1191
|
+
byIdObject[id] = task
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
return {
|
|
1195
|
+
version: 'TASKGRAPH-V1',
|
|
1196
|
+
tasks,
|
|
1197
|
+
byId: byIdObject,
|
|
1198
|
+
topo,
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
function computeTaskHash(task: TaskGraphTask): string {
|
|
1203
|
+
const payload = `${task.id}|${task.desc}|${task.deps.join(',')}`
|
|
1204
|
+
return crypto
|
|
1205
|
+
.createHash('sha256')
|
|
1206
|
+
.update(payload, 'utf8')
|
|
1207
|
+
.digest('hex')
|
|
1208
|
+
.slice(0, TASK_HASH_LENGTH)
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
function buildIssuePayloadFromTask(
|
|
1212
|
+
task: TaskGraphTask,
|
|
1213
|
+
index: number,
|
|
1214
|
+
total: number,
|
|
1215
|
+
options: Pick<ProjectSyncTaskOptions, 'prefix' | 'maxBodyLength' | 'labels'>
|
|
1216
|
+
) {
|
|
1217
|
+
const shortHash = computeTaskHash(task)
|
|
1218
|
+
const deps = task.deps.length === 0 ? 'None' : task.deps.join(', ')
|
|
1219
|
+
const body = [
|
|
1220
|
+
`# ${task.id}`,
|
|
1221
|
+
'',
|
|
1222
|
+
`- Topological index: ${index + 1} / ${total}`,
|
|
1223
|
+
`- Dependencies: ${deps}`,
|
|
1224
|
+
'',
|
|
1225
|
+
'## Description',
|
|
1226
|
+
task.desc,
|
|
1227
|
+
].join('\n')
|
|
1228
|
+
|
|
1229
|
+
return {
|
|
1230
|
+
title: `${options.prefix} ${task.id} [${shortHash}]`,
|
|
1231
|
+
body: options.maxBodyLength ? body.slice(0, options.maxBodyLength) : body,
|
|
1232
|
+
labels: [...new Set(options.labels)],
|
|
1233
|
+
task_id: task.id,
|
|
1234
|
+
task_dependencies: task.deps,
|
|
1235
|
+
task_signature: shortHash,
|
|
1236
|
+
}
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
function resolveIssueApiMethod(api: GitServiceApi, feature: 'create' | 'edit' | 'list' | 'delete' | 'view'): GitServiceApiMethod {
|
|
1240
|
+
const issuesNamespace = api['issue']
|
|
1241
|
+
if (!issuesNamespace || typeof issuesNamespace !== 'object') {
|
|
1242
|
+
throw new Error('Git service API does not expose issue namespace')
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
const method = (issuesNamespace as {
|
|
1246
|
+
create?: unknown
|
|
1247
|
+
edit?: unknown
|
|
1248
|
+
list?: unknown
|
|
1249
|
+
delete?: unknown
|
|
1250
|
+
view?: unknown
|
|
1251
|
+
})[feature]
|
|
1252
|
+
if (typeof method !== 'function') {
|
|
1253
|
+
throw new Error(`Git service API does not expose issue.${feature}`)
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
return method as GitServiceApiMethod
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
type ProjectGitTaskIdentifier = {
|
|
1260
|
+
type: 'number' | 'taskId'
|
|
1261
|
+
value: number | string
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
function parseGitTaskReference(taskRef: string): ProjectGitTaskIdentifier {
|
|
1265
|
+
const trimmed = taskRef.trim()
|
|
1266
|
+
if (!trimmed) {
|
|
1267
|
+
throw new Error('Task reference is required.')
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
const numeric = trimmed.match(/^([1-9]\d*)$/)
|
|
1271
|
+
if (numeric?.[1]) {
|
|
1272
|
+
return { type: 'number', value: Number(numeric[1]) }
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
const taskId = trimmed.toUpperCase()
|
|
1276
|
+
if (!TASK_ID_RE.test(taskId)) {
|
|
1277
|
+
throw new Error(`Invalid task reference: ${taskRef}`)
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
return { type: 'taskId', value: taskId }
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
function normalizeIssueLabels(raw: unknown): string[] {
|
|
1284
|
+
if (!Array.isArray(raw)) {
|
|
1285
|
+
return []
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
const labels = raw
|
|
1289
|
+
.map((item) => {
|
|
1290
|
+
if (typeof item === 'string') return item.trim()
|
|
1291
|
+
if (item && typeof item === 'object' && 'name' in item && typeof (item as { name?: unknown }).name === 'string') {
|
|
1292
|
+
return (item as { name: string }).name.trim()
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
return ''
|
|
1296
|
+
})
|
|
1297
|
+
.filter((value): value is string => value.length > 0)
|
|
1298
|
+
|
|
1299
|
+
return [...new Set(labels)]
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
function parseTaskDependencyList(raw: unknown): string[] {
|
|
1303
|
+
if (Array.isArray(raw)) {
|
|
1304
|
+
return [...new Set(
|
|
1305
|
+
raw
|
|
1306
|
+
.map((item) => (typeof item === 'string' ? item.trim().toUpperCase() : ''))
|
|
1307
|
+
.filter((value): value is string => value.length > 0 && TASK_ID_RE.test(value)),
|
|
1308
|
+
)]
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
if (typeof raw === 'string') {
|
|
1312
|
+
return [...new Set(
|
|
1313
|
+
normalizeTaskDependencies(raw).map((value) => value.toUpperCase()).filter((value) => TASK_ID_RE.test(value)),
|
|
1314
|
+
)]
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
return []
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
function normalizeTaskId(value: unknown): string | null {
|
|
1321
|
+
if (typeof value !== 'string') {
|
|
1322
|
+
return null
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
const normalized = value.trim().toUpperCase()
|
|
1326
|
+
return TASK_ID_RE.test(normalized) ? normalized : null
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
function normalizeTaskSignature(value: unknown): string | null {
|
|
1330
|
+
if (typeof value !== 'string') {
|
|
1331
|
+
return null
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
const normalized = value.trim()
|
|
1335
|
+
return /^[0-9a-fA-F]{8}$/.test(normalized) ? normalized.toUpperCase() : null
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
function buildGitTaskPayload(ref: ProjectGitTaskIdentifier, options: ProjectWriteGitTaskOptions): Record<string, unknown> {
|
|
1339
|
+
const payload: Record<string, unknown> = {}
|
|
1340
|
+
|
|
1341
|
+
if (typeof options.title === 'string') payload.title = options.title
|
|
1342
|
+
if (typeof options.body === 'string') payload.body = options.body
|
|
1343
|
+
if (options.labels) payload.labels = options.labels
|
|
1344
|
+
if (options.state) payload.state = options.state
|
|
1345
|
+
if (options.taskDependencies) payload.task_dependencies = options.taskDependencies
|
|
1346
|
+
if (options.taskSignature) payload.task_signature = options.taskSignature
|
|
1347
|
+
|
|
1348
|
+
if (ref.type === 'taskId' && payload.title === undefined) {
|
|
1349
|
+
payload.title = `[TASK] ${ref.value}`
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
return payload
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
function toProjectGitTaskRecord(issue: GitIssueLike): ProjectGitTaskRecord | null {
|
|
1356
|
+
const raw = issue as Record<string, unknown>
|
|
1357
|
+
|
|
1358
|
+
const issueNumber = Number(raw.number)
|
|
1359
|
+
if (!Number.isInteger(issueNumber) || issueNumber <= 0) return null
|
|
1360
|
+
|
|
1361
|
+
const title = typeof raw.title === 'string' ? raw.title : `Task #${issueNumber}`
|
|
1362
|
+
const body = typeof raw.body === 'string' ? raw.body : ''
|
|
1363
|
+
const parsedTitle = parseIssueTaskIdFromTitle(title)
|
|
1364
|
+
const parsedBody = body ? parseIssueTaskIdFromBody(body) : null
|
|
1365
|
+
const taskIdFromPayload = normalizeTaskId(raw.task_id)
|
|
1366
|
+
const signatureFromPayload = normalizeTaskSignature(raw.task_signature)
|
|
1367
|
+
|
|
1368
|
+
return {
|
|
1369
|
+
number: issueNumber,
|
|
1370
|
+
title,
|
|
1371
|
+
taskId: taskIdFromPayload ?? parsedTitle?.taskId ?? parsedBody?.taskId ?? null,
|
|
1372
|
+
taskSignature: signatureFromPayload ?? parsedTitle?.hash ?? parsedBody?.hash ?? null,
|
|
1373
|
+
body,
|
|
1374
|
+
state: typeof raw.state === 'string' ? raw.state : 'unknown',
|
|
1375
|
+
labels: normalizeIssueLabels(raw.labels),
|
|
1376
|
+
taskDependenciesFromPayload: parseTaskDependencyList(raw.task_dependencies),
|
|
1377
|
+
taskDependenciesFromBody: body ? parseIssueDependenciesFromBody(body) : [],
|
|
1378
|
+
}
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
function resolveProjectGitRepository(
|
|
1382
|
+
projectName: string,
|
|
1383
|
+
processRoot: string,
|
|
1384
|
+
options: { owner?: string; repo?: string },
|
|
1385
|
+
): { owner: string; repo: string } {
|
|
1386
|
+
const projectRoot = resolveProjectRoot(projectName, processRoot)
|
|
1387
|
+
const resolved = resolveGitIdentityFromProject(projectRoot)
|
|
1388
|
+
const fallbackOwner = process.env.GITEA_TEST_OWNER || process.env.GITEA_OWNER
|
|
1389
|
+
const fallbackRepo = process.env.GITEA_TEST_REPO || process.env.GITEA_REPO
|
|
1390
|
+
|
|
1391
|
+
const owner = options.owner ?? resolved?.owner ?? fallbackOwner
|
|
1392
|
+
const repo = options.repo ?? resolved?.repo ?? fallbackRepo
|
|
1393
|
+
|
|
1394
|
+
if (!owner || !repo) {
|
|
1395
|
+
throw new Error(
|
|
1396
|
+
`Could not resolve project git identity from ${path.join(projectRoot, 'code', '.git', 'config')} or ${path.join(projectRoot, '.git', 'config')} and no owner/repo defaults are configured. Pass --owner and --repo (or set GITEA_TEST_OWNER and GITEA_TEST_REPO).`
|
|
1397
|
+
)
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
return {
|
|
1401
|
+
owner,
|
|
1402
|
+
repo,
|
|
1403
|
+
}
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1406
|
+
function parseIssueTaskIdFromTitle(title: string): { taskId: string; hash?: string } | null {
|
|
1407
|
+
const normalizedTitle = title.trim().toUpperCase()
|
|
1408
|
+
const match = normalizedTitle.match(TASK_TITLE_RE)
|
|
1409
|
+
if (match && match[1] && match[2]) {
|
|
1410
|
+
return {
|
|
1411
|
+
taskId: match[2],
|
|
1412
|
+
hash: match[3],
|
|
1413
|
+
}
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1416
|
+
const fallbackMatch = normalizedTitle.match(/^(?:\[[^\]]+\]\s*)?(TASK-[0-9]{3}[A-Za-z0-9]*)/)
|
|
1417
|
+
if (!fallbackMatch?.[1]) {
|
|
1418
|
+
return null
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
return {
|
|
1422
|
+
taskId: fallbackMatch[1],
|
|
1423
|
+
}
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
function parseIssueTaskIdFromBody(body: string): { taskId: string; hash?: string } | null {
|
|
1427
|
+
const firstLine = body
|
|
1428
|
+
.replace(/\r/g, '')
|
|
1429
|
+
.split('\n')
|
|
1430
|
+
.map((line) => line.trim())
|
|
1431
|
+
.find((line) => line.length > 0)
|
|
1432
|
+
|
|
1433
|
+
if (!firstLine) {
|
|
1434
|
+
return null
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1437
|
+
const normalized = firstLine.toUpperCase().replace(/^#\s*/, '')
|
|
1438
|
+
const match = normalized.match(/^(TASK-[0-9]{3}[A-Za-z0-9]*)(?:\s+\[([0-9A-F]{8})\])?/)
|
|
1439
|
+
if (!match?.[1]) {
|
|
1440
|
+
return null
|
|
1441
|
+
}
|
|
1442
|
+
|
|
1443
|
+
return {
|
|
1444
|
+
taskId: match[1],
|
|
1445
|
+
hash: match[2],
|
|
1446
|
+
}
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
function parseIssueDependenciesFromBody(body: string): string[] {
|
|
1450
|
+
const match = body.match(/^\s*-\s*Dependencies:\s*(.+)$/im)
|
|
1451
|
+
if (!match || !match[1]) {
|
|
1452
|
+
return []
|
|
1453
|
+
}
|
|
1454
|
+
|
|
1455
|
+
const text = match[1].trim()
|
|
1456
|
+
if (!text || /^none$/i.test(text)) {
|
|
1457
|
+
return []
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1460
|
+
return normalizeTaskDependencies(text.replace(/`/g, ','))
|
|
1461
|
+
}
|
|
1462
|
+
|
|
1463
|
+
type GitIssueLike = {
|
|
1464
|
+
title?: unknown
|
|
1465
|
+
number?: unknown
|
|
1466
|
+
task_id?: unknown
|
|
1467
|
+
task_signature?: unknown
|
|
1468
|
+
state?: unknown
|
|
1469
|
+
labels?: unknown
|
|
1470
|
+
task_dependencies?: unknown
|
|
1471
|
+
body?: unknown
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
async function fetchAllIssues(
|
|
1475
|
+
issueList: GitServiceApiMethod,
|
|
1476
|
+
owner: string,
|
|
1477
|
+
repo: string,
|
|
1478
|
+
state: 'open' | 'closed' | 'all',
|
|
1479
|
+
projectName: string,
|
|
1480
|
+
log?: (message: string) => void
|
|
1481
|
+
): Promise<GitIssueLike[]> {
|
|
1482
|
+
const allIssues: GitIssueLike[] = []
|
|
1483
|
+
let page = 1
|
|
1484
|
+
|
|
1485
|
+
while (true) {
|
|
1486
|
+
log?.(`Listing ${state} issues page ${page} for ${owner}/${repo}...`)
|
|
1487
|
+
const listResult = await issueList(owner, repo, {
|
|
1488
|
+
state,
|
|
1489
|
+
limit: ISSUE_LIST_PAGE_SIZE,
|
|
1490
|
+
query: { page },
|
|
1491
|
+
})
|
|
1492
|
+
|
|
1493
|
+
if (!listResult.ok) {
|
|
1494
|
+
throw new Error(
|
|
1495
|
+
`Failed to list ${state} issues for ${owner}/${repo}: status=${listResult.status} url=${listResult.request.url}`
|
|
1496
|
+
)
|
|
1497
|
+
}
|
|
1498
|
+
|
|
1499
|
+
if (!Array.isArray(listResult.body)) {
|
|
1500
|
+
throw new Error(`Expected array from issue.list for ${projectName} (page ${page})`)
|
|
1501
|
+
}
|
|
1502
|
+
|
|
1503
|
+
const batch = listResult.body as GitIssueLike[]
|
|
1504
|
+
allIssues.push(...batch)
|
|
1505
|
+
log?.(`Loaded page ${page} with ${batch.length} ${state} issues for ${owner}/${repo}.`)
|
|
1506
|
+
|
|
1507
|
+
if (batch.length === 0) {
|
|
1508
|
+
break
|
|
1509
|
+
}
|
|
1510
|
+
page += 1
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1513
|
+
log?.(`Loaded ${allIssues.length} ${state} issues in ${owner}/${repo} across pages.`)
|
|
1514
|
+
return allIssues
|
|
1515
|
+
}
|
|
1516
|
+
|
|
1517
|
+
function issueDependenciesMatches(taskDeps: string[], issue: ProjectSyncIssueRecord): boolean {
|
|
1518
|
+
const fromBody = issue.dependenciesFromBody
|
|
1519
|
+
if (fromBody) {
|
|
1520
|
+
return areDependencyListsEqual(fromBody, taskDeps)
|
|
1521
|
+
}
|
|
1522
|
+
|
|
1523
|
+
if (issue.dependenciesFromPayload) {
|
|
1524
|
+
return areDependencyListsEqual(issue.dependenciesFromPayload, taskDeps)
|
|
1525
|
+
}
|
|
1526
|
+
|
|
1527
|
+
return taskDeps.length === 0
|
|
1528
|
+
}
|
|
1529
|
+
|
|
1530
|
+
function areDependencyListsEqual(left: string[], right: string[]): boolean {
|
|
1531
|
+
const normalize = (value: string[]) => {
|
|
1532
|
+
const uniq = [...new Set(value.map((item) => item.toUpperCase()))].sort()
|
|
1533
|
+
return uniq.join(',')
|
|
1534
|
+
}
|
|
1535
|
+
|
|
1536
|
+
return normalize(left) === normalize(right)
|
|
1537
|
+
}
|
|
1538
|
+
|
|
1539
|
+
type ProjectSyncIssueRecord = {
|
|
1540
|
+
number: number
|
|
1541
|
+
title: string
|
|
1542
|
+
hash: string
|
|
1543
|
+
rawHash: string | null
|
|
1544
|
+
state: string
|
|
1545
|
+
dependenciesFromPayload: string[] | null
|
|
1546
|
+
dependenciesFromBody: string[] | null
|
|
1547
|
+
}
|
|
1548
|
+
|
|
1549
|
+
function buildIssueNumberFromPayload(payload: unknown): number | null {
|
|
1550
|
+
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
|
|
1551
|
+
return null
|
|
1552
|
+
}
|
|
1553
|
+
|
|
1554
|
+
const number = Number((payload as { number?: unknown }).number)
|
|
1555
|
+
if (Number.isInteger(number) && number > 0) {
|
|
1556
|
+
return number
|
|
1557
|
+
}
|
|
1558
|
+
return null
|
|
1559
|
+
}
|
|
1560
|
+
|
|
1561
|
+
function indexExistingTaskIssues(rawBody: unknown, projectName: string): Map<string, ProjectSyncIssueRecord[]> {
|
|
1562
|
+
if (!Array.isArray(rawBody)) {
|
|
1563
|
+
throw new Error(`Expected array from issue.list for ${projectName}`)
|
|
1564
|
+
}
|
|
1565
|
+
|
|
1566
|
+
const byTask = new Map<string, ProjectSyncIssueRecord[]>()
|
|
1567
|
+
for (const issue of rawBody as Array<Record<string, unknown>>) {
|
|
1568
|
+
const parsedIssue = toProjectGitTaskRecord(issue)
|
|
1569
|
+
if (!parsedIssue || !parsedIssue.taskId) {
|
|
1570
|
+
continue
|
|
1571
|
+
}
|
|
1572
|
+
|
|
1573
|
+
const taskId = parsedIssue.taskId
|
|
1574
|
+
const issueHash = parsedIssue.taskSignature
|
|
1575
|
+
|
|
1576
|
+
const existing = byTask.get(taskId) ?? []
|
|
1577
|
+
existing.push({
|
|
1578
|
+
number: parsedIssue.number,
|
|
1579
|
+
title: parsedIssue.title,
|
|
1580
|
+
hash: issueHash ?? '',
|
|
1581
|
+
rawHash: issueHash ?? null,
|
|
1582
|
+
state: parsedIssue.state,
|
|
1583
|
+
dependenciesFromPayload: parsedIssue.taskDependenciesFromPayload.length === 0 ? null : parsedIssue.taskDependenciesFromPayload,
|
|
1584
|
+
dependenciesFromBody: parsedIssue.body ? parsedIssue.taskDependenciesFromBody : null,
|
|
1585
|
+
})
|
|
1586
|
+
byTask.set(taskId, existing)
|
|
1587
|
+
}
|
|
1588
|
+
|
|
1589
|
+
for (const [, existing] of byTask) {
|
|
1590
|
+
existing.sort((left, right) => {
|
|
1591
|
+
const leftOpen = left.state === 'open'
|
|
1592
|
+
const rightOpen = right.state === 'open'
|
|
1593
|
+
|
|
1594
|
+
if (leftOpen && !rightOpen) return -1
|
|
1595
|
+
if (!leftOpen && rightOpen) return 1
|
|
1596
|
+
return 0
|
|
1597
|
+
})
|
|
1598
|
+
}
|
|
1599
|
+
|
|
1600
|
+
return byTask
|
|
1601
|
+
}
|
|
1602
|
+
|
|
1603
|
+
function hasSyncMatch(existing: ProjectSyncIssueRecord, task: TaskGraphTask): boolean {
|
|
1604
|
+
const expectedHash = computeTaskHash(task)
|
|
1605
|
+
if (!existing.rawHash || existing.rawHash.toLowerCase() !== expectedHash.toLowerCase()) {
|
|
1606
|
+
return false
|
|
1607
|
+
}
|
|
1608
|
+
|
|
1609
|
+
return issueDependenciesMatches(task.deps, existing)
|
|
1610
|
+
}
|
|
1611
|
+
|
|
1612
|
+
function pickTaskIssueMatch(
|
|
1613
|
+
issues: ProjectSyncIssueRecord[],
|
|
1614
|
+
task: TaskGraphTask
|
|
1615
|
+
): { issue: ProjectSyncIssueRecord; isMatch: boolean } | null {
|
|
1616
|
+
const matched = issues.find((issue) => hasSyncMatch(issue, task))
|
|
1617
|
+
if (matched) {
|
|
1618
|
+
return { issue: matched, isMatch: true }
|
|
1619
|
+
}
|
|
1620
|
+
|
|
1621
|
+
if (issues.length === 0) {
|
|
1622
|
+
return null
|
|
1623
|
+
}
|
|
1624
|
+
|
|
1625
|
+
const openIssue = issues.find((issue) => issue.state === 'open')
|
|
1626
|
+
return { issue: openIssue ?? issues[0], isMatch: false }
|
|
1627
|
+
}
|
|
1628
|
+
|
|
1629
|
+
function pickBestMatchFromTasks(issues: ProjectGitTaskRecord[]): ProjectGitTaskRecord | null {
|
|
1630
|
+
if (issues.length === 0) {
|
|
1631
|
+
return null
|
|
1632
|
+
}
|
|
1633
|
+
|
|
1634
|
+
const openIssue = issues.find((issue) => issue.state === 'open')
|
|
1635
|
+
if (openIssue) {
|
|
1636
|
+
return openIssue
|
|
1637
|
+
}
|
|
1638
|
+
|
|
1639
|
+
return issues.sort((a, b) => a.number - b.number)[0] ?? null
|
|
1640
|
+
}
|
|
1641
|
+
|
|
1642
|
+
export async function fetchGitTasks(
|
|
1643
|
+
projectName: string,
|
|
1644
|
+
options: Partial<ProjectGitTaskQueryOptions> = {},
|
|
1645
|
+
processRoot: string = process.cwd()
|
|
1646
|
+
): Promise<ProjectFetchGitTasksResult> {
|
|
1647
|
+
const { owner, repo } = resolveProjectGitRepository(projectName, processRoot, options)
|
|
1648
|
+
const state: GitTaskIssueState = options.state ?? 'open'
|
|
1649
|
+
const taskOnly = options.taskOnly ?? true
|
|
1650
|
+
|
|
1651
|
+
const git = createGitServiceApi({
|
|
1652
|
+
config: {
|
|
1653
|
+
platform: 'GITEA',
|
|
1654
|
+
giteaHost: resolveGiteaHost(),
|
|
1655
|
+
giteaToken: process.env.GITEA_TOKEN,
|
|
1656
|
+
},
|
|
1657
|
+
defaultOwner: owner,
|
|
1658
|
+
defaultRepo: repo,
|
|
1659
|
+
})
|
|
1660
|
+
const issueList = resolveIssueApiMethod(git, 'list')
|
|
1661
|
+
const issues = await fetchAllIssues(issueList, owner, repo, state, projectName)
|
|
1662
|
+
const tasks = issues
|
|
1663
|
+
.map((issue) => toProjectGitTaskRecord(issue))
|
|
1664
|
+
.filter((issue): issue is ProjectGitTaskRecord => {
|
|
1665
|
+
if (!issue) return false
|
|
1666
|
+
return !taskOnly || Boolean(issue.taskId)
|
|
1667
|
+
})
|
|
1668
|
+
|
|
1669
|
+
return {
|
|
1670
|
+
projectName,
|
|
1671
|
+
owner,
|
|
1672
|
+
repo,
|
|
1673
|
+
state,
|
|
1674
|
+
taskOnly,
|
|
1675
|
+
total: tasks.length,
|
|
1676
|
+
tasks,
|
|
1677
|
+
}
|
|
1678
|
+
}
|
|
1679
|
+
|
|
1680
|
+
export async function readGitTask(
|
|
1681
|
+
projectName: string,
|
|
1682
|
+
taskRef: string,
|
|
1683
|
+
options: Partial<ProjectGitTaskQueryOptions> = {},
|
|
1684
|
+
processRoot: string = process.cwd()
|
|
1685
|
+
): Promise<ProjectReadGitTaskResult> {
|
|
1686
|
+
const parsedRef = parseGitTaskReference(taskRef)
|
|
1687
|
+
const state: GitTaskIssueState = options.state ?? 'all'
|
|
1688
|
+
const { owner, repo } = resolveProjectGitRepository(projectName, processRoot, options)
|
|
1689
|
+
|
|
1690
|
+
const git = createGitServiceApi({
|
|
1691
|
+
config: {
|
|
1692
|
+
platform: 'GITEA',
|
|
1693
|
+
giteaHost: resolveGiteaHost(),
|
|
1694
|
+
giteaToken: process.env.GITEA_TOKEN,
|
|
1695
|
+
},
|
|
1696
|
+
defaultOwner: owner,
|
|
1697
|
+
defaultRepo: repo,
|
|
1698
|
+
})
|
|
1699
|
+
|
|
1700
|
+
if (parsedRef.type === 'number') {
|
|
1701
|
+
const issueView = resolveIssueApiMethod(git, 'view')
|
|
1702
|
+
const result = await issueView(owner, repo, parsedRef.value) as GitServiceApiExecutionResult<GitIssueLike>
|
|
1703
|
+
|
|
1704
|
+
if (!result.ok) {
|
|
1705
|
+
throw new Error(`Failed to read issue #${parsedRef.value} for ${owner}/${repo}: status=${result.status} url=${result.request.url}`)
|
|
1706
|
+
}
|
|
1707
|
+
|
|
1708
|
+
const issue = toProjectGitTaskRecord(result.body)
|
|
1709
|
+
if (!issue) {
|
|
1710
|
+
throw new Error(`Read issue payload invalid for ${owner}/${repo} issue #${parsedRef.value}`)
|
|
1711
|
+
}
|
|
1712
|
+
|
|
1713
|
+
return {
|
|
1714
|
+
projectName,
|
|
1715
|
+
owner,
|
|
1716
|
+
repo,
|
|
1717
|
+
issueNumber: issue.number,
|
|
1718
|
+
issue,
|
|
1719
|
+
foundBy: 'number',
|
|
1720
|
+
searchScope: state,
|
|
1721
|
+
}
|
|
1722
|
+
}
|
|
1723
|
+
|
|
1724
|
+
const issueList = resolveIssueApiMethod(git, 'list')
|
|
1725
|
+
const issues = await fetchAllIssues(issueList, owner, repo, state, projectName)
|
|
1726
|
+
const match = issues
|
|
1727
|
+
.map((issue) => toProjectGitTaskRecord(issue))
|
|
1728
|
+
.find((task) => task?.taskId === parsedRef.value)
|
|
1729
|
+
|
|
1730
|
+
if (!match) {
|
|
1731
|
+
throw new Error(`Task ${parsedRef.value} not found in ${owner}/${repo}`)
|
|
1732
|
+
}
|
|
1733
|
+
|
|
1734
|
+
return {
|
|
1735
|
+
projectName,
|
|
1736
|
+
owner,
|
|
1737
|
+
repo,
|
|
1738
|
+
issueNumber: match.number,
|
|
1739
|
+
issue: match,
|
|
1740
|
+
foundBy: 'taskId',
|
|
1741
|
+
searchScope: state,
|
|
1742
|
+
}
|
|
1743
|
+
}
|
|
1744
|
+
|
|
1745
|
+
export async function writeGitTask(
|
|
1746
|
+
projectName: string,
|
|
1747
|
+
taskRef: string,
|
|
1748
|
+
options: Partial<ProjectWriteGitTaskOptions> = {},
|
|
1749
|
+
processRoot: string = process.cwd()
|
|
1750
|
+
): Promise<ProjectWriteGitTaskResult> {
|
|
1751
|
+
const parsedRef = parseGitTaskReference(taskRef)
|
|
1752
|
+
const { owner, repo } = resolveProjectGitRepository(projectName, processRoot, options)
|
|
1753
|
+
const payload = buildGitTaskPayload(parsedRef, options as ProjectWriteGitTaskOptions)
|
|
1754
|
+
|
|
1755
|
+
if (Object.keys(payload).length === 0) {
|
|
1756
|
+
throw new Error('Nothing to write. Provide at least one writable field (title, body, labels, state, taskDependencies, or taskSignature).')
|
|
1757
|
+
}
|
|
1758
|
+
|
|
1759
|
+
const git = createGitServiceApi({
|
|
1760
|
+
config: {
|
|
1761
|
+
platform: 'GITEA',
|
|
1762
|
+
giteaHost: resolveGiteaHost(),
|
|
1763
|
+
giteaToken: process.env.GITEA_TOKEN,
|
|
1764
|
+
},
|
|
1765
|
+
defaultOwner: owner,
|
|
1766
|
+
defaultRepo: repo,
|
|
1767
|
+
})
|
|
1768
|
+
|
|
1769
|
+
const issueList = resolveIssueApiMethod(git, 'list')
|
|
1770
|
+
const issueEdit = resolveIssueApiMethod(git, 'edit')
|
|
1771
|
+
const issueCreate = resolveIssueApiMethod(git, 'create')
|
|
1772
|
+
|
|
1773
|
+
let issueNumber: number | null = null
|
|
1774
|
+
if (parsedRef.type === 'number') {
|
|
1775
|
+
issueNumber = parsedRef.value
|
|
1776
|
+
} else {
|
|
1777
|
+
const issues = await fetchAllIssues(issueList, owner, repo, 'all', projectName)
|
|
1778
|
+
const matchingIssues = issues
|
|
1779
|
+
.map((issue) => toProjectGitTaskRecord(issue))
|
|
1780
|
+
.filter((task): task is ProjectGitTaskRecord => Boolean(task?.taskId === parsedRef.value))
|
|
1781
|
+
const match = pickBestMatchFromTasks(matchingIssues)
|
|
1782
|
+
|
|
1783
|
+
if (match) {
|
|
1784
|
+
issueNumber = match.number
|
|
1785
|
+
} else if (!options.createIfMissing) {
|
|
1786
|
+
throw new Error(`Task ${parsedRef.value} not found for ${owner}/${repo}. Pass createIfMissing:true to create it.`)
|
|
1787
|
+
}
|
|
1788
|
+
}
|
|
1789
|
+
|
|
1790
|
+
if (!issueNumber) {
|
|
1791
|
+
const createResult = await issueCreate(owner, repo, payload)
|
|
1792
|
+
if (!createResult.ok) {
|
|
1793
|
+
throw new Error(`Failed to create task in ${owner}/${repo}: status=${createResult.status} url=${createResult.request.url}`)
|
|
1794
|
+
}
|
|
1795
|
+
|
|
1796
|
+
const issue = toProjectGitTaskRecord(createResult.body as GitIssueLike)
|
|
1797
|
+
if (!issue) {
|
|
1798
|
+
throw new Error(`Create task response invalid for ${owner}/${repo}`)
|
|
1799
|
+
}
|
|
1800
|
+
|
|
1801
|
+
return {
|
|
1802
|
+
projectName,
|
|
1803
|
+
owner,
|
|
1804
|
+
repo,
|
|
1805
|
+
action: 'created',
|
|
1806
|
+
issueNumber: issue.number,
|
|
1807
|
+
issue,
|
|
1808
|
+
}
|
|
1809
|
+
}
|
|
1810
|
+
|
|
1811
|
+
const updateResult = await issueEdit(owner, repo, issueNumber, payload)
|
|
1812
|
+
if (!updateResult.ok) {
|
|
1813
|
+
throw new Error(`Failed to update issue #${issueNumber} for ${owner}/${repo}: status=${updateResult.status} url=${updateResult.request.url}`)
|
|
1814
|
+
}
|
|
1815
|
+
|
|
1816
|
+
const issue = toProjectGitTaskRecord(updateResult.body as GitIssueLike)
|
|
1817
|
+
if (!issue) {
|
|
1818
|
+
throw new Error(`Update task response invalid for ${owner}/${repo} issue #${issueNumber}`)
|
|
1819
|
+
}
|
|
1820
|
+
|
|
1821
|
+
return {
|
|
1822
|
+
projectName,
|
|
1823
|
+
owner,
|
|
1824
|
+
repo,
|
|
1825
|
+
action: 'updated',
|
|
1826
|
+
issueNumber: issue.number,
|
|
1827
|
+
issue,
|
|
1828
|
+
}
|
|
1829
|
+
}
|
|
1830
|
+
|
|
1831
|
+
export async function syncTasks(
|
|
1832
|
+
projectName: string,
|
|
1833
|
+
options: Partial<ProjectSyncTaskOptions> = {},
|
|
1834
|
+
processRoot: string = process.cwd()
|
|
1835
|
+
): Promise<ProjectSyncTaskResult> {
|
|
1836
|
+
const projectRoot = resolveProjectRoot(projectName, processRoot)
|
|
1837
|
+
const tracksPath = resolveTracksPath(projectRoot, options.tracks)
|
|
1838
|
+
const resolved = options.owner && options.repo
|
|
1839
|
+
? { owner: options.owner, repo: options.repo }
|
|
1840
|
+
: resolveGitIdentityFromProject(projectRoot)
|
|
1841
|
+
|
|
1842
|
+
const owner = options.owner ?? resolved.owner
|
|
1843
|
+
const repo = options.repo ?? resolved.repo
|
|
1844
|
+
const syncOptions: ProjectSyncTaskOptions = {
|
|
1845
|
+
labels: options.labels ?? [],
|
|
1846
|
+
prefix: options.prefix ?? '[TASK]',
|
|
1847
|
+
dryRun: options.dryRun ?? false,
|
|
1848
|
+
max: options.max,
|
|
1849
|
+
maxBodyLength: options.maxBodyLength,
|
|
1850
|
+
requestTimeoutMs: options.requestTimeoutMs,
|
|
1851
|
+
throttleMs: options.throttleMs,
|
|
1852
|
+
skipDependencies: options.skipDependencies,
|
|
1853
|
+
owner,
|
|
1854
|
+
repo,
|
|
1855
|
+
verbose: options.verbose ?? false,
|
|
1856
|
+
}
|
|
1857
|
+
const log = syncOptions.verbose
|
|
1858
|
+
? (message: string) => {
|
|
1859
|
+
process.stderr.write(`[f0 projects ${projectName}] ${message}\n`)
|
|
1860
|
+
}
|
|
1861
|
+
: null
|
|
1862
|
+
|
|
1863
|
+
const previousTimeoutEnv = process.env.F0_GIT_REQUEST_TIMEOUT_MS
|
|
1864
|
+
if (syncOptions.requestTimeoutMs) {
|
|
1865
|
+
process.env.F0_GIT_REQUEST_TIMEOUT_MS = String(syncOptions.requestTimeoutMs)
|
|
1866
|
+
}
|
|
1867
|
+
|
|
1868
|
+
log?.(`Resolved project root: ${projectRoot}`)
|
|
1869
|
+
log?.(`Resolved tracks file: ${tracksPath}`)
|
|
1870
|
+
log?.(`Using issue destination: ${owner}/${repo}`)
|
|
1871
|
+
log?.(`HTTP request timeout: ${syncOptions.requestTimeoutMs ?? 'default'}`)
|
|
1872
|
+
log?.(`HTTP throttle: ${syncOptions.throttleMs ?? 0}ms`)
|
|
1873
|
+
log?.(`Dependency sync: ${syncOptions.skipDependencies ? 'skipped' : 'enabled'}`)
|
|
1874
|
+
const markdown = await fs.readFile(tracksPath, 'utf8')
|
|
1875
|
+
log?.(`Loaded tracks content (${markdown.length} chars).`)
|
|
1876
|
+
const graph = parseTaskGraphFromMarkdownForProjects(markdown, (message) => log?.(message))
|
|
1877
|
+
const topo = graph.topo
|
|
1878
|
+
const total = graph.topo.length
|
|
1879
|
+
const ordered = topo.map((taskId) => graph.byId[taskId]).filter((value): value is TaskGraphTask => Boolean(value))
|
|
1880
|
+
const selected = syncOptions.max ? ordered.slice(0, syncOptions.max) : ordered
|
|
1881
|
+
log?.(`Parsed ${total} tasks from tracks. ${selected.length} selected for sync.`)
|
|
1882
|
+
if (selected.length === 0) {
|
|
1883
|
+
log?.('No tasks selected for sync. Nothing to do.')
|
|
1884
|
+
}
|
|
1885
|
+
|
|
1886
|
+
const truncated = syncOptions.max ? total > selected.length : false
|
|
1887
|
+
|
|
1888
|
+
const git = createGitServiceApi({
|
|
1889
|
+
config: {
|
|
1890
|
+
platform: 'GITEA',
|
|
1891
|
+
giteaHost: resolveGiteaHost(),
|
|
1892
|
+
giteaToken: process.env.GITEA_TOKEN,
|
|
1893
|
+
},
|
|
1894
|
+
defaultOwner: owner,
|
|
1895
|
+
defaultRepo: repo,
|
|
1896
|
+
...(syncOptions.requestTimeoutMs ? { requestTimeoutMs: syncOptions.requestTimeoutMs } : {}),
|
|
1897
|
+
...(log ? { log: (message: string) => log(message) } : {}),
|
|
1898
|
+
})
|
|
1899
|
+
|
|
1900
|
+
const issueList = resolveIssueApiMethod(git, 'list')
|
|
1901
|
+
const issueCreate = resolveIssueApiMethod(git, 'create')
|
|
1902
|
+
const issueEdit = resolveIssueApiMethod(git, 'edit')
|
|
1903
|
+
|
|
1904
|
+
const allIssues = await fetchAllIssues(issueList, owner, repo, 'all', projectName, log)
|
|
1905
|
+
const existing = indexExistingTaskIssues(allIssues, projectName)
|
|
1906
|
+
const indexedIssueCount = [...existing.values()].reduce((acc, bucket) => acc + bucket.length, 0)
|
|
1907
|
+
log?.(`Loaded ${indexedIssueCount} task-like issues in ${owner}/${repo}.`)
|
|
1908
|
+
let created = 0
|
|
1909
|
+
let updated = 0
|
|
1910
|
+
let unchanged = 0
|
|
1911
|
+
const issueNumbersByTask = new Map<string, number>()
|
|
1912
|
+
const host = resolveGiteaHost()
|
|
1913
|
+
const token = process.env.GITEA_TOKEN
|
|
1914
|
+
const dependencyStats = {
|
|
1915
|
+
added: 0,
|
|
1916
|
+
removed: 0,
|
|
1917
|
+
unchanged: 0,
|
|
1918
|
+
}
|
|
1919
|
+
|
|
1920
|
+
const sleep = (ms: number) =>
|
|
1921
|
+
new Promise<void>((resolve) => setTimeout(resolve, ms))
|
|
1922
|
+
|
|
1923
|
+
for (let i = 0; i < selected.length; i += 1) {
|
|
1924
|
+
const task = selected[i]
|
|
1925
|
+
const marker = `[${i + 1}/${selected.length}] ${task.id}`
|
|
1926
|
+
const payload = buildIssuePayloadFromTask(task, i, selected.length, syncOptions)
|
|
1927
|
+
const byId = existing.get(task.id)
|
|
1928
|
+
const selection = byId ? pickTaskIssueMatch(byId, task) : null
|
|
1929
|
+
const targetIssue = selection?.issue
|
|
1930
|
+
|
|
1931
|
+
if (selection?.isMatch) {
|
|
1932
|
+
issueNumbersByTask.set(task.id, targetIssue.number)
|
|
1933
|
+
unchanged += 1
|
|
1934
|
+
log?.(`${marker} unchanged`)
|
|
1935
|
+
continue
|
|
1936
|
+
}
|
|
1937
|
+
|
|
1938
|
+
if (selection) {
|
|
1939
|
+
issueNumbersByTask.set(task.id, targetIssue.number)
|
|
1940
|
+
updated += 1
|
|
1941
|
+
log?.(`${marker} updating #${targetIssue.number}`)
|
|
1942
|
+
if (!syncOptions.dryRun) {
|
|
1943
|
+
log?.(`${marker} sending update request...`)
|
|
1944
|
+
const updatedResult = await issueEdit(owner, repo, targetIssue.number, {
|
|
1945
|
+
data: {
|
|
1946
|
+
title: payload.title,
|
|
1947
|
+
body: payload.body,
|
|
1948
|
+
labels: payload.labels,
|
|
1949
|
+
task_id: payload.task_id,
|
|
1950
|
+
task_dependencies: payload.task_dependencies,
|
|
1951
|
+
},
|
|
1952
|
+
}) as GitServiceApiExecutionResult
|
|
1953
|
+
|
|
1954
|
+
if (!updatedResult.ok) {
|
|
1955
|
+
throw new Error(
|
|
1956
|
+
`Failed to update issue ${targetIssue.number} for ${owner}/${repo}: status=${updatedResult.status} url=${updatedResult.request.url}`
|
|
1957
|
+
)
|
|
1958
|
+
}
|
|
1959
|
+
|
|
1960
|
+
if (syncOptions.throttleMs && syncOptions.throttleMs > 0) {
|
|
1961
|
+
await sleep(syncOptions.throttleMs)
|
|
1962
|
+
}
|
|
1963
|
+
}
|
|
1964
|
+
continue
|
|
1965
|
+
}
|
|
1966
|
+
|
|
1967
|
+
log?.(`${marker} creating new task issue`)
|
|
1968
|
+
if (!syncOptions.dryRun) {
|
|
1969
|
+
let createdResult: GitServiceApiExecutionResult
|
|
1970
|
+
try {
|
|
1971
|
+
log?.(`${marker} sending create request...`)
|
|
1972
|
+
createdResult = await issueCreate(owner, repo, {
|
|
1973
|
+
data: {
|
|
1974
|
+
title: payload.title,
|
|
1975
|
+
body: payload.body,
|
|
1976
|
+
labels: payload.labels,
|
|
1977
|
+
task_id: payload.task_id,
|
|
1978
|
+
task_dependencies: payload.task_dependencies,
|
|
1979
|
+
},
|
|
1980
|
+
}) as GitServiceApiExecutionResult
|
|
1981
|
+
} catch (error) {
|
|
1982
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
1983
|
+
throw new Error(`Failed to create issue for ${task.id} in ${owner}/${repo}: ${message}`)
|
|
1984
|
+
}
|
|
1985
|
+
|
|
1986
|
+
if (!createdResult.ok) {
|
|
1987
|
+
const bodySummary = (() => {
|
|
1988
|
+
try {
|
|
1989
|
+
if (typeof createdResult.body === 'string') {
|
|
1990
|
+
return createdResult.body.slice(0, 500)
|
|
1991
|
+
}
|
|
1992
|
+
return JSON.stringify(createdResult.body)?.slice(0, 500) ?? String(createdResult.body)
|
|
1993
|
+
} catch {
|
|
1994
|
+
return String(createdResult.body)
|
|
1995
|
+
}
|
|
1996
|
+
})()
|
|
1997
|
+
throw new Error(
|
|
1998
|
+
`Failed to create issue for ${task.id} in ${owner}/${repo}: status=${createdResult.status} url=${createdResult.request.url} body=${bodySummary}`
|
|
1999
|
+
)
|
|
2000
|
+
}
|
|
2001
|
+
|
|
2002
|
+
const issueNumber = buildIssueNumberFromPayload(createdResult.body)
|
|
2003
|
+
if (issueNumber === null) {
|
|
2004
|
+
throw new Error(
|
|
2005
|
+
`Created issue response for ${task.id} in ${owner}/${repo} is invalid: ${JSON.stringify(createdResult.body)}`
|
|
2006
|
+
)
|
|
2007
|
+
}
|
|
2008
|
+
issueNumbersByTask.set(task.id, issueNumber)
|
|
2009
|
+
|
|
2010
|
+
log?.(`${marker} created #${issueNumber}`)
|
|
2011
|
+
|
|
2012
|
+
if (syncOptions.throttleMs && syncOptions.throttleMs > 0) {
|
|
2013
|
+
await sleep(syncOptions.throttleMs)
|
|
2014
|
+
}
|
|
2015
|
+
} else {
|
|
2016
|
+
issueNumbersByTask.set(task.id, 0)
|
|
2017
|
+
}
|
|
2018
|
+
created += 1
|
|
2019
|
+
}
|
|
2020
|
+
|
|
2021
|
+
if (syncOptions.skipDependencies) {
|
|
2022
|
+
if (previousTimeoutEnv === undefined) {
|
|
2023
|
+
delete process.env.F0_GIT_REQUEST_TIMEOUT_MS
|
|
2024
|
+
} else {
|
|
2025
|
+
process.env.F0_GIT_REQUEST_TIMEOUT_MS = previousTimeoutEnv
|
|
2026
|
+
}
|
|
2027
|
+
|
|
2028
|
+
return {
|
|
2029
|
+
projectName,
|
|
2030
|
+
tracksPath,
|
|
2031
|
+
owner,
|
|
2032
|
+
repo,
|
|
2033
|
+
totalTasks: total,
|
|
2034
|
+
created,
|
|
2035
|
+
updated,
|
|
2036
|
+
skipped: unchanged,
|
|
2037
|
+
dryRun: syncOptions.dryRun,
|
|
2038
|
+
truncated,
|
|
2039
|
+
}
|
|
2040
|
+
}
|
|
2041
|
+
|
|
2042
|
+
for (let i = 0; i < selected.length; i += 1) {
|
|
2043
|
+
const task = selected[i]
|
|
2044
|
+
const issueNumber = issueNumbersByTask.get(task.id)
|
|
2045
|
+
if (!issueNumber) {
|
|
2046
|
+
log?.(`[${i + 1}/${selected.length}] ${task.id} has no issue number; skipping dependency sync`)
|
|
2047
|
+
continue
|
|
2048
|
+
}
|
|
2049
|
+
|
|
2050
|
+
const missingDependencies = task.deps.filter((dep) => !issueNumbersByTask.has(dep))
|
|
2051
|
+
if (missingDependencies.length > 0) {
|
|
2052
|
+
log?.(`Issue #${issueNumber} (task ${task.id}) skipped dependency(s): ${missingDependencies.join(', ')} not synced/open`)
|
|
2053
|
+
}
|
|
2054
|
+
|
|
2055
|
+
const desiredDependencyIssues = task.deps
|
|
2056
|
+
.map((dep) => issueNumbersByTask.get(dep))
|
|
2057
|
+
.filter((value): value is number => value !== undefined && value > 0)
|
|
2058
|
+
|
|
2059
|
+
log?.(`Issue #${issueNumber}: syncing dependencies (${desiredDependencyIssues.length} desired)...`)
|
|
2060
|
+
const syncResult = await syncIssueDependenciesViaApi(
|
|
2061
|
+
owner,
|
|
2062
|
+
repo,
|
|
2063
|
+
issueNumber,
|
|
2064
|
+
desiredDependencyIssues,
|
|
2065
|
+
host,
|
|
2066
|
+
token,
|
|
2067
|
+
syncOptions.dryRun,
|
|
2068
|
+
log
|
|
2069
|
+
)
|
|
2070
|
+
dependencyStats.added += syncResult.added
|
|
2071
|
+
dependencyStats.removed += syncResult.removed
|
|
2072
|
+
dependencyStats.unchanged += syncResult.unchanged
|
|
2073
|
+
|
|
2074
|
+
if (syncOptions.throttleMs && syncOptions.throttleMs > 0) {
|
|
2075
|
+
await sleep(syncOptions.throttleMs)
|
|
2076
|
+
}
|
|
2077
|
+
}
|
|
2078
|
+
|
|
2079
|
+
if (dependencyStats.added > 0 || dependencyStats.removed > 0) {
|
|
2080
|
+
log?.(`Dependency sync summary: +${dependencyStats.added} -${dependencyStats.removed}`)
|
|
2081
|
+
}
|
|
2082
|
+
|
|
2083
|
+
if (previousTimeoutEnv === undefined) {
|
|
2084
|
+
delete process.env.F0_GIT_REQUEST_TIMEOUT_MS
|
|
2085
|
+
} else {
|
|
2086
|
+
process.env.F0_GIT_REQUEST_TIMEOUT_MS = previousTimeoutEnv
|
|
2087
|
+
}
|
|
2088
|
+
|
|
2089
|
+
return {
|
|
2090
|
+
projectName,
|
|
2091
|
+
tracksPath,
|
|
2092
|
+
owner,
|
|
2093
|
+
repo,
|
|
2094
|
+
totalTasks: total,
|
|
2095
|
+
created,
|
|
2096
|
+
updated,
|
|
2097
|
+
skipped: unchanged,
|
|
2098
|
+
dryRun: syncOptions.dryRun,
|
|
2099
|
+
truncated,
|
|
2100
|
+
}
|
|
2101
|
+
}
|
|
2102
|
+
|
|
2103
|
+
export async function clearIssues(
|
|
2104
|
+
projectName: string,
|
|
2105
|
+
options: Partial<ProjectClearIssuesOptions> = {},
|
|
2106
|
+
processRoot: string = process.cwd()
|
|
2107
|
+
): Promise<ProjectClearIssuesResult> {
|
|
2108
|
+
const projectRoot = resolveProjectRoot(projectName, processRoot)
|
|
2109
|
+
const resolved = options.owner && options.repo
|
|
2110
|
+
? { owner: options.owner, repo: options.repo }
|
|
2111
|
+
: resolveGitIdentityFromProject(projectRoot)
|
|
2112
|
+
|
|
2113
|
+
const owner = options.owner ?? resolved.owner
|
|
2114
|
+
const repo = options.repo ?? resolved.repo
|
|
2115
|
+
const clearOptions: ProjectClearIssuesOptions = {
|
|
2116
|
+
state: options.state ?? 'all',
|
|
2117
|
+
dryRun: options.dryRun ?? false,
|
|
2118
|
+
force: options.force ?? false,
|
|
2119
|
+
verbose: options.verbose ?? false,
|
|
2120
|
+
owner,
|
|
2121
|
+
repo,
|
|
2122
|
+
}
|
|
2123
|
+
|
|
2124
|
+
if (!clearOptions.dryRun && !clearOptions.force) {
|
|
2125
|
+
throw new Error('Refusing to delete issues without --force. Re-run with --dry-run for preview.')
|
|
2126
|
+
}
|
|
2127
|
+
|
|
2128
|
+
const log = clearOptions.verbose
|
|
2129
|
+
? (message: string) => {
|
|
2130
|
+
process.stderr.write(`[f0 projects ${projectName}] ${message}\n`)
|
|
2131
|
+
}
|
|
2132
|
+
: null
|
|
2133
|
+
|
|
2134
|
+
log?.(`Resolved project root: ${projectRoot}`)
|
|
2135
|
+
log?.(`Using issue destination: ${owner}/${repo}`)
|
|
2136
|
+
|
|
2137
|
+
const git = createGitServiceApi({
|
|
2138
|
+
config: {
|
|
2139
|
+
platform: 'GITEA',
|
|
2140
|
+
giteaHost: resolveGiteaHost(),
|
|
2141
|
+
giteaToken: process.env.GITEA_TOKEN,
|
|
2142
|
+
},
|
|
2143
|
+
defaultOwner: owner,
|
|
2144
|
+
defaultRepo: repo,
|
|
2145
|
+
})
|
|
2146
|
+
|
|
2147
|
+
const issueList = resolveIssueApiMethod(git, 'list')
|
|
2148
|
+
const issueDelete = resolveIssueApiMethod(git, 'delete')
|
|
2149
|
+
|
|
2150
|
+
const requestedStates: Array<'open' | 'closed' | 'all'> = clearOptions.state === 'all'
|
|
2151
|
+
? ['open', 'closed']
|
|
2152
|
+
: [clearOptions.state]
|
|
2153
|
+
|
|
2154
|
+
const issueBatches = await Promise.all(
|
|
2155
|
+
requestedStates.map((state) => fetchAllIssues(issueList, owner, repo, state, projectName, log))
|
|
2156
|
+
)
|
|
2157
|
+
const issueByNumber = new Map<number, GitIssueLike>()
|
|
2158
|
+
|
|
2159
|
+
for (const batch of issueBatches) {
|
|
2160
|
+
for (const issue of batch) {
|
|
2161
|
+
const number = Number((issue as Record<string, unknown>).number)
|
|
2162
|
+
if (!Number.isFinite(number) || number <= 0) {
|
|
2163
|
+
continue
|
|
2164
|
+
}
|
|
2165
|
+
issueByNumber.set(number, issue)
|
|
2166
|
+
}
|
|
2167
|
+
}
|
|
2168
|
+
|
|
2169
|
+
const issues = Array.from(issueByNumber.values())
|
|
2170
|
+
log?.(`Found ${issues.length} issues to process.`)
|
|
2171
|
+
|
|
2172
|
+
let deleted = 0
|
|
2173
|
+
for (const issue of issues) {
|
|
2174
|
+
const issueNumber = Number((issue as Record<string, unknown>).number)
|
|
2175
|
+
if (!Number.isFinite(issueNumber) || issueNumber <= 0) {
|
|
2176
|
+
continue
|
|
2177
|
+
}
|
|
2178
|
+
|
|
2179
|
+
log?.(`Deleting #${issueNumber}`)
|
|
2180
|
+
if (clearOptions.dryRun) {
|
|
2181
|
+
continue
|
|
2182
|
+
}
|
|
2183
|
+
|
|
2184
|
+
const deleteResult = await issueDelete(owner, repo, issueNumber, { yes: true }) as GitServiceApiExecutionResult
|
|
2185
|
+
if (!deleteResult.ok) {
|
|
2186
|
+
throw new Error(
|
|
2187
|
+
`Failed to delete issue ${issueNumber} for ${owner}/${repo}: status=${deleteResult.status} url=${deleteResult.request.url}`
|
|
2188
|
+
)
|
|
2189
|
+
}
|
|
2190
|
+
|
|
2191
|
+
deleted += 1
|
|
2192
|
+
}
|
|
2193
|
+
|
|
2194
|
+
return {
|
|
2195
|
+
projectName,
|
|
2196
|
+
owner,
|
|
2197
|
+
repo,
|
|
2198
|
+
state: clearOptions.state,
|
|
2199
|
+
total: issues.length,
|
|
2200
|
+
deleted,
|
|
2201
|
+
dryRun: clearOptions.dryRun,
|
|
2202
|
+
}
|
|
2203
|
+
}
|
|
2204
|
+
|
|
2205
|
+
export async function main(argv: string[], processRoot: string = process.cwd()): Promise<string> {
|
|
2206
|
+
if (
|
|
2207
|
+
argv.length === 0 ||
|
|
2208
|
+
argv.includes('--help') ||
|
|
2209
|
+
argv.includes('-h')
|
|
2210
|
+
) {
|
|
2211
|
+
return usage()
|
|
2212
|
+
}
|
|
2213
|
+
|
|
2214
|
+
const [scope, maybeProject, maybeTarget, ...rest] = argv
|
|
2215
|
+
const listMode = scope === 'projects' && maybeProject === '--list'
|
|
2216
|
+
|
|
2217
|
+
if (scope !== 'projects') {
|
|
2218
|
+
throw new Error('Expected command `projects` as first positional argument.')
|
|
2219
|
+
}
|
|
2220
|
+
|
|
2221
|
+
if (listMode) {
|
|
2222
|
+
const unknownFlags = rest.filter(Boolean)
|
|
2223
|
+
if (unknownFlags.length > 0) {
|
|
2224
|
+
throw new Error(`Unknown flags: ${unknownFlags.join(', ')}`)
|
|
2225
|
+
}
|
|
2226
|
+
return listProjects(processRoot).join('\n')
|
|
2227
|
+
}
|
|
2228
|
+
|
|
2229
|
+
const projectName = maybeProject
|
|
2230
|
+
if (!projectName) {
|
|
2231
|
+
throw new Error('Missing required arguments: <project-name>.')
|
|
2232
|
+
}
|
|
2233
|
+
|
|
2234
|
+
if (maybeTarget === 'sync-tasks') {
|
|
2235
|
+
const parsedSync = parseSyncTasksArgs(rest)
|
|
2236
|
+
if (parsedSync.unknownFlags.length > 0) {
|
|
2237
|
+
throw new Error(`Unknown flags: ${parsedSync.unknownFlags.join(', ')}`)
|
|
2238
|
+
}
|
|
2239
|
+
|
|
2240
|
+
const result = await syncTasks(projectName, { ...parsedSync.options, tracks: parsedSync.tracks }, processRoot)
|
|
2241
|
+
const dryRun = result.dryRun ? ' [dry-run]' : ''
|
|
2242
|
+
const truncated = result.truncated ? ' (truncated)' : ''
|
|
2243
|
+
return `${CLI_NAME}: synced ${result.totalTasks} tasks for ${result.projectName}${dryRun} from ${path.relative(processRoot, result.tracksPath)}${truncated} (created ${result.created}, updated ${result.updated}, skipped ${result.skipped})`
|
|
2244
|
+
}
|
|
2245
|
+
|
|
2246
|
+
if (maybeTarget === 'clear-issues') {
|
|
2247
|
+
const parsedClear = parseClearIssuesArgs(rest)
|
|
2248
|
+
if (parsedClear.unknownFlags.length > 0) {
|
|
2249
|
+
throw new Error(`Unknown flags: ${parsedClear.unknownFlags.join(', ')}`)
|
|
2250
|
+
}
|
|
2251
|
+
|
|
2252
|
+
const result = await clearIssues(projectName, parsedClear.options, processRoot)
|
|
2253
|
+
const dryRun = result.dryRun ? ' [dry-run]' : ''
|
|
2254
|
+
return `${CLI_NAME}: clear-issues for ${result.projectName}${dryRun} (${result.owner}/${result.repo}, state=${result.state}) total=${result.total}, deleted=${result.deleted}`
|
|
2255
|
+
}
|
|
2256
|
+
|
|
2257
|
+
const parsed = parseGenerateArgs(maybeTarget ? [maybeTarget, ...rest] : rest)
|
|
2258
|
+
if (parsed.command === 'list') {
|
|
2259
|
+
throw new Error('`--list` can only be used without a project name.')
|
|
2260
|
+
}
|
|
2261
|
+
|
|
2262
|
+
if (parsed.setActive) {
|
|
2263
|
+
if (parsed.requestedGenerateSpec) {
|
|
2264
|
+
throw new Error('`--set-active` cannot be combined with `--generate-spec`.')
|
|
2265
|
+
}
|
|
2266
|
+
|
|
2267
|
+
if (parsed.target === undefined) {
|
|
2268
|
+
throw new Error('Missing required arguments: <project-name> and <file-path>.')
|
|
2269
|
+
}
|
|
2270
|
+
if (parsed.unknownFlags.length > 0) {
|
|
2271
|
+
throw new Error(`Unknown flags: ${parsed.unknownFlags.join(', ')}`)
|
|
2272
|
+
}
|
|
2273
|
+
const optionFlags = Object.keys(parsed.options)
|
|
2274
|
+
if (optionFlags.length > 0) {
|
|
2275
|
+
throw new Error(`Unknown flags: --${optionFlags.join(', --')}`)
|
|
2276
|
+
}
|
|
2277
|
+
|
|
2278
|
+
const result = setActive(projectName, parsed.target, processRoot, { latest: parsed.latest })
|
|
2279
|
+
const matchedFileName = path.basename(result.sourceFile).toLowerCase()
|
|
2280
|
+
const isImplementationPlan = /^implementation[-_]?plan(?:\.[^.]+)*$/i.test(matchedFileName)
|
|
2281
|
+
if (!isImplementationPlan) {
|
|
2282
|
+
return `[${CLI_NAME}] set active: ${result.matchedFile} -> ${path.basename(result.activeFile)} (${result.mode})`
|
|
2283
|
+
}
|
|
2284
|
+
|
|
2285
|
+
const generated = await generateSpec(projectName, { input: result.sourceFile }, processRoot)
|
|
2286
|
+
const dryRun = generated.dryRun ? ' [dry-run]' : ''
|
|
2287
|
+
return `[${CLI_NAME}] set active: ${result.matchedFile} -> ${path.basename(result.activeFile)} (${result.mode})` +
|
|
2288
|
+
`\n${CLI_NAME}: generated ${generated.files} spec files for ${generated.projectName}${dryRun} from ${path.relative(processRoot, generated.inputPath)}`
|
|
2289
|
+
}
|
|
2290
|
+
|
|
2291
|
+
if (!parsed.requestedGenerateSpec) {
|
|
2292
|
+
throw new Error('`--generate-spec` is required.')
|
|
2293
|
+
}
|
|
2294
|
+
if (parsed.target !== undefined) {
|
|
2295
|
+
throw new Error(`Unknown argument: ${parsed.target}`)
|
|
2296
|
+
}
|
|
2297
|
+
|
|
2298
|
+
if (parsed.unknownFlags.length > 0) {
|
|
2299
|
+
throw new Error(`Unknown flags: ${parsed.unknownFlags.join(', ')}`)
|
|
2300
|
+
}
|
|
2301
|
+
|
|
2302
|
+
const result = await generateSpec(projectName, parsed.options, processRoot)
|
|
2303
|
+
const dryRun = result.dryRun ? ' [dry-run]' : ''
|
|
2304
|
+
|
|
2305
|
+
return `${CLI_NAME}: generated ${result.files} spec files for ${result.projectName}${dryRun} from ${path.relative(processRoot, result.inputPath)}`
|
|
2306
|
+
}
|
|
2307
|
+
|
|
2308
|
+
export function parseProjectTargetSpec(spec: string): ProjectVersionedFileSpec {
|
|
2309
|
+
if (!spec || typeof spec !== 'string') {
|
|
2310
|
+
throw new Error('file-path is required.')
|
|
2311
|
+
}
|
|
2312
|
+
|
|
2313
|
+
const normalized = spec.replace(/\\/g, '/').trim()
|
|
2314
|
+
if (!normalized.startsWith('/')) {
|
|
2315
|
+
throw new Error('file-path must start with a leading slash, e.g. /implementation-plan.v0.0.1')
|
|
2316
|
+
}
|
|
2317
|
+
|
|
2318
|
+
const body = normalized.slice(1).replace(/^docs\//, '')
|
|
2319
|
+
if (!body) {
|
|
2320
|
+
throw new Error('file-path is empty.')
|
|
2321
|
+
}
|
|
2322
|
+
|
|
2323
|
+
const parts = body.split('/').filter(Boolean)
|
|
2324
|
+
if (parts.length === 0) {
|
|
2325
|
+
throw new Error('file-path must include a file name, for example /implementation-plan.v0.0.1')
|
|
2326
|
+
}
|
|
2327
|
+
|
|
2328
|
+
const file = parts.pop()
|
|
2329
|
+
if (!file) {
|
|
2330
|
+
throw new Error('file-path must include a file name, for example /implementation-plan.v0.0.1')
|
|
2331
|
+
}
|
|
2332
|
+
|
|
2333
|
+
const withExt = file.match(new RegExp(`^(.+)\\.(${VERSION_RE})\\.([A-Za-z][A-Za-z0-9_-]*)$`))
|
|
2334
|
+
const withoutExt = file.match(new RegExp(`^(.+)\\.(${VERSION_RE})$`))
|
|
2335
|
+
if (withExt) {
|
|
2336
|
+
return {
|
|
2337
|
+
parts,
|
|
2338
|
+
base: withExt[1],
|
|
2339
|
+
version: withExt[2],
|
|
2340
|
+
ext: withExt[3],
|
|
2341
|
+
}
|
|
2342
|
+
}
|
|
2343
|
+
if (withoutExt) {
|
|
2344
|
+
return {
|
|
2345
|
+
parts,
|
|
2346
|
+
base: withoutExt[1],
|
|
2347
|
+
version: withoutExt[2],
|
|
2348
|
+
ext: null,
|
|
2349
|
+
}
|
|
2350
|
+
}
|
|
2351
|
+
|
|
2352
|
+
return {
|
|
2353
|
+
parts,
|
|
2354
|
+
base: file,
|
|
2355
|
+
version: null,
|
|
2356
|
+
ext: null,
|
|
2357
|
+
}
|
|
2358
|
+
}
|
|
2359
|
+
|
|
2360
|
+
function parseVersion(version: string): number[] {
|
|
2361
|
+
const clean = version.replace(/^v/, '')
|
|
2362
|
+
return clean.split('.').filter(Boolean).map((part) => Number.parseInt(part, 10))
|
|
2363
|
+
}
|
|
2364
|
+
|
|
2365
|
+
function compareVersions(left: string, right: string): number {
|
|
2366
|
+
const a = parseVersion(left)
|
|
2367
|
+
const b = parseVersion(right)
|
|
2368
|
+
const size = Math.max(a.length, b.length)
|
|
2369
|
+
|
|
2370
|
+
for (let i = 0; i < size; i += 1) {
|
|
2371
|
+
const leftValue = a[i] ?? 0
|
|
2372
|
+
const rightValue = b[i] ?? 0
|
|
2373
|
+
if (leftValue > rightValue) return 1
|
|
2374
|
+
if (leftValue < rightValue) return -1
|
|
2375
|
+
}
|
|
2376
|
+
|
|
2377
|
+
return 0
|
|
2378
|
+
}
|
|
2379
|
+
|
|
2380
|
+
function escapeRegExp(value: string): string {
|
|
2381
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
2382
|
+
}
|
|
2383
|
+
|
|
2384
|
+
function findLatestVersionedFile(targetDir: string, base: string, ext: string | null): string | null {
|
|
2385
|
+
const fileEntries = fsSync
|
|
2386
|
+
.readdirSync(targetDir, { withFileTypes: true })
|
|
2387
|
+
.filter((entry) => entry.isFile())
|
|
2388
|
+
.map((entry) => entry.name)
|
|
2389
|
+
|
|
2390
|
+
const safeBase = escapeRegExp(base)
|
|
2391
|
+
const regex = new RegExp(`^${safeBase}\\.(${VERSION_RE_CORE})\\.([^.]+)$`)
|
|
2392
|
+
const matches = fileEntries
|
|
2393
|
+
.map((entry) => {
|
|
2394
|
+
const match = entry.match(regex)
|
|
2395
|
+
if (!match) return null
|
|
2396
|
+
|
|
2397
|
+
const version = match[1]
|
|
2398
|
+
const detectedExt = `.${match[2]}`
|
|
2399
|
+
if (ext && detectedExt !== `.${ext}`) {
|
|
2400
|
+
return null
|
|
2401
|
+
}
|
|
2402
|
+
|
|
2403
|
+
return { entry, version, detectedExt }
|
|
2404
|
+
})
|
|
2405
|
+
.filter((value): value is { entry: string; version: string; detectedExt: string } => value !== null)
|
|
2406
|
+
|
|
2407
|
+
if (matches.length === 0) return null
|
|
2408
|
+
|
|
2409
|
+
matches.sort((a, b) => compareVersions(a.version, b.version))
|
|
2410
|
+
const latestVersion = matches[matches.length - 1].version
|
|
2411
|
+
const sameVersion = matches
|
|
2412
|
+
.filter((item) => compareVersions(item.version, latestVersion) === 0)
|
|
2413
|
+
.map((item) => item.entry)
|
|
2414
|
+
.sort()
|
|
2415
|
+
|
|
2416
|
+
if (sameVersion.length <= 1) {
|
|
2417
|
+
return sameVersion[0]
|
|
2418
|
+
}
|
|
2419
|
+
|
|
2420
|
+
for (const extName of ACTIVE_EXT_PRIORITY) {
|
|
2421
|
+
const preferred = sameVersion.find((entryName) => entryName.endsWith(extName))
|
|
2422
|
+
if (preferred) {
|
|
2423
|
+
return preferred
|
|
2424
|
+
}
|
|
2425
|
+
}
|
|
2426
|
+
|
|
2427
|
+
return sameVersion.sort()[0]
|
|
2428
|
+
}
|
|
2429
|
+
|
|
2430
|
+
export function resolveProjectTargetFile(
|
|
2431
|
+
targetDir: string,
|
|
2432
|
+
spec: ProjectVersionedFileSpec,
|
|
2433
|
+
options: { latest?: boolean } = {}
|
|
2434
|
+
): string | null {
|
|
2435
|
+
const entries = fsSync
|
|
2436
|
+
.readdirSync(targetDir, { withFileTypes: true })
|
|
2437
|
+
.filter((entry) => entry.isFile())
|
|
2438
|
+
.map((entry) => entry.name)
|
|
2439
|
+
|
|
2440
|
+
if (!spec.version) {
|
|
2441
|
+
if (!options.latest) {
|
|
2442
|
+
return null
|
|
2443
|
+
}
|
|
2444
|
+
return findLatestVersionedFile(targetDir, spec.base, null)
|
|
2445
|
+
}
|
|
2446
|
+
|
|
2447
|
+
if (spec.ext) {
|
|
2448
|
+
const exact = `${spec.base}.${spec.version}.${spec.ext}`
|
|
2449
|
+
if (entries.includes(exact)) {
|
|
2450
|
+
return exact
|
|
2451
|
+
}
|
|
2452
|
+
return null
|
|
2453
|
+
}
|
|
2454
|
+
|
|
2455
|
+
const prefix = `${spec.base}.${spec.version}.`
|
|
2456
|
+
const candidates = entries.filter((entry) => entry.startsWith(prefix))
|
|
2457
|
+
if (candidates.length === 0) {
|
|
2458
|
+
return null
|
|
2459
|
+
}
|
|
2460
|
+
|
|
2461
|
+
for (const ext of ACTIVE_EXT_PRIORITY) {
|
|
2462
|
+
const matched = candidates.find((entry) => entry.endsWith(ext))
|
|
2463
|
+
if (matched) {
|
|
2464
|
+
return matched
|
|
2465
|
+
}
|
|
2466
|
+
}
|
|
2467
|
+
|
|
2468
|
+
return candidates.sort()[0]
|
|
2469
|
+
}
|
|
2470
|
+
|
|
2471
|
+
export function setActive(
|
|
2472
|
+
projectName: string,
|
|
2473
|
+
target: string,
|
|
2474
|
+
processRoot: string = process.cwd(),
|
|
2475
|
+
options: { latest?: boolean } = {}
|
|
2476
|
+
): ProjectSetActiveResult {
|
|
2477
|
+
const projectRoot = resolveProjectRoot(projectName, processRoot)
|
|
2478
|
+
const docsRoot = path.join(projectRoot, 'docs')
|
|
2479
|
+
const spec = parseProjectTargetSpec(target)
|
|
2480
|
+
const targetDir = path.join(docsRoot, ...spec.parts)
|
|
2481
|
+
if (!existsDir(docsRoot)) {
|
|
2482
|
+
throw new Error(`Docs directory not found for project: ${projectRoot}`)
|
|
2483
|
+
}
|
|
2484
|
+
|
|
2485
|
+
if (!existsDir(targetDir)) {
|
|
2486
|
+
throw new Error(`Target folder not found: ${targetDir}`)
|
|
2487
|
+
}
|
|
2488
|
+
|
|
2489
|
+
const matchedFile = resolveProjectTargetFile(targetDir, spec, options)
|
|
2490
|
+
if (!matchedFile && !spec.version && options.latest) {
|
|
2491
|
+
throw new Error(`No files found for /${spec.parts.join('/')}/${spec.base}`)
|
|
2492
|
+
}
|
|
2493
|
+
if (!matchedFile) {
|
|
2494
|
+
if (!spec.version && !options.latest) {
|
|
2495
|
+
throw new Error(
|
|
2496
|
+
`Version missing for /${spec.parts.join('/')}/${spec.base}. Use --latest to select the latest version.`
|
|
2497
|
+
)
|
|
2498
|
+
}
|
|
2499
|
+
const extensionHint = spec.ext ? `.${spec.ext}` : ' with any extension'
|
|
2500
|
+
throw new Error(
|
|
2501
|
+
`Versioned file not found for /${spec.parts.join('/')}/${spec.base}.${spec.version}${extensionHint}`
|
|
2502
|
+
)
|
|
2503
|
+
}
|
|
2504
|
+
|
|
2505
|
+
const sourceFile = path.join(targetDir, matchedFile)
|
|
2506
|
+
const activeFile = path.join(targetDir, `${spec.base}.active${path.extname(matchedFile)}`)
|
|
2507
|
+
const mode = setActiveLink(sourceFile, activeFile)
|
|
2508
|
+
|
|
2509
|
+
return {
|
|
2510
|
+
projectName,
|
|
2511
|
+
sourceFile,
|
|
2512
|
+
activeFile,
|
|
2513
|
+
matchedFile,
|
|
2514
|
+
mode,
|
|
2515
|
+
}
|
|
2516
|
+
}
|
|
2517
|
+
|
|
2518
|
+
function parseMonolith(monolith: string): MonolithFile[] {
|
|
2519
|
+
const hasBeginMarker = /BEGIN(?:\s|_)FILE:/.test(monolith)
|
|
2520
|
+
const hasEndMarker = /END(?:\s|_)FILE:/.test(monolith)
|
|
2521
|
+
if (!hasBeginMarker || !hasEndMarker) {
|
|
2522
|
+
throw new Error(
|
|
2523
|
+
'Monolith does not appear to contain BEGIN/END markers (expected BEGIN FILE/END FILE or BEGIN_FILE/END_FILE).'
|
|
2524
|
+
)
|
|
2525
|
+
}
|
|
2526
|
+
|
|
2527
|
+
const re =
|
|
2528
|
+
/<!--\s*BEGIN(?:\s|_)FILE:\s*(.+?)\s*-->\s*\r?\n([\s\S]*?)\r?\n<!--\s*END(?:\s|_)FILE:\s*\1\s*-->/g
|
|
2529
|
+
|
|
2530
|
+
const entries: MonolithFile[] = []
|
|
2531
|
+
const seen = new Set<string>()
|
|
2532
|
+
|
|
2533
|
+
let match: RegExpExecArray | null
|
|
2534
|
+
while ((match = re.exec(monolith)) !== null) {
|
|
2535
|
+
const relPath = normalizeRelPath(match[1])
|
|
2536
|
+
|
|
2537
|
+
if (seen.has(relPath)) {
|
|
2538
|
+
throw new Error(`Duplicate file path in monolith: ${relPath}`)
|
|
2539
|
+
}
|
|
2540
|
+
|
|
2541
|
+
const raw = match[2]
|
|
2542
|
+
const content = raw.endsWith('\n') ? raw : `${raw}\n`
|
|
2543
|
+
const bytes = Buffer.byteLength(content, 'utf8')
|
|
2544
|
+
const sha256 = sha256Hex(content)
|
|
2545
|
+
|
|
2546
|
+
entries.push({
|
|
2547
|
+
relPath,
|
|
2548
|
+
content,
|
|
2549
|
+
sha256,
|
|
2550
|
+
bytes,
|
|
2551
|
+
meta: extractTopMetadataBlock(content),
|
|
2552
|
+
})
|
|
2553
|
+
|
|
2554
|
+
seen.add(relPath)
|
|
2555
|
+
}
|
|
2556
|
+
|
|
2557
|
+
if (entries.length === 0) {
|
|
2558
|
+
throw new Error('No files found. Expected matching BEGIN/END FILE markers in monolith.')
|
|
2559
|
+
}
|
|
2560
|
+
|
|
2561
|
+
return entries
|
|
2562
|
+
}
|
|
2563
|
+
|
|
2564
|
+
function normalizeRelPath(relPath: string): string {
|
|
2565
|
+
const p = relPath.trim()
|
|
2566
|
+
if (p.length === 0) {
|
|
2567
|
+
throw new Error('Empty path in BEGIN FILE marker')
|
|
2568
|
+
}
|
|
2569
|
+
if (path.isAbsolute(p)) {
|
|
2570
|
+
throw new Error(`Absolute paths are not allowed: ${p}`)
|
|
2571
|
+
}
|
|
2572
|
+
|
|
2573
|
+
const normalized = p.replace(/\\/g, '/')
|
|
2574
|
+
const parts = normalized.split('/')
|
|
2575
|
+
for (const part of parts) {
|
|
2576
|
+
if (part === '..') {
|
|
2577
|
+
throw new Error(`Path traversal is not allowed: ${p}`)
|
|
2578
|
+
}
|
|
2579
|
+
if (part === '') {
|
|
2580
|
+
throw new Error(`Empty path segment is not allowed: ${p}`)
|
|
2581
|
+
}
|
|
2582
|
+
}
|
|
2583
|
+
|
|
2584
|
+
return normalized
|
|
2585
|
+
}
|
|
2586
|
+
|
|
2587
|
+
function extractTopMetadataBlock(content: string): MonolithFile['meta'] {
|
|
2588
|
+
const match = content.match(/^\s*<!--\s*\n([\s\S]*?)\n-->\s*\n/m)
|
|
2589
|
+
if (!match) return undefined
|
|
2590
|
+
|
|
2591
|
+
const lines = match[1]
|
|
2592
|
+
const meta: NonNullable<MonolithFile['meta']> = {}
|
|
2593
|
+
|
|
2594
|
+
for (const line of lines.split(/\r?\n/)) {
|
|
2595
|
+
const parsed = line.match(/^\s*([a-zA-Z0-9_]+)\s*:\s*(.*?)\s*$/)
|
|
2596
|
+
if (!parsed) continue
|
|
2597
|
+
|
|
2598
|
+
const key = parsed[1]
|
|
2599
|
+
const value = parsed[2]
|
|
2600
|
+
if (key === 'spec_id') meta.spec_id = value
|
|
2601
|
+
if (key === 'title') meta.title = value
|
|
2602
|
+
if (key === 'source') meta.source = value
|
|
2603
|
+
if (key === 'source_version') meta.source_version = value
|
|
2604
|
+
if (key === 'source_anchor') meta.source_anchor = value
|
|
2605
|
+
}
|
|
2606
|
+
|
|
2607
|
+
return Object.keys(meta).length === 0 ? undefined : meta
|
|
2608
|
+
}
|
|
2609
|
+
|
|
2610
|
+
async function readTextIfExists(filePath: string): Promise<string | null> {
|
|
2611
|
+
try {
|
|
2612
|
+
return await fs.readFile(filePath, 'utf8')
|
|
2613
|
+
} catch (error: any) {
|
|
2614
|
+
if (error?.code === 'ENOENT') {
|
|
2615
|
+
return null
|
|
2616
|
+
}
|
|
2617
|
+
throw error
|
|
2618
|
+
}
|
|
2619
|
+
}
|
|
2620
|
+
|
|
2621
|
+
async function writeIfChanged(filePath: string, content: string): Promise<'created' | 'updated' | 'unchanged'> {
|
|
2622
|
+
const existing = await readTextIfExists(filePath)
|
|
2623
|
+
if (existing === null) {
|
|
2624
|
+
await fs.writeFile(filePath, content, 'utf8')
|
|
2625
|
+
return 'created'
|
|
2626
|
+
}
|
|
2627
|
+
|
|
2628
|
+
if (existing === content) return 'unchanged'
|
|
2629
|
+
await fs.writeFile(filePath, content, 'utf8')
|
|
2630
|
+
return 'updated'
|
|
2631
|
+
}
|
|
2632
|
+
|
|
2633
|
+
async function loadPreviousManifest(manifestAbs: string): Promise<{ files: Array<{ path: string }> } | null> {
|
|
2634
|
+
const manifestRaw = await readTextIfExists(manifestAbs)
|
|
2635
|
+
if (!manifestRaw) return null
|
|
2636
|
+
|
|
2637
|
+
try {
|
|
2638
|
+
const parsed = JSON.parse(manifestRaw)
|
|
2639
|
+
if (!parsed || typeof parsed !== 'object') return null
|
|
2640
|
+
if (!Array.isArray((parsed as any).files)) return null
|
|
2641
|
+
return parsed as { files: Array<{ path: string }> }
|
|
2642
|
+
} catch {
|
|
2643
|
+
return null
|
|
2644
|
+
}
|
|
2645
|
+
}
|
|
2646
|
+
|
|
2647
|
+
function sha256Hex(value: string): string {
|
|
2648
|
+
return crypto.createHash('sha256').update(value, 'utf8').digest('hex')
|
|
2649
|
+
}
|
|
2650
|
+
|
|
2651
|
+
function assertWithinOutRoot(outRoot: string, candidate: string): void {
|
|
2652
|
+
const rel = path.relative(path.resolve(outRoot), path.resolve(candidate))
|
|
2653
|
+
if (rel === '' || (!rel.startsWith(`..${path.sep}`) && rel !== '..')) {
|
|
2654
|
+
return
|
|
2655
|
+
}
|
|
2656
|
+
throw new Error(`Refusing to write outside project root. outRoot=${outRoot} candidate=${candidate}`)
|
|
2657
|
+
}
|
|
2658
|
+
|
|
2659
|
+
function isSubPath(parent: string, child: string): boolean {
|
|
2660
|
+
const resolvedParent = path.resolve(parent)
|
|
2661
|
+
const resolvedChild = path.resolve(child)
|
|
2662
|
+
|
|
2663
|
+
const parentRoot = path.parse(resolvedParent).root.toLowerCase()
|
|
2664
|
+
const childRoot = path.parse(resolvedChild).root.toLowerCase()
|
|
2665
|
+
if (parentRoot !== childRoot) {
|
|
2666
|
+
return false
|
|
2667
|
+
}
|
|
2668
|
+
|
|
2669
|
+
const relative = path.relative(path.resolve(parent), path.resolve(child))
|
|
2670
|
+
if (relative === '' || relative === '.') {
|
|
2671
|
+
return true
|
|
2672
|
+
}
|
|
2673
|
+
return !relative.startsWith(`..${path.sep}`) && relative !== '..'
|
|
2674
|
+
}
|
|
2675
|
+
|
|
2676
|
+
function existsDir(input: string): boolean {
|
|
2677
|
+
try {
|
|
2678
|
+
return fsSync.statSync(input).isDirectory()
|
|
2679
|
+
} catch {
|
|
2680
|
+
return false
|
|
2681
|
+
}
|
|
2682
|
+
}
|
|
2683
|
+
|
|
2684
|
+
function existsFile(input: string): boolean {
|
|
2685
|
+
try {
|
|
2686
|
+
return fsSync.statSync(input).isFile()
|
|
2687
|
+
} catch {
|
|
2688
|
+
return false
|
|
2689
|
+
}
|
|
2690
|
+
}
|