@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/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
- const CLI_NAME = 'example'
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 --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` +
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
- 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)
1457
+ function globToRegExp(glob: string): RegExp {
1458
+ let index = 0
1459
+ let source = '^'
261
1460
 
262
- if (!sourcePath) {
263
- throw new Error(`Doc file not found: ${requestPath}`)
264
- }
1461
+ while (index < glob.length) {
1462
+ const char = glob[index]
265
1463
 
266
- const content = await readTextIfExists(sourcePath)
267
- if (content === null) {
268
- throw new Error(`Doc file not found: ${requestPath}`)
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
- return content
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: 0,
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 issueList(owner, repo, {
1488
- state,
1489
- limit: ISSUE_LIST_PAGE_SIZE,
1490
- query: { page },
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
- throw new Error(`Failed to create task in ${owner}/${repo}: status=${createResult.status} url=${createResult.request.url}`)
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
- throw new Error(`Failed to update issue #${issueNumber} for ${owner}/${repo}: status=${updateResult.status} url=${updateResult.request.url}`)
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 allIssues = await fetchAllIssues(issueList, owner, repo, 'all', projectName, log)
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 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,
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
- }) as GitServiceApiExecutionResult
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
- 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
- }
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 syncIssueDependenciesViaApi(
2061
- owner,
2062
- repo,
2063
- issueNumber,
2064
- desiredDependencyIssues,
2065
- host,
2066
- token,
2067
- syncOptions.dryRun,
2068
- log
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 (maybeTarget === 'sync-tasks') {
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(', ')}`)