@swarmclawai/swarmclaw 1.0.0 → 1.0.2
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/bin/doctor-cmd.js +149 -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 +37 -6
- 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 +8 -2
- package/package.json +9 -8
- 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/cli/binary.test.js +57 -0
- package/src/cli/index.js +13 -1
- package/src/lib/server/data-dir.test.ts +35 -0
- 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/bin/worker-cmd.js
CHANGED
|
@@ -4,9 +4,11 @@
|
|
|
4
4
|
const { spawn } = require('node:child_process')
|
|
5
5
|
|
|
6
6
|
const {
|
|
7
|
+
BROWSER_PROFILES_DIR,
|
|
7
8
|
DATA_DIR,
|
|
8
9
|
PKG_ROOT,
|
|
9
10
|
SWARMCLAW_HOME,
|
|
11
|
+
WORKSPACE_DIR,
|
|
10
12
|
findStandaloneServer,
|
|
11
13
|
} = require('./server-cmd.js')
|
|
12
14
|
|
|
@@ -23,8 +25,7 @@ Options:
|
|
|
23
25
|
console.log(help)
|
|
24
26
|
}
|
|
25
27
|
|
|
26
|
-
function main() {
|
|
27
|
-
const args = process.argv.slice(3)
|
|
28
|
+
function main(args = process.argv.slice(3)) {
|
|
28
29
|
for (const arg of args) {
|
|
29
30
|
if (arg === '-h' || arg === '--help') {
|
|
30
31
|
printHelp()
|
|
@@ -36,7 +37,10 @@ function main() {
|
|
|
36
37
|
}
|
|
37
38
|
}
|
|
38
39
|
|
|
40
|
+
process.env.SWARMCLAW_HOME = SWARMCLAW_HOME
|
|
39
41
|
process.env.DATA_DIR = DATA_DIR
|
|
42
|
+
process.env.WORKSPACE_DIR = WORKSPACE_DIR
|
|
43
|
+
process.env.BROWSER_PROFILES_DIR = BROWSER_PROFILES_DIR
|
|
40
44
|
process.env.SWARMCLAW_DAEMON_BACKGROUND_SERVICES = '1'
|
|
41
45
|
process.env.SWARMCLAW_WORKER_ONLY = '1'
|
|
42
46
|
|
|
@@ -44,6 +48,8 @@ function main() {
|
|
|
44
48
|
console.log(`[swarmclaw] Package root: ${PKG_ROOT}`)
|
|
45
49
|
console.log(`[swarmclaw] Home: ${SWARMCLAW_HOME}`)
|
|
46
50
|
console.log(`[swarmclaw] Data directory: ${DATA_DIR}`)
|
|
51
|
+
console.log(`[swarmclaw] Workspace directory: ${WORKSPACE_DIR}`)
|
|
52
|
+
console.log(`[swarmclaw] Browser profiles: ${BROWSER_PROFILES_DIR}`)
|
|
47
53
|
|
|
48
54
|
const serverJs = findStandaloneServer()
|
|
49
55
|
if (!serverJs) {
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@swarmclawai/swarmclaw",
|
|
3
|
-
"version": "1.0.
|
|
4
|
-
"description": "Self-hosted AI agent orchestration dashboard
|
|
3
|
+
"version": "1.0.2",
|
|
4
|
+
"description": "Self-hosted AI agent orchestration dashboard with OpenClaw integration, multi-provider support, LangGraph workflows, and chat platform connectors.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"publishConfig": {
|
|
7
7
|
"access": "public",
|
|
@@ -51,8 +51,8 @@
|
|
|
51
51
|
"dev": "next dev --turbopack --hostname 0.0.0.0 -p 3456",
|
|
52
52
|
"dev:webpack": "next dev --webpack --hostname 0.0.0.0 -p 3456",
|
|
53
53
|
"dev:clean": "rm -rf .next && next dev --turbopack --hostname 0.0.0.0 -p 3456",
|
|
54
|
-
"build": "next build",
|
|
55
|
-
"build:ci": "NEXT_DISABLE_ESLINT=1 next build",
|
|
54
|
+
"build": "next build --webpack",
|
|
55
|
+
"build:ci": "NEXT_DISABLE_ESLINT=1 next build --webpack",
|
|
56
56
|
"start": "node .next/standalone/server.js",
|
|
57
57
|
"start:standalone": "node .next/standalone/server.js",
|
|
58
58
|
"smoke:browser": "node ./scripts/browser-route-smoke.mjs",
|
|
@@ -69,6 +69,7 @@
|
|
|
69
69
|
"test:setup": "tsx --test src/app/api/setup/check-provider/route.test.ts src/lib/server/provider-model-discovery.test.ts src/components/auth/setup-wizard/utils.test.ts src/components/auth/setup-wizard/types.test.ts src/hooks/setup-done-detection.test.ts src/lib/setup-defaults.test.ts",
|
|
70
70
|
"test:openclaw": "tsx --test src/lib/openclaw/openclaw-agent-id.test.ts src/lib/openclaw/openclaw-endpoint.test.ts src/lib/server/agents/agent-runtime-config.test.ts src/lib/server/build-llm.test.ts src/lib/server/connectors/connector-routing.test.ts src/lib/server/connectors/openclaw.test.ts src/lib/server/gateway/protocol.test.ts src/lib/server/llm-response-cache.test.ts src/lib/server/mcp-conformance.test.ts src/lib/server/openclaw/agent-resolver.test.ts src/lib/server/openclaw/deploy.test.ts src/lib/server/openclaw/skills-normalize.test.ts src/lib/server/session-tools/openclaw-nodes.test.ts src/lib/server/tasks/task-quality-gate.test.ts src/lib/server/tasks/task-validation.test.ts src/lib/server/tool-capability-policy.test.ts src/lib/providers/openclaw-exports.test.ts src/app/api/openclaw/dashboard-url/route.test.ts",
|
|
71
71
|
"test:mcp:conformance": "node --import tsx ./scripts/mcp-conformance-check.ts",
|
|
72
|
+
"prepack": "npm run build:ci",
|
|
72
73
|
"postinstall": "node ./scripts/postinstall.mjs"
|
|
73
74
|
},
|
|
74
75
|
"dependencies": {
|
|
@@ -76,6 +77,7 @@
|
|
|
76
77
|
"@langchain/anthropic": "^1.3.18",
|
|
77
78
|
"@langchain/core": "^1.1.31",
|
|
78
79
|
"@modelcontextprotocol/sdk": "^1.27.1",
|
|
80
|
+
"@tailwindcss/postcss": "^4",
|
|
79
81
|
"@langchain/langgraph": "^1.2.2",
|
|
80
82
|
"@langchain/openai": "^1.2.8",
|
|
81
83
|
"@multiavatar/multiavatar": "^1.0.7",
|
|
@@ -116,14 +118,16 @@
|
|
|
116
118
|
"rehype-highlight": "^7.0.2",
|
|
117
119
|
"remark-gfm": "^4.0.1",
|
|
118
120
|
"remove-markdown": "^0.6.3",
|
|
121
|
+
"shadcn": "^3.8.5",
|
|
119
122
|
"sonner": "^2.0.7",
|
|
123
|
+
"tailwindcss": "^4",
|
|
120
124
|
"tailwind-merge": "^3.4.1",
|
|
125
|
+
"tw-animate-css": "^1.4.0",
|
|
121
126
|
"ws": "^8.19.0",
|
|
122
127
|
"zod": "^4.3.6",
|
|
123
128
|
"zustand": "^5.0.11"
|
|
124
129
|
},
|
|
125
130
|
"devDependencies": {
|
|
126
|
-
"@tailwindcss/postcss": "^4",
|
|
127
131
|
"@types/better-sqlite3": "^7.6.13",
|
|
128
132
|
"@types/mailparser": "^3.4.6",
|
|
129
133
|
"@types/node": "^20",
|
|
@@ -134,10 +138,7 @@
|
|
|
134
138
|
"@types/ws": "^8.18.1",
|
|
135
139
|
"eslint": "^9",
|
|
136
140
|
"eslint-config-next": "16.1.6",
|
|
137
|
-
"shadcn": "^3.8.5",
|
|
138
|
-
"tailwindcss": "^4",
|
|
139
141
|
"tsx": "^4.20.6",
|
|
140
|
-
"tw-animate-css": "^1.4.0",
|
|
141
142
|
"typescript": "^5"
|
|
142
143
|
},
|
|
143
144
|
"optionalDependencies": {
|
|
@@ -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,
|