@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/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.0",
4
- "description": "Self-hosted AI agent orchestration dashboard manage LLM providers, orchestrate agent swarms, schedule tasks, and bridge agents to chat platforms.",
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, saveGatewayProfiles } from '@/lib/server/storage'
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 './[id]/health/route'
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 './route'
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 any
262
- const models = Array.isArray(payload?.models) ? payload.models : Array.isArray(payload?.data) ? payload.data : Array.isArray(payload) ? payload : []
263
- const first = models[0]
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 './route'
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 { dedup } from '@/lib/shared-utils'
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,