@foundation0/git 1.3.0 → 1.3.1

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.
@@ -1,533 +1,533 @@
1
- import type { GitServiceApiExecutionResult } from './git-service-api'
2
- import { spawn } from 'node:child_process'
3
- import crypto from 'node:crypto'
4
-
5
- const DEFAULT_REQUEST_TIMEOUT_MS = 60_000
6
-
7
- const parseRequestTimeoutMs = (value: unknown): number | null => {
8
- if (typeof value === 'number' && Number.isFinite(value) && value > 0) {
9
- return Math.floor(value)
10
- }
11
- if (typeof value !== 'string') {
12
- return null
13
- }
14
- const trimmed = value.trim()
15
- if (!trimmed) return null
16
- const parsed = Number(trimmed)
17
- if (!Number.isFinite(parsed) || parsed <= 0) return null
18
- return Math.floor(parsed)
19
- }
20
-
21
- const resolveRequestTimeoutMs = (): number => {
22
- const fromEnv =
23
- parseRequestTimeoutMs(process.env.EXAMPLE_GIT_REQUEST_TIMEOUT_MS) ??
24
- parseRequestTimeoutMs(process.env.EXAMPLE_HTTP_REQUEST_TIMEOUT_MS) ??
25
- parseRequestTimeoutMs(process.env.EXAMPLE_HTTP_TIMEOUT_MS)
26
- return fromEnv ?? DEFAULT_REQUEST_TIMEOUT_MS
27
- }
28
-
29
- const canUseAbortSignalTimeout = (): boolean =>
30
- typeof AbortSignal !== 'undefined' && typeof (AbortSignal as unknown as { timeout?: unknown }).timeout === 'function'
31
-
32
- const isTestRuntime = (): boolean =>
33
- Boolean(process.env.VITEST) || process.env.NODE_ENV === 'test'
34
-
35
- const resolveHttpTransport = (requested?: string): 'fetch' | 'curl' => {
36
- const normalized = (requested ?? '').trim().toLowerCase()
37
- if (normalized === 'fetch' || normalized === 'curl') {
38
- return normalized
39
- }
40
-
41
- const isBun = Boolean(process.versions?.bun)
42
- if (!isTestRuntime() && isBun && process.platform === 'win32') {
43
- return 'curl'
44
- }
45
-
46
- return 'fetch'
47
- }
48
-
49
- const fetchWithTimeout = async (url: string, init: RequestInit): Promise<Response> => {
50
- const requestTimeoutMs = resolveRequestTimeoutMs()
51
- if (!Number.isFinite(requestTimeoutMs) || requestTimeoutMs <= 0) {
52
- return fetch(url, init)
53
- }
54
-
55
- const timeoutSignal =
56
- requestTimeoutMs > 0 && canUseAbortSignalTimeout()
57
- ? (AbortSignal as unknown as { timeout: (ms: number) => AbortSignal }).timeout(requestTimeoutMs)
58
- : null
59
- const controller = !timeoutSignal ? new AbortController() : null
60
- const timeoutId = controller ? setTimeout(() => controller.abort(), requestTimeoutMs) : null
61
- try {
62
- return await fetch(url, {
63
- ...init,
64
- ...(timeoutSignal ? { signal: timeoutSignal } : {}),
65
- ...(controller ? { signal: controller.signal } : {}),
66
- })
67
- } catch (error) {
68
- if ((timeoutSignal && timeoutSignal.aborted) || controller?.signal.aborted) {
69
- throw new Error(`Request timed out after ${requestTimeoutMs}ms: ${init.method ?? 'GET'} ${url}`)
70
- }
71
- throw error
72
- } finally {
73
- if (timeoutId) {
74
- clearTimeout(timeoutId)
75
- }
76
- }
77
- }
78
-
79
- const readStream = async (stream: NodeJS.ReadableStream | null): Promise<Buffer> => {
80
- if (!stream) return Buffer.from([])
81
-
82
- return await new Promise<Buffer>((resolve) => {
83
- const chunks: Buffer[] = []
84
- let settled = false
85
-
86
- const cleanup = () => {
87
- stream.removeListener('data', onData)
88
- stream.removeListener('end', onDone)
89
- stream.removeListener('close', onDone)
90
- stream.removeListener('error', onDone)
91
- }
92
-
93
- const settle = () => {
94
- if (settled) return
95
- settled = true
96
- cleanup()
97
- resolve(Buffer.concat(chunks))
98
- }
99
-
100
- const onDone = () => settle()
101
-
102
- const onData = (chunk: unknown) => {
103
- try {
104
- if (typeof chunk === 'string') {
105
- chunks.push(Buffer.from(chunk))
106
- return
107
- }
108
-
109
- chunks.push(Buffer.from(chunk as ArrayBufferView))
110
- } catch {
111
- // best effort
112
- }
113
- }
114
-
115
- stream.on('data', onData)
116
- stream.on('end', onDone)
117
- stream.on('close', onDone)
118
- stream.on('error', onDone)
119
- })
120
- }
121
-
122
- const callCurl = async (
123
- requestUrl: string,
124
- init: { method: string; headers: Record<string, string>; body?: string },
125
- requestTimeoutMs: number,
126
- ): Promise<{ status: number; ok: boolean; bodyText: string }> => {
127
- const curlExe = process.platform === 'win32' ? 'curl.exe' : 'curl'
128
- const statusToken = crypto.randomBytes(8).toString('hex')
129
- const marker = `\n__EXAMPLE_CURL_STATUS_${statusToken}__`
130
- const writeOut = `${marker}%{http_code}${marker}`
131
-
132
- const args: string[] = [
133
- '--silent',
134
- '--show-error',
135
- '--location',
136
- '--request', init.method,
137
- '--write-out', writeOut,
138
- ]
139
-
140
- const timeoutSeconds = requestTimeoutMs > 0 ? Math.max(1, Math.ceil(requestTimeoutMs / 1000)) : null
141
- if (timeoutSeconds !== null) {
142
- args.push('--max-time', String(timeoutSeconds))
143
- args.push('--connect-timeout', String(Math.max(1, Math.min(30, timeoutSeconds))))
144
- }
145
-
146
- for (const [name, value] of Object.entries(init.headers)) {
147
- args.push('--header', `${name}: ${value}`)
148
- }
149
-
150
- if (init.body !== undefined) {
151
- args.push('--data-binary', '@-')
152
- }
153
-
154
- args.push(requestUrl)
155
-
156
- const child = spawn(curlExe, args, {
157
- stdio: ['pipe', 'pipe', 'pipe'],
158
- windowsHide: true,
159
- })
160
-
161
- const hardTimeoutMs = requestTimeoutMs > 0 ? requestTimeoutMs + 2_000 : null
162
- let hardTimedOut = false
163
- const hardTimeoutId = hardTimeoutMs
164
- ? setTimeout(() => {
165
- hardTimedOut = true
166
- try {
167
- child.kill()
168
- } catch {
169
- // best effort
170
- }
171
- try {
172
- child.stdout?.destroy()
173
- } catch {
174
- // best effort
175
- }
176
- try {
177
- child.stderr?.destroy()
178
- } catch {
179
- // best effort
180
- }
181
- }, hardTimeoutMs)
182
- : null
183
-
184
- if (init.body !== undefined) {
185
- child.stdin.write(init.body)
186
- }
187
- child.stdin.end()
188
-
189
- const stdoutPromise = readStream(child.stdout)
190
- const stderrPromise = readStream(child.stderr)
191
-
192
- let exitCode: number
193
- try {
194
- exitCode = await new Promise((resolve) => {
195
- child.on('close', (code) => resolve(code ?? 0))
196
- child.on('error', () => resolve(1))
197
- })
198
- } finally {
199
- if (hardTimeoutId) {
200
- clearTimeout(hardTimeoutId)
201
- }
202
- }
203
-
204
- const [stdoutBytes, stderrBytes] = await Promise.all([stdoutPromise, stderrPromise])
205
-
206
- if (hardTimedOut && requestTimeoutMs > 0) {
207
- throw new Error(`Request timed out after ${requestTimeoutMs}ms: ${init.method} ${requestUrl}`)
208
- }
209
-
210
- const stdout = stdoutBytes.toString('utf8')
211
- const stderr = stderrBytes.toString('utf8').trim()
212
-
213
- if (exitCode !== 0) {
214
- const message = stderr || `curl failed with exit code ${exitCode}`
215
- if (exitCode === 28 && requestTimeoutMs > 0) {
216
- throw new Error(`Request timed out after ${requestTimeoutMs}ms: ${init.method} ${requestUrl}`)
217
- }
218
- throw new Error(message)
219
- }
220
-
221
- const endMarkerIndex = stdout.lastIndexOf(marker)
222
- const startMarkerIndex = endMarkerIndex > -1 ? stdout.lastIndexOf(marker, endMarkerIndex - 1) : -1
223
- if (startMarkerIndex < 0 || endMarkerIndex < 0) {
224
- throw new Error('Failed to parse curl response status code.')
225
- }
226
-
227
- const statusText = stdout.slice(startMarkerIndex + marker.length, endMarkerIndex).trim()
228
- const status = Number(statusText)
229
- const bodyText = stdout.slice(0, startMarkerIndex)
230
-
231
- if (!Number.isFinite(status) || status <= 0) {
232
- throw new Error(`Invalid curl status code: ${statusText}`)
233
- }
234
-
235
- return {
236
- status,
237
- ok: status >= 200 && status < 300,
238
- bodyText,
239
- }
240
- }
241
-
242
- export interface GitIssueDependencyPayload {
243
- index: number
244
- owner: string
245
- repo: string
246
- }
247
-
248
- export interface GitIssueDependencySyncResult {
249
- added: number
250
- removed: number
251
- unchanged: number
252
- }
253
-
254
- type DependencyIssueRecord = {
255
- number?: unknown
256
- index?: unknown
257
- issue?: unknown
258
- }
259
-
260
- const toIssueNumber = (value: unknown): number | null => {
261
- const candidate = Number(value)
262
- if (!Number.isFinite(candidate) || candidate <= 0 || Math.floor(candidate) !== candidate) {
263
- return null
264
- }
265
- return candidate
266
- }
267
-
268
- const collectFromIssueRecord = (record: Record<string, unknown> | null | undefined): number[] => {
269
- const values: number[] = []
270
- if (!record) return values
271
-
272
- const direct = toIssueNumber(record.number)
273
- if (direct !== null) values.push(direct)
274
-
275
- const fallback = toIssueNumber(record.index)
276
- if (fallback !== null) values.push(fallback)
277
-
278
- const issue = record.issue
279
- if (issue && typeof issue === 'object' && !Array.isArray(issue)) {
280
- const nested = issue as Record<string, unknown>
281
- const nestedNumber = toIssueNumber(nested.number)
282
- if (nestedNumber !== null) values.push(nestedNumber)
283
-
284
- const nestedIndex = toIssueNumber(nested.index)
285
- if (nestedIndex !== null) values.push(nestedIndex)
286
- }
287
-
288
- return values
289
- }
290
-
291
- export function extractDependencyIssueNumbers(raw: unknown): number[] {
292
- const values: number[] = []
293
-
294
- if (Array.isArray(raw)) {
295
- for (const item of raw) {
296
- if (!item || typeof item !== 'object' || Array.isArray(item)) {
297
- continue
298
- }
299
-
300
- const candidate = item as DependencyIssueRecord
301
- const direct = toIssueNumber(candidate.number)
302
- if (direct !== null) values.push(direct)
303
-
304
- const fallback = toIssueNumber(candidate.index)
305
- if (fallback !== null) {
306
- values.push(fallback)
307
- }
308
-
309
- values.push(...collectFromIssueRecord(candidate))
310
- }
311
-
312
- return [...new Set(values)]
313
- }
314
-
315
- if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
316
- const container = raw as { dependencies?: unknown }
317
- if (Array.isArray(container.dependencies)) {
318
- return extractDependencyIssueNumbers(container.dependencies)
319
- }
320
-
321
- if (Array.isArray((container as { blocks?: unknown }).blocks)) {
322
- return extractDependencyIssueNumbers((container as { blocks?: unknown }).blocks)
323
- }
324
-
325
- if (Array.isArray((container as { issues?: unknown }).issues)) {
326
- return extractDependencyIssueNumbers((container as { issues?: unknown }).issues)
327
- }
328
-
329
- return collectFromIssueRecord(container as Record<string, unknown>)
330
- }
331
-
332
- return []
333
- }
334
-
335
- const resolveGiteaApiBase = (host: string): string => {
336
- const trimmed = host.trim().replace(/\/$/, '')
337
- return trimmed.endsWith('/api/v1') ? trimmed : `${trimmed}/api/v1`
338
- }
339
-
340
- const resolveRequiredGiteaHost = (host: string | undefined): string => {
341
- const resolved = host?.trim() ?? process.env.GITEA_HOST?.trim()
342
- if (!resolved) {
343
- throw new Error('GITEA_HOST is required. Pass host explicitly or set process.env.GITEA_HOST.')
344
- }
345
- return resolved
346
- }
347
-
348
- const buildIssueDependenciesUrl = (host: string, owner: string, repo: string, issueNumber: number): string => {
349
- return `${resolveGiteaApiBase(host)}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues/${issueNumber}/dependencies`
350
- }
351
-
352
- export async function callIssueDependenciesApi(
353
- method: 'GET' | 'POST' | 'DELETE',
354
- owner: string,
355
- repo: string,
356
- issueNumber: number,
357
- host: string | undefined,
358
- token: string | undefined,
359
- payload?: GitIssueDependencyPayload
360
- ): Promise<GitServiceApiExecutionResult<unknown>> {
361
- const resolvedHost = resolveRequiredGiteaHost(host)
362
- const requestUrl = buildIssueDependenciesUrl(resolvedHost, owner, repo, issueNumber)
363
- const headers = {
364
- Accept: 'application/json',
365
- ...(token ? { Authorization: `token ${token}` } : {}),
366
- ...(payload ? { 'Content-Type': 'application/json' } : {}),
367
- }
368
-
369
- const requestTimeoutMs = resolveRequestTimeoutMs()
370
- const transport = resolveHttpTransport(process.env.EXAMPLE_GIT_HTTP_TRANSPORT)
371
-
372
- let ok = false
373
- let status = 0
374
- let parsedBody: unknown = ''
375
- let responseText = ''
376
-
377
- if (transport === 'curl') {
378
- const result = await callCurl(
379
- requestUrl,
380
- {
381
- method,
382
- headers,
383
- ...(payload ? { body: JSON.stringify(payload) } : {}),
384
- },
385
- requestTimeoutMs,
386
- )
387
- ok = result.ok
388
- status = result.status
389
- responseText = result.bodyText
390
- } else {
391
- const response = await fetchWithTimeout(requestUrl, {
392
- method,
393
- headers,
394
- body: payload ? JSON.stringify(payload) : undefined,
395
- })
396
- ok = response.ok
397
- status = response.status
398
- responseText = await response.text()
399
- }
400
-
401
- parsedBody = responseText
402
- try {
403
- parsedBody = JSON.parse(responseText)
404
- } catch {
405
- // keep raw text
406
- }
407
-
408
- return {
409
- ok,
410
- status,
411
- body: parsedBody,
412
- mapping: {
413
- featurePath: ['issue', 'dependencies'],
414
- mappedPath: ['issue', 'dependencies'],
415
- method,
416
- query: [],
417
- headers: [],
418
- apiBase: resolveGiteaApiBase(resolvedHost),
419
- swaggerPath: '/repos/{owner}/{repo}/issues/{index}/dependencies',
420
- mapped: true,
421
- },
422
- request: {
423
- url: requestUrl,
424
- method,
425
- headers: {
426
- Accept: 'application/json',
427
- ...(payload ? { 'Content-Type': 'application/json' } : {}),
428
- },
429
- query: [],
430
- body: payload,
431
- },
432
- response: {
433
- headers: {},
434
- },
435
- }
436
- }
437
-
438
- export async function syncIssueDependencies(
439
- owner: string,
440
- repo: string,
441
- issueNumber: number,
442
- desiredDependencyIssueNumbers: number[],
443
- host: string | undefined,
444
- token: string | undefined,
445
- dryRun: boolean,
446
- log?: ((message: string) => void) | null
447
- ): Promise<GitIssueDependencySyncResult> {
448
- const existingResult = await callIssueDependenciesApi('GET', owner, repo, issueNumber, host, token)
449
- if (!existingResult.ok) {
450
- throw new Error(
451
- `Failed to load issue dependencies for #${issueNumber}: status=${existingResult.status} url=${existingResult.request.url}`
452
- )
453
- }
454
-
455
- const existing = extractDependencyIssueNumbers(existingResult.body)
456
- const desired = [...new Set(desiredDependencyIssueNumbers.filter((value): value is number => value > 0))]
457
- const existingSet = new Set(existing)
458
- const desiredSet = new Set(desired)
459
- const toAdd = desired.filter((value) => !existingSet.has(value))
460
- const toRemove = existing.filter((value) => !desiredSet.has(value))
461
- const unchanged = desired.filter((value) => existingSet.has(value)).length
462
-
463
- if (log) {
464
- if (toAdd.length > 0 || toRemove.length > 0) {
465
- log(`Issue #${issueNumber}: dependency sync [add=${toAdd.join(',') || 'none'}, remove=${toRemove.join(',') || 'none'}]`)
466
- } else {
467
- log(`Issue #${issueNumber}: dependency sync already correct`)
468
- }
469
- }
470
-
471
- let added = 0
472
- let removed = 0
473
-
474
- if (!dryRun) {
475
- for (const dep of toRemove) {
476
- const removedResult = await callIssueDependenciesApi(
477
- 'DELETE',
478
- owner,
479
- repo,
480
- issueNumber,
481
- host,
482
- token,
483
- { index: dep, owner, repo },
484
- )
485
- if (!removedResult.ok) {
486
- throw new Error(
487
- `Failed to remove dependency ${dep} from issue #${issueNumber}: status=${removedResult.status} url=${removedResult.request.url}`
488
- )
489
- }
490
- removed += 1
491
- }
492
-
493
- for (const dep of toAdd) {
494
- const addedResult = await callIssueDependenciesApi(
495
- 'POST',
496
- owner,
497
- repo,
498
- issueNumber,
499
- host,
500
- token,
501
- { index: dep, owner, repo },
502
- )
503
- if (!addedResult.ok) {
504
- throw new Error(
505
- `Failed to add dependency ${dep} to issue #${issueNumber}: status=${addedResult.status} url=${addedResult.request.url}`
506
- )
507
- }
508
- added += 1
509
- }
510
-
511
- const verifyResult = await callIssueDependenciesApi('GET', owner, repo, issueNumber, host, token)
512
- if (!verifyResult.ok) {
513
- throw new Error(
514
- `Failed to verify dependencies for issue #${issueNumber}: status=${verifyResult.status} url=${verifyResult.request.url}`
515
- )
516
- }
517
-
518
- const verified = extractDependencyIssueNumbers(verifyResult.body)
519
- const verifiedSet = new Set(verified)
520
- const missing = desired.filter((value) => !verifiedSet.has(value))
521
- if (missing.length > 0) {
522
- throw new Error(
523
- `Dependency sync mismatch for issue #${issueNumber}. Missing dependencies: ${missing.join(', ')}`
524
- )
525
- }
526
- }
527
-
528
- return {
529
- added,
530
- removed,
531
- unchanged,
532
- }
533
- }
1
+ import type { GitServiceApiExecutionResult } from './git-service-api'
2
+ import { spawn } from 'node:child_process'
3
+ import crypto from 'node:crypto'
4
+
5
+ const DEFAULT_REQUEST_TIMEOUT_MS = 60_000
6
+
7
+ const parseRequestTimeoutMs = (value: unknown): number | null => {
8
+ if (typeof value === 'number' && Number.isFinite(value) && value > 0) {
9
+ return Math.floor(value)
10
+ }
11
+ if (typeof value !== 'string') {
12
+ return null
13
+ }
14
+ const trimmed = value.trim()
15
+ if (!trimmed) return null
16
+ const parsed = Number(trimmed)
17
+ if (!Number.isFinite(parsed) || parsed <= 0) return null
18
+ return Math.floor(parsed)
19
+ }
20
+
21
+ const resolveRequestTimeoutMs = (): number => {
22
+ const fromEnv =
23
+ parseRequestTimeoutMs(process.env.EXAMPLE_GIT_REQUEST_TIMEOUT_MS) ??
24
+ parseRequestTimeoutMs(process.env.EXAMPLE_HTTP_REQUEST_TIMEOUT_MS) ??
25
+ parseRequestTimeoutMs(process.env.EXAMPLE_HTTP_TIMEOUT_MS)
26
+ return fromEnv ?? DEFAULT_REQUEST_TIMEOUT_MS
27
+ }
28
+
29
+ const canUseAbortSignalTimeout = (): boolean =>
30
+ typeof AbortSignal !== 'undefined' && typeof (AbortSignal as unknown as { timeout?: unknown }).timeout === 'function'
31
+
32
+ const isTestRuntime = (): boolean =>
33
+ Boolean(process.env.VITEST) || process.env.NODE_ENV === 'test'
34
+
35
+ const resolveHttpTransport = (requested?: string): 'fetch' | 'curl' => {
36
+ const normalized = (requested ?? '').trim().toLowerCase()
37
+ if (normalized === 'fetch' || normalized === 'curl') {
38
+ return normalized
39
+ }
40
+
41
+ const isBun = Boolean(process.versions?.bun)
42
+ if (!isTestRuntime() && isBun && process.platform === 'win32') {
43
+ return 'curl'
44
+ }
45
+
46
+ return 'fetch'
47
+ }
48
+
49
+ const fetchWithTimeout = async (url: string, init: RequestInit): Promise<Response> => {
50
+ const requestTimeoutMs = resolveRequestTimeoutMs()
51
+ if (!Number.isFinite(requestTimeoutMs) || requestTimeoutMs <= 0) {
52
+ return fetch(url, init)
53
+ }
54
+
55
+ const timeoutSignal =
56
+ requestTimeoutMs > 0 && canUseAbortSignalTimeout()
57
+ ? (AbortSignal as unknown as { timeout: (ms: number) => AbortSignal }).timeout(requestTimeoutMs)
58
+ : null
59
+ const controller = !timeoutSignal ? new AbortController() : null
60
+ const timeoutId = controller ? setTimeout(() => controller.abort(), requestTimeoutMs) : null
61
+ try {
62
+ return await fetch(url, {
63
+ ...init,
64
+ ...(timeoutSignal ? { signal: timeoutSignal } : {}),
65
+ ...(controller ? { signal: controller.signal } : {}),
66
+ })
67
+ } catch (error) {
68
+ if ((timeoutSignal && timeoutSignal.aborted) || controller?.signal.aborted) {
69
+ throw new Error(`Request timed out after ${requestTimeoutMs}ms: ${init.method ?? 'GET'} ${url}`)
70
+ }
71
+ throw error
72
+ } finally {
73
+ if (timeoutId) {
74
+ clearTimeout(timeoutId)
75
+ }
76
+ }
77
+ }
78
+
79
+ const readStream = async (stream: NodeJS.ReadableStream | null): Promise<Buffer> => {
80
+ if (!stream) return Buffer.from([])
81
+
82
+ return await new Promise<Buffer>((resolve) => {
83
+ const chunks: Buffer[] = []
84
+ let settled = false
85
+
86
+ const cleanup = () => {
87
+ stream.removeListener('data', onData)
88
+ stream.removeListener('end', onDone)
89
+ stream.removeListener('close', onDone)
90
+ stream.removeListener('error', onDone)
91
+ }
92
+
93
+ const settle = () => {
94
+ if (settled) return
95
+ settled = true
96
+ cleanup()
97
+ resolve(Buffer.concat(chunks))
98
+ }
99
+
100
+ const onDone = () => settle()
101
+
102
+ const onData = (chunk: unknown) => {
103
+ try {
104
+ if (typeof chunk === 'string') {
105
+ chunks.push(Buffer.from(chunk))
106
+ return
107
+ }
108
+
109
+ chunks.push(Buffer.from(chunk as ArrayBufferView))
110
+ } catch {
111
+ // best effort
112
+ }
113
+ }
114
+
115
+ stream.on('data', onData)
116
+ stream.on('end', onDone)
117
+ stream.on('close', onDone)
118
+ stream.on('error', onDone)
119
+ })
120
+ }
121
+
122
+ const callCurl = async (
123
+ requestUrl: string,
124
+ init: { method: string; headers: Record<string, string>; body?: string },
125
+ requestTimeoutMs: number,
126
+ ): Promise<{ status: number; ok: boolean; bodyText: string }> => {
127
+ const curlExe = process.platform === 'win32' ? 'curl.exe' : 'curl'
128
+ const statusToken = crypto.randomBytes(8).toString('hex')
129
+ const marker = `\n__EXAMPLE_CURL_STATUS_${statusToken}__`
130
+ const writeOut = `${marker}%{http_code}${marker}`
131
+
132
+ const args: string[] = [
133
+ '--silent',
134
+ '--show-error',
135
+ '--location',
136
+ '--request', init.method,
137
+ '--write-out', writeOut,
138
+ ]
139
+
140
+ const timeoutSeconds = requestTimeoutMs > 0 ? Math.max(1, Math.ceil(requestTimeoutMs / 1000)) : null
141
+ if (timeoutSeconds !== null) {
142
+ args.push('--max-time', String(timeoutSeconds))
143
+ args.push('--connect-timeout', String(Math.max(1, Math.min(30, timeoutSeconds))))
144
+ }
145
+
146
+ for (const [name, value] of Object.entries(init.headers)) {
147
+ args.push('--header', `${name}: ${value}`)
148
+ }
149
+
150
+ if (init.body !== undefined) {
151
+ args.push('--data-binary', '@-')
152
+ }
153
+
154
+ args.push(requestUrl)
155
+
156
+ const child = spawn(curlExe, args, {
157
+ stdio: ['pipe', 'pipe', 'pipe'],
158
+ windowsHide: true,
159
+ })
160
+
161
+ const hardTimeoutMs = requestTimeoutMs > 0 ? requestTimeoutMs + 2_000 : null
162
+ let hardTimedOut = false
163
+ const hardTimeoutId = hardTimeoutMs
164
+ ? setTimeout(() => {
165
+ hardTimedOut = true
166
+ try {
167
+ child.kill()
168
+ } catch {
169
+ // best effort
170
+ }
171
+ try {
172
+ child.stdout?.destroy()
173
+ } catch {
174
+ // best effort
175
+ }
176
+ try {
177
+ child.stderr?.destroy()
178
+ } catch {
179
+ // best effort
180
+ }
181
+ }, hardTimeoutMs)
182
+ : null
183
+
184
+ if (init.body !== undefined) {
185
+ child.stdin.write(init.body)
186
+ }
187
+ child.stdin.end()
188
+
189
+ const stdoutPromise = readStream(child.stdout)
190
+ const stderrPromise = readStream(child.stderr)
191
+
192
+ let exitCode: number
193
+ try {
194
+ exitCode = await new Promise((resolve) => {
195
+ child.on('close', (code) => resolve(code ?? 0))
196
+ child.on('error', () => resolve(1))
197
+ })
198
+ } finally {
199
+ if (hardTimeoutId) {
200
+ clearTimeout(hardTimeoutId)
201
+ }
202
+ }
203
+
204
+ const [stdoutBytes, stderrBytes] = await Promise.all([stdoutPromise, stderrPromise])
205
+
206
+ if (hardTimedOut && requestTimeoutMs > 0) {
207
+ throw new Error(`Request timed out after ${requestTimeoutMs}ms: ${init.method} ${requestUrl}`)
208
+ }
209
+
210
+ const stdout = stdoutBytes.toString('utf8')
211
+ const stderr = stderrBytes.toString('utf8').trim()
212
+
213
+ if (exitCode !== 0) {
214
+ const message = stderr || `curl failed with exit code ${exitCode}`
215
+ if (exitCode === 28 && requestTimeoutMs > 0) {
216
+ throw new Error(`Request timed out after ${requestTimeoutMs}ms: ${init.method} ${requestUrl}`)
217
+ }
218
+ throw new Error(message)
219
+ }
220
+
221
+ const endMarkerIndex = stdout.lastIndexOf(marker)
222
+ const startMarkerIndex = endMarkerIndex > -1 ? stdout.lastIndexOf(marker, endMarkerIndex - 1) : -1
223
+ if (startMarkerIndex < 0 || endMarkerIndex < 0) {
224
+ throw new Error('Failed to parse curl response status code.')
225
+ }
226
+
227
+ const statusText = stdout.slice(startMarkerIndex + marker.length, endMarkerIndex).trim()
228
+ const status = Number(statusText)
229
+ const bodyText = stdout.slice(0, startMarkerIndex)
230
+
231
+ if (!Number.isFinite(status) || status <= 0) {
232
+ throw new Error(`Invalid curl status code: ${statusText}`)
233
+ }
234
+
235
+ return {
236
+ status,
237
+ ok: status >= 200 && status < 300,
238
+ bodyText,
239
+ }
240
+ }
241
+
242
+ export interface GitIssueDependencyPayload {
243
+ index: number
244
+ owner: string
245
+ repo: string
246
+ }
247
+
248
+ export interface GitIssueDependencySyncResult {
249
+ added: number
250
+ removed: number
251
+ unchanged: number
252
+ }
253
+
254
+ type DependencyIssueRecord = {
255
+ number?: unknown
256
+ index?: unknown
257
+ issue?: unknown
258
+ }
259
+
260
+ const toIssueNumber = (value: unknown): number | null => {
261
+ const candidate = Number(value)
262
+ if (!Number.isFinite(candidate) || candidate <= 0 || Math.floor(candidate) !== candidate) {
263
+ return null
264
+ }
265
+ return candidate
266
+ }
267
+
268
+ const collectFromIssueRecord = (record: Record<string, unknown> | null | undefined): number[] => {
269
+ const values: number[] = []
270
+ if (!record) return values
271
+
272
+ const direct = toIssueNumber(record.number)
273
+ if (direct !== null) values.push(direct)
274
+
275
+ const fallback = toIssueNumber(record.index)
276
+ if (fallback !== null) values.push(fallback)
277
+
278
+ const issue = record.issue
279
+ if (issue && typeof issue === 'object' && !Array.isArray(issue)) {
280
+ const nested = issue as Record<string, unknown>
281
+ const nestedNumber = toIssueNumber(nested.number)
282
+ if (nestedNumber !== null) values.push(nestedNumber)
283
+
284
+ const nestedIndex = toIssueNumber(nested.index)
285
+ if (nestedIndex !== null) values.push(nestedIndex)
286
+ }
287
+
288
+ return values
289
+ }
290
+
291
+ export function extractDependencyIssueNumbers(raw: unknown): number[] {
292
+ const values: number[] = []
293
+
294
+ if (Array.isArray(raw)) {
295
+ for (const item of raw) {
296
+ if (!item || typeof item !== 'object' || Array.isArray(item)) {
297
+ continue
298
+ }
299
+
300
+ const candidate = item as DependencyIssueRecord
301
+ const direct = toIssueNumber(candidate.number)
302
+ if (direct !== null) values.push(direct)
303
+
304
+ const fallback = toIssueNumber(candidate.index)
305
+ if (fallback !== null) {
306
+ values.push(fallback)
307
+ }
308
+
309
+ values.push(...collectFromIssueRecord(candidate))
310
+ }
311
+
312
+ return [...new Set(values)]
313
+ }
314
+
315
+ if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
316
+ const container = raw as { dependencies?: unknown }
317
+ if (Array.isArray(container.dependencies)) {
318
+ return extractDependencyIssueNumbers(container.dependencies)
319
+ }
320
+
321
+ if (Array.isArray((container as { blocks?: unknown }).blocks)) {
322
+ return extractDependencyIssueNumbers((container as { blocks?: unknown }).blocks)
323
+ }
324
+
325
+ if (Array.isArray((container as { issues?: unknown }).issues)) {
326
+ return extractDependencyIssueNumbers((container as { issues?: unknown }).issues)
327
+ }
328
+
329
+ return collectFromIssueRecord(container as Record<string, unknown>)
330
+ }
331
+
332
+ return []
333
+ }
334
+
335
+ const resolveGiteaApiBase = (host: string): string => {
336
+ const trimmed = host.trim().replace(/\/$/, '')
337
+ return trimmed.endsWith('/api/v1') ? trimmed : `${trimmed}/api/v1`
338
+ }
339
+
340
+ const resolveRequiredGiteaHost = (host: string | undefined): string => {
341
+ const resolved = host?.trim() ?? process.env.GITEA_HOST?.trim()
342
+ if (!resolved) {
343
+ throw new Error('GITEA_HOST is required. Pass host explicitly or set process.env.GITEA_HOST.')
344
+ }
345
+ return resolved
346
+ }
347
+
348
+ const buildIssueDependenciesUrl = (host: string, owner: string, repo: string, issueNumber: number): string => {
349
+ return `${resolveGiteaApiBase(host)}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues/${issueNumber}/dependencies`
350
+ }
351
+
352
+ export async function callIssueDependenciesApi(
353
+ method: 'GET' | 'POST' | 'DELETE',
354
+ owner: string,
355
+ repo: string,
356
+ issueNumber: number,
357
+ host: string | undefined,
358
+ token: string | undefined,
359
+ payload?: GitIssueDependencyPayload
360
+ ): Promise<GitServiceApiExecutionResult<unknown>> {
361
+ const resolvedHost = resolveRequiredGiteaHost(host)
362
+ const requestUrl = buildIssueDependenciesUrl(resolvedHost, owner, repo, issueNumber)
363
+ const headers = {
364
+ Accept: 'application/json',
365
+ ...(token ? { Authorization: `token ${token}` } : {}),
366
+ ...(payload ? { 'Content-Type': 'application/json' } : {}),
367
+ }
368
+
369
+ const requestTimeoutMs = resolveRequestTimeoutMs()
370
+ const transport = resolveHttpTransport(process.env.EXAMPLE_GIT_HTTP_TRANSPORT)
371
+
372
+ let ok = false
373
+ let status = 0
374
+ let parsedBody: unknown = ''
375
+ let responseText = ''
376
+
377
+ if (transport === 'curl') {
378
+ const result = await callCurl(
379
+ requestUrl,
380
+ {
381
+ method,
382
+ headers,
383
+ ...(payload ? { body: JSON.stringify(payload) } : {}),
384
+ },
385
+ requestTimeoutMs,
386
+ )
387
+ ok = result.ok
388
+ status = result.status
389
+ responseText = result.bodyText
390
+ } else {
391
+ const response = await fetchWithTimeout(requestUrl, {
392
+ method,
393
+ headers,
394
+ body: payload ? JSON.stringify(payload) : undefined,
395
+ })
396
+ ok = response.ok
397
+ status = response.status
398
+ responseText = await response.text()
399
+ }
400
+
401
+ parsedBody = responseText
402
+ try {
403
+ parsedBody = JSON.parse(responseText)
404
+ } catch {
405
+ // keep raw text
406
+ }
407
+
408
+ return {
409
+ ok,
410
+ status,
411
+ body: parsedBody,
412
+ mapping: {
413
+ featurePath: ['issue', 'dependencies'],
414
+ mappedPath: ['issue', 'dependencies'],
415
+ method,
416
+ query: [],
417
+ headers: [],
418
+ apiBase: resolveGiteaApiBase(resolvedHost),
419
+ swaggerPath: '/repos/{owner}/{repo}/issues/{index}/dependencies',
420
+ mapped: true,
421
+ },
422
+ request: {
423
+ url: requestUrl,
424
+ method,
425
+ headers: {
426
+ Accept: 'application/json',
427
+ ...(payload ? { 'Content-Type': 'application/json' } : {}),
428
+ },
429
+ query: [],
430
+ body: payload,
431
+ },
432
+ response: {
433
+ headers: {},
434
+ },
435
+ }
436
+ }
437
+
438
+ export async function syncIssueDependencies(
439
+ owner: string,
440
+ repo: string,
441
+ issueNumber: number,
442
+ desiredDependencyIssueNumbers: number[],
443
+ host: string | undefined,
444
+ token: string | undefined,
445
+ dryRun: boolean,
446
+ log?: ((message: string) => void) | null
447
+ ): Promise<GitIssueDependencySyncResult> {
448
+ const existingResult = await callIssueDependenciesApi('GET', owner, repo, issueNumber, host, token)
449
+ if (!existingResult.ok) {
450
+ throw new Error(
451
+ `Failed to load issue dependencies for #${issueNumber}: status=${existingResult.status} url=${existingResult.request.url}`
452
+ )
453
+ }
454
+
455
+ const existing = extractDependencyIssueNumbers(existingResult.body)
456
+ const desired = [...new Set(desiredDependencyIssueNumbers.filter((value): value is number => value > 0))]
457
+ const existingSet = new Set(existing)
458
+ const desiredSet = new Set(desired)
459
+ const toAdd = desired.filter((value) => !existingSet.has(value))
460
+ const toRemove = existing.filter((value) => !desiredSet.has(value))
461
+ const unchanged = desired.filter((value) => existingSet.has(value)).length
462
+
463
+ if (log) {
464
+ if (toAdd.length > 0 || toRemove.length > 0) {
465
+ log(`Issue #${issueNumber}: dependency sync [add=${toAdd.join(',') || 'none'}, remove=${toRemove.join(',') || 'none'}]`)
466
+ } else {
467
+ log(`Issue #${issueNumber}: dependency sync already correct`)
468
+ }
469
+ }
470
+
471
+ let added = 0
472
+ let removed = 0
473
+
474
+ if (!dryRun) {
475
+ for (const dep of toRemove) {
476
+ const removedResult = await callIssueDependenciesApi(
477
+ 'DELETE',
478
+ owner,
479
+ repo,
480
+ issueNumber,
481
+ host,
482
+ token,
483
+ { index: dep, owner, repo },
484
+ )
485
+ if (!removedResult.ok) {
486
+ throw new Error(
487
+ `Failed to remove dependency ${dep} from issue #${issueNumber}: status=${removedResult.status} url=${removedResult.request.url}`
488
+ )
489
+ }
490
+ removed += 1
491
+ }
492
+
493
+ for (const dep of toAdd) {
494
+ const addedResult = await callIssueDependenciesApi(
495
+ 'POST',
496
+ owner,
497
+ repo,
498
+ issueNumber,
499
+ host,
500
+ token,
501
+ { index: dep, owner, repo },
502
+ )
503
+ if (!addedResult.ok) {
504
+ throw new Error(
505
+ `Failed to add dependency ${dep} to issue #${issueNumber}: status=${addedResult.status} url=${addedResult.request.url}`
506
+ )
507
+ }
508
+ added += 1
509
+ }
510
+
511
+ const verifyResult = await callIssueDependenciesApi('GET', owner, repo, issueNumber, host, token)
512
+ if (!verifyResult.ok) {
513
+ throw new Error(
514
+ `Failed to verify dependencies for issue #${issueNumber}: status=${verifyResult.status} url=${verifyResult.request.url}`
515
+ )
516
+ }
517
+
518
+ const verified = extractDependencyIssueNumbers(verifyResult.body)
519
+ const verifiedSet = new Set(verified)
520
+ const missing = desired.filter((value) => !verifiedSet.has(value))
521
+ if (missing.length > 0) {
522
+ throw new Error(
523
+ `Dependency sync mismatch for issue #${issueNumber}. Missing dependencies: ${missing.join(', ')}`
524
+ )
525
+ }
526
+ }
527
+
528
+ return {
529
+ added,
530
+ removed,
531
+ unchanged,
532
+ }
533
+ }