@foundation0/api 1.1.2 → 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,6 +171,22 @@ export type ProjectWriteGitTaskResult = {
160
171
  issue: ProjectGitTaskRecord
161
172
  }
162
173
 
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
+
163
190
  type ProjectSearchTargetSection = 'docs' | 'spec'
164
191
 
165
192
  type ProjectSearchOptions = {
@@ -227,7 +254,7 @@ export type ProjectSearchResult = {
227
254
  output: string
228
255
  }
229
256
 
230
- const CLI_NAME = 'example'
257
+ const CLI_NAME = 'f0'
231
258
  const VERSION_RE = 'v?\\d+(?:\\.\\d+){2,}'
232
259
  const VERSION_RE_CORE = `(?:${VERSION_RE})`
233
260
  const ACTIVE_EXT_PRIORITY = ['.md', '.json', '.yaml', '.yml', '.ts', '.js', '.txt']
@@ -268,10 +295,10 @@ function resolveProjectsRoot(processRoot: string): string {
268
295
  }
269
296
 
270
297
  export function usage(): string {
271
- 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` +
272
- `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` +
273
300
  `file-path is relative to the project root (leading slash required).\n` +
274
- `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` +
275
302
  `Use --verbose with clear-issues to print each deletion.\n` +
276
303
  `Use --latest to resolve /file-name to the latest version.\n` +
277
304
  `The active file created is [file].active.<ext>.\n`
@@ -1943,7 +1970,10 @@ function parseSyncTasksArgs(argv: string[]): {
1943
1970
  prefix: '[TASK]',
1944
1971
  dryRun: false,
1945
1972
  requestTimeoutMs: 60_000,
1946
- throttleMs: 0,
1973
+ throttleMs: 200,
1974
+ retryMax: 5,
1975
+ retryBaseDelayMs: 500,
1976
+ retryMaxDelayMs: 10_000,
1947
1977
  skipDependencies: false,
1948
1978
  verbose: false,
1949
1979
  }
@@ -2053,6 +2083,41 @@ function parseSyncTasksArgs(argv: string[]): {
2053
2083
  continue
2054
2084
  }
2055
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
+
2056
2121
  if (arg === '--skip-dependencies') {
2057
2122
  options.skipDependencies = true
2058
2123
  continue
@@ -2567,6 +2632,21 @@ function toProjectGitTaskRecord(issue: GitIssueLike): ProjectGitTaskRecord | nul
2567
2632
  }
2568
2633
  }
2569
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
+
2570
2650
  function resolveProjectGitRepository(
2571
2651
  projectName: string,
2572
2652
  processRoot: string,
@@ -2660,24 +2740,141 @@ type GitIssueLike = {
2660
2740
  body?: unknown
2661
2741
  }
2662
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
+
2663
2847
  async function fetchAllIssues(
2664
2848
  issueList: GitServiceApiMethod,
2665
2849
  owner: string,
2666
2850
  repo: string,
2667
2851
  state: 'open' | 'closed' | 'all',
2668
2852
  projectName: string,
2669
- log?: (message: string) => void
2853
+ log?: (message: string) => void,
2854
+ requestOptions?: { retry?: NetworkRetryOptions; throttleMs?: number },
2670
2855
  ): Promise<GitIssueLike[]> {
2671
2856
  const allIssues: GitIssueLike[] = []
2672
2857
  let page = 1
2858
+ const retry = requestOptions?.retry ?? resolveNetworkRetryOptions({ retryMax: 0, retryBaseDelayMs: 0, retryMaxDelayMs: 0 })
2859
+ const throttleMs = requestOptions?.throttleMs ?? 0
2673
2860
 
2674
2861
  while (true) {
2675
2862
  log?.(`Listing ${state} issues page ${page} for ${owner}/${repo}...`)
2676
- const listResult = await issueList(owner, repo, {
2677
- state,
2678
- limit: ISSUE_LIST_PAGE_SIZE,
2679
- query: { page },
2680
- })
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
+ )
2681
2878
 
2682
2879
  if (!listResult.ok) {
2683
2880
  throw new Error(
@@ -2697,12 +2894,205 @@ async function fetchAllIssues(
2697
2894
  break
2698
2895
  }
2699
2896
  page += 1
2897
+
2898
+ if (throttleMs > 0) {
2899
+ await sleep(throttleMs)
2900
+ }
2700
2901
  }
2701
2902
 
2702
2903
  log?.(`Loaded ${allIssues.length} ${state} issues in ${owner}/${repo} across pages.`)
2703
2904
  return allIssues
2704
2905
  }
2705
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
+
2706
3096
  function issueDependenciesMatches(taskDeps: string[], issue: ProjectSyncIssueRecord): boolean {
2707
3097
  const fromBody = issue.dependenciesFromBody
2708
3098
  if (fromBody) {
@@ -2977,9 +3367,21 @@ export async function writeGitTask(
2977
3367
  }
2978
3368
 
2979
3369
  if (!issueNumber) {
2980
- const createResult = await issueCreate(owner, repo, payload)
3370
+ const createResult = await issueCreate(owner, repo, { data: payload })
2981
3371
  if (!createResult.ok) {
2982
- 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
+ )
2983
3385
  }
2984
3386
 
2985
3387
  const issue = toProjectGitTaskRecord(createResult.body as GitIssueLike)
@@ -2997,9 +3399,21 @@ export async function writeGitTask(
2997
3399
  }
2998
3400
  }
2999
3401
 
3000
- const updateResult = await issueEdit(owner, repo, issueNumber, payload)
3402
+ const updateResult = await issueEdit(owner, repo, issueNumber, { data: payload })
3001
3403
  if (!updateResult.ok) {
3002
- 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
+ )
3003
3417
  }
3004
3418
 
3005
3419
  const issue = toProjectGitTaskRecord(updateResult.body as GitIssueLike)
@@ -3017,6 +3431,63 @@ export async function writeGitTask(
3017
3431
  }
3018
3432
  }
3019
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
+
3020
3491
  export async function syncTasks(
3021
3492
  projectName: string,
3022
3493
  options: Partial<ProjectSyncTaskOptions> = {},
@@ -3036,8 +3507,11 @@ export async function syncTasks(
3036
3507
  dryRun: options.dryRun ?? false,
3037
3508
  max: options.max,
3038
3509
  maxBodyLength: options.maxBodyLength,
3039
- requestTimeoutMs: options.requestTimeoutMs,
3040
- 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,
3041
3515
  skipDependencies: options.skipDependencies,
3042
3516
  owner,
3043
3517
  repo,
@@ -3058,7 +3532,9 @@ export async function syncTasks(
3058
3532
  log?.(`Resolved tracks file: ${tracksPath}`)
3059
3533
  log?.(`Using issue destination: ${owner}/${repo}`)
3060
3534
  log?.(`HTTP request timeout: ${syncOptions.requestTimeoutMs ?? 'default'}`)
3535
+ log?.(`HTTP transport: ${(process.env.EXAMPLE_GIT_HTTP_TRANSPORT ?? '').trim() || '(auto)'}`)
3061
3536
  log?.(`HTTP throttle: ${syncOptions.throttleMs ?? 0}ms`)
3537
+ log?.(`HTTP retry: max=${syncOptions.retryMax ?? 0} baseDelayMs=${syncOptions.retryBaseDelayMs ?? 0} maxDelayMs=${syncOptions.retryMaxDelayMs ?? 0}`)
3062
3538
  log?.(`Dependency sync: ${syncOptions.skipDependencies ? 'skipped' : 'enabled'}`)
3063
3539
  const markdown = await fs.readFile(tracksPath, 'utf8')
3064
3540
  log?.(`Loaded tracks content (${markdown.length} chars).`)
@@ -3089,8 +3565,10 @@ export async function syncTasks(
3089
3565
  const issueList = resolveIssueApiMethod(git, 'list')
3090
3566
  const issueCreate = resolveIssueApiMethod(git, 'create')
3091
3567
  const issueEdit = resolveIssueApiMethod(git, 'edit')
3568
+ const issueView = resolveIssueApiMethod(git, 'view')
3092
3569
 
3093
- 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 })
3094
3572
  const existing = indexExistingTaskIssues(allIssues, projectName)
3095
3573
  const indexedIssueCount = [...existing.values()].reduce((acc, bucket) => acc + bucket.length, 0)
3096
3574
  log?.(`Loaded ${indexedIssueCount} task-like issues in ${owner}/${repo}.`)
@@ -3106,9 +3584,6 @@ export async function syncTasks(
3106
3584
  unchanged: 0,
3107
3585
  }
3108
3586
 
3109
- const sleep = (ms: number) =>
3110
- new Promise<void>((resolve) => setTimeout(resolve, ms))
3111
-
3112
3587
  for (let i = 0; i < selected.length; i += 1) {
3113
3588
  const task = selected[i]
3114
3589
  const marker = `[${i + 1}/${selected.length}] ${task.id}`
@@ -3130,17 +3605,55 @@ export async function syncTasks(
3130
3605
  log?.(`${marker} updating #${targetIssue.number}`)
3131
3606
  if (!syncOptions.dryRun) {
3132
3607
  log?.(`${marker} sending update request...`)
3133
- const updatedResult = await issueEdit(owner, repo, targetIssue.number, {
3134
- data: {
3135
- title: payload.title,
3136
- body: payload.body,
3137
- labels: payload.labels,
3138
- task_id: payload.task_id,
3139
- 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
3140
3624
  },
3141
- }) as GitServiceApiExecutionResult
3625
+ )
3142
3626
 
3143
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
+
3144
3657
  throw new Error(
3145
3658
  `Failed to update issue ${targetIssue.number} for ${owner}/${repo}: status=${updatedResult.status} url=${updatedResult.request.url}`
3146
3659
  )
@@ -3155,45 +3668,25 @@ export async function syncTasks(
3155
3668
 
3156
3669
  log?.(`${marker} creating new task issue`)
3157
3670
  if (!syncOptions.dryRun) {
3158
- let createdResult: GitServiceApiExecutionResult
3159
- try {
3160
- log?.(`${marker} sending create request...`)
3161
- createdResult = await issueCreate(owner, repo, {
3162
- data: {
3163
- title: payload.title,
3164
- body: payload.body,
3165
- labels: payload.labels,
3166
- task_id: payload.task_id,
3167
- task_dependencies: payload.task_dependencies,
3168
- },
3169
- }) as GitServiceApiExecutionResult
3170
- } catch (error) {
3171
- const message = error instanceof Error ? error.message : String(error)
3172
- throw new Error(`Failed to create issue for ${task.id} in ${owner}/${repo}: ${message}`)
3173
- }
3174
-
3175
- if (!createdResult.ok) {
3176
- const bodySummary = (() => {
3177
- try {
3178
- if (typeof createdResult.body === 'string') {
3179
- return createdResult.body.slice(0, 500)
3180
- }
3181
- return JSON.stringify(createdResult.body)?.slice(0, 500) ?? String(createdResult.body)
3182
- } catch {
3183
- return String(createdResult.body)
3184
- }
3185
- })()
3186
- throw new Error(
3187
- `Failed to create issue for ${task.id} in ${owner}/${repo}: status=${createdResult.status} url=${createdResult.request.url} body=${bodySummary}`
3188
- )
3189
- }
3190
-
3191
- const issueNumber = buildIssueNumberFromPayload(createdResult.body)
3192
- if (issueNumber === null) {
3193
- throw new Error(
3194
- `Created issue response for ${task.id} in ${owner}/${repo} is invalid: ${JSON.stringify(createdResult.body)}`
3195
- )
3196
- }
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
+ )
3197
3690
  issueNumbersByTask.set(task.id, issueNumber)
3198
3691
 
3199
3692
  log?.(`${marker} created #${issueNumber}`)
@@ -3246,15 +3739,20 @@ export async function syncTasks(
3246
3739
  .filter((value): value is number => value !== undefined && value > 0)
3247
3740
 
3248
3741
  log?.(`Issue #${issueNumber}: syncing dependencies (${desiredDependencyIssues.length} desired)...`)
3249
- const syncResult = await syncIssueDependenciesViaApi(
3250
- owner,
3251
- repo,
3252
- issueNumber,
3253
- desiredDependencyIssues,
3254
- host,
3255
- token,
3256
- syncOptions.dryRun,
3257
- 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,
3258
3756
  )
3259
3757
  dependencyStats.added += syncResult.added
3260
3758
  dependencyStats.removed += syncResult.removed
@@ -3420,7 +3918,12 @@ export async function main(argv: string[], processRoot: string = process.cwd()):
3420
3918
  throw new Error('Missing required arguments: <project-name>.')
3421
3919
  }
3422
3920
 
3423
- if (maybeTarget === 'sync-tasks') {
3921
+ if (
3922
+ maybeTarget === 'sync-tasks' ||
3923
+ maybeTarget === '--sync-tasks' ||
3924
+ maybeTarget === 'sync-issues' ||
3925
+ maybeTarget === '--sync-issues'
3926
+ ) {
3424
3927
  const parsedSync = parseSyncTasksArgs(rest)
3425
3928
  if (parsedSync.unknownFlags.length > 0) {
3426
3929
  throw new Error(`Unknown flags: ${parsedSync.unknownFlags.join(', ')}`)
@@ -3432,7 +3935,7 @@ export async function main(argv: string[], processRoot: string = process.cwd()):
3432
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})`
3433
3936
  }
3434
3937
 
3435
- if (maybeTarget === 'clear-issues') {
3938
+ if (maybeTarget === 'clear-issues' || maybeTarget === '--clear-issues') {
3436
3939
  const parsedClear = parseClearIssuesArgs(rest)
3437
3940
  if (parsedClear.unknownFlags.length > 0) {
3438
3941
  throw new Error(`Unknown flags: ${parsedClear.unknownFlags.join(', ')}`)