tissues 0.3.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/LICENSE +21 -0
- package/README.md +353 -0
- package/bin/gitissues.js +11 -0
- package/package.json +56 -0
- package/src/cli.js +64 -0
- package/src/commands/auth.js +62 -0
- package/src/commands/create.js +360 -0
- package/src/commands/list.js +59 -0
- package/src/commands/open.js +17 -0
- package/src/commands/status.js +166 -0
- package/src/lib/attribution.js +216 -0
- package/src/lib/config.js +16 -0
- package/src/lib/db.js +436 -0
- package/src/lib/dedup.js +205 -0
- package/src/lib/defaults.js +252 -0
- package/src/lib/gh.js +273 -0
- package/src/lib/repo-picker.js +54 -0
- package/src/lib/safety.js +259 -0
- package/src/lib/templates.js +220 -0
package/src/lib/dedup.js
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import crypto from 'node:crypto'
|
|
2
|
+
import { checkFingerprint, storeFingerprint, checkIdempotencyKey, storeIdempotencyKey } from './db.js'
|
|
3
|
+
import { listIssues } from './gh.js'
|
|
4
|
+
|
|
5
|
+
/** @type {Map<string, { issues: Array, fetchedAt: number }>} */
|
|
6
|
+
const issuesCache = new Map()
|
|
7
|
+
const CACHE_TTL_MS = 5 * 60 * 1000 // 5 minutes
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Normalize text for comparison: lowercase, replace non-alphanumeric with
|
|
11
|
+
* space, collapse multiple spaces, trim.
|
|
12
|
+
*
|
|
13
|
+
* @param {string} text
|
|
14
|
+
* @returns {string}
|
|
15
|
+
*/
|
|
16
|
+
export function normalizeText(text) {
|
|
17
|
+
return text
|
|
18
|
+
.toLowerCase()
|
|
19
|
+
.replace(/[^a-z0-9]+/g, ' ')
|
|
20
|
+
.replace(/\s+/g, ' ')
|
|
21
|
+
.trim()
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Compute a SHA-256 content fingerprint from a title and the first 500
|
|
26
|
+
* characters of a body.
|
|
27
|
+
*
|
|
28
|
+
* @param {string} title
|
|
29
|
+
* @param {string} body
|
|
30
|
+
* @returns {string} hex digest
|
|
31
|
+
*/
|
|
32
|
+
export function computeFingerprint(title, body) {
|
|
33
|
+
const normalized = normalizeText(title) + '|||' + normalizeText((body ?? '').slice(0, 500))
|
|
34
|
+
return crypto.createHash('sha256').update(normalized).digest('hex')
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Compute a deterministic idempotency key from trigger context.
|
|
39
|
+
*
|
|
40
|
+
* @param {{ agent: string, trigger: string, issueType: string, repo: string }} context
|
|
41
|
+
* @returns {string} hex digest
|
|
42
|
+
*/
|
|
43
|
+
export function computeIdempotencyKey({ agent, trigger, issueType, repo }) {
|
|
44
|
+
const raw = [agent, trigger, issueType, repo].join('|')
|
|
45
|
+
return crypto.createHash('sha256').update(raw).digest('hex')
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Compute Levenshtein distance between two strings.
|
|
50
|
+
*
|
|
51
|
+
* @param {string} a
|
|
52
|
+
* @param {string} b
|
|
53
|
+
* @returns {number}
|
|
54
|
+
*/
|
|
55
|
+
function levenshteinDistance(a, b) {
|
|
56
|
+
const m = a.length
|
|
57
|
+
const n = b.length
|
|
58
|
+
const dp = Array.from({ length: m + 1 }, (_, i) => [i, ...Array(n).fill(0)])
|
|
59
|
+
for (let j = 0; j <= n; j++) dp[0][j] = j
|
|
60
|
+
for (let i = 1; i <= m; i++) {
|
|
61
|
+
for (let j = 1; j <= n; j++) {
|
|
62
|
+
dp[i][j] =
|
|
63
|
+
a[i - 1] === b[j - 1]
|
|
64
|
+
? dp[i - 1][j - 1]
|
|
65
|
+
: 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1])
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return dp[m][n]
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Compute similarity between two strings as a value in [0, 1].
|
|
73
|
+
* 1.0 means identical, 0.0 means completely different.
|
|
74
|
+
*
|
|
75
|
+
* @param {string} a
|
|
76
|
+
* @param {string} b
|
|
77
|
+
* @returns {number}
|
|
78
|
+
*/
|
|
79
|
+
export function levenshteinSimilarity(a, b) {
|
|
80
|
+
if (a === b) return 1
|
|
81
|
+
const maxLen = Math.max(a.length, b.length)
|
|
82
|
+
if (maxLen === 0) return 1
|
|
83
|
+
return 1 - levenshteinDistance(a, b) / maxLen
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Fetch open issues for a repo, using a session-level cache that expires after
|
|
88
|
+
* 5 minutes.
|
|
89
|
+
*
|
|
90
|
+
* @param {string} repo
|
|
91
|
+
* @returns {Promise<Array>}
|
|
92
|
+
*/
|
|
93
|
+
async function getCachedIssues(repo) {
|
|
94
|
+
const cached = issuesCache.get(repo)
|
|
95
|
+
if (cached && Date.now() - cached.fetchedAt < CACHE_TTL_MS) {
|
|
96
|
+
return cached.issues
|
|
97
|
+
}
|
|
98
|
+
const issues = listIssues(repo)
|
|
99
|
+
issuesCache.set(repo, { issues, fetchedAt: Date.now() })
|
|
100
|
+
return issues
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* @typedef {{ action: 'block' | 'warn' | 'allow', reason: string, existingIssue?: { number: number, title: string, url: string } }} LayerResult
|
|
105
|
+
*/
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Run the full deduplication pipeline for a prospective issue.
|
|
109
|
+
*
|
|
110
|
+
* Layers run cheapest-first:
|
|
111
|
+
* 1. Idempotency key (O(1) DB lookup)
|
|
112
|
+
* 2. Content fingerprint (O(1) DB lookup)
|
|
113
|
+
* 3. Fuzzy title match against open GitHub issues (O(n))
|
|
114
|
+
*
|
|
115
|
+
* @param {string} repo e.g. "owner/name"
|
|
116
|
+
* @param {{ title: string, body: string, idempotencyKey?: string, agent?: string }} options
|
|
117
|
+
* @returns {Promise<{ action: 'block' | 'warn' | 'allow', results: LayerResult[] }>}
|
|
118
|
+
*/
|
|
119
|
+
export async function checkDuplicate(repo, { title, body, idempotencyKey, agent }) {
|
|
120
|
+
const results = []
|
|
121
|
+
|
|
122
|
+
// Layer 1: idempotency key
|
|
123
|
+
if (idempotencyKey) {
|
|
124
|
+
const existingNumber = await checkIdempotencyKey(idempotencyKey)
|
|
125
|
+
if (existingNumber != null) {
|
|
126
|
+
results.push({
|
|
127
|
+
action: 'block',
|
|
128
|
+
reason: 'Idempotency key matches existing issue',
|
|
129
|
+
existingIssue: { number: existingNumber, title: null, url: null },
|
|
130
|
+
})
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Layer 2: content fingerprint
|
|
135
|
+
const fingerprint = computeFingerprint(title, body ?? '')
|
|
136
|
+
const fpResult = await checkFingerprint(repo, fingerprint)
|
|
137
|
+
if (fpResult && fpResult.exists) {
|
|
138
|
+
results.push({
|
|
139
|
+
action: 'block',
|
|
140
|
+
reason: 'Content fingerprint matches existing issue',
|
|
141
|
+
existingIssue: {
|
|
142
|
+
number: fpResult.issueNumber,
|
|
143
|
+
title: fpResult.title,
|
|
144
|
+
url: null,
|
|
145
|
+
},
|
|
146
|
+
})
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Layer 3: fuzzy title match
|
|
150
|
+
const normalizedTitle = normalizeText(title)
|
|
151
|
+
const openIssues = await getCachedIssues(repo)
|
|
152
|
+
for (const issue of openIssues) {
|
|
153
|
+
const similarity = levenshteinSimilarity(normalizedTitle, normalizeText(issue.title))
|
|
154
|
+
if (similarity > 0.95) {
|
|
155
|
+
results.push({
|
|
156
|
+
action: 'block',
|
|
157
|
+
reason: `Fuzzy title match (similarity ${(similarity * 100).toFixed(1)}%) with existing issue`,
|
|
158
|
+
existingIssue: {
|
|
159
|
+
number: issue.number,
|
|
160
|
+
title: issue.title,
|
|
161
|
+
url: issue.url,
|
|
162
|
+
},
|
|
163
|
+
})
|
|
164
|
+
break
|
|
165
|
+
} else if (similarity > 0.80) {
|
|
166
|
+
results.push({
|
|
167
|
+
action: 'warn',
|
|
168
|
+
reason: `Fuzzy title is similar (similarity ${(similarity * 100).toFixed(1)}%) to existing issue`,
|
|
169
|
+
existingIssue: {
|
|
170
|
+
number: issue.number,
|
|
171
|
+
title: issue.title,
|
|
172
|
+
url: issue.url,
|
|
173
|
+
},
|
|
174
|
+
})
|
|
175
|
+
break
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Determine overall action
|
|
180
|
+
let action = 'allow'
|
|
181
|
+
if (results.some((r) => r.action === 'block')) {
|
|
182
|
+
action = 'block'
|
|
183
|
+
} else if (results.some((r) => r.action === 'warn')) {
|
|
184
|
+
action = 'warn'
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return { action, results }
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Persist deduplication records after a successful issue creation.
|
|
192
|
+
* Stores both the content fingerprint and, if provided, the idempotency key.
|
|
193
|
+
*
|
|
194
|
+
* @param {string} repo
|
|
195
|
+
* @param {{ title: string, body: string, issueNumber: number, idempotencyKey?: string, agent?: string }} options
|
|
196
|
+
* @returns {Promise<void>}
|
|
197
|
+
*/
|
|
198
|
+
export async function recordCreation(repo, { title, body, issueNumber, idempotencyKey, agent }) {
|
|
199
|
+
const fingerprint = computeFingerprint(title, body ?? '')
|
|
200
|
+
await storeFingerprint(repo, fingerprint, issueNumber, title, agent ?? 'unknown')
|
|
201
|
+
|
|
202
|
+
if (idempotencyKey) {
|
|
203
|
+
await storeIdempotencyKey(idempotencyKey, repo, issueNumber)
|
|
204
|
+
}
|
|
205
|
+
}
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import os from 'node:os'
|
|
4
|
+
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// Built-in defaults
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
|
|
9
|
+
const BUILT_IN_DEFAULTS = {
|
|
10
|
+
// Safety
|
|
11
|
+
safety: {
|
|
12
|
+
maxPerHour: 10,
|
|
13
|
+
burstLimit: 5,
|
|
14
|
+
burstWindowMinutes: 5,
|
|
15
|
+
tripThreshold: 3,
|
|
16
|
+
cooldownMinutes: 30,
|
|
17
|
+
globalMaxPerHour: 30,
|
|
18
|
+
},
|
|
19
|
+
|
|
20
|
+
// Deduplication
|
|
21
|
+
dedup: {
|
|
22
|
+
blockAbove: 0.95, // fuzzy similarity threshold to block
|
|
23
|
+
warnAbove: 0.80, // fuzzy similarity threshold to warn
|
|
24
|
+
fingerprintTTLDays: 90, // expire fingerprints after this many days
|
|
25
|
+
},
|
|
26
|
+
|
|
27
|
+
// Attribution (can be set globally or per-repo)
|
|
28
|
+
attribution: {
|
|
29
|
+
required: false, // if true, --agent is mandatory
|
|
30
|
+
defaultAgent: 'human',
|
|
31
|
+
},
|
|
32
|
+
|
|
33
|
+
// Templates
|
|
34
|
+
templates: {
|
|
35
|
+
dir: '.gitissues/templates', // relative to repo root, or absolute
|
|
36
|
+
default: 'default', // default template name
|
|
37
|
+
},
|
|
38
|
+
|
|
39
|
+
// AI
|
|
40
|
+
ai: {
|
|
41
|
+
enabled: true,
|
|
42
|
+
provider: 'anthropic', // or 'openai'
|
|
43
|
+
model: 'claude-haiku-4-5-20251001',
|
|
44
|
+
},
|
|
45
|
+
|
|
46
|
+
// Hooks (shell commands to run)
|
|
47
|
+
hooks: {
|
|
48
|
+
postCreate: null, // e.g., 'slack-notify.sh'
|
|
49
|
+
postClose: null,
|
|
50
|
+
},
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
// Env var prefix
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
const ENV_PREFIX = 'GITISSUES_'
|
|
58
|
+
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
// Helpers
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Deep-merge two plain objects. `override` values take priority over `base`.
|
|
65
|
+
* Arrays in `override` replace (not append to) arrays in `base`.
|
|
66
|
+
*
|
|
67
|
+
* @param {object} base
|
|
68
|
+
* @param {object} override
|
|
69
|
+
* @returns {object}
|
|
70
|
+
*/
|
|
71
|
+
function deepMerge(base, override) {
|
|
72
|
+
if (override === null || typeof override !== 'object' || Array.isArray(override)) {
|
|
73
|
+
return override !== undefined ? override : base
|
|
74
|
+
}
|
|
75
|
+
const result = { ...base }
|
|
76
|
+
for (const key of Object.keys(override)) {
|
|
77
|
+
const baseVal = base[key]
|
|
78
|
+
const overrideVal = override[key]
|
|
79
|
+
if (
|
|
80
|
+
overrideVal !== null &&
|
|
81
|
+
typeof overrideVal === 'object' &&
|
|
82
|
+
!Array.isArray(overrideVal) &&
|
|
83
|
+
baseVal !== null &&
|
|
84
|
+
typeof baseVal === 'object' &&
|
|
85
|
+
!Array.isArray(baseVal)
|
|
86
|
+
) {
|
|
87
|
+
result[key] = deepMerge(baseVal, overrideVal)
|
|
88
|
+
} else if (overrideVal !== undefined) {
|
|
89
|
+
result[key] = overrideVal
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return result
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Attempt to read and parse a JSON file. Returns `null` if the file does not
|
|
97
|
+
* exist or cannot be parsed.
|
|
98
|
+
*
|
|
99
|
+
* @param {string} filePath
|
|
100
|
+
* @returns {object|null}
|
|
101
|
+
*/
|
|
102
|
+
function readJsonFile(filePath) {
|
|
103
|
+
try {
|
|
104
|
+
const raw = fs.readFileSync(filePath, 'utf8')
|
|
105
|
+
return JSON.parse(raw)
|
|
106
|
+
} catch {
|
|
107
|
+
return null
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Get the path to the user-level config file.
|
|
113
|
+
*
|
|
114
|
+
* @returns {string}
|
|
115
|
+
*/
|
|
116
|
+
function userConfigPath() {
|
|
117
|
+
return path.join(os.homedir(), '.config', 'gitissues', 'config.json')
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Convert a flat `GITISSUES_SECTION_KEY=value` environment variable map into
|
|
122
|
+
* a nested object matching the config shape.
|
|
123
|
+
*
|
|
124
|
+
* Variable names are lowercased and split on `_` to build the path:
|
|
125
|
+
* GITISSUES_SAFETY_MAX_PER_HOUR=20 → { safety: { maxPerHour: 20 } }
|
|
126
|
+
*
|
|
127
|
+
* Simple camelCase reconstruction: after stripping the prefix and splitting on
|
|
128
|
+
* `_`, the first segment is the section; the remaining segments are joined in
|
|
129
|
+
* camelCase.
|
|
130
|
+
*
|
|
131
|
+
* Only variables whose section exists in BUILT_IN_DEFAULTS are included so we
|
|
132
|
+
* do not accidentally pollute the config with unrelated env vars.
|
|
133
|
+
*
|
|
134
|
+
* @returns {object}
|
|
135
|
+
*/
|
|
136
|
+
function configFromEnv() {
|
|
137
|
+
const result = {}
|
|
138
|
+
const knownSections = new Set(Object.keys(BUILT_IN_DEFAULTS))
|
|
139
|
+
|
|
140
|
+
for (const [rawKey, rawValue] of Object.entries(process.env)) {
|
|
141
|
+
if (!rawKey.startsWith(ENV_PREFIX)) continue
|
|
142
|
+
const stripped = rawKey.slice(ENV_PREFIX.length).toLowerCase()
|
|
143
|
+
const parts = stripped.split('_')
|
|
144
|
+
if (parts.length < 2) continue
|
|
145
|
+
|
|
146
|
+
const section = parts[0]
|
|
147
|
+
if (!knownSections.has(section)) continue
|
|
148
|
+
|
|
149
|
+
// Join remaining parts as camelCase
|
|
150
|
+
const remainingParts = parts.slice(1)
|
|
151
|
+
const fieldName = remainingParts
|
|
152
|
+
.map((p, i) => (i === 0 ? p : p.charAt(0).toUpperCase() + p.slice(1)))
|
|
153
|
+
.join('')
|
|
154
|
+
|
|
155
|
+
// Coerce value type based on built-in defaults
|
|
156
|
+
let value = rawValue
|
|
157
|
+
const builtInSection = BUILT_IN_DEFAULTS[section]
|
|
158
|
+
if (builtInSection && fieldName in builtInSection) {
|
|
159
|
+
const defaultVal = builtInSection[fieldName]
|
|
160
|
+
if (typeof defaultVal === 'number') {
|
|
161
|
+
value = Number(rawValue)
|
|
162
|
+
} else if (typeof defaultVal === 'boolean') {
|
|
163
|
+
value = rawValue === 'true' || rawValue === '1'
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (!result[section]) result[section] = {}
|
|
168
|
+
result[section][fieldName] = value
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return result
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ---------------------------------------------------------------------------
|
|
175
|
+
// Public API
|
|
176
|
+
// ---------------------------------------------------------------------------
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Walk up from `startDir` (defaults to `process.cwd()`) looking for a `.git`
|
|
180
|
+
* directory. Returns the first directory that contains `.git`, or `null` if no
|
|
181
|
+
* git repo is found.
|
|
182
|
+
*
|
|
183
|
+
* @param {string} [startDir]
|
|
184
|
+
* @returns {string|null}
|
|
185
|
+
*/
|
|
186
|
+
export function findRepoRoot(startDir) {
|
|
187
|
+
let dir = path.resolve(startDir ?? process.cwd())
|
|
188
|
+
const { root } = path.parse(dir)
|
|
189
|
+
|
|
190
|
+
while (true) {
|
|
191
|
+
const gitDir = path.join(dir, '.git')
|
|
192
|
+
if (fs.existsSync(gitDir)) return dir
|
|
193
|
+
if (dir === root) return null
|
|
194
|
+
dir = path.dirname(dir)
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Load merged configuration from all sources in ascending priority order:
|
|
200
|
+
* 1. Built-in defaults (lowest)
|
|
201
|
+
* 2. User-level config (~/.config/gitissues/config.json)
|
|
202
|
+
* 3. Repo-level config (<repoRoot>/.gitissues/config.json)
|
|
203
|
+
* 4. Environment vars (GITISSUES_*)
|
|
204
|
+
* 5. CLI flags (passed as `cliOverrides`, highest)
|
|
205
|
+
*
|
|
206
|
+
* @param {string} [repoRoot] - path to the repo root; auto-detected if omitted
|
|
207
|
+
* @param {object} [cliOverrides] - values from parsed CLI flags (already nested)
|
|
208
|
+
* @returns {object} merged config
|
|
209
|
+
*/
|
|
210
|
+
export function loadConfig(repoRoot, cliOverrides) {
|
|
211
|
+
const root = repoRoot ?? findRepoRoot()
|
|
212
|
+
|
|
213
|
+
// 1. Built-in defaults
|
|
214
|
+
let merged = deepMerge({}, BUILT_IN_DEFAULTS)
|
|
215
|
+
|
|
216
|
+
// 2. User-level config
|
|
217
|
+
const userCfg = readJsonFile(userConfigPath())
|
|
218
|
+
if (userCfg) merged = deepMerge(merged, userCfg)
|
|
219
|
+
|
|
220
|
+
// 3. Repo-level config
|
|
221
|
+
if (root) {
|
|
222
|
+
const repoCfg = readJsonFile(path.join(root, '.gitissues', 'config.json'))
|
|
223
|
+
if (repoCfg) merged = deepMerge(merged, repoCfg)
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// 4. Environment variables
|
|
227
|
+
const envCfg = configFromEnv()
|
|
228
|
+
if (Object.keys(envCfg).length > 0) merged = deepMerge(merged, envCfg)
|
|
229
|
+
|
|
230
|
+
// 5. CLI overrides
|
|
231
|
+
if (cliOverrides && typeof cliOverrides === 'object') {
|
|
232
|
+
merged = deepMerge(merged, cliOverrides)
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
return merged
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Get a specific config value using dot notation.
|
|
240
|
+
*
|
|
241
|
+
* @example
|
|
242
|
+
* getConfigValue('safety.maxPerHour') // 10
|
|
243
|
+
* getConfigValue('ai.model', '/path/to/repo') // 'claude-haiku-4-5-20251001'
|
|
244
|
+
*
|
|
245
|
+
* @param {string} key - dot-separated path (e.g. 'safety.maxPerHour')
|
|
246
|
+
* @param {string} [repoRoot] - optional repo root for repo-level config lookup
|
|
247
|
+
* @returns {*} the resolved value, or `undefined` if not found
|
|
248
|
+
*/
|
|
249
|
+
export function getConfigValue(key, repoRoot) {
|
|
250
|
+
const cfg = loadConfig(repoRoot)
|
|
251
|
+
return key.split('.').reduce((obj, part) => (obj != null ? obj[part] : undefined), cfg)
|
|
252
|
+
}
|