@andypai/agent-kanban 0.7.0 → 0.8.0
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 +13 -8
- package/package.json +1 -1
- package/src/__tests__/api.test.ts +220 -0
- package/src/__tests__/cli-task-lifecycle-matrix.test.ts +1262 -0
- package/src/__tests__/index.test.ts +107 -3
- package/src/__tests__/jira-wiring.test.ts +25 -2
- package/src/__tests__/server.test.ts +78 -0
- package/src/__tests__/sync-config.test.ts +14 -1
- package/src/__tests__/tracker-config-webhook-secret.test.ts +89 -0
- package/src/__tests__/transport-input.test.ts +67 -1
- package/src/__tests__/tunnel.test.ts +116 -0
- package/src/__tests__/webhook-events-receipts.test.ts +155 -0
- package/src/__tests__/webhooks.test.ts +47 -1
- package/src/api.ts +53 -15
- package/src/db.ts +18 -1
- package/src/index.ts +76 -17
- package/src/providers/factory.ts +1 -1
- package/src/providers/jira-core.ts +9 -3
- package/src/providers/linear-core.ts +9 -3
- package/src/providers/local.ts +2 -2
- package/src/providers/sqlite-local-store.ts +5 -1
- package/src/sync-config.ts +7 -2
- package/src/tracker-config.ts +72 -4
- package/src/transport-input.ts +46 -14
- package/src/tunnel.ts +22 -5
package/src/tracker-config.ts
CHANGED
|
@@ -1,9 +1,60 @@
|
|
|
1
1
|
import { ErrorCode, KanbanError } from './errors'
|
|
2
2
|
import { providerNotConfigured } from './providers/errors'
|
|
3
3
|
import { resolvePollingSyncIntervalMs } from './sync-config'
|
|
4
|
+
import { parseDecimalDigits } from './transport-input'
|
|
4
5
|
|
|
5
6
|
export type TrackerProvider = 'local' | 'linear' | 'jira'
|
|
6
7
|
|
|
8
|
+
/**
|
|
9
|
+
* Single source of truth for which env var holds each provider's webhook signing
|
|
10
|
+
* secret (`null` = the provider has no webhook ingestion). Typed as
|
|
11
|
+
* `Record<TrackerProvider, …>`, so adding a new provider to the union is a
|
|
12
|
+
* compile error here until its secret env is declared — preventing a new
|
|
13
|
+
* webhook-capable provider from silently slipping past the tunnel-security gate
|
|
14
|
+
* (assertTunnelSecurity) with no secret enforced.
|
|
15
|
+
*/
|
|
16
|
+
export const WEBHOOK_SECRET_ENV: Record<TrackerProvider, string | null> = {
|
|
17
|
+
local: null,
|
|
18
|
+
linear: 'LINEAR_WEBHOOK_SECRET',
|
|
19
|
+
jira: 'JIRA_WEBHOOK_SECRET',
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Normalize the raw `KANBAN_PROVIDER` env value to a known `TrackerProvider`,
|
|
24
|
+
* falling back to `'local'` for anything unset/unrecognized — matching how
|
|
25
|
+
* trackerConfigFromEnv resolves the provider. Centralized so the security gate
|
|
26
|
+
* and the config loader agree on a single derivation.
|
|
27
|
+
*/
|
|
28
|
+
export function trackerProviderFromEnv(
|
|
29
|
+
env: Record<string, string | undefined> = process.env,
|
|
30
|
+
): TrackerProvider {
|
|
31
|
+
const raw = (env['KANBAN_PROVIDER'] ?? 'local').trim().toLowerCase()
|
|
32
|
+
// Recognize exactly the providers declared in WEBHOOK_SECRET_ENV — own keys
|
|
33
|
+
// only, so inherited names like "constructor"/"toString" don't match — and
|
|
34
|
+
// fall back to local otherwise. Deriving from the map keeps this normalizer in
|
|
35
|
+
// lockstep with it, so a newly-added provider is picked up here automatically.
|
|
36
|
+
return Object.prototype.hasOwnProperty.call(WEBHOOK_SECRET_ENV, raw)
|
|
37
|
+
? (raw as TrackerProvider)
|
|
38
|
+
: 'local'
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Resolve a provider's webhook signing secret from `env` via WEBHOOK_SECRET_ENV
|
|
43
|
+
* (the single source of truth), returning `undefined` when the provider declares
|
|
44
|
+
* no secret env or the env var is unset — the case where the webhook handler
|
|
45
|
+
* falls back to open dev mode. The runtime signature enforcement (the provider
|
|
46
|
+
* webhook handlers) and the assertTunnelSecurity tunnel gate both resolve the
|
|
47
|
+
* secret env name through this same map, so the gate cannot require one env name
|
|
48
|
+
* while enforcement reads another (a fail-open if they drifted).
|
|
49
|
+
*/
|
|
50
|
+
export function webhookSecretFromEnv(
|
|
51
|
+
provider: TrackerProvider,
|
|
52
|
+
env: Record<string, string | undefined> = process.env,
|
|
53
|
+
): string | undefined {
|
|
54
|
+
const envName = WEBHOOK_SECRET_ENV[provider]
|
|
55
|
+
return envName ? env[envName] : undefined
|
|
56
|
+
}
|
|
57
|
+
|
|
7
58
|
export interface LocalTrackerConfig {
|
|
8
59
|
provider: 'local'
|
|
9
60
|
defaultColumns?: string[]
|
|
@@ -34,7 +85,7 @@ export type TrackerConfig = LocalTrackerConfig | LinearTrackerConfig | JiraTrack
|
|
|
34
85
|
export function trackerConfigFromEnv(
|
|
35
86
|
env: Record<string, string | undefined> = process.env,
|
|
36
87
|
): TrackerConfig {
|
|
37
|
-
const provider = (env
|
|
88
|
+
const provider = trackerProviderFromEnv(env)
|
|
38
89
|
|
|
39
90
|
if (provider === 'linear') {
|
|
40
91
|
const apiKey = env['LINEAR_API_KEY']
|
|
@@ -70,15 +121,14 @@ export function trackerConfigFromEnv(
|
|
|
70
121
|
`${missing.join(', ')} ${missing.length === 1 ? 'is' : 'are'} required when KANBAN_PROVIDER=jira`,
|
|
71
122
|
)
|
|
72
123
|
}
|
|
73
|
-
const
|
|
74
|
-
const boardId = boardIdRaw ? Number.parseInt(boardIdRaw, 10) : undefined
|
|
124
|
+
const boardId = jiraBoardIdFromEnv(env['JIRA_BOARD_ID'])
|
|
75
125
|
return {
|
|
76
126
|
provider,
|
|
77
127
|
baseUrl: baseUrl!,
|
|
78
128
|
email: email!,
|
|
79
129
|
apiToken: apiToken!,
|
|
80
130
|
projectKey: projectKey!,
|
|
81
|
-
...(
|
|
131
|
+
...(boardId !== undefined ? { boardId } : {}),
|
|
82
132
|
defaultIssueType: env['JIRA_ISSUE_TYPE'] ?? 'Task',
|
|
83
133
|
syncIntervalMs: resolvePollingSyncIntervalMs(env['KANBAN_SYNC_INTERVAL_MS']),
|
|
84
134
|
}
|
|
@@ -94,6 +144,24 @@ export function trackerConfigFromEnv(
|
|
|
94
144
|
}
|
|
95
145
|
}
|
|
96
146
|
|
|
147
|
+
/**
|
|
148
|
+
* Resolve the optional `JIRA_BOARD_ID` env var to a positive integer board id.
|
|
149
|
+
* Unset/blank → `undefined` (no board pinned). A set-but-invalid value throws
|
|
150
|
+
* `INVALID_CONFIG` rather than silently falling back: the previous
|
|
151
|
+
* `Number.parseInt` accepted trailing garbage and signs (`'12abc'→12`,
|
|
152
|
+
* `'-5'→-5`, `'1e3'→1`), quietly pinning a *wrong* board. This matches how
|
|
153
|
+
* resolvePollingSyncIntervalMs treats a malformed optional numeric env var.
|
|
154
|
+
*/
|
|
155
|
+
function jiraBoardIdFromEnv(raw: string | undefined): number | undefined {
|
|
156
|
+
const value = raw?.trim()
|
|
157
|
+
if (!value) return undefined
|
|
158
|
+
const parsed = parseDecimalDigits(value)
|
|
159
|
+
if (parsed === null || parsed < 1) {
|
|
160
|
+
throw new KanbanError(ErrorCode.INVALID_CONFIG, 'JIRA_BOARD_ID must be a positive integer')
|
|
161
|
+
}
|
|
162
|
+
return parsed
|
|
163
|
+
}
|
|
164
|
+
|
|
97
165
|
function defaultColumnsFromEnv(env: Record<string, string | undefined>): string[] | undefined {
|
|
98
166
|
const raw = env['KANBAN_DEFAULT_COLUMNS']?.trim()
|
|
99
167
|
if (!raw) return undefined
|
package/src/transport-input.ts
CHANGED
|
@@ -1,26 +1,58 @@
|
|
|
1
1
|
import { ErrorCode, KanbanError } from './errors'
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* Parse
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
4
|
+
* Parse a plain decimal-digit string into an exact non-negative integer, or
|
|
5
|
+
* `null` if it is not one. Rejects signs, decimals, and exponent/hex forms like
|
|
6
|
+
* `"-1"` / `"3.5"` / `"1e100"` / `"0x10"` that `Number()` would otherwise coerce
|
|
7
|
+
* to an integer, and rejects an over-long digit string that `Number()` rounds
|
|
8
|
+
* past `MAX_SAFE_INTEGER` or overflows to `Infinity`, so the result is always
|
|
9
|
+
* exact. Surrounding whitespace is trimmed. This is the shared digits-only
|
|
10
|
+
* primitive the throwing boundary/config parsers (`parseBoundedInt`,
|
|
11
|
+
* `resolvePollingSyncIntervalMs`, the JIRA_BOARD_ID loader) build on — keeping a
|
|
12
|
+
* single definition of "what counts as an integer" so they can't drift.
|
|
13
|
+
*/
|
|
14
|
+
export function parseDecimalDigits(value: string): number | null {
|
|
15
|
+
const trimmed = value.trim()
|
|
16
|
+
if (!/^\d+$/.test(trimmed)) return null
|
|
17
|
+
const parsed = Number(trimmed)
|
|
18
|
+
return Number.isSafeInteger(parsed) ? parsed : null
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Parse a required integer supplied through a transport boundary (HTTP query
|
|
23
|
+
* string, CLI flag) into the inclusive range `[min, max]` (max defaults to
|
|
24
|
+
* `MAX_SAFE_INTEGER`), via {@link parseDecimalDigits}. Throws
|
|
25
|
+
* `KanbanError(INVALID_ARGUMENT)` on any violation.
|
|
26
|
+
*/
|
|
27
|
+
export function parseBoundedInt(
|
|
28
|
+
value: string,
|
|
29
|
+
opts: { min: number; max?: number; field: string },
|
|
30
|
+
): number {
|
|
31
|
+
const parsed = parseDecimalDigits(value)
|
|
32
|
+
const max = opts.max ?? Number.MAX_SAFE_INTEGER
|
|
33
|
+
if (parsed === null || parsed < opts.min || parsed > max) {
|
|
34
|
+
const range = opts.max === undefined ? `>= ${opts.min}` : `between ${opts.min} and ${opts.max}`
|
|
35
|
+
throw new KanbanError(ErrorCode.INVALID_ARGUMENT, `${opts.field} must be an integer ${range}`)
|
|
36
|
+
}
|
|
37
|
+
return parsed
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Parse an optional positive integer supplied through a transport boundary.
|
|
42
|
+
* Returns `undefined` when the value is absent/blank, otherwise resolves via
|
|
43
|
+
* {@link parseDecimalDigits} (which trims and caps at `MAX_SAFE_INTEGER`) and
|
|
44
|
+
* enforces `>= 1`, throwing `KanbanError(INVALID_ARGUMENT)` framed as "positive
|
|
45
|
+
* integer" so invalid limits don't leak into provider logic (SQL `LIMIT`,
|
|
46
|
+
* `slice`, PG).
|
|
11
47
|
*/
|
|
12
48
|
export function parsePositiveInt(
|
|
13
49
|
value: string | null | undefined,
|
|
14
50
|
field = 'limit',
|
|
15
51
|
): number | undefined {
|
|
16
52
|
if (value === null || value === undefined) return undefined
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
// "1e100" that Number() would otherwise treat as integers and let escape into
|
|
21
|
-
// SQL `LIMIT`. Cap at MAX_SAFE_INTEGER so the parsed value stays exact.
|
|
22
|
-
const parsed = /^\d+$/.test(trimmed) ? Number(trimmed) : NaN
|
|
23
|
-
if (!Number.isInteger(parsed) || parsed <= 0 || parsed > Number.MAX_SAFE_INTEGER) {
|
|
53
|
+
if (value.trim() === '') return undefined
|
|
54
|
+
const parsed = parseDecimalDigits(value)
|
|
55
|
+
if (parsed === null || parsed < 1) {
|
|
24
56
|
throw new KanbanError(
|
|
25
57
|
ErrorCode.INVALID_ARGUMENT,
|
|
26
58
|
`${field} must be a positive integer (received '${value}')`,
|
package/src/tunnel.ts
CHANGED
|
@@ -57,15 +57,32 @@ export function startCloudflareTunnel(port: number, opts: TunnelOptions = {}): T
|
|
|
57
57
|
): Promise<void> => {
|
|
58
58
|
if (!stream) return
|
|
59
59
|
const decoder = new TextDecoder()
|
|
60
|
+
// Accumulate across chunks: cloudflared may split the URL across read
|
|
61
|
+
// boundaries, so matching each chunk in isolation can miss it.
|
|
62
|
+
let buffer = ''
|
|
60
63
|
for await (const chunk of stream) {
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
if (
|
|
64
|
+
// Once announced, keep draining the pipe (so the child doesn't block on a
|
|
65
|
+
// full stdout buffer) but stop scanning.
|
|
66
|
+
if (announced) continue
|
|
67
|
+
buffer += decoder.decode(chunk as Uint8Array, { stream: true })
|
|
68
|
+
const match = buffer.match(TRYCLOUDFLARE_URL)
|
|
69
|
+
if (match) {
|
|
70
|
+
announce(match[0])
|
|
71
|
+
} else if (buffer.length > 4096) {
|
|
72
|
+
// Bound memory while keeping a tail long enough to span a split URL.
|
|
73
|
+
buffer = buffer.slice(-256)
|
|
74
|
+
}
|
|
64
75
|
}
|
|
65
76
|
}
|
|
66
77
|
|
|
67
|
-
|
|
68
|
-
|
|
78
|
+
// A stream error while draining stdout/stderr (e.g. the pipe tears down as the
|
|
79
|
+
// child is killed) must not surface as an unhandled rejection; the child.exited
|
|
80
|
+
// handler below still emits the no-URL warning when nothing was announced.
|
|
81
|
+
const drain = (stream: ReadableStream<Uint8Array> | null | undefined): void => {
|
|
82
|
+
void scanForUrl(stream).catch(() => {})
|
|
83
|
+
}
|
|
84
|
+
drain(child.stdout as ReadableStream<Uint8Array>)
|
|
85
|
+
drain(child.stderr as ReadableStream<Uint8Array>)
|
|
69
86
|
|
|
70
87
|
void child.exited.then((code) => {
|
|
71
88
|
if (!announced) {
|