@foundation0/api 1.1.1 → 1.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/agents.ts +7 -6
- package/git.ts +2 -2
- package/mcp/AGENTS.md +130 -0
- package/mcp/cli.mjs +1 -1
- package/mcp/cli.ts +3 -3
- package/mcp/client.test.ts +13 -0
- package/mcp/client.ts +12 -4
- package/mcp/server.test.ts +464 -117
- package/mcp/server.ts +2497 -484
- package/package.json +2 -2
- package/projects.ts +1791 -99
package/projects.ts
CHANGED
|
@@ -94,6 +94,9 @@ type ProjectSyncTaskOptions = {
|
|
|
94
94
|
maxBodyLength?: number
|
|
95
95
|
requestTimeoutMs?: number
|
|
96
96
|
throttleMs?: number
|
|
97
|
+
retryMax?: number
|
|
98
|
+
retryBaseDelayMs?: number
|
|
99
|
+
retryMaxDelayMs?: number
|
|
97
100
|
skipDependencies?: boolean
|
|
98
101
|
verbose?: boolean
|
|
99
102
|
}
|
|
@@ -119,6 +122,14 @@ type ProjectWriteGitTaskOptions = {
|
|
|
119
122
|
taskSignature?: string
|
|
120
123
|
}
|
|
121
124
|
|
|
125
|
+
type ProjectCreateGitIssueOptions = {
|
|
126
|
+
owner?: string
|
|
127
|
+
repo?: string
|
|
128
|
+
title: string
|
|
129
|
+
body?: string
|
|
130
|
+
labels?: string[]
|
|
131
|
+
}
|
|
132
|
+
|
|
122
133
|
export type ProjectGitTaskRecord = {
|
|
123
134
|
number: number
|
|
124
135
|
title: string
|
|
@@ -160,7 +171,90 @@ export type ProjectWriteGitTaskResult = {
|
|
|
160
171
|
issue: ProjectGitTaskRecord
|
|
161
172
|
}
|
|
162
173
|
|
|
163
|
-
|
|
174
|
+
export type ProjectGitIssueRecord = {
|
|
175
|
+
number: number
|
|
176
|
+
title: string
|
|
177
|
+
body: string
|
|
178
|
+
state: string
|
|
179
|
+
labels: string[]
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export type ProjectCreateGitIssueResult = {
|
|
183
|
+
projectName: string
|
|
184
|
+
owner: string
|
|
185
|
+
repo: string
|
|
186
|
+
issueNumber: number
|
|
187
|
+
issue: ProjectGitIssueRecord
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
type ProjectSearchTargetSection = 'docs' | 'spec'
|
|
191
|
+
|
|
192
|
+
type ProjectSearchOptions = {
|
|
193
|
+
projectName?: string
|
|
194
|
+
owner?: string
|
|
195
|
+
repo?: string
|
|
196
|
+
ref?: string
|
|
197
|
+
branch?: string
|
|
198
|
+
sha?: string
|
|
199
|
+
source?: 'local' | 'gitea' | 'auto'
|
|
200
|
+
remote?: boolean
|
|
201
|
+
processRoot?: string
|
|
202
|
+
cacheDir?: string
|
|
203
|
+
refresh?: boolean
|
|
204
|
+
args?: unknown[]
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
type RgLikeSearchQuery = {
|
|
208
|
+
patterns: string[]
|
|
209
|
+
paths: string[]
|
|
210
|
+
lineNumber: boolean
|
|
211
|
+
withFilename: boolean
|
|
212
|
+
noFilename: boolean
|
|
213
|
+
ignoreCase: boolean
|
|
214
|
+
caseSensitive: boolean
|
|
215
|
+
smartCase: boolean
|
|
216
|
+
fixedStrings: boolean
|
|
217
|
+
wordRegexp: boolean
|
|
218
|
+
maxCount: number | null
|
|
219
|
+
countOnly: boolean
|
|
220
|
+
filesWithMatches: boolean
|
|
221
|
+
filesWithoutMatch: boolean
|
|
222
|
+
onlyMatching: boolean
|
|
223
|
+
includeHidden: boolean
|
|
224
|
+
globs: string[]
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
type RgLikeCompiledPattern = {
|
|
228
|
+
test: RegExp
|
|
229
|
+
global: RegExp
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
type RgLikeSearchExecutionResult = {
|
|
233
|
+
filesSearched: number
|
|
234
|
+
filesMatched: number
|
|
235
|
+
matchedLines: number
|
|
236
|
+
matchedSegments: number
|
|
237
|
+
outputLines: string[]
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export type ProjectSearchResult = {
|
|
241
|
+
projectName: string
|
|
242
|
+
owner: string | null
|
|
243
|
+
repo: string | null
|
|
244
|
+
section: ProjectSearchTargetSection
|
|
245
|
+
ref: string | null
|
|
246
|
+
source: 'gitea' | 'local'
|
|
247
|
+
cacheDir: string
|
|
248
|
+
cacheRefreshed: boolean
|
|
249
|
+
args: string[]
|
|
250
|
+
filesSearched: number
|
|
251
|
+
filesMatched: number
|
|
252
|
+
matchedLines: number
|
|
253
|
+
matchedSegments: number
|
|
254
|
+
output: string
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const CLI_NAME = 'f0'
|
|
164
258
|
const VERSION_RE = 'v?\\d+(?:\\.\\d+){2,}'
|
|
165
259
|
const VERSION_RE_CORE = `(?:${VERSION_RE})`
|
|
166
260
|
const ACTIVE_EXT_PRIORITY = ['.md', '.json', '.yaml', '.yml', '.ts', '.js', '.txt']
|
|
@@ -185,6 +279,8 @@ const DEFAULT_TRACKS_CANDIDATES = [
|
|
|
185
279
|
path.join('spec', '07_roadmap', 'tracks.md'),
|
|
186
280
|
] as const
|
|
187
281
|
const PROJECTS_DIRECTORY = 'projects'
|
|
282
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
283
|
+
typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
188
284
|
|
|
189
285
|
type ProjectDocsCandidateKind = 'active' | 'plain' | 'versioned'
|
|
190
286
|
|
|
@@ -199,10 +295,10 @@ function resolveProjectsRoot(processRoot: string): string {
|
|
|
199
295
|
}
|
|
200
296
|
|
|
201
297
|
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
|
|
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` +
|
|
298
|
+
return `Usage:\n ${CLI_NAME} projects <project-name> --generate-spec\n ${CLI_NAME} projects <project-name> <file-path> --set-active\n ${CLI_NAME} projects <project-name> --sync-tasks (alias: --sync-issues)\n ${CLI_NAME} projects <project-name> --clear-issues\n ${CLI_NAME} projects --list\n\n` +
|
|
299
|
+
`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 --retry 5 --retry-base-delay-ms 500\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
300
|
`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` +
|
|
301
|
+
`Sync-tasks flags: --verbose --max --max-body --request-timeout-ms --throttle-ms --retry --retry-base-delay-ms --retry-max-delay-ms --no-retry --skip-dependencies --owner --repo --label --prefix --tracks.\n` +
|
|
206
302
|
`Use --verbose with clear-issues to print each deletion.\n` +
|
|
207
303
|
`Use --latest to resolve /file-name to the latest version.\n` +
|
|
208
304
|
`The active file created is [file].active.<ext>.\n`
|
|
@@ -243,32 +339,1152 @@ export function listProjects(processRoot: string = process.cwd()): string[] {
|
|
|
243
339
|
.sort((a, b) => a.localeCompare(b))
|
|
244
340
|
}
|
|
245
341
|
|
|
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))
|
|
342
|
+
export function listProjectDocs(projectName: string, processRoot: string = process.cwd()): string[] {
|
|
343
|
+
const projectRoot = resolveProjectRoot(projectName, processRoot)
|
|
344
|
+
const candidates = buildProjectDocsCatalog(projectRoot)
|
|
345
|
+
return Array.from(candidates.keys()).sort((a, b) => a.localeCompare(b))
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
export async function readProjectDoc(
|
|
349
|
+
projectName: string,
|
|
350
|
+
requestPath: string,
|
|
351
|
+
processRoot: string = process.cwd()
|
|
352
|
+
): Promise<string> {
|
|
353
|
+
const projectRoot = resolveProjectRoot(projectName, processRoot)
|
|
354
|
+
const catalog = buildProjectDocsCatalog(projectRoot)
|
|
355
|
+
const normalized = normalizeProjectDocRequest(requestPath)
|
|
356
|
+
const sourcePath = resolveProjectDocPath(catalog, normalized)
|
|
357
|
+
|
|
358
|
+
if (!sourcePath) {
|
|
359
|
+
throw new Error(`Doc file not found: ${requestPath}`)
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
const content = await readTextIfExists(sourcePath)
|
|
363
|
+
if (content === null) {
|
|
364
|
+
throw new Error(`Doc file not found: ${requestPath}`)
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
return content
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
export async function searchDocs(projectName: string, ...rawArgs: unknown[]): Promise<ProjectSearchResult> {
|
|
371
|
+
return searchProjectSection(projectName, 'docs', rawArgs)
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
export async function searchSpecs(projectName: string, ...rawArgs: unknown[]): Promise<ProjectSearchResult> {
|
|
375
|
+
return searchProjectSection(projectName, 'spec', rawArgs)
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
async function searchProjectSection(
|
|
379
|
+
projectNameInput: unknown,
|
|
380
|
+
section: ProjectSearchTargetSection,
|
|
381
|
+
rawArgs: unknown[]
|
|
382
|
+
): Promise<ProjectSearchResult> {
|
|
383
|
+
const seedOptions = isRecord(projectNameInput) ? projectNameInput : {}
|
|
384
|
+
const { rgArgs, options } = parseProjectSearchInvocationWithSeed(rawArgs, seedOptions)
|
|
385
|
+
const processRoot = parseStringOption(options.processRoot) ?? process.cwd()
|
|
386
|
+
const positionalProjectName = typeof projectNameInput === 'string' ? projectNameInput.trim() : ''
|
|
387
|
+
const optionProjectName = parseStringOption(options.projectName) ?? ''
|
|
388
|
+
const sourcePreference = parseSearchSourceOption(options.source, options.remote)
|
|
389
|
+
|
|
390
|
+
if (!positionalProjectName && !optionProjectName) {
|
|
391
|
+
throw new Error('Missing project name. Pass it as first arg or options.projectName.')
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
let projectName = positionalProjectName || optionProjectName
|
|
395
|
+
let projectRoot: string | null = null
|
|
396
|
+
|
|
397
|
+
if (positionalProjectName && optionProjectName && positionalProjectName !== optionProjectName) {
|
|
398
|
+
const resolvedPositional = tryResolveProjectRoot(positionalProjectName, processRoot)
|
|
399
|
+
const resolvedOption = tryResolveProjectRoot(optionProjectName, processRoot)
|
|
400
|
+
|
|
401
|
+
if (!resolvedPositional && resolvedOption) {
|
|
402
|
+
projectName = optionProjectName
|
|
403
|
+
projectRoot = resolvedOption
|
|
404
|
+
} else if (resolvedPositional && !resolvedOption) {
|
|
405
|
+
projectName = positionalProjectName
|
|
406
|
+
projectRoot = resolvedPositional
|
|
407
|
+
} else if (!resolvedPositional && !resolvedOption) {
|
|
408
|
+
throw new Error(
|
|
409
|
+
`Could not resolve project-name from positional "${positionalProjectName}" or options.projectName "${optionProjectName}".`,
|
|
410
|
+
)
|
|
411
|
+
} else {
|
|
412
|
+
throw new Error(
|
|
413
|
+
`Conflicting project names: positional "${positionalProjectName}" and options.projectName "${optionProjectName}". Use one.`,
|
|
414
|
+
)
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
if (!projectRoot) {
|
|
419
|
+
projectRoot = resolveProjectRoot(projectName, processRoot)
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
const ownerFromOptions = parseStringOption(options.owner)
|
|
423
|
+
const repoFromOptions = parseStringOption(options.repo)
|
|
424
|
+
const resolvedIdentity = (!ownerFromOptions || !repoFromOptions)
|
|
425
|
+
? tryResolveGitIdentityFromProject(projectRoot)
|
|
426
|
+
: null
|
|
427
|
+
const owner = ownerFromOptions ?? resolvedIdentity?.owner ?? null
|
|
428
|
+
const repo = repoFromOptions ?? resolvedIdentity?.repo ?? null
|
|
429
|
+
const ref = parseStringOption(options.ref) ?? parseStringOption(options.branch) ?? parseStringOption(options.sha) ?? null
|
|
430
|
+
|
|
431
|
+
const cacheRoot = resolveProjectSearchCacheRoot({
|
|
432
|
+
processRoot,
|
|
433
|
+
projectName,
|
|
434
|
+
owner: owner ?? 'unknown-owner',
|
|
435
|
+
repo: repo ?? 'unknown-repo',
|
|
436
|
+
section,
|
|
437
|
+
ref,
|
|
438
|
+
cacheDir: parseStringOption(options.cacheDir),
|
|
439
|
+
})
|
|
440
|
+
|
|
441
|
+
const cacheSync = await syncProjectSearchCache({
|
|
442
|
+
owner,
|
|
443
|
+
repo,
|
|
444
|
+
projectRoot,
|
|
445
|
+
section,
|
|
446
|
+
ref,
|
|
447
|
+
cacheRoot,
|
|
448
|
+
sourcePreference,
|
|
449
|
+
refresh: parseBooleanOption(options.refresh),
|
|
450
|
+
})
|
|
451
|
+
|
|
452
|
+
const query = parseRgLikeQueryArgs(rgArgs)
|
|
453
|
+
const searchResult = await executeRgLikeSearch(cacheRoot, query)
|
|
454
|
+
|
|
455
|
+
return {
|
|
456
|
+
projectName,
|
|
457
|
+
owner,
|
|
458
|
+
repo,
|
|
459
|
+
section,
|
|
460
|
+
ref,
|
|
461
|
+
source: cacheSync.source,
|
|
462
|
+
cacheDir: cacheRoot,
|
|
463
|
+
cacheRefreshed: cacheSync.refreshed,
|
|
464
|
+
args: rgArgs,
|
|
465
|
+
filesSearched: searchResult.filesSearched,
|
|
466
|
+
filesMatched: searchResult.filesMatched,
|
|
467
|
+
matchedLines: searchResult.matchedLines,
|
|
468
|
+
matchedSegments: searchResult.matchedSegments,
|
|
469
|
+
output: searchResult.outputLines.join('\n'),
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
function parseProjectSearchInvocation(rawArgs: unknown[]): { rgArgs: string[]; options: ProjectSearchOptions } {
|
|
474
|
+
return parseProjectSearchInvocationWithSeed(rawArgs, {})
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function parseProjectSearchInvocationWithSeed(
|
|
478
|
+
rawArgs: unknown[],
|
|
479
|
+
seedOptions: Record<string, unknown>,
|
|
480
|
+
): { rgArgs: string[]; options: ProjectSearchOptions } {
|
|
481
|
+
const options: Record<string, unknown> = { ...seedOptions }
|
|
482
|
+
let rgArgs: string[] = []
|
|
483
|
+
|
|
484
|
+
if (rawArgs.length > 0) {
|
|
485
|
+
const maybeOptions = rawArgs[rawArgs.length - 1]
|
|
486
|
+
if (isRecord(maybeOptions)) {
|
|
487
|
+
rgArgs = rawArgs.slice(0, -1).map((value) => String(value))
|
|
488
|
+
for (const [key, value] of Object.entries(maybeOptions)) {
|
|
489
|
+
options[key] = value
|
|
490
|
+
}
|
|
491
|
+
} else {
|
|
492
|
+
rgArgs = rawArgs.map((value) => String(value))
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
if (rgArgs.length === 0 && Array.isArray(options.args)) {
|
|
497
|
+
rgArgs = options.args.map((value) => String(value))
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
return {
|
|
501
|
+
rgArgs,
|
|
502
|
+
options: options as ProjectSearchOptions,
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
function parseStringOption(value: unknown): string | null {
|
|
507
|
+
if (typeof value !== 'string') {
|
|
508
|
+
return null
|
|
509
|
+
}
|
|
510
|
+
const trimmed = value.trim()
|
|
511
|
+
return trimmed.length > 0 ? trimmed : null
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
function parseBooleanOption(value: unknown): boolean {
|
|
515
|
+
return value === true || value === 1 || value === '1' || value === 'true' || value === 'yes' || value === 'on'
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
function tryResolveProjectRoot(projectName: string, processRoot: string): string | null {
|
|
519
|
+
try {
|
|
520
|
+
return resolveProjectRoot(projectName, processRoot)
|
|
521
|
+
} catch {
|
|
522
|
+
return null
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
function tryResolveGitIdentityFromProject(projectRoot: string): { owner: string; repo: string } | null {
|
|
527
|
+
try {
|
|
528
|
+
return resolveGitIdentityFromProject(projectRoot)
|
|
529
|
+
} catch {
|
|
530
|
+
return null
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
function resolveProjectSearchCacheRoot(input: {
|
|
535
|
+
processRoot: string
|
|
536
|
+
projectName: string
|
|
537
|
+
owner: string
|
|
538
|
+
repo: string
|
|
539
|
+
section: ProjectSearchTargetSection
|
|
540
|
+
ref: string | null
|
|
541
|
+
cacheDir: string | null
|
|
542
|
+
}): string {
|
|
543
|
+
if (input.cacheDir) {
|
|
544
|
+
return path.resolve(input.cacheDir)
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
const refToken = input.ref ?? 'HEAD'
|
|
548
|
+
const refHash = crypto.createHash('sha256').update(refToken).digest('hex').slice(0, 12)
|
|
549
|
+
const ownerToken = input.owner.replace(/[^a-zA-Z0-9_.-]+/g, '_')
|
|
550
|
+
const repoToken = input.repo.replace(/[^a-zA-Z0-9_.-]+/g, '_')
|
|
551
|
+
const projectToken = input.projectName.replace(/[^a-zA-Z0-9_.-]+/g, '_')
|
|
552
|
+
|
|
553
|
+
return path.join(
|
|
554
|
+
input.processRoot,
|
|
555
|
+
'.cache',
|
|
556
|
+
'project-search',
|
|
557
|
+
projectToken,
|
|
558
|
+
ownerToken,
|
|
559
|
+
repoToken,
|
|
560
|
+
input.section,
|
|
561
|
+
refHash,
|
|
562
|
+
)
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
async function syncProjectSearchCache(input: {
|
|
566
|
+
owner: string | null
|
|
567
|
+
repo: string | null
|
|
568
|
+
projectRoot: string
|
|
569
|
+
section: ProjectSearchTargetSection
|
|
570
|
+
ref: string | null
|
|
571
|
+
cacheRoot: string
|
|
572
|
+
sourcePreference: 'local' | 'gitea' | 'auto'
|
|
573
|
+
refresh: boolean
|
|
574
|
+
}): Promise<{ refreshed: boolean; source: 'gitea' | 'local' }> {
|
|
575
|
+
const manifestPath = path.join(input.cacheRoot, '.search-cache.json')
|
|
576
|
+
const localSectionRoot = path.join(input.projectRoot, input.section)
|
|
577
|
+
const localAvailable = existsDir(localSectionRoot)
|
|
578
|
+
|
|
579
|
+
if (!input.refresh && existsFile(manifestPath)) {
|
|
580
|
+
const existingSource = (await readCacheSource(manifestPath)) ?? 'local'
|
|
581
|
+
|
|
582
|
+
// Prefer local if available unless the caller explicitly forces remote.
|
|
583
|
+
if (existingSource === 'gitea' && input.sourcePreference !== 'gitea' && localAvailable) {
|
|
584
|
+
// fall through and refresh to local
|
|
585
|
+
} else if (existingSource === 'local' && input.sourcePreference === 'gitea') {
|
|
586
|
+
// fall through and refresh to remote
|
|
587
|
+
} else {
|
|
588
|
+
return { refreshed: false, source: existingSource }
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
await fs.rm(input.cacheRoot, { recursive: true, force: true })
|
|
593
|
+
await fs.mkdir(input.cacheRoot, { recursive: true })
|
|
594
|
+
|
|
595
|
+
const canUseRemote = Boolean(input.owner && input.repo) && canResolveGiteaHost()
|
|
596
|
+
const shouldUseRemote = (() => {
|
|
597
|
+
if (input.sourcePreference === 'gitea') return true
|
|
598
|
+
if (input.sourcePreference === 'local') return false
|
|
599
|
+
// auto: local-first, remote only if local isn't available
|
|
600
|
+
return !localAvailable
|
|
601
|
+
})()
|
|
602
|
+
|
|
603
|
+
if (shouldUseRemote) {
|
|
604
|
+
if (!canUseRemote) {
|
|
605
|
+
const missing = [
|
|
606
|
+
!input.owner ? 'owner' : null,
|
|
607
|
+
!input.repo ? 'repo' : null,
|
|
608
|
+
!canResolveGiteaHost() ? 'GITEA_HOST' : null,
|
|
609
|
+
].filter((value): value is string => Boolean(value))
|
|
610
|
+
throw new Error(`Remote search requested but missing: ${missing.join(', ')}`)
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
const git = createGitServiceApi({
|
|
614
|
+
config: {
|
|
615
|
+
platform: 'GITEA',
|
|
616
|
+
giteaHost: resolveGiteaHost(),
|
|
617
|
+
giteaToken: process.env.GITEA_TOKEN,
|
|
618
|
+
},
|
|
619
|
+
defaultOwner: input.owner ?? undefined,
|
|
620
|
+
defaultRepo: input.repo ?? undefined,
|
|
621
|
+
})
|
|
622
|
+
|
|
623
|
+
const listContents = resolveRepoContentsApiMethod(git, 'list')
|
|
624
|
+
const viewContents = resolveRepoContentsApiMethod(git, 'view')
|
|
625
|
+
|
|
626
|
+
const remoteFiles = await collectRepoSectionFiles(listContents, input.owner as string, input.repo as string, input.section, input.ref)
|
|
627
|
+
for (const remotePath of remoteFiles) {
|
|
628
|
+
const relative = stripSectionPrefix(remotePath, input.section)
|
|
629
|
+
if (!relative) {
|
|
630
|
+
continue
|
|
631
|
+
}
|
|
632
|
+
const content = await loadRepoFileContent(viewContents, input.owner as string, input.repo as string, remotePath, input.ref)
|
|
633
|
+
const outPath = path.join(input.cacheRoot, relative)
|
|
634
|
+
await fs.mkdir(path.dirname(outPath), { recursive: true })
|
|
635
|
+
await fs.writeFile(outPath, content, 'utf8')
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
await fs.writeFile(
|
|
639
|
+
manifestPath,
|
|
640
|
+
JSON.stringify(
|
|
641
|
+
{
|
|
642
|
+
source: 'gitea',
|
|
643
|
+
owner: input.owner,
|
|
644
|
+
repo: input.repo,
|
|
645
|
+
section: input.section,
|
|
646
|
+
ref: input.ref,
|
|
647
|
+
fileCount: remoteFiles.length,
|
|
648
|
+
updatedAt: new Date().toISOString(),
|
|
649
|
+
},
|
|
650
|
+
null,
|
|
651
|
+
2,
|
|
652
|
+
) + '\n',
|
|
653
|
+
'utf8',
|
|
654
|
+
)
|
|
655
|
+
|
|
656
|
+
return { refreshed: true, source: 'gitea' }
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
if (!localAvailable) {
|
|
660
|
+
throw new Error(`Section directory not found in project: ${localSectionRoot}`)
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
const copied = await copyProjectSectionToCache(localSectionRoot, input.cacheRoot)
|
|
664
|
+
|
|
665
|
+
await fs.writeFile(
|
|
666
|
+
manifestPath,
|
|
667
|
+
JSON.stringify(
|
|
668
|
+
{
|
|
669
|
+
source: 'local',
|
|
670
|
+
owner: input.owner,
|
|
671
|
+
repo: input.repo,
|
|
672
|
+
section: input.section,
|
|
673
|
+
ref: input.ref,
|
|
674
|
+
fileCount: copied,
|
|
675
|
+
updatedAt: new Date().toISOString(),
|
|
676
|
+
},
|
|
677
|
+
null,
|
|
678
|
+
2,
|
|
679
|
+
) + '\n',
|
|
680
|
+
'utf8',
|
|
681
|
+
)
|
|
682
|
+
|
|
683
|
+
return { refreshed: true, source: 'local' }
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
function parseSearchSourceOption(value: unknown, remoteFlag: unknown): 'local' | 'gitea' | 'auto' {
|
|
687
|
+
if (parseBooleanOption(remoteFlag)) {
|
|
688
|
+
return 'gitea'
|
|
689
|
+
}
|
|
690
|
+
const normalized = parseStringOption(value)?.toLowerCase()
|
|
691
|
+
if (!normalized) return 'auto'
|
|
692
|
+
if (normalized === 'local') return 'local'
|
|
693
|
+
if (normalized === 'gitea' || normalized === 'remote') return 'gitea'
|
|
694
|
+
if (normalized === 'auto') return 'auto'
|
|
695
|
+
throw new Error(`Invalid search source: ${normalized}. Expected "local", "gitea", or "auto".`)
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
function canResolveGiteaHost(): boolean {
|
|
699
|
+
return Boolean(process.env.GITEA_HOST ?? process.env.EXAMPLE_GITEA_HOST)
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
async function readCacheSource(manifestPath: string): Promise<'gitea' | 'local' | null> {
|
|
703
|
+
try {
|
|
704
|
+
const raw = await fs.readFile(manifestPath, 'utf8')
|
|
705
|
+
const parsed = JSON.parse(raw)
|
|
706
|
+
const source = (parsed as any)?.source
|
|
707
|
+
if (source === 'gitea' || source === 'local') {
|
|
708
|
+
return source
|
|
709
|
+
}
|
|
710
|
+
return null
|
|
711
|
+
} catch {
|
|
712
|
+
return null
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
async function copyProjectSectionToCache(sectionRoot: string, cacheRoot: string): Promise<number> {
|
|
717
|
+
let copied = 0
|
|
718
|
+
const queue: Array<{ from: string; rel: string }> = [{ from: sectionRoot, rel: '' }]
|
|
719
|
+
|
|
720
|
+
while (queue.length > 0) {
|
|
721
|
+
const current = queue.shift()
|
|
722
|
+
if (!current) {
|
|
723
|
+
continue
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
const entries = await fs.readdir(current.from, { withFileTypes: true })
|
|
727
|
+
for (const entry of entries) {
|
|
728
|
+
if (entry.name.toLowerCase() === PROJECT_ARCHIVE_DIR_NAME) {
|
|
729
|
+
continue
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
const fromPath = path.join(current.from, entry.name)
|
|
733
|
+
const relPath = current.rel ? `${current.rel}/${entry.name}` : entry.name
|
|
734
|
+
const outPath = path.join(cacheRoot, relPath)
|
|
735
|
+
|
|
736
|
+
if (entry.isDirectory()) {
|
|
737
|
+
queue.push({ from: fromPath, rel: relPath })
|
|
738
|
+
continue
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
if (!entry.isFile() && !entry.isSymbolicLink()) {
|
|
742
|
+
continue
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
const content = await readTextIfExists(fromPath)
|
|
746
|
+
if (content === null) {
|
|
747
|
+
continue
|
|
748
|
+
}
|
|
749
|
+
await fs.mkdir(path.dirname(outPath), { recursive: true })
|
|
750
|
+
await fs.writeFile(outPath, content, 'utf8')
|
|
751
|
+
copied += 1
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
return copied
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
function resolveRepoContentsApiMethod(api: GitServiceApi, action: 'list' | 'view'): GitServiceApiMethod {
|
|
759
|
+
const repoNamespace = api.repo
|
|
760
|
+
if (!repoNamespace || typeof repoNamespace !== 'object') {
|
|
761
|
+
throw new Error('Git service API does not expose repo namespace')
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
const contentsNamespace = (repoNamespace as Record<string, unknown>).contents
|
|
765
|
+
if (!contentsNamespace || typeof contentsNamespace !== 'object') {
|
|
766
|
+
throw new Error('Git service API does not expose repo.contents namespace')
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
const method = (contentsNamespace as Record<string, unknown>)[action]
|
|
770
|
+
if (typeof method !== 'function') {
|
|
771
|
+
throw new Error(`Git service API does not expose repo.contents.${action}`)
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
return method as GitServiceApiMethod
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
function buildRepoContentsQueryOptions(ref: string | null): Record<string, unknown> | null {
|
|
778
|
+
if (!ref) {
|
|
779
|
+
return null
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
return {
|
|
783
|
+
query: {
|
|
784
|
+
ref,
|
|
785
|
+
},
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
async function collectRepoSectionFiles(
|
|
790
|
+
listContents: GitServiceApiMethod,
|
|
791
|
+
owner: string,
|
|
792
|
+
repo: string,
|
|
793
|
+
section: ProjectSearchTargetSection,
|
|
794
|
+
ref: string | null,
|
|
795
|
+
): Promise<string[]> {
|
|
796
|
+
const discovered = new Set<string>()
|
|
797
|
+
const queue: string[] = [section]
|
|
798
|
+
const queryOptions = buildRepoContentsQueryOptions(ref)
|
|
799
|
+
|
|
800
|
+
while (queue.length > 0) {
|
|
801
|
+
const current = queue.shift()
|
|
802
|
+
if (!current) {
|
|
803
|
+
continue
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
const args: unknown[] = [owner, repo, current]
|
|
807
|
+
if (queryOptions) {
|
|
808
|
+
args.push(queryOptions)
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
const result = await listContents(...args)
|
|
812
|
+
if (!result.ok) {
|
|
813
|
+
throw new Error(
|
|
814
|
+
`Failed to list repo contents at ${current} (${owner}/${repo}). status=${result.status}`,
|
|
815
|
+
)
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
const entries = normalizeRepoContentsEntries(result.body, current)
|
|
819
|
+
for (const entry of entries) {
|
|
820
|
+
if (entry.type === 'dir') {
|
|
821
|
+
queue.push(entry.path)
|
|
822
|
+
continue
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
if (entry.type === 'file') {
|
|
826
|
+
discovered.add(entry.path)
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
return Array.from(discovered).sort((left, right) => left.localeCompare(right))
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
function normalizeRepoContentsEntries(
|
|
835
|
+
body: unknown,
|
|
836
|
+
currentPath: string,
|
|
837
|
+
): Array<{ path: string; type: 'file' | 'dir' | 'other' }> {
|
|
838
|
+
const entries = Array.isArray(body) ? body : [body]
|
|
839
|
+
const normalized: Array<{ path: string; type: 'file' | 'dir' | 'other' }> = []
|
|
840
|
+
|
|
841
|
+
for (const entry of entries) {
|
|
842
|
+
if (!isRecord(entry)) {
|
|
843
|
+
continue
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
const rawType = typeof entry.type === 'string' ? entry.type.toLowerCase() : 'other'
|
|
847
|
+
const type = rawType === 'dir' || rawType === 'file' ? rawType : 'other'
|
|
848
|
+
const explicitPath = typeof entry.path === 'string' && entry.path.trim().length > 0
|
|
849
|
+
? entry.path.trim().replace(/\\/g, '/')
|
|
850
|
+
: null
|
|
851
|
+
const explicitName = typeof entry.name === 'string' && entry.name.trim().length > 0
|
|
852
|
+
? entry.name.trim()
|
|
853
|
+
: null
|
|
854
|
+
const resolvedPath = explicitPath
|
|
855
|
+
?? (explicitName ? `${currentPath.replace(/\\/g, '/')}/${explicitName}` : null)
|
|
856
|
+
|
|
857
|
+
if (!resolvedPath) {
|
|
858
|
+
continue
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
normalized.push({
|
|
862
|
+
path: resolvedPath,
|
|
863
|
+
type,
|
|
864
|
+
})
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
return normalized
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
function stripSectionPrefix(remotePath: string, section: ProjectSearchTargetSection): string {
|
|
871
|
+
const normalized = remotePath.replace(/\\/g, '/')
|
|
872
|
+
if (normalized === section) {
|
|
873
|
+
return ''
|
|
874
|
+
}
|
|
875
|
+
if (normalized.startsWith(`${section}/`)) {
|
|
876
|
+
return normalized.slice(section.length + 1)
|
|
877
|
+
}
|
|
878
|
+
return ''
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
async function loadRepoFileContent(
|
|
882
|
+
viewContents: GitServiceApiMethod,
|
|
883
|
+
owner: string,
|
|
884
|
+
repo: string,
|
|
885
|
+
remotePath: string,
|
|
886
|
+
ref: string | null,
|
|
887
|
+
): Promise<string> {
|
|
888
|
+
const args: unknown[] = [owner, repo, remotePath]
|
|
889
|
+
const queryOptions = buildRepoContentsQueryOptions(ref)
|
|
890
|
+
if (queryOptions) {
|
|
891
|
+
args.push(queryOptions)
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
const result = await viewContents(...args)
|
|
895
|
+
if (!result.ok) {
|
|
896
|
+
throw new Error(
|
|
897
|
+
`Failed to read repo file ${remotePath} (${owner}/${repo}). status=${result.status}`,
|
|
898
|
+
)
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
const content = extractRepoFileContent(result.body)
|
|
902
|
+
if (content === null) {
|
|
903
|
+
throw new Error(`Repo response for ${remotePath} did not contain textual file content.`)
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
return content
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
function extractRepoFileContent(body: unknown): string | null {
|
|
910
|
+
if (!isRecord(body)) {
|
|
911
|
+
return null
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
if (typeof body.content !== 'string') {
|
|
915
|
+
return null
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
const raw = body.content
|
|
919
|
+
const encoding = typeof body.encoding === 'string' ? body.encoding.toLowerCase() : ''
|
|
920
|
+
|
|
921
|
+
if (encoding === 'base64') {
|
|
922
|
+
try {
|
|
923
|
+
return Buffer.from(raw.replace(/\s+/g, ''), 'base64').toString('utf8')
|
|
924
|
+
} catch {
|
|
925
|
+
return null
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
return raw
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
function parseRgLikeQueryArgs(args: string[]): RgLikeSearchQuery {
|
|
933
|
+
const query: RgLikeSearchQuery = {
|
|
934
|
+
patterns: [],
|
|
935
|
+
paths: [],
|
|
936
|
+
lineNumber: true,
|
|
937
|
+
withFilename: false,
|
|
938
|
+
noFilename: false,
|
|
939
|
+
ignoreCase: false,
|
|
940
|
+
caseSensitive: false,
|
|
941
|
+
smartCase: false,
|
|
942
|
+
fixedStrings: false,
|
|
943
|
+
wordRegexp: false,
|
|
944
|
+
maxCount: null,
|
|
945
|
+
countOnly: false,
|
|
946
|
+
filesWithMatches: false,
|
|
947
|
+
filesWithoutMatch: false,
|
|
948
|
+
onlyMatching: false,
|
|
949
|
+
includeHidden: false,
|
|
950
|
+
globs: [],
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
const positionals: string[] = []
|
|
954
|
+
let stopOptionParsing = false
|
|
955
|
+
|
|
956
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
957
|
+
const arg = args[i]
|
|
958
|
+
if (!stopOptionParsing && arg === '--') {
|
|
959
|
+
stopOptionParsing = true
|
|
960
|
+
continue
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
if (!stopOptionParsing && arg.startsWith('--')) {
|
|
964
|
+
const consumed = parseRgLikeLongOption(arg, args[i + 1], query)
|
|
965
|
+
if (consumed) {
|
|
966
|
+
i += 1
|
|
967
|
+
}
|
|
968
|
+
continue
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
if (!stopOptionParsing && arg.startsWith('-') && arg !== '-') {
|
|
972
|
+
const consumed = parseRgLikeShortOption(arg, args[i + 1], query)
|
|
973
|
+
if (consumed) {
|
|
974
|
+
i += 1
|
|
975
|
+
}
|
|
976
|
+
continue
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
positionals.push(arg)
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
if (query.patterns.length === 0) {
|
|
983
|
+
const positionalPattern = positionals.shift()
|
|
984
|
+
if (!positionalPattern) {
|
|
985
|
+
throw new Error('Missing search pattern. Usage mirrors rg: PATTERN [PATH ...]')
|
|
986
|
+
}
|
|
987
|
+
query.patterns.push(positionalPattern)
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
query.paths = positionals.length > 0 ? positionals : ['.']
|
|
991
|
+
|
|
992
|
+
if (query.filesWithMatches && query.filesWithoutMatch) {
|
|
993
|
+
throw new Error('Cannot combine --files-with-matches and --files-without-match.')
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
return query
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
function parseRgLikeLongOption(option: string, next: string | undefined, query: RgLikeSearchQuery): boolean {
|
|
1000
|
+
const equalsIndex = option.indexOf('=')
|
|
1001
|
+
const name = equalsIndex >= 0 ? option.slice(0, equalsIndex) : option
|
|
1002
|
+
const inlineValue = equalsIndex >= 0 ? option.slice(equalsIndex + 1) : null
|
|
1003
|
+
|
|
1004
|
+
const readValue = (): string => {
|
|
1005
|
+
const value = inlineValue ?? next
|
|
1006
|
+
if (value === undefined || value === null || value.length === 0) {
|
|
1007
|
+
throw new Error(`Missing value for ${name}`)
|
|
1008
|
+
}
|
|
1009
|
+
return value
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
switch (name) {
|
|
1013
|
+
case '--ignore-case':
|
|
1014
|
+
query.ignoreCase = true
|
|
1015
|
+
return false
|
|
1016
|
+
case '--case-sensitive':
|
|
1017
|
+
query.caseSensitive = true
|
|
1018
|
+
return false
|
|
1019
|
+
case '--smart-case':
|
|
1020
|
+
query.smartCase = true
|
|
1021
|
+
return false
|
|
1022
|
+
case '--fixed-strings':
|
|
1023
|
+
query.fixedStrings = true
|
|
1024
|
+
return false
|
|
1025
|
+
case '--word-regexp':
|
|
1026
|
+
query.wordRegexp = true
|
|
1027
|
+
return false
|
|
1028
|
+
case '--line-number':
|
|
1029
|
+
query.lineNumber = true
|
|
1030
|
+
return false
|
|
1031
|
+
case '--with-filename':
|
|
1032
|
+
query.withFilename = true
|
|
1033
|
+
return false
|
|
1034
|
+
case '--no-filename':
|
|
1035
|
+
query.noFilename = true
|
|
1036
|
+
return false
|
|
1037
|
+
case '--count':
|
|
1038
|
+
query.countOnly = true
|
|
1039
|
+
return false
|
|
1040
|
+
case '--files-with-matches':
|
|
1041
|
+
query.filesWithMatches = true
|
|
1042
|
+
return false
|
|
1043
|
+
case '--files-without-match':
|
|
1044
|
+
query.filesWithoutMatch = true
|
|
1045
|
+
return false
|
|
1046
|
+
case '--only-matching':
|
|
1047
|
+
query.onlyMatching = true
|
|
1048
|
+
return false
|
|
1049
|
+
case '--hidden':
|
|
1050
|
+
query.includeHidden = true
|
|
1051
|
+
return false
|
|
1052
|
+
case '--no-ignore':
|
|
1053
|
+
return false
|
|
1054
|
+
case '--glob': {
|
|
1055
|
+
query.globs.push(readValue())
|
|
1056
|
+
return inlineValue === null
|
|
1057
|
+
}
|
|
1058
|
+
case '--regexp': {
|
|
1059
|
+
query.patterns.push(readValue())
|
|
1060
|
+
return inlineValue === null
|
|
1061
|
+
}
|
|
1062
|
+
case '--max-count': {
|
|
1063
|
+
const value = readValue()
|
|
1064
|
+
const parsed = Number.parseInt(value, 10)
|
|
1065
|
+
if (!Number.isFinite(parsed) || parsed < 0) {
|
|
1066
|
+
throw new Error(`Invalid value for --max-count: ${value}`)
|
|
1067
|
+
}
|
|
1068
|
+
query.maxCount = parsed
|
|
1069
|
+
return inlineValue === null
|
|
1070
|
+
}
|
|
1071
|
+
default:
|
|
1072
|
+
throw new Error(`Unsupported rg option: ${name}`)
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
function parseRgLikeShortOption(option: string, next: string | undefined, query: RgLikeSearchQuery): boolean {
|
|
1077
|
+
const cluster = option.slice(1)
|
|
1078
|
+
if (!cluster) {
|
|
1079
|
+
return false
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
const valueFlags = new Set(['m', 'g', 'e'])
|
|
1083
|
+
let consumedNext = false
|
|
1084
|
+
|
|
1085
|
+
for (let i = 0; i < cluster.length; i += 1) {
|
|
1086
|
+
const token = cluster[i]
|
|
1087
|
+
|
|
1088
|
+
if (valueFlags.has(token)) {
|
|
1089
|
+
const inline = cluster.slice(i + 1)
|
|
1090
|
+
const value = inline.length > 0 ? inline : next
|
|
1091
|
+
if (!value) {
|
|
1092
|
+
throw new Error(`Missing value for -${token}`)
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
if (token === 'm') {
|
|
1096
|
+
const parsed = Number.parseInt(value, 10)
|
|
1097
|
+
if (!Number.isFinite(parsed) || parsed < 0) {
|
|
1098
|
+
throw new Error(`Invalid value for -m: ${value}`)
|
|
1099
|
+
}
|
|
1100
|
+
query.maxCount = parsed
|
|
1101
|
+
} else if (token === 'g') {
|
|
1102
|
+
query.globs.push(value)
|
|
1103
|
+
} else if (token === 'e') {
|
|
1104
|
+
query.patterns.push(value)
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
consumedNext = inline.length === 0
|
|
1108
|
+
break
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
switch (token) {
|
|
1112
|
+
case 'i':
|
|
1113
|
+
query.ignoreCase = true
|
|
1114
|
+
break
|
|
1115
|
+
case 's':
|
|
1116
|
+
query.caseSensitive = true
|
|
1117
|
+
break
|
|
1118
|
+
case 'S':
|
|
1119
|
+
query.smartCase = true
|
|
1120
|
+
break
|
|
1121
|
+
case 'F':
|
|
1122
|
+
query.fixedStrings = true
|
|
1123
|
+
break
|
|
1124
|
+
case 'w':
|
|
1125
|
+
query.wordRegexp = true
|
|
1126
|
+
break
|
|
1127
|
+
case 'n':
|
|
1128
|
+
query.lineNumber = true
|
|
1129
|
+
break
|
|
1130
|
+
case 'H':
|
|
1131
|
+
query.withFilename = true
|
|
1132
|
+
break
|
|
1133
|
+
case 'h':
|
|
1134
|
+
query.noFilename = true
|
|
1135
|
+
break
|
|
1136
|
+
case 'c':
|
|
1137
|
+
query.countOnly = true
|
|
1138
|
+
break
|
|
1139
|
+
case 'l':
|
|
1140
|
+
query.filesWithMatches = true
|
|
1141
|
+
break
|
|
1142
|
+
case 'L':
|
|
1143
|
+
query.filesWithoutMatch = true
|
|
1144
|
+
break
|
|
1145
|
+
case 'o':
|
|
1146
|
+
query.onlyMatching = true
|
|
1147
|
+
break
|
|
1148
|
+
default:
|
|
1149
|
+
throw new Error(`Unsupported rg option: -${token}`)
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
return consumedNext
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
async function executeRgLikeSearch(
|
|
1157
|
+
cacheRoot: string,
|
|
1158
|
+
query: RgLikeSearchQuery,
|
|
1159
|
+
): Promise<RgLikeSearchExecutionResult> {
|
|
1160
|
+
const files = await collectSearchFiles(cacheRoot, query)
|
|
1161
|
+
const compiledPatterns = compileRgLikePatterns(query)
|
|
1162
|
+
const outputLines: string[] = []
|
|
1163
|
+
const multipleFiles = query.withFilename || files.length > 1
|
|
1164
|
+
|
|
1165
|
+
let filesMatched = 0
|
|
1166
|
+
let matchedLines = 0
|
|
1167
|
+
let matchedSegments = 0
|
|
1168
|
+
|
|
1169
|
+
for (const filePath of files) {
|
|
1170
|
+
const relativePath = path.relative(cacheRoot, filePath).replace(/\\/g, '/')
|
|
1171
|
+
const content = await fs.readFile(filePath, 'utf8')
|
|
1172
|
+
const normalized = content.replace(/\r/g, '')
|
|
1173
|
+
const lines = normalized.split('\n')
|
|
1174
|
+
if (lines.length > 0 && lines[lines.length - 1] === '') {
|
|
1175
|
+
lines.pop()
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
let fileMatchedLines = 0
|
|
1179
|
+
let fileMatchedSegments = 0
|
|
1180
|
+
const fileLineOutputs: string[] = []
|
|
1181
|
+
|
|
1182
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
1183
|
+
const lineText = lines[index] ?? ''
|
|
1184
|
+
const matches = matchLineWithPatterns(lineText, compiledPatterns)
|
|
1185
|
+
if (matches.length === 0) {
|
|
1186
|
+
continue
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
fileMatchedLines += 1
|
|
1190
|
+
fileMatchedSegments += matches.length
|
|
1191
|
+
|
|
1192
|
+
if (!query.countOnly && !query.filesWithMatches && !query.filesWithoutMatch) {
|
|
1193
|
+
if (query.onlyMatching) {
|
|
1194
|
+
for (const matchText of matches) {
|
|
1195
|
+
fileLineOutputs.push(formatSearchOutputLine({
|
|
1196
|
+
filePath: relativePath,
|
|
1197
|
+
includeFilePath: multipleFiles && !query.noFilename,
|
|
1198
|
+
lineNumber: index + 1,
|
|
1199
|
+
includeLineNumber: query.lineNumber,
|
|
1200
|
+
text: matchText,
|
|
1201
|
+
}))
|
|
1202
|
+
}
|
|
1203
|
+
} else {
|
|
1204
|
+
fileLineOutputs.push(formatSearchOutputLine({
|
|
1205
|
+
filePath: relativePath,
|
|
1206
|
+
includeFilePath: multipleFiles && !query.noFilename,
|
|
1207
|
+
lineNumber: index + 1,
|
|
1208
|
+
includeLineNumber: query.lineNumber,
|
|
1209
|
+
text: lineText,
|
|
1210
|
+
}))
|
|
1211
|
+
}
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
if (query.maxCount !== null && fileMatchedLines >= query.maxCount) {
|
|
1215
|
+
break
|
|
1216
|
+
}
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
matchedLines += fileMatchedLines
|
|
1220
|
+
matchedSegments += fileMatchedSegments
|
|
1221
|
+
|
|
1222
|
+
if (query.filesWithoutMatch) {
|
|
1223
|
+
if (fileMatchedLines === 0) {
|
|
1224
|
+
outputLines.push(relativePath)
|
|
1225
|
+
}
|
|
1226
|
+
continue
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
if (fileMatchedLines > 0) {
|
|
1230
|
+
filesMatched += 1
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
if (query.filesWithMatches) {
|
|
1234
|
+
if (fileMatchedLines > 0) {
|
|
1235
|
+
outputLines.push(relativePath)
|
|
1236
|
+
}
|
|
1237
|
+
continue
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
if (query.countOnly) {
|
|
1241
|
+
const includeFilePath = multipleFiles && !query.noFilename
|
|
1242
|
+
outputLines.push(includeFilePath ? `${relativePath}:${fileMatchedLines}` : String(fileMatchedLines))
|
|
1243
|
+
continue
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
outputLines.push(...fileLineOutputs)
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
return {
|
|
1250
|
+
filesSearched: files.length,
|
|
1251
|
+
filesMatched,
|
|
1252
|
+
matchedLines,
|
|
1253
|
+
matchedSegments,
|
|
1254
|
+
outputLines,
|
|
1255
|
+
}
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
function formatSearchOutputLine(input: {
|
|
1259
|
+
filePath: string
|
|
1260
|
+
includeFilePath: boolean
|
|
1261
|
+
lineNumber: number
|
|
1262
|
+
includeLineNumber: boolean
|
|
1263
|
+
text: string
|
|
1264
|
+
}): string {
|
|
1265
|
+
const prefixParts: string[] = []
|
|
1266
|
+
if (input.includeFilePath) {
|
|
1267
|
+
prefixParts.push(input.filePath)
|
|
1268
|
+
}
|
|
1269
|
+
if (input.includeLineNumber) {
|
|
1270
|
+
prefixParts.push(String(input.lineNumber))
|
|
1271
|
+
}
|
|
1272
|
+
if (prefixParts.length === 0) {
|
|
1273
|
+
return input.text
|
|
1274
|
+
}
|
|
1275
|
+
return `${prefixParts.join(':')}:${input.text}`
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
function matchLineWithPatterns(line: string, patterns: RgLikeCompiledPattern[]): string[] {
|
|
1279
|
+
const collected: string[] = []
|
|
1280
|
+
|
|
1281
|
+
for (const pattern of patterns) {
|
|
1282
|
+
if (!pattern.test.test(line)) {
|
|
1283
|
+
continue
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
pattern.global.lastIndex = 0
|
|
1287
|
+
let match: RegExpExecArray | null = null
|
|
1288
|
+
while ((match = pattern.global.exec(line)) !== null) {
|
|
1289
|
+
collected.push(match[0] ?? '')
|
|
1290
|
+
if ((match[0] ?? '').length === 0) {
|
|
1291
|
+
pattern.global.lastIndex += 1
|
|
1292
|
+
}
|
|
1293
|
+
}
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
return collected
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
function compileRgLikePatterns(query: RgLikeSearchQuery): RgLikeCompiledPattern[] {
|
|
1300
|
+
return query.patterns.map((pattern) => {
|
|
1301
|
+
const source = query.fixedStrings ? escapeRegExp(pattern) : pattern
|
|
1302
|
+
const wrappedSource = query.wordRegexp ? `\\b(?:${source})\\b` : source
|
|
1303
|
+
const flags = resolveRgLikePatternFlags(pattern, query)
|
|
1304
|
+
|
|
1305
|
+
try {
|
|
1306
|
+
return {
|
|
1307
|
+
test: new RegExp(wrappedSource, flags),
|
|
1308
|
+
global: new RegExp(wrappedSource, `${flags}g`),
|
|
1309
|
+
}
|
|
1310
|
+
} catch (error) {
|
|
1311
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
1312
|
+
throw new Error(`Invalid search pattern "${pattern}": ${message}`)
|
|
1313
|
+
}
|
|
1314
|
+
})
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
function resolveRgLikePatternFlags(pattern: string, query: RgLikeSearchQuery): string {
|
|
1318
|
+
if (query.caseSensitive) {
|
|
1319
|
+
return ''
|
|
1320
|
+
}
|
|
1321
|
+
if (query.ignoreCase) {
|
|
1322
|
+
return 'i'
|
|
1323
|
+
}
|
|
1324
|
+
if (query.smartCase) {
|
|
1325
|
+
return /[A-Z]/.test(pattern) ? '' : 'i'
|
|
1326
|
+
}
|
|
1327
|
+
return ''
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
type CompiledSearchGlob = {
|
|
1331
|
+
raw: string
|
|
1332
|
+
negate: boolean
|
|
1333
|
+
regex: RegExp
|
|
1334
|
+
basenameOnly: boolean
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
async function collectSearchFiles(cacheRoot: string, query: RgLikeSearchQuery): Promise<string[]> {
|
|
1338
|
+
const compiledGlobs = compileSearchGlobs(query.globs)
|
|
1339
|
+
const files = new Set<string>()
|
|
1340
|
+
|
|
1341
|
+
for (const requested of query.paths) {
|
|
1342
|
+
const normalized = requested === '.' ? '' : requested.replace(/\\/g, '/')
|
|
1343
|
+
const candidate = path.resolve(cacheRoot, normalized)
|
|
1344
|
+
if (!isSubPath(cacheRoot, candidate)) {
|
|
1345
|
+
throw new Error(`Path escapes cache root: ${requested}`)
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
let stats: fsSync.Stats
|
|
1349
|
+
try {
|
|
1350
|
+
stats = await fs.stat(candidate)
|
|
1351
|
+
} catch {
|
|
1352
|
+
throw new Error(`Path not found in cached section: ${requested}`)
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
if (stats.isDirectory()) {
|
|
1356
|
+
await collectFilesFromDirectory(cacheRoot, candidate, query.includeHidden, compiledGlobs, files)
|
|
1357
|
+
continue
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1360
|
+
if (stats.isFile() && shouldIncludeFile(cacheRoot, candidate, query.includeHidden, compiledGlobs)) {
|
|
1361
|
+
files.add(candidate)
|
|
1362
|
+
}
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
return Array.from(files).sort((left, right) => left.localeCompare(right))
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
async function collectFilesFromDirectory(
|
|
1369
|
+
cacheRoot: string,
|
|
1370
|
+
directory: string,
|
|
1371
|
+
includeHidden: boolean,
|
|
1372
|
+
globs: CompiledSearchGlob[],
|
|
1373
|
+
files: Set<string>,
|
|
1374
|
+
): Promise<void> {
|
|
1375
|
+
const entries = await fs.readdir(directory, { withFileTypes: true })
|
|
1376
|
+
|
|
1377
|
+
for (const entry of entries) {
|
|
1378
|
+
if (entry.name === PROJECT_ARCHIVE_DIR_NAME) {
|
|
1379
|
+
continue
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
if (!includeHidden && entry.name.startsWith('.')) {
|
|
1383
|
+
continue
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
const absolute = path.join(directory, entry.name)
|
|
1387
|
+
if (entry.isDirectory()) {
|
|
1388
|
+
await collectFilesFromDirectory(cacheRoot, absolute, includeHidden, globs, files)
|
|
1389
|
+
continue
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
if (entry.isFile() && shouldIncludeFile(cacheRoot, absolute, includeHidden, globs)) {
|
|
1393
|
+
files.add(absolute)
|
|
1394
|
+
}
|
|
1395
|
+
}
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1398
|
+
function shouldIncludeFile(
|
|
1399
|
+
cacheRoot: string,
|
|
1400
|
+
filePath: string,
|
|
1401
|
+
includeHidden: boolean,
|
|
1402
|
+
globs: CompiledSearchGlob[],
|
|
1403
|
+
): boolean {
|
|
1404
|
+
const relative = path.relative(cacheRoot, filePath).replace(/\\/g, '/')
|
|
1405
|
+
if (!includeHidden && relative.split('/').some((segment) => segment.startsWith('.'))) {
|
|
1406
|
+
return false
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
return matchesSearchGlobs(relative, globs)
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1412
|
+
function compileSearchGlobs(globs: string[]): CompiledSearchGlob[] {
|
|
1413
|
+
return globs
|
|
1414
|
+
.map((raw) => raw.trim())
|
|
1415
|
+
.filter((raw) => raw.length > 0)
|
|
1416
|
+
.map((raw) => {
|
|
1417
|
+
const negate = raw.startsWith('!')
|
|
1418
|
+
const normalized = (negate ? raw.slice(1) : raw).replace(/\\/g, '/')
|
|
1419
|
+
const basenameOnly = !normalized.includes('/')
|
|
1420
|
+
|
|
1421
|
+
return {
|
|
1422
|
+
raw,
|
|
1423
|
+
negate,
|
|
1424
|
+
regex: globToRegExp(normalized),
|
|
1425
|
+
basenameOnly,
|
|
1426
|
+
}
|
|
1427
|
+
})
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1430
|
+
function matchesSearchGlobs(relativePath: string, globs: CompiledSearchGlob[]): boolean {
|
|
1431
|
+
if (globs.length === 0) {
|
|
1432
|
+
return true
|
|
1433
|
+
}
|
|
1434
|
+
|
|
1435
|
+
const includeGlobs = globs.filter((glob) => !glob.negate)
|
|
1436
|
+
const excludeGlobs = globs.filter((glob) => glob.negate)
|
|
1437
|
+
const fileName = relativePath.split('/').pop() ?? relativePath
|
|
1438
|
+
|
|
1439
|
+
const matchesGlob = (glob: CompiledSearchGlob): boolean => {
|
|
1440
|
+
if (glob.basenameOnly) {
|
|
1441
|
+
return glob.regex.test(fileName)
|
|
1442
|
+
}
|
|
1443
|
+
return glob.regex.test(relativePath)
|
|
1444
|
+
}
|
|
1445
|
+
|
|
1446
|
+
if (includeGlobs.length > 0 && !includeGlobs.some((glob) => matchesGlob(glob))) {
|
|
1447
|
+
return false
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1450
|
+
if (excludeGlobs.some((glob) => matchesGlob(glob))) {
|
|
1451
|
+
return false
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
return true
|
|
250
1455
|
}
|
|
251
1456
|
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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)
|
|
1457
|
+
function globToRegExp(glob: string): RegExp {
|
|
1458
|
+
let index = 0
|
|
1459
|
+
let source = '^'
|
|
261
1460
|
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
}
|
|
1461
|
+
while (index < glob.length) {
|
|
1462
|
+
const char = glob[index]
|
|
265
1463
|
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
1464
|
+
if (char === '*') {
|
|
1465
|
+
const next = glob[index + 1]
|
|
1466
|
+
if (next === '*') {
|
|
1467
|
+
source += '.*'
|
|
1468
|
+
index += 2
|
|
1469
|
+
continue
|
|
1470
|
+
}
|
|
1471
|
+
source += '[^/]*'
|
|
1472
|
+
index += 1
|
|
1473
|
+
continue
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
if (char === '?') {
|
|
1477
|
+
source += '[^/]'
|
|
1478
|
+
index += 1
|
|
1479
|
+
continue
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1482
|
+
source += escapeRegExp(char)
|
|
1483
|
+
index += 1
|
|
269
1484
|
}
|
|
270
1485
|
|
|
271
|
-
|
|
1486
|
+
source += '$'
|
|
1487
|
+
return new RegExp(source)
|
|
272
1488
|
}
|
|
273
1489
|
|
|
274
1490
|
type ProjectVersionedFileSpec = {
|
|
@@ -754,7 +1970,10 @@ function parseSyncTasksArgs(argv: string[]): {
|
|
|
754
1970
|
prefix: '[TASK]',
|
|
755
1971
|
dryRun: false,
|
|
756
1972
|
requestTimeoutMs: 60_000,
|
|
757
|
-
throttleMs:
|
|
1973
|
+
throttleMs: 200,
|
|
1974
|
+
retryMax: 5,
|
|
1975
|
+
retryBaseDelayMs: 500,
|
|
1976
|
+
retryMaxDelayMs: 10_000,
|
|
758
1977
|
skipDependencies: false,
|
|
759
1978
|
verbose: false,
|
|
760
1979
|
}
|
|
@@ -864,6 +2083,41 @@ function parseSyncTasksArgs(argv: string[]): {
|
|
|
864
2083
|
continue
|
|
865
2084
|
}
|
|
866
2085
|
|
|
2086
|
+
if (arg === '--retry') {
|
|
2087
|
+
const value = Number(argv[i + 1])
|
|
2088
|
+
if (!Number.isFinite(value) || value < 0 || Math.floor(value) !== value) {
|
|
2089
|
+
throw new Error(`Invalid --retry value: ${argv[i + 1]}`)
|
|
2090
|
+
}
|
|
2091
|
+
options.retryMax = value
|
|
2092
|
+
i += 1
|
|
2093
|
+
continue
|
|
2094
|
+
}
|
|
2095
|
+
|
|
2096
|
+
if (arg === '--retry-base-delay-ms') {
|
|
2097
|
+
const value = Number(argv[i + 1])
|
|
2098
|
+
if (!Number.isFinite(value) || value < 0 || Math.floor(value) !== value) {
|
|
2099
|
+
throw new Error(`Invalid --retry-base-delay-ms value: ${argv[i + 1]}`)
|
|
2100
|
+
}
|
|
2101
|
+
options.retryBaseDelayMs = value
|
|
2102
|
+
i += 1
|
|
2103
|
+
continue
|
|
2104
|
+
}
|
|
2105
|
+
|
|
2106
|
+
if (arg === '--retry-max-delay-ms') {
|
|
2107
|
+
const value = Number(argv[i + 1])
|
|
2108
|
+
if (!Number.isFinite(value) || value < 0 || Math.floor(value) !== value) {
|
|
2109
|
+
throw new Error(`Invalid --retry-max-delay-ms value: ${argv[i + 1]}`)
|
|
2110
|
+
}
|
|
2111
|
+
options.retryMaxDelayMs = value
|
|
2112
|
+
i += 1
|
|
2113
|
+
continue
|
|
2114
|
+
}
|
|
2115
|
+
|
|
2116
|
+
if (arg === '--no-retry') {
|
|
2117
|
+
options.retryMax = 0
|
|
2118
|
+
continue
|
|
2119
|
+
}
|
|
2120
|
+
|
|
867
2121
|
if (arg === '--skip-dependencies') {
|
|
868
2122
|
options.skipDependencies = true
|
|
869
2123
|
continue
|
|
@@ -1378,6 +2632,21 @@ function toProjectGitTaskRecord(issue: GitIssueLike): ProjectGitTaskRecord | nul
|
|
|
1378
2632
|
}
|
|
1379
2633
|
}
|
|
1380
2634
|
|
|
2635
|
+
function toProjectGitIssueRecord(issue: GitIssueLike): ProjectGitIssueRecord | null {
|
|
2636
|
+
const raw = issue as Record<string, unknown>
|
|
2637
|
+
|
|
2638
|
+
const issueNumber = Number(raw.number)
|
|
2639
|
+
if (!Number.isInteger(issueNumber) || issueNumber <= 0) return null
|
|
2640
|
+
|
|
2641
|
+
return {
|
|
2642
|
+
number: issueNumber,
|
|
2643
|
+
title: typeof raw.title === 'string' ? raw.title : `Issue #${issueNumber}`,
|
|
2644
|
+
body: typeof raw.body === 'string' ? raw.body : '',
|
|
2645
|
+
state: typeof raw.state === 'string' ? raw.state : 'unknown',
|
|
2646
|
+
labels: normalizeIssueLabels(raw.labels),
|
|
2647
|
+
}
|
|
2648
|
+
}
|
|
2649
|
+
|
|
1381
2650
|
function resolveProjectGitRepository(
|
|
1382
2651
|
projectName: string,
|
|
1383
2652
|
processRoot: string,
|
|
@@ -1471,24 +2740,141 @@ type GitIssueLike = {
|
|
|
1471
2740
|
body?: unknown
|
|
1472
2741
|
}
|
|
1473
2742
|
|
|
2743
|
+
type NetworkRetryOptions = {
|
|
2744
|
+
maxRetries: number
|
|
2745
|
+
baseDelayMs: number
|
|
2746
|
+
maxDelayMs: number
|
|
2747
|
+
retryOnStatuses: ReadonlySet<number>
|
|
2748
|
+
}
|
|
2749
|
+
|
|
2750
|
+
const DEFAULT_RETRYABLE_HTTP_STATUSES = new Set<number>([408, 425, 429, 500, 502, 503, 504])
|
|
2751
|
+
|
|
2752
|
+
function resolveNetworkRetryOptions(options: {
|
|
2753
|
+
retryMax?: number
|
|
2754
|
+
retryBaseDelayMs?: number
|
|
2755
|
+
retryMaxDelayMs?: number
|
|
2756
|
+
}): NetworkRetryOptions {
|
|
2757
|
+
const maxRetries = Number.isFinite(options.retryMax) ? Math.max(0, Math.floor(options.retryMax ?? 0)) : 0
|
|
2758
|
+
const baseDelayMs = Number.isFinite(options.retryBaseDelayMs) ? Math.max(0, Math.floor(options.retryBaseDelayMs ?? 0)) : 0
|
|
2759
|
+
const maxDelayMs = Number.isFinite(options.retryMaxDelayMs) ? Math.max(0, Math.floor(options.retryMaxDelayMs ?? 0)) : 0
|
|
2760
|
+
|
|
2761
|
+
return {
|
|
2762
|
+
maxRetries,
|
|
2763
|
+
baseDelayMs,
|
|
2764
|
+
maxDelayMs,
|
|
2765
|
+
retryOnStatuses: DEFAULT_RETRYABLE_HTTP_STATUSES,
|
|
2766
|
+
}
|
|
2767
|
+
}
|
|
2768
|
+
|
|
2769
|
+
const sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms))
|
|
2770
|
+
|
|
2771
|
+
function retryDelayMs(retryNumber: number, baseDelayMs: number, maxDelayMs: number): number {
|
|
2772
|
+
if (retryNumber <= 0) {
|
|
2773
|
+
return 0
|
|
2774
|
+
}
|
|
2775
|
+
|
|
2776
|
+
const exponent = Math.max(0, retryNumber - 1)
|
|
2777
|
+
const raw = baseDelayMs * (2 ** exponent)
|
|
2778
|
+
const capped = maxDelayMs > 0 ? Math.min(raw, maxDelayMs) : raw
|
|
2779
|
+
const jitter = 0.5 + Math.random()
|
|
2780
|
+
return Math.max(0, Math.floor(capped * jitter))
|
|
2781
|
+
}
|
|
2782
|
+
|
|
2783
|
+
function isRetryableError(error: unknown, retryOnStatuses: ReadonlySet<number>): boolean {
|
|
2784
|
+
if (!(error instanceof Error)) {
|
|
2785
|
+
return false
|
|
2786
|
+
}
|
|
2787
|
+
|
|
2788
|
+
const message = String(error.message ?? '').toLowerCase()
|
|
2789
|
+
const statusMatch = message.match(/status\\s*=\\s*(\\d{3})/)
|
|
2790
|
+
if (statusMatch) {
|
|
2791
|
+
const status = Number(statusMatch[1])
|
|
2792
|
+
if (Number.isInteger(status) && retryOnStatuses.has(status)) {
|
|
2793
|
+
return true
|
|
2794
|
+
}
|
|
2795
|
+
}
|
|
2796
|
+
|
|
2797
|
+
if (message.includes('request timed out')) return true
|
|
2798
|
+
if (message.includes('timeout')) return true
|
|
2799
|
+
if (message.includes('fetch failed')) return true
|
|
2800
|
+
if (message.includes('network')) return true
|
|
2801
|
+
if (message.includes('socket')) return true
|
|
2802
|
+
if (message.includes('econnreset')) return true
|
|
2803
|
+
if (message.includes('econnrefused')) return true
|
|
2804
|
+
if (message.includes('enotfound')) return true
|
|
2805
|
+
if (message.includes('eai_again')) return true
|
|
2806
|
+
if (message.includes('tls')) return true
|
|
2807
|
+
|
|
2808
|
+
return false
|
|
2809
|
+
}
|
|
2810
|
+
|
|
2811
|
+
async function withRetry<T>(
|
|
2812
|
+
label: string,
|
|
2813
|
+
operation: () => Promise<T>,
|
|
2814
|
+
retry: NetworkRetryOptions,
|
|
2815
|
+
log: ((message: string) => void) | null,
|
|
2816
|
+
shouldRetryValue?: (value: T) => string | null,
|
|
2817
|
+
): Promise<T> {
|
|
2818
|
+
const maxAttempts = Math.max(1, retry.maxRetries + 1)
|
|
2819
|
+
|
|
2820
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
|
2821
|
+
try {
|
|
2822
|
+
const value = await operation()
|
|
2823
|
+
const retryReason = shouldRetryValue ? shouldRetryValue(value) : null
|
|
2824
|
+
if (retryReason && attempt < maxAttempts) {
|
|
2825
|
+
const delay = retryDelayMs(attempt, retry.baseDelayMs, retry.maxDelayMs)
|
|
2826
|
+
log?.(`${label}: retrying (attempt ${attempt}/${maxAttempts}) reason=${retryReason} delay=${delay}ms`)
|
|
2827
|
+
if (delay > 0) await sleep(delay)
|
|
2828
|
+
continue
|
|
2829
|
+
}
|
|
2830
|
+
return value
|
|
2831
|
+
} catch (error) {
|
|
2832
|
+
if (attempt >= maxAttempts || !isRetryableError(error, retry.retryOnStatuses)) {
|
|
2833
|
+
throw error
|
|
2834
|
+
}
|
|
2835
|
+
|
|
2836
|
+
const delay = retryDelayMs(attempt, retry.baseDelayMs, retry.maxDelayMs)
|
|
2837
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
2838
|
+
log?.(`${label}: retrying (attempt ${attempt}/${maxAttempts}) error=${message} delay=${delay}ms`)
|
|
2839
|
+
if (delay > 0) await sleep(delay)
|
|
2840
|
+
}
|
|
2841
|
+
}
|
|
2842
|
+
|
|
2843
|
+
// Unreachable, but keeps TS happy.
|
|
2844
|
+
throw new Error(`${label}: retry loop exhausted`)
|
|
2845
|
+
}
|
|
2846
|
+
|
|
1474
2847
|
async function fetchAllIssues(
|
|
1475
2848
|
issueList: GitServiceApiMethod,
|
|
1476
2849
|
owner: string,
|
|
1477
2850
|
repo: string,
|
|
1478
2851
|
state: 'open' | 'closed' | 'all',
|
|
1479
2852
|
projectName: string,
|
|
1480
|
-
log?: (message: string) => void
|
|
2853
|
+
log?: (message: string) => void,
|
|
2854
|
+
requestOptions?: { retry?: NetworkRetryOptions; throttleMs?: number },
|
|
1481
2855
|
): Promise<GitIssueLike[]> {
|
|
1482
2856
|
const allIssues: GitIssueLike[] = []
|
|
1483
2857
|
let page = 1
|
|
2858
|
+
const retry = requestOptions?.retry ?? resolveNetworkRetryOptions({ retryMax: 0, retryBaseDelayMs: 0, retryMaxDelayMs: 0 })
|
|
2859
|
+
const throttleMs = requestOptions?.throttleMs ?? 0
|
|
1484
2860
|
|
|
1485
2861
|
while (true) {
|
|
1486
2862
|
log?.(`Listing ${state} issues page ${page} for ${owner}/${repo}...`)
|
|
1487
|
-
const listResult = await
|
|
1488
|
-
state
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
2863
|
+
const listResult = await withRetry(
|
|
2864
|
+
`issue.list ${owner}/${repo} state=${state} page=${page}`,
|
|
2865
|
+
() => issueList(owner, repo, {
|
|
2866
|
+
state,
|
|
2867
|
+
limit: ISSUE_LIST_PAGE_SIZE,
|
|
2868
|
+
query: { page },
|
|
2869
|
+
}),
|
|
2870
|
+
retry,
|
|
2871
|
+
log ?? null,
|
|
2872
|
+
(result) => {
|
|
2873
|
+
const response = result as GitServiceApiExecutionResult
|
|
2874
|
+
if (response.ok) return null
|
|
2875
|
+
return retry.retryOnStatuses.has(response.status) ? `status=${response.status}` : null
|
|
2876
|
+
},
|
|
2877
|
+
)
|
|
1492
2878
|
|
|
1493
2879
|
if (!listResult.ok) {
|
|
1494
2880
|
throw new Error(
|
|
@@ -1508,12 +2894,205 @@ async function fetchAllIssues(
|
|
|
1508
2894
|
break
|
|
1509
2895
|
}
|
|
1510
2896
|
page += 1
|
|
2897
|
+
|
|
2898
|
+
if (throttleMs > 0) {
|
|
2899
|
+
await sleep(throttleMs)
|
|
2900
|
+
}
|
|
1511
2901
|
}
|
|
1512
2902
|
|
|
1513
2903
|
log?.(`Loaded ${allIssues.length} ${state} issues in ${owner}/${repo} across pages.`)
|
|
1514
2904
|
return allIssues
|
|
1515
2905
|
}
|
|
1516
2906
|
|
|
2907
|
+
async function findIssueNumberByTaskId(
|
|
2908
|
+
issueList: GitServiceApiMethod,
|
|
2909
|
+
owner: string,
|
|
2910
|
+
repo: string,
|
|
2911
|
+
taskId: string,
|
|
2912
|
+
projectName: string,
|
|
2913
|
+
retry: NetworkRetryOptions,
|
|
2914
|
+
throttleMs: number,
|
|
2915
|
+
log: ((message: string) => void) | null,
|
|
2916
|
+
): Promise<number | null> {
|
|
2917
|
+
let page = 1
|
|
2918
|
+
|
|
2919
|
+
while (true) {
|
|
2920
|
+
const listResult = await withRetry(
|
|
2921
|
+
`issue.list (lookup ${taskId}) ${owner}/${repo} page=${page}`,
|
|
2922
|
+
() => issueList(owner, repo, {
|
|
2923
|
+
state: 'open',
|
|
2924
|
+
limit: ISSUE_LIST_PAGE_SIZE,
|
|
2925
|
+
query: { page },
|
|
2926
|
+
}),
|
|
2927
|
+
retry,
|
|
2928
|
+
log,
|
|
2929
|
+
(result) => {
|
|
2930
|
+
const response = result as GitServiceApiExecutionResult
|
|
2931
|
+
if (response.ok) return null
|
|
2932
|
+
return retry.retryOnStatuses.has(response.status) ? `status=${response.status}` : null
|
|
2933
|
+
},
|
|
2934
|
+
)
|
|
2935
|
+
|
|
2936
|
+
if (!listResult.ok) {
|
|
2937
|
+
throw new Error(
|
|
2938
|
+
`Failed to list open issues for ${owner}/${repo}: status=${listResult.status} url=${listResult.request.url}`
|
|
2939
|
+
)
|
|
2940
|
+
}
|
|
2941
|
+
|
|
2942
|
+
if (!Array.isArray(listResult.body)) {
|
|
2943
|
+
throw new Error(`Expected array from issue.list for ${projectName} (open issues lookup, page ${page})`)
|
|
2944
|
+
}
|
|
2945
|
+
|
|
2946
|
+
const batch = listResult.body as GitIssueLike[]
|
|
2947
|
+
for (const issue of batch) {
|
|
2948
|
+
const record = toProjectGitTaskRecord(issue)
|
|
2949
|
+
if (record?.taskId === taskId) {
|
|
2950
|
+
return record.number
|
|
2951
|
+
}
|
|
2952
|
+
}
|
|
2953
|
+
|
|
2954
|
+
if (batch.length === 0) {
|
|
2955
|
+
break
|
|
2956
|
+
}
|
|
2957
|
+
page += 1
|
|
2958
|
+
|
|
2959
|
+
if (throttleMs > 0) {
|
|
2960
|
+
await sleep(throttleMs)
|
|
2961
|
+
}
|
|
2962
|
+
}
|
|
2963
|
+
|
|
2964
|
+
return null
|
|
2965
|
+
}
|
|
2966
|
+
|
|
2967
|
+
async function findIssueNumberByTaskIdEventually(
|
|
2968
|
+
issueList: GitServiceApiMethod,
|
|
2969
|
+
owner: string,
|
|
2970
|
+
repo: string,
|
|
2971
|
+
taskId: string,
|
|
2972
|
+
projectName: string,
|
|
2973
|
+
retry: NetworkRetryOptions,
|
|
2974
|
+
throttleMs: number,
|
|
2975
|
+
log: ((message: string) => void) | null,
|
|
2976
|
+
attempts: number,
|
|
2977
|
+
delayMs: number,
|
|
2978
|
+
): Promise<number | null> {
|
|
2979
|
+
const totalAttempts = Math.max(1, Math.floor(attempts))
|
|
2980
|
+
|
|
2981
|
+
for (let i = 0; i < totalAttempts; i += 1) {
|
|
2982
|
+
const existing = await findIssueNumberByTaskId(issueList, owner, repo, taskId, projectName, retry, throttleMs, log)
|
|
2983
|
+
if (existing) {
|
|
2984
|
+
return existing
|
|
2985
|
+
}
|
|
2986
|
+
if (i < totalAttempts - 1 && delayMs > 0) {
|
|
2987
|
+
await sleep(delayMs)
|
|
2988
|
+
}
|
|
2989
|
+
}
|
|
2990
|
+
|
|
2991
|
+
return null
|
|
2992
|
+
}
|
|
2993
|
+
|
|
2994
|
+
async function createTaskIssueNumberWithRetry(
|
|
2995
|
+
issueCreate: GitServiceApiMethod,
|
|
2996
|
+
issueList: GitServiceApiMethod,
|
|
2997
|
+
owner: string,
|
|
2998
|
+
repo: string,
|
|
2999
|
+
taskId: string,
|
|
3000
|
+
payload: Record<string, unknown>,
|
|
3001
|
+
projectName: string,
|
|
3002
|
+
retry: NetworkRetryOptions,
|
|
3003
|
+
throttleMs: number,
|
|
3004
|
+
log: ((message: string) => void) | null,
|
|
3005
|
+
): Promise<number> {
|
|
3006
|
+
const maxAttempts = Math.max(1, retry.maxRetries + 1)
|
|
3007
|
+
const verifyDelayMs = retry.baseDelayMs > 0 ? Math.min(500, retry.baseDelayMs) : 0
|
|
3008
|
+
const verifyBudgetMs = retry.maxDelayMs > 0 ? Math.min(10_000, retry.maxDelayMs) : 0
|
|
3009
|
+
const verifyAttempts = verifyDelayMs > 0 ? Math.max(1, Math.ceil(verifyBudgetMs / verifyDelayMs)) : 1
|
|
3010
|
+
|
|
3011
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
|
3012
|
+
try {
|
|
3013
|
+
const createResult = await issueCreate(owner, repo, { data: payload }) as GitServiceApiExecutionResult
|
|
3014
|
+
|
|
3015
|
+
if (createResult.ok) {
|
|
3016
|
+
const issueNumber = buildIssueNumberFromPayload(createResult.body)
|
|
3017
|
+
if (issueNumber === null) {
|
|
3018
|
+
throw new Error(
|
|
3019
|
+
`Created issue response for ${taskId} in ${owner}/${repo} is invalid: ${JSON.stringify(createResult.body)}`
|
|
3020
|
+
)
|
|
3021
|
+
}
|
|
3022
|
+
return issueNumber
|
|
3023
|
+
}
|
|
3024
|
+
|
|
3025
|
+
const shouldRetry = retry.retryOnStatuses.has(createResult.status) && attempt < maxAttempts
|
|
3026
|
+
if (!shouldRetry) {
|
|
3027
|
+
const bodySummary = (() => {
|
|
3028
|
+
try {
|
|
3029
|
+
if (typeof createResult.body === 'string') {
|
|
3030
|
+
return createResult.body.slice(0, 500)
|
|
3031
|
+
}
|
|
3032
|
+
return JSON.stringify(createResult.body)?.slice(0, 500) ?? String(createResult.body)
|
|
3033
|
+
} catch {
|
|
3034
|
+
return String(createResult.body)
|
|
3035
|
+
}
|
|
3036
|
+
})()
|
|
3037
|
+
throw new Error(
|
|
3038
|
+
`Failed to create issue for ${taskId} in ${owner}/${repo}: status=${createResult.status} url=${createResult.request.url} body=${bodySummary}`
|
|
3039
|
+
)
|
|
3040
|
+
}
|
|
3041
|
+
|
|
3042
|
+
log?.(`Create issue for ${taskId}: retryable response status=${createResult.status}; checking if issue already exists...`)
|
|
3043
|
+
const existing = await findIssueNumberByTaskIdEventually(
|
|
3044
|
+
issueList,
|
|
3045
|
+
owner,
|
|
3046
|
+
repo,
|
|
3047
|
+
taskId,
|
|
3048
|
+
projectName,
|
|
3049
|
+
retry,
|
|
3050
|
+
throttleMs,
|
|
3051
|
+
log,
|
|
3052
|
+
verifyAttempts,
|
|
3053
|
+
verifyDelayMs,
|
|
3054
|
+
)
|
|
3055
|
+
if (existing) {
|
|
3056
|
+
log?.(`Create issue for ${taskId}: found existing issue #${existing} after failure; continuing.`)
|
|
3057
|
+
return existing
|
|
3058
|
+
}
|
|
3059
|
+
|
|
3060
|
+
const delay = retryDelayMs(attempt, retry.baseDelayMs, retry.maxDelayMs)
|
|
3061
|
+
log?.(`Create issue for ${taskId}: retrying (attempt ${attempt}/${maxAttempts}) delay=${delay}ms`)
|
|
3062
|
+
if (delay > 0) await sleep(delay)
|
|
3063
|
+
} catch (error) {
|
|
3064
|
+
if (attempt >= maxAttempts || !isRetryableError(error, retry.retryOnStatuses)) {
|
|
3065
|
+
throw error
|
|
3066
|
+
}
|
|
3067
|
+
|
|
3068
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
3069
|
+
log?.(`Create issue for ${taskId}: retryable error (${message}); checking if issue already exists...`)
|
|
3070
|
+
const existing = await findIssueNumberByTaskIdEventually(
|
|
3071
|
+
issueList,
|
|
3072
|
+
owner,
|
|
3073
|
+
repo,
|
|
3074
|
+
taskId,
|
|
3075
|
+
projectName,
|
|
3076
|
+
retry,
|
|
3077
|
+
throttleMs,
|
|
3078
|
+
log,
|
|
3079
|
+
verifyAttempts,
|
|
3080
|
+
verifyDelayMs,
|
|
3081
|
+
)
|
|
3082
|
+
if (existing) {
|
|
3083
|
+
log?.(`Create issue for ${taskId}: found existing issue #${existing} after error; continuing.`)
|
|
3084
|
+
return existing
|
|
3085
|
+
}
|
|
3086
|
+
|
|
3087
|
+
const delay = retryDelayMs(attempt, retry.baseDelayMs, retry.maxDelayMs)
|
|
3088
|
+
log?.(`Create issue for ${taskId}: retrying (attempt ${attempt}/${maxAttempts}) delay=${delay}ms`)
|
|
3089
|
+
if (delay > 0) await sleep(delay)
|
|
3090
|
+
}
|
|
3091
|
+
}
|
|
3092
|
+
|
|
3093
|
+
throw new Error(`Create issue for ${taskId}: retry loop exhausted`)
|
|
3094
|
+
}
|
|
3095
|
+
|
|
1517
3096
|
function issueDependenciesMatches(taskDeps: string[], issue: ProjectSyncIssueRecord): boolean {
|
|
1518
3097
|
const fromBody = issue.dependenciesFromBody
|
|
1519
3098
|
if (fromBody) {
|
|
@@ -1788,9 +3367,21 @@ export async function writeGitTask(
|
|
|
1788
3367
|
}
|
|
1789
3368
|
|
|
1790
3369
|
if (!issueNumber) {
|
|
1791
|
-
const createResult = await issueCreate(owner, repo, payload)
|
|
3370
|
+
const createResult = await issueCreate(owner, repo, { data: payload })
|
|
1792
3371
|
if (!createResult.ok) {
|
|
1793
|
-
|
|
3372
|
+
const bodySummary = (() => {
|
|
3373
|
+
try {
|
|
3374
|
+
if (typeof createResult.body === 'string') {
|
|
3375
|
+
return createResult.body.slice(0, 500)
|
|
3376
|
+
}
|
|
3377
|
+
return JSON.stringify(createResult.body)?.slice(0, 500) ?? String(createResult.body)
|
|
3378
|
+
} catch {
|
|
3379
|
+
return String(createResult.body)
|
|
3380
|
+
}
|
|
3381
|
+
})()
|
|
3382
|
+
throw new Error(
|
|
3383
|
+
`Failed to create task in ${owner}/${repo}: status=${createResult.status} url=${createResult.request.url} body=${bodySummary}`
|
|
3384
|
+
)
|
|
1794
3385
|
}
|
|
1795
3386
|
|
|
1796
3387
|
const issue = toProjectGitTaskRecord(createResult.body as GitIssueLike)
|
|
@@ -1808,9 +3399,21 @@ export async function writeGitTask(
|
|
|
1808
3399
|
}
|
|
1809
3400
|
}
|
|
1810
3401
|
|
|
1811
|
-
const updateResult = await issueEdit(owner, repo, issueNumber, payload)
|
|
3402
|
+
const updateResult = await issueEdit(owner, repo, issueNumber, { data: payload })
|
|
1812
3403
|
if (!updateResult.ok) {
|
|
1813
|
-
|
|
3404
|
+
const bodySummary = (() => {
|
|
3405
|
+
try {
|
|
3406
|
+
if (typeof updateResult.body === 'string') {
|
|
3407
|
+
return updateResult.body.slice(0, 500)
|
|
3408
|
+
}
|
|
3409
|
+
return JSON.stringify(updateResult.body)?.slice(0, 500) ?? String(updateResult.body)
|
|
3410
|
+
} catch {
|
|
3411
|
+
return String(updateResult.body)
|
|
3412
|
+
}
|
|
3413
|
+
})()
|
|
3414
|
+
throw new Error(
|
|
3415
|
+
`Failed to update issue #${issueNumber} for ${owner}/${repo}: status=${updateResult.status} url=${updateResult.request.url} body=${bodySummary}`
|
|
3416
|
+
)
|
|
1814
3417
|
}
|
|
1815
3418
|
|
|
1816
3419
|
const issue = toProjectGitTaskRecord(updateResult.body as GitIssueLike)
|
|
@@ -1828,6 +3431,63 @@ export async function writeGitTask(
|
|
|
1828
3431
|
}
|
|
1829
3432
|
}
|
|
1830
3433
|
|
|
3434
|
+
export async function createGitIssue(
|
|
3435
|
+
projectName: string,
|
|
3436
|
+
options: ProjectCreateGitIssueOptions,
|
|
3437
|
+
processRoot: string = process.cwd()
|
|
3438
|
+
): Promise<ProjectCreateGitIssueResult> {
|
|
3439
|
+
const { owner, repo } = resolveProjectGitRepository(projectName, processRoot, options)
|
|
3440
|
+
const title = typeof options.title === 'string' ? options.title.trim() : ''
|
|
3441
|
+
if (!title) {
|
|
3442
|
+
throw new Error('Issue title is required.')
|
|
3443
|
+
}
|
|
3444
|
+
|
|
3445
|
+
const payload: Record<string, unknown> = { title }
|
|
3446
|
+
if (typeof options.body === 'string') payload.body = options.body
|
|
3447
|
+
if (options.labels) payload.labels = options.labels
|
|
3448
|
+
|
|
3449
|
+
const git = createGitServiceApi({
|
|
3450
|
+
config: {
|
|
3451
|
+
platform: 'GITEA',
|
|
3452
|
+
giteaHost: resolveGiteaHost(),
|
|
3453
|
+
giteaToken: process.env.GITEA_TOKEN,
|
|
3454
|
+
},
|
|
3455
|
+
defaultOwner: owner,
|
|
3456
|
+
defaultRepo: repo,
|
|
3457
|
+
})
|
|
3458
|
+
|
|
3459
|
+
const issueCreate = resolveIssueApiMethod(git, 'create')
|
|
3460
|
+
const createResult = await issueCreate(owner, repo, { data: payload })
|
|
3461
|
+
if (!createResult.ok) {
|
|
3462
|
+
const bodySummary = (() => {
|
|
3463
|
+
try {
|
|
3464
|
+
if (typeof createResult.body === 'string') {
|
|
3465
|
+
return createResult.body.slice(0, 500)
|
|
3466
|
+
}
|
|
3467
|
+
return JSON.stringify(createResult.body)?.slice(0, 500) ?? String(createResult.body)
|
|
3468
|
+
} catch {
|
|
3469
|
+
return String(createResult.body)
|
|
3470
|
+
}
|
|
3471
|
+
})()
|
|
3472
|
+
throw new Error(
|
|
3473
|
+
`Failed to create issue in ${owner}/${repo}: status=${createResult.status} url=${createResult.request.url} body=${bodySummary}`
|
|
3474
|
+
)
|
|
3475
|
+
}
|
|
3476
|
+
|
|
3477
|
+
const issue = toProjectGitIssueRecord(createResult.body as GitIssueLike)
|
|
3478
|
+
if (!issue) {
|
|
3479
|
+
throw new Error(`Create issue response invalid for ${owner}/${repo}`)
|
|
3480
|
+
}
|
|
3481
|
+
|
|
3482
|
+
return {
|
|
3483
|
+
projectName,
|
|
3484
|
+
owner,
|
|
3485
|
+
repo,
|
|
3486
|
+
issueNumber: issue.number,
|
|
3487
|
+
issue,
|
|
3488
|
+
}
|
|
3489
|
+
}
|
|
3490
|
+
|
|
1831
3491
|
export async function syncTasks(
|
|
1832
3492
|
projectName: string,
|
|
1833
3493
|
options: Partial<ProjectSyncTaskOptions> = {},
|
|
@@ -1847,8 +3507,11 @@ export async function syncTasks(
|
|
|
1847
3507
|
dryRun: options.dryRun ?? false,
|
|
1848
3508
|
max: options.max,
|
|
1849
3509
|
maxBodyLength: options.maxBodyLength,
|
|
1850
|
-
requestTimeoutMs: options.requestTimeoutMs,
|
|
1851
|
-
throttleMs: options.throttleMs,
|
|
3510
|
+
requestTimeoutMs: options.requestTimeoutMs ?? 60_000,
|
|
3511
|
+
throttleMs: options.throttleMs ?? 200,
|
|
3512
|
+
retryMax: options.retryMax ?? 5,
|
|
3513
|
+
retryBaseDelayMs: options.retryBaseDelayMs ?? 500,
|
|
3514
|
+
retryMaxDelayMs: options.retryMaxDelayMs ?? 10_000,
|
|
1852
3515
|
skipDependencies: options.skipDependencies,
|
|
1853
3516
|
owner,
|
|
1854
3517
|
repo,
|
|
@@ -1869,7 +3532,9 @@ export async function syncTasks(
|
|
|
1869
3532
|
log?.(`Resolved tracks file: ${tracksPath}`)
|
|
1870
3533
|
log?.(`Using issue destination: ${owner}/${repo}`)
|
|
1871
3534
|
log?.(`HTTP request timeout: ${syncOptions.requestTimeoutMs ?? 'default'}`)
|
|
3535
|
+
log?.(`HTTP transport: ${(process.env.EXAMPLE_GIT_HTTP_TRANSPORT ?? '').trim() || '(auto)'}`)
|
|
1872
3536
|
log?.(`HTTP throttle: ${syncOptions.throttleMs ?? 0}ms`)
|
|
3537
|
+
log?.(`HTTP retry: max=${syncOptions.retryMax ?? 0} baseDelayMs=${syncOptions.retryBaseDelayMs ?? 0} maxDelayMs=${syncOptions.retryMaxDelayMs ?? 0}`)
|
|
1873
3538
|
log?.(`Dependency sync: ${syncOptions.skipDependencies ? 'skipped' : 'enabled'}`)
|
|
1874
3539
|
const markdown = await fs.readFile(tracksPath, 'utf8')
|
|
1875
3540
|
log?.(`Loaded tracks content (${markdown.length} chars).`)
|
|
@@ -1900,8 +3565,10 @@ export async function syncTasks(
|
|
|
1900
3565
|
const issueList = resolveIssueApiMethod(git, 'list')
|
|
1901
3566
|
const issueCreate = resolveIssueApiMethod(git, 'create')
|
|
1902
3567
|
const issueEdit = resolveIssueApiMethod(git, 'edit')
|
|
3568
|
+
const issueView = resolveIssueApiMethod(git, 'view')
|
|
1903
3569
|
|
|
1904
|
-
const
|
|
3570
|
+
const retry = resolveNetworkRetryOptions(syncOptions)
|
|
3571
|
+
const allIssues = await fetchAllIssues(issueList, owner, repo, 'all', projectName, log, { retry, throttleMs: syncOptions.throttleMs ?? 0 })
|
|
1905
3572
|
const existing = indexExistingTaskIssues(allIssues, projectName)
|
|
1906
3573
|
const indexedIssueCount = [...existing.values()].reduce((acc, bucket) => acc + bucket.length, 0)
|
|
1907
3574
|
log?.(`Loaded ${indexedIssueCount} task-like issues in ${owner}/${repo}.`)
|
|
@@ -1917,9 +3584,6 @@ export async function syncTasks(
|
|
|
1917
3584
|
unchanged: 0,
|
|
1918
3585
|
}
|
|
1919
3586
|
|
|
1920
|
-
const sleep = (ms: number) =>
|
|
1921
|
-
new Promise<void>((resolve) => setTimeout(resolve, ms))
|
|
1922
|
-
|
|
1923
3587
|
for (let i = 0; i < selected.length; i += 1) {
|
|
1924
3588
|
const task = selected[i]
|
|
1925
3589
|
const marker = `[${i + 1}/${selected.length}] ${task.id}`
|
|
@@ -1941,17 +3605,55 @@ export async function syncTasks(
|
|
|
1941
3605
|
log?.(`${marker} updating #${targetIssue.number}`)
|
|
1942
3606
|
if (!syncOptions.dryRun) {
|
|
1943
3607
|
log?.(`${marker} sending update request...`)
|
|
1944
|
-
const updatedResult = await
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
|
|
3608
|
+
const updatedResult = await withRetry(
|
|
3609
|
+
`${marker} issue.edit #${targetIssue.number}`,
|
|
3610
|
+
() => issueEdit(owner, repo, targetIssue.number, {
|
|
3611
|
+
data: {
|
|
3612
|
+
title: payload.title,
|
|
3613
|
+
body: payload.body,
|
|
3614
|
+
labels: payload.labels,
|
|
3615
|
+
task_id: payload.task_id,
|
|
3616
|
+
task_dependencies: payload.task_dependencies,
|
|
3617
|
+
},
|
|
3618
|
+
}) as Promise<GitServiceApiExecutionResult>,
|
|
3619
|
+
retry,
|
|
3620
|
+
log,
|
|
3621
|
+
(result) => {
|
|
3622
|
+
if (result.ok) return null
|
|
3623
|
+
return retry.retryOnStatuses.has(result.status) ? `status=${result.status}` : null
|
|
1951
3624
|
},
|
|
1952
|
-
|
|
3625
|
+
)
|
|
1953
3626
|
|
|
1954
3627
|
if (!updatedResult.ok) {
|
|
3628
|
+
const isTimeoutOrRetryable = retry.retryOnStatuses.has(updatedResult.status)
|
|
3629
|
+
if (isTimeoutOrRetryable) {
|
|
3630
|
+
try {
|
|
3631
|
+
const viewResult = await withRetry(
|
|
3632
|
+
`${marker} issue.view #${targetIssue.number}`,
|
|
3633
|
+
() => issueView(owner, repo, targetIssue.number) as Promise<GitServiceApiExecutionResult<GitIssueLike>>,
|
|
3634
|
+
retry,
|
|
3635
|
+
log,
|
|
3636
|
+
(result) => {
|
|
3637
|
+
if (result.ok) return null
|
|
3638
|
+
return retry.retryOnStatuses.has(result.status) ? `status=${result.status}` : null
|
|
3639
|
+
},
|
|
3640
|
+
)
|
|
3641
|
+
|
|
3642
|
+
if (viewResult.ok) {
|
|
3643
|
+
const record = toProjectGitTaskRecord(viewResult.body as GitIssueLike)
|
|
3644
|
+
const expectedHash = computeTaskHash(task)
|
|
3645
|
+
const depsMatch = areDependencyListsEqual(task.deps, record?.taskDependenciesFromPayload ?? [])
|
|
3646
|
+
const signatureMatch = record?.taskSignature?.toLowerCase() === expectedHash.toLowerCase()
|
|
3647
|
+
if (signatureMatch && depsMatch) {
|
|
3648
|
+
log?.(`${marker} update likely succeeded (verified after retryable failure)`)
|
|
3649
|
+
continue
|
|
3650
|
+
}
|
|
3651
|
+
}
|
|
3652
|
+
} catch {
|
|
3653
|
+
// best effort
|
|
3654
|
+
}
|
|
3655
|
+
}
|
|
3656
|
+
|
|
1955
3657
|
throw new Error(
|
|
1956
3658
|
`Failed to update issue ${targetIssue.number} for ${owner}/${repo}: status=${updatedResult.status} url=${updatedResult.request.url}`
|
|
1957
3659
|
)
|
|
@@ -1966,45 +3668,25 @@ export async function syncTasks(
|
|
|
1966
3668
|
|
|
1967
3669
|
log?.(`${marker} creating new task issue`)
|
|
1968
3670
|
if (!syncOptions.dryRun) {
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
|
|
1987
|
-
|
|
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
|
-
}
|
|
3671
|
+
log?.(`${marker} sending create request...`)
|
|
3672
|
+
const issueNumber = await createTaskIssueNumberWithRetry(
|
|
3673
|
+
issueCreate,
|
|
3674
|
+
issueList,
|
|
3675
|
+
owner,
|
|
3676
|
+
repo,
|
|
3677
|
+
task.id,
|
|
3678
|
+
{
|
|
3679
|
+
title: payload.title,
|
|
3680
|
+
body: payload.body,
|
|
3681
|
+
labels: payload.labels,
|
|
3682
|
+
task_id: payload.task_id,
|
|
3683
|
+
task_dependencies: payload.task_dependencies,
|
|
3684
|
+
},
|
|
3685
|
+
projectName,
|
|
3686
|
+
retry,
|
|
3687
|
+
syncOptions.throttleMs ?? 0,
|
|
3688
|
+
log,
|
|
3689
|
+
)
|
|
2008
3690
|
issueNumbersByTask.set(task.id, issueNumber)
|
|
2009
3691
|
|
|
2010
3692
|
log?.(`${marker} created #${issueNumber}`)
|
|
@@ -2057,15 +3739,20 @@ export async function syncTasks(
|
|
|
2057
3739
|
.filter((value): value is number => value !== undefined && value > 0)
|
|
2058
3740
|
|
|
2059
3741
|
log?.(`Issue #${issueNumber}: syncing dependencies (${desiredDependencyIssues.length} desired)...`)
|
|
2060
|
-
const syncResult = await
|
|
2061
|
-
owner
|
|
2062
|
-
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
|
|
2067
|
-
|
|
2068
|
-
|
|
3742
|
+
const syncResult = await withRetry(
|
|
3743
|
+
`issue.dependencies ${owner}/${repo} #${issueNumber}`,
|
|
3744
|
+
() => syncIssueDependenciesViaApi(
|
|
3745
|
+
owner,
|
|
3746
|
+
repo,
|
|
3747
|
+
issueNumber,
|
|
3748
|
+
desiredDependencyIssues,
|
|
3749
|
+
host,
|
|
3750
|
+
token,
|
|
3751
|
+
syncOptions.dryRun,
|
|
3752
|
+
log
|
|
3753
|
+
),
|
|
3754
|
+
retry,
|
|
3755
|
+
log,
|
|
2069
3756
|
)
|
|
2070
3757
|
dependencyStats.added += syncResult.added
|
|
2071
3758
|
dependencyStats.removed += syncResult.removed
|
|
@@ -2231,7 +3918,12 @@ export async function main(argv: string[], processRoot: string = process.cwd()):
|
|
|
2231
3918
|
throw new Error('Missing required arguments: <project-name>.')
|
|
2232
3919
|
}
|
|
2233
3920
|
|
|
2234
|
-
if (
|
|
3921
|
+
if (
|
|
3922
|
+
maybeTarget === 'sync-tasks' ||
|
|
3923
|
+
maybeTarget === '--sync-tasks' ||
|
|
3924
|
+
maybeTarget === 'sync-issues' ||
|
|
3925
|
+
maybeTarget === '--sync-issues'
|
|
3926
|
+
) {
|
|
2235
3927
|
const parsedSync = parseSyncTasksArgs(rest)
|
|
2236
3928
|
if (parsedSync.unknownFlags.length > 0) {
|
|
2237
3929
|
throw new Error(`Unknown flags: ${parsedSync.unknownFlags.join(', ')}`)
|
|
@@ -2243,7 +3935,7 @@ export async function main(argv: string[], processRoot: string = process.cwd()):
|
|
|
2243
3935
|
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
3936
|
}
|
|
2245
3937
|
|
|
2246
|
-
if (maybeTarget === 'clear-issues') {
|
|
3938
|
+
if (maybeTarget === 'clear-issues' || maybeTarget === '--clear-issues') {
|
|
2247
3939
|
const parsedClear = parseClearIssuesArgs(rest)
|
|
2248
3940
|
if (parsedClear.unknownFlags.length > 0) {
|
|
2249
3941
|
throw new Error(`Unknown flags: ${parsedClear.unknownFlags.join(', ')}`)
|