@swarmclawai/swarmclaw 1.0.0 → 1.0.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/README.md +15 -2
- package/bin/doctor-cmd.js +155 -0
- package/bin/doctor-cmd.test.js +50 -0
- package/bin/install-root.js +39 -0
- package/bin/install-root.test.js +60 -0
- package/bin/server-cmd.js +160 -38
- package/bin/swarmclaw.js +83 -3
- package/bin/update-cmd.js +1 -6
- package/bin/update-cmd.test.js +1 -36
- package/bin/worker-cmd.js +13 -6
- package/package.json +16 -15
- package/scripts/postinstall.mjs +17 -13
- package/src/app/api/gateways/[id]/health/route.ts +2 -32
- package/src/app/api/gateways/health-route.test.ts +1 -1
- package/src/app/api/setup/check-provider/helpers.ts +28 -0
- package/src/app/api/setup/check-provider/route.test.ts +1 -1
- package/src/app/api/setup/check-provider/route.ts +5 -32
- package/src/app/api/tasks/import/github/helpers.ts +100 -0
- package/src/app/api/tasks/import/github/route.test.ts +1 -1
- package/src/app/api/tasks/import/github/route.ts +2 -92
- package/src/app/api/webhooks/[id]/helpers.ts +253 -0
- package/src/app/api/webhooks/[id]/route.ts +2 -243
- package/src/app/api/webhooks/route.test.ts +4 -2
- package/src/app/usage/page.tsx +22 -12
- package/src/cli/binary.test.js +57 -0
- package/src/cli/index.js +13 -1
- package/src/cli/server-cmd.test.js +77 -0
- package/src/lib/server/data-dir.test.ts +38 -3
- package/src/lib/server/data-dir.ts +11 -0
- package/src/lib/server/openclaw/health.ts +30 -1
- package/src/lib/server/session-tools/file-send.test.ts +18 -2
- package/src/lib/server/session-tools/file.ts +11 -7
- package/src/lib/server/skills/skill-discovery.test.ts +34 -1
- package/src/lib/server/skills/skill-discovery.ts +9 -4
package/scripts/postinstall.mjs
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
+
import fs from 'node:fs'
|
|
3
4
|
import path from 'node:path'
|
|
4
5
|
import { fileURLToPath } from 'node:url'
|
|
5
|
-
import { writeFileSync } from 'node:fs'
|
|
6
6
|
import { spawnSync } from 'node:child_process'
|
|
7
7
|
const INSTALL_METADATA_FILE = '.swarmclaw-install.json'
|
|
8
8
|
const scriptDir = path.dirname(fileURLToPath(import.meta.url))
|
|
@@ -48,7 +48,7 @@ function formatFailure(result) {
|
|
|
48
48
|
}
|
|
49
49
|
|
|
50
50
|
try {
|
|
51
|
-
writeFileSync(
|
|
51
|
+
fs.writeFileSync(
|
|
52
52
|
new URL(`../${INSTALL_METADATA_FILE}`, import.meta.url),
|
|
53
53
|
JSON.stringify({
|
|
54
54
|
packageManager: installedWith,
|
|
@@ -72,18 +72,22 @@ if (result.error || (result.status ?? 0) !== 0) {
|
|
|
72
72
|
}
|
|
73
73
|
|
|
74
74
|
if (!process.env.CI) {
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
75
|
+
if (!fs.existsSync(ensureSandboxBrowserScript)) {
|
|
76
|
+
logNote('Sandbox browser image helper is not present in this install context. Skipping setup.')
|
|
77
|
+
} else {
|
|
78
|
+
const sandboxImage = spawnSync(process.execPath, [ensureSandboxBrowserScript, '--quiet'], {
|
|
79
|
+
cwd: packageRoot,
|
|
80
|
+
encoding: 'utf8',
|
|
81
|
+
stdio: 'pipe',
|
|
82
|
+
})
|
|
83
|
+
if (sandboxImage.error || (sandboxImage.status ?? 0) !== 0) {
|
|
84
|
+
logWarn(`sandbox browser image setup failed: ${formatFailure(sandboxImage)}`)
|
|
85
|
+
logWarn('Retry manually with: node ./scripts/ensure-sandbox-browser-image.mjs')
|
|
86
|
+
}
|
|
84
87
|
|
|
85
|
-
|
|
86
|
-
|
|
88
|
+
if (!commandExists('docker')) {
|
|
89
|
+
logNote('Docker was not found. Container sandboxes will fall back to host execution until Docker is installed.')
|
|
90
|
+
}
|
|
87
91
|
}
|
|
88
92
|
}
|
|
89
93
|
|
|
@@ -1,39 +1,9 @@
|
|
|
1
1
|
import { NextResponse } from 'next/server'
|
|
2
|
-
import { probeOpenClawHealth } from '@/lib/server/openclaw/health'
|
|
3
|
-
import { loadGatewayProfiles
|
|
2
|
+
import { probeOpenClawHealth, persistGatewayHealthResult } from '@/lib/server/openclaw/health'
|
|
3
|
+
import { loadGatewayProfiles } from '@/lib/server/storage'
|
|
4
4
|
import { notFound } from '@/lib/server/collection-helpers'
|
|
5
|
-
import { notify } from '@/lib/server/ws-hub'
|
|
6
|
-
import type { GatewayProfile } from '@/types'
|
|
7
|
-
import type { OpenClawHealthResult } from '@/lib/server/openclaw/health'
|
|
8
5
|
export const dynamic = 'force-dynamic'
|
|
9
6
|
|
|
10
|
-
export function persistGatewayHealthResult(
|
|
11
|
-
id: string,
|
|
12
|
-
result: OpenClawHealthResult,
|
|
13
|
-
now = Date.now(),
|
|
14
|
-
): GatewayProfile | null {
|
|
15
|
-
const gateways = loadGatewayProfiles()
|
|
16
|
-
const gateway = gateways[id]
|
|
17
|
-
if (!gateway) return null
|
|
18
|
-
|
|
19
|
-
gateway.status = result.ok ? 'healthy' : (result.authProvided ? 'degraded' : 'offline')
|
|
20
|
-
gateway.lastCheckedAt = now
|
|
21
|
-
gateway.lastError = result.ok ? null : (result.error || result.hint || 'Gateway health check failed.')
|
|
22
|
-
gateway.lastModelCount = Array.isArray(result.models) ? result.models.length : 0
|
|
23
|
-
gateway.deployment = {
|
|
24
|
-
...(gateway.deployment || {}),
|
|
25
|
-
lastVerifiedAt: now,
|
|
26
|
-
lastVerifiedOk: result.ok,
|
|
27
|
-
lastVerifiedMessage: result.ok
|
|
28
|
-
? result.message
|
|
29
|
-
: (result.error || result.hint || 'Gateway health check failed.'),
|
|
30
|
-
}
|
|
31
|
-
gateway.updatedAt = now
|
|
32
|
-
saveGatewayProfiles(gateways)
|
|
33
|
-
notify('gateways')
|
|
34
|
-
return gateway
|
|
35
|
-
}
|
|
36
|
-
|
|
37
7
|
export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
|
|
38
8
|
const { id } = await params
|
|
39
9
|
const gateways = loadGatewayProfiles()
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import assert from 'node:assert/strict'
|
|
2
2
|
import { afterEach, test } from 'node:test'
|
|
3
3
|
|
|
4
|
-
import { persistGatewayHealthResult } from '
|
|
4
|
+
import { persistGatewayHealthResult } from '@/lib/server/openclaw/health'
|
|
5
5
|
import { loadGatewayProfiles, saveGatewayProfiles } from '@/lib/server/storage'
|
|
6
6
|
|
|
7
7
|
const originalGateways = loadGatewayProfiles()
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export function normalizeOllamaSetupEndpoint(endpoint: string, useCloud: boolean): string {
|
|
2
|
+
const normalized = endpoint.replace(/\/+$/, '')
|
|
3
|
+
if (useCloud) return normalized
|
|
4
|
+
return normalized.replace(/\/v1$/i, '')
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export async function parseErrorMessage(res: Response, fallback: string): Promise<string> {
|
|
8
|
+
const text = await res.text().catch(() => '')
|
|
9
|
+
if (!text) return fallback
|
|
10
|
+
try {
|
|
11
|
+
const parsed = JSON.parse(text)
|
|
12
|
+
if (typeof parsed?.error?.message === 'string' && parsed.error.message.trim()) return parsed.error.message.trim()
|
|
13
|
+
if (typeof parsed?.error === 'string' && parsed.error.trim()) return parsed.error.trim()
|
|
14
|
+
if (typeof parsed?.message === 'string' && parsed.message.trim()) return parsed.message.trim()
|
|
15
|
+
if (typeof parsed?.detail === 'string' && parsed.detail.trim()) return parsed.detail.trim()
|
|
16
|
+
} catch {
|
|
17
|
+
// Non-JSON response body.
|
|
18
|
+
}
|
|
19
|
+
return text.slice(0, 300).trim() || fallback
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function normalizeOpenClawUrl(raw: string): { httpUrl: string; wsUrl: string } {
|
|
23
|
+
let url = (raw || 'http://localhost:18789').replace(/\/+$/, '')
|
|
24
|
+
if (!/^(https?|wss?):\/\//i.test(url)) url = `http://${url}`
|
|
25
|
+
const httpUrl = url.replace(/^ws:/i, 'http:').replace(/^wss:/i, 'https:')
|
|
26
|
+
const wsUrl = httpUrl.replace(/^http:/i, 'ws:').replace(/^https:/i, 'wss:')
|
|
27
|
+
return { httpUrl, wsUrl }
|
|
28
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import assert from 'node:assert/strict'
|
|
2
2
|
import test from 'node:test'
|
|
3
3
|
|
|
4
|
-
import { normalizeOllamaSetupEndpoint, normalizeOpenClawUrl, parseErrorMessage } from './
|
|
4
|
+
import { normalizeOllamaSetupEndpoint, normalizeOpenClawUrl, parseErrorMessage } from './helpers'
|
|
5
5
|
|
|
6
6
|
test('normalizeOllamaSetupEndpoint strips local /v1 suffixes but preserves cloud endpoints', () => {
|
|
7
7
|
assert.equal(
|
|
@@ -3,6 +3,7 @@ import { loadCredentials, decryptKey } from '@/lib/server/storage'
|
|
|
3
3
|
import { getDeviceId, wsConnect, rpcOnConnectedGateway } from '@/lib/providers/openclaw'
|
|
4
4
|
import { OPENAI_COMPATIBLE_DEFAULTS } from '@/lib/server/provider-health'
|
|
5
5
|
import { resolveOllamaRuntimeConfig } from '@/lib/server/ollama-runtime'
|
|
6
|
+
import { normalizeOllamaSetupEndpoint, normalizeOpenClawUrl, parseErrorMessage } from './helpers'
|
|
6
7
|
|
|
7
8
|
type SetupProvider =
|
|
8
9
|
| 'openai'
|
|
@@ -29,32 +30,11 @@ function clean(value: unknown): string {
|
|
|
29
30
|
return typeof value === 'string' ? value.trim() : ''
|
|
30
31
|
}
|
|
31
32
|
|
|
32
|
-
export function normalizeOllamaSetupEndpoint(endpoint: string, useCloud: boolean): string {
|
|
33
|
-
const normalized = endpoint.replace(/\/+$/, '')
|
|
34
|
-
if (useCloud) return normalized
|
|
35
|
-
return normalized.replace(/\/v1$/i, '')
|
|
36
|
-
}
|
|
37
|
-
|
|
38
33
|
function parseBody(input: unknown): SetupCheckBody {
|
|
39
34
|
if (!input || typeof input !== 'object' || Array.isArray(input)) return {}
|
|
40
35
|
return input as SetupCheckBody
|
|
41
36
|
}
|
|
42
37
|
|
|
43
|
-
export async function parseErrorMessage(res: Response, fallback: string): Promise<string> {
|
|
44
|
-
const text = await res.text().catch(() => '')
|
|
45
|
-
if (!text) return fallback
|
|
46
|
-
try {
|
|
47
|
-
const parsed = JSON.parse(text)
|
|
48
|
-
if (typeof parsed?.error?.message === 'string' && parsed.error.message.trim()) return parsed.error.message.trim()
|
|
49
|
-
if (typeof parsed?.error === 'string' && parsed.error.trim()) return parsed.error.trim()
|
|
50
|
-
if (typeof parsed?.message === 'string' && parsed.message.trim()) return parsed.message.trim()
|
|
51
|
-
if (typeof parsed?.detail === 'string' && parsed.detail.trim()) return parsed.detail.trim()
|
|
52
|
-
} catch {
|
|
53
|
-
// Non-JSON response body.
|
|
54
|
-
}
|
|
55
|
-
return text.slice(0, 300).trim() || fallback
|
|
56
|
-
}
|
|
57
|
-
|
|
58
38
|
async function checkOpenAiCompatible(
|
|
59
39
|
providerName: string,
|
|
60
40
|
apiKey: string,
|
|
@@ -234,14 +214,6 @@ async function checkOllama(params: {
|
|
|
234
214
|
}
|
|
235
215
|
}
|
|
236
216
|
|
|
237
|
-
export function normalizeOpenClawUrl(raw: string): { httpUrl: string; wsUrl: string } {
|
|
238
|
-
let url = (raw || 'http://localhost:18789').replace(/\/+$/, '')
|
|
239
|
-
if (!/^(https?|wss?):\/\//i.test(url)) url = `http://${url}`
|
|
240
|
-
const httpUrl = url.replace(/^ws:/i, 'http:').replace(/^wss:/i, 'https:')
|
|
241
|
-
const wsUrl = httpUrl.replace(/^http:/i, 'ws:').replace(/^https:/i, 'wss:')
|
|
242
|
-
return { httpUrl, wsUrl }
|
|
243
|
-
}
|
|
244
|
-
|
|
245
217
|
async function checkOpenClaw(apiKey: string, endpointRaw: string): Promise<{ ok: boolean; message: string; normalizedEndpoint: string; deviceId?: string; errorCode?: string; recommendedModel?: string }> {
|
|
246
218
|
const { httpUrl: normalizedEndpoint, wsUrl } = normalizeOpenClawUrl(endpointRaw)
|
|
247
219
|
const token = apiKey || undefined
|
|
@@ -258,9 +230,10 @@ async function checkOpenClaw(apiKey: string, endpointRaw: string): Promise<{ ok:
|
|
|
258
230
|
let recommendedModel: string | undefined
|
|
259
231
|
if (result.ws) {
|
|
260
232
|
try {
|
|
261
|
-
const payload = await rpcOnConnectedGateway(result.ws, 'models.list', {}, 8_000) as
|
|
262
|
-
const
|
|
263
|
-
const
|
|
233
|
+
const payload = await rpcOnConnectedGateway(result.ws, 'models.list', {}, 8_000) as Record<string, unknown> | unknown[] | undefined
|
|
234
|
+
const p = payload as Record<string, unknown> | undefined
|
|
235
|
+
const models: unknown[] = Array.isArray(p?.models) ? p.models as unknown[] : Array.isArray(p?.data) ? p.data as unknown[] : Array.isArray(payload) ? payload : []
|
|
236
|
+
const first = models[0] as Record<string, unknown> | string | undefined
|
|
264
237
|
if (typeof first === 'string') {
|
|
265
238
|
recommendedModel = first
|
|
266
239
|
} else if (typeof first?.id === 'string') {
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { dedup } from '@/lib/shared-utils'
|
|
2
|
+
|
|
3
|
+
type GitHubIssueLabel = string | { name?: string | null }
|
|
4
|
+
|
|
5
|
+
export interface GitHubIssueRecord {
|
|
6
|
+
id: number | string
|
|
7
|
+
number: number
|
|
8
|
+
title: string
|
|
9
|
+
body?: string | null
|
|
10
|
+
state?: string | null
|
|
11
|
+
html_url?: string | null
|
|
12
|
+
labels?: GitHubIssueLabel[]
|
|
13
|
+
assignee?: { login?: string | null } | null
|
|
14
|
+
user?: { login?: string | null } | null
|
|
15
|
+
pull_request?: unknown
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface ParsedRepo {
|
|
19
|
+
owner: string
|
|
20
|
+
repo: string
|
|
21
|
+
fullName: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const BODY_CHAR_LIMIT = 12_000
|
|
25
|
+
|
|
26
|
+
function normalizeLabelName(label: GitHubIssueLabel): string {
|
|
27
|
+
if (typeof label === 'string') return label.trim()
|
|
28
|
+
return String(label?.name || '').trim()
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function normalizeTag(value: string): string {
|
|
32
|
+
return value.trim().replace(/\s+/g, ' ').slice(0, 60)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function parseGitHubRepoInput(input: string): ParsedRepo | null {
|
|
36
|
+
const trimmed = input.trim().replace(/\.git$/i, '')
|
|
37
|
+
if (!trimmed) return null
|
|
38
|
+
|
|
39
|
+
if (/^https?:\/\//i.test(trimmed)) {
|
|
40
|
+
try {
|
|
41
|
+
const url = new URL(trimmed)
|
|
42
|
+
if (!/github\.com$/i.test(url.hostname)) return null
|
|
43
|
+
const parts = url.pathname.split('/').filter(Boolean)
|
|
44
|
+
if (parts.length < 2) return null
|
|
45
|
+
const owner = parts[0]
|
|
46
|
+
const repo = parts[1].replace(/\.git$/i, '')
|
|
47
|
+
if (!owner || !repo) return null
|
|
48
|
+
return { owner, repo, fullName: `${owner}/${repo}` }
|
|
49
|
+
} catch {
|
|
50
|
+
return null
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const compact = trimmed.replace(/^github\.com\//i, '')
|
|
55
|
+
const parts = compact.split('/').filter(Boolean)
|
|
56
|
+
if (parts.length < 2) return null
|
|
57
|
+
const owner = parts[0]
|
|
58
|
+
const repo = parts[1].replace(/\.git$/i, '')
|
|
59
|
+
if (!owner || !repo) return null
|
|
60
|
+
return { owner, repo, fullName: `${owner}/${repo}` }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function buildGitHubIssueTaskTitle(issue: GitHubIssueRecord, repoFullName: string): string {
|
|
64
|
+
const title = issue.title?.trim() || `Issue ${issue.number}`
|
|
65
|
+
return `[${repoFullName}#${issue.number}] ${title}`
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function buildGitHubIssueTaskDescription(issue: GitHubIssueRecord, repoFullName: string): string {
|
|
69
|
+
const labels = (issue.labels || [])
|
|
70
|
+
.map(normalizeLabelName)
|
|
71
|
+
.filter(Boolean)
|
|
72
|
+
const header = [
|
|
73
|
+
`Imported from GitHub issue ${repoFullName}#${issue.number}`,
|
|
74
|
+
issue.html_url ? `URL: ${issue.html_url}` : '',
|
|
75
|
+
issue.state ? `State: ${issue.state}` : '',
|
|
76
|
+
labels.length > 0 ? `Labels: ${labels.join(', ')}` : '',
|
|
77
|
+
issue.assignee?.login ? `Assignee: ${issue.assignee.login}` : '',
|
|
78
|
+
issue.user?.login ? `Opened by: ${issue.user.login}` : '',
|
|
79
|
+
]
|
|
80
|
+
.filter(Boolean)
|
|
81
|
+
.join('\n')
|
|
82
|
+
|
|
83
|
+
const rawBody = String(issue.body || '').trim()
|
|
84
|
+
if (!rawBody) return header
|
|
85
|
+
|
|
86
|
+
const body = rawBody.length > BODY_CHAR_LIMIT
|
|
87
|
+
? `${rawBody.slice(0, BODY_CHAR_LIMIT).trimEnd()}\n\n[Truncated during import]`
|
|
88
|
+
: rawBody
|
|
89
|
+
|
|
90
|
+
return `${header}\n\n${body}`
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function buildGitHubIssueTaskTags(issue: GitHubIssueRecord, repoFullName: string): string[] {
|
|
94
|
+
const raw = [
|
|
95
|
+
'github',
|
|
96
|
+
repoFullName,
|
|
97
|
+
...(issue.labels || []).map(normalizeLabelName),
|
|
98
|
+
]
|
|
99
|
+
return dedup(raw.map(normalizeTag).filter(Boolean)).slice(0, 8)
|
|
100
|
+
}
|
|
@@ -5,7 +5,7 @@ import {
|
|
|
5
5
|
buildGitHubIssueTaskTags,
|
|
6
6
|
buildGitHubIssueTaskTitle,
|
|
7
7
|
parseGitHubRepoInput,
|
|
8
|
-
} from './
|
|
8
|
+
} from './helpers'
|
|
9
9
|
|
|
10
10
|
test('parseGitHubRepoInput accepts repo slugs and GitHub URLs', () => {
|
|
11
11
|
assert.deepEqual(parseGitHubRepoInput('swarmclawai/swarmclaw'), {
|
|
@@ -6,10 +6,10 @@ import { formatZodError } from '@/lib/validation/schemas'
|
|
|
6
6
|
import { loadSettings, loadTasks, logActivity, upsertStoredItems } from '@/lib/server/storage'
|
|
7
7
|
import { notify } from '@/lib/server/ws-hub'
|
|
8
8
|
import type { BoardTask } from '@/types'
|
|
9
|
-
import {
|
|
9
|
+
import { parseGitHubRepoInput, buildGitHubIssueTaskTitle, buildGitHubIssueTaskDescription, buildGitHubIssueTaskTags } from './helpers'
|
|
10
|
+
import type { GitHubIssueRecord } from './helpers'
|
|
10
11
|
|
|
11
12
|
const MAX_IMPORT_LIMIT = 200
|
|
12
|
-
const BODY_CHAR_LIMIT = 12_000
|
|
13
13
|
|
|
14
14
|
const GitHubIssueImportSchema = z.object({
|
|
15
15
|
repo: z.string().trim().min(1, 'Repository is required'),
|
|
@@ -23,25 +23,6 @@ const GitHubIssueImportSchema = z.object({
|
|
|
23
23
|
|
|
24
24
|
type GitHubIssueLabel = string | { name?: string | null }
|
|
25
25
|
|
|
26
|
-
interface GitHubIssueRecord {
|
|
27
|
-
id: number | string
|
|
28
|
-
number: number
|
|
29
|
-
title: string
|
|
30
|
-
body?: string | null
|
|
31
|
-
state?: string | null
|
|
32
|
-
html_url?: string | null
|
|
33
|
-
labels?: GitHubIssueLabel[]
|
|
34
|
-
assignee?: { login?: string | null } | null
|
|
35
|
-
user?: { login?: string | null } | null
|
|
36
|
-
pull_request?: unknown
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
interface ParsedRepo {
|
|
40
|
-
owner: string
|
|
41
|
-
repo: string
|
|
42
|
-
fullName: string
|
|
43
|
-
}
|
|
44
|
-
|
|
45
26
|
function getGitHubToken(explicitToken: string): string {
|
|
46
27
|
return explicitToken.trim()
|
|
47
28
|
|| process.env.GITHUB_TOKEN
|
|
@@ -55,10 +36,6 @@ function normalizeLabelName(label: GitHubIssueLabel): string {
|
|
|
55
36
|
return String(label?.name || '').trim()
|
|
56
37
|
}
|
|
57
38
|
|
|
58
|
-
function normalizeTag(value: string): string {
|
|
59
|
-
return value.trim().replace(/\s+/g, ' ').slice(0, 60)
|
|
60
|
-
}
|
|
61
|
-
|
|
62
39
|
function toIssueSummary(issue: GitHubIssueRecord, taskId?: string) {
|
|
63
40
|
return {
|
|
64
41
|
taskId,
|
|
@@ -68,73 +45,6 @@ function toIssueSummary(issue: GitHubIssueRecord, taskId?: string) {
|
|
|
68
45
|
}
|
|
69
46
|
}
|
|
70
47
|
|
|
71
|
-
export function parseGitHubRepoInput(input: string): ParsedRepo | null {
|
|
72
|
-
const trimmed = input.trim().replace(/\.git$/i, '')
|
|
73
|
-
if (!trimmed) return null
|
|
74
|
-
|
|
75
|
-
if (/^https?:\/\//i.test(trimmed)) {
|
|
76
|
-
try {
|
|
77
|
-
const url = new URL(trimmed)
|
|
78
|
-
if (!/github\.com$/i.test(url.hostname)) return null
|
|
79
|
-
const parts = url.pathname.split('/').filter(Boolean)
|
|
80
|
-
if (parts.length < 2) return null
|
|
81
|
-
const owner = parts[0]
|
|
82
|
-
const repo = parts[1].replace(/\.git$/i, '')
|
|
83
|
-
if (!owner || !repo) return null
|
|
84
|
-
return { owner, repo, fullName: `${owner}/${repo}` }
|
|
85
|
-
} catch {
|
|
86
|
-
return null
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
const compact = trimmed.replace(/^github\.com\//i, '')
|
|
91
|
-
const parts = compact.split('/').filter(Boolean)
|
|
92
|
-
if (parts.length < 2) return null
|
|
93
|
-
const owner = parts[0]
|
|
94
|
-
const repo = parts[1].replace(/\.git$/i, '')
|
|
95
|
-
if (!owner || !repo) return null
|
|
96
|
-
return { owner, repo, fullName: `${owner}/${repo}` }
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
export function buildGitHubIssueTaskTitle(issue: GitHubIssueRecord, repoFullName: string): string {
|
|
100
|
-
const title = issue.title?.trim() || `Issue ${issue.number}`
|
|
101
|
-
return `[${repoFullName}#${issue.number}] ${title}`
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
export function buildGitHubIssueTaskDescription(issue: GitHubIssueRecord, repoFullName: string): string {
|
|
105
|
-
const labels = (issue.labels || [])
|
|
106
|
-
.map(normalizeLabelName)
|
|
107
|
-
.filter(Boolean)
|
|
108
|
-
const header = [
|
|
109
|
-
`Imported from GitHub issue ${repoFullName}#${issue.number}`,
|
|
110
|
-
issue.html_url ? `URL: ${issue.html_url}` : '',
|
|
111
|
-
issue.state ? `State: ${issue.state}` : '',
|
|
112
|
-
labels.length > 0 ? `Labels: ${labels.join(', ')}` : '',
|
|
113
|
-
issue.assignee?.login ? `Assignee: ${issue.assignee.login}` : '',
|
|
114
|
-
issue.user?.login ? `Opened by: ${issue.user.login}` : '',
|
|
115
|
-
]
|
|
116
|
-
.filter(Boolean)
|
|
117
|
-
.join('\n')
|
|
118
|
-
|
|
119
|
-
const rawBody = String(issue.body || '').trim()
|
|
120
|
-
if (!rawBody) return header
|
|
121
|
-
|
|
122
|
-
const body = rawBody.length > BODY_CHAR_LIMIT
|
|
123
|
-
? `${rawBody.slice(0, BODY_CHAR_LIMIT).trimEnd()}\n\n[Truncated during import]`
|
|
124
|
-
: rawBody
|
|
125
|
-
|
|
126
|
-
return `${header}\n\n${body}`
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
export function buildGitHubIssueTaskTags(issue: GitHubIssueRecord, repoFullName: string): string[] {
|
|
130
|
-
const raw = [
|
|
131
|
-
'github',
|
|
132
|
-
repoFullName,
|
|
133
|
-
...(issue.labels || []).map(normalizeLabelName),
|
|
134
|
-
]
|
|
135
|
-
return dedup(raw.map(normalizeTag).filter(Boolean)).slice(0, 8)
|
|
136
|
-
}
|
|
137
|
-
|
|
138
48
|
function findExistingImportedTask(
|
|
139
49
|
tasks: Record<string, BoardTask>,
|
|
140
50
|
repoFullName: string,
|