tspl-cli 0.0.0-stage → 0.1.1
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 +127 -2
- package/bin/tspl.js +10 -0
- package/package.json +41 -4
- package/src/active-org.js +49 -0
- package/src/analysis/analyzer.js +61 -0
- package/src/analysis/extract.js +37 -0
- package/src/analysis/file-filter.js +69 -0
- package/src/analysis/git-reader.js +108 -0
- package/src/analysis/html-parser.js +46 -0
- package/src/analysis/js-parser.js +473 -0
- package/src/analysis/py-parser.js +66 -0
- package/src/analysis/repo-index.js +107 -0
- package/src/api-client.js +108 -0
- package/src/browser-tests.js +209 -0
- package/src/commands/analyze.command.js +81 -0
- package/src/commands/auth.command.js +250 -0
- package/src/commands/build.command.js +267 -0
- package/src/commands/connect.command.js +270 -0
- package/src/commands/engines.command.js +136 -0
- package/src/commands/find.command.js +107 -0
- package/src/commands/init.command.js +79 -0
- package/src/commands/link.command.js +65 -0
- package/src/commands/mcp.command.js +12 -0
- package/src/commands/member.command.js +82 -0
- package/src/commands/plan.command.js +161 -0
- package/src/commands/project.command.js +191 -0
- package/src/commands/setup.command.js +63 -0
- package/src/commands/sync.command.js +59 -0
- package/src/commands/task.command.js +475 -0
- package/src/commands/usage.command.js +105 -0
- package/src/config-store.js +255 -0
- package/src/connect/claude-skill.md +30 -0
- package/src/engines.js +218 -0
- package/src/find/graph.js +272 -0
- package/src/find/search.js +473 -0
- package/src/find/text.js +86 -0
- package/src/git-helper.js +102 -0
- package/src/index.js +91 -0
- package/src/mcp/server.js +708 -0
- package/src/project-config.js +197 -0
- package/src/project-init.js +447 -0
- package/src/providers/github.js +249 -0
- package/src/providers/index.js +83 -0
- package/src/runtime-check.js +18 -0
- package/src/setup.js +180 -0
- package/src/shared/approaches.js +44 -0
- package/src/shared/constants.js +89 -0
- package/src/shared/index.js +8 -0
- package/src/shared/workflow-progress.js +67 -0
- package/src/shared/workflow.js +381 -0
- package/src/stack-detector.js +47 -0
- package/src/task/approaches.js +53 -0
- package/src/task/branch-choice.js +69 -0
- package/src/task/brief.js +115 -0
- package/src/task/build.js +549 -0
- package/src/task/check.js +415 -0
- package/src/task/conventions.js +113 -0
- package/src/task/gate.js +88 -0
- package/src/task/hooks.js +82 -0
- package/src/task/plan-draft.js +43 -0
- package/src/task/plan-page.js +123 -0
- package/src/task/plan.js +293 -0
- package/src/task/planning-context.js +165 -0
- package/src/task/refine.js +73 -0
- package/src/workflow/agents.js +336 -0
- package/src/workflow/checkout-lock.js +92 -0
- package/src/workflow/client.js +82 -0
- package/src/workflow/controller.js +585 -0
- package/src/workflow/evidence.js +276 -0
- package/src/workflow/pr-body.js +52 -0
- package/src/workflow/process.js +82 -0
- package/src/workflow/review.js +128 -0
- package/src/workflow/runtime.js +148 -0
- package/src/workflow/wait.js +58 -0
- package/src/workspace.js +250 -0
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
const { completionProblems } = require('./workflow')
|
|
3
|
+
const STAGES = ['implementing', 'testing', 'reviewing', 'awaiting-delivery', 'creating-pr', 'awaiting-ci']
|
|
4
|
+
const LABELS = { implementing: 'Implementation', testing: 'Tests', reviewing: 'Review', 'awaiting-delivery': 'Push confirmation', 'creating-pr': 'Pull request', 'awaiting-ci': 'CI' }
|
|
5
|
+
const effectiveStage = workflow => {
|
|
6
|
+
const stage = workflow.stage === 'blocked' ? workflow.resumeStage || 'implementing' : workflow.stage
|
|
7
|
+
return stage === 'repairing' ? 'testing' : stage
|
|
8
|
+
}
|
|
9
|
+
const workflowProgress = (task, at = new Date()) => {
|
|
10
|
+
const wf = task.workflow
|
|
11
|
+
if (!wf) return null
|
|
12
|
+
const current = effectiveStage(wf), position = wf.stage === 'ready' ? STAGES.length : STAGES.indexOf(current)
|
|
13
|
+
const options = task.plan?.workflow?.options || {}
|
|
14
|
+
const steps = STAGES.map((stage, i) => {
|
|
15
|
+
const saved = wf.stageResults?.[stage] || {}
|
|
16
|
+
let status = i < position ? 'done' : i === position ? 'running' : 'pending'
|
|
17
|
+
let reason = status === 'done' ? `${LABELS[stage]} completed; workflow advanced.` : status === 'pending' ? `Waiting for ${LABELS[STAGES[i - 1]] || 'plan approval'} to complete.` : `${LABELS[stage]} is running.`
|
|
18
|
+
if (stage === 'reviewing' && options.review === false || ['awaiting-delivery', 'creating-pr', 'awaiting-ci'].includes(stage) && options.pullRequest === false) { status = 'skipped'; reason = 'Not required by this task’s approved workflow.' }
|
|
19
|
+
else if (i === position) {
|
|
20
|
+
if (wf.stage === 'blocked') { status = 'failed'; reason = wf.blocker?.message || wf.blocker?.reason || 'The runner stopped without a detailed error.' }
|
|
21
|
+
else if (stage === 'awaiting-delivery') { status = 'waiting'; reason = 'A person must confirm pushing the branch and opening the pull request.' }
|
|
22
|
+
else if (stage === 'awaiting-ci') { status = 'waiting'; reason = 'Waiting for required CI checks; rerun to poll their results.' }
|
|
23
|
+
else if (stage === 'reviewing' && wf.review?.verdict === 'passed') { status = 'waiting'; reason = 'Review passed; complete the outstanding acceptance checks before delivery.' }
|
|
24
|
+
else if (!wf.leaseExpiresAt || new Date(wf.leaseExpiresAt).getTime() <= new Date(at).getTime()) { status = 'interrupted'; reason = 'No active runner heartbeat. The process exited or its lease expired; resume this stage.' }
|
|
25
|
+
else if (wf.stage === 'repairing') reason = `Repairing failures (repair rounds used: ${wf.attempts || 0}). ${saved.reason || ''}`
|
|
26
|
+
}
|
|
27
|
+
if (status === 'pending' && saved.status === 'failed') { status = 'failed'; reason = saved.reason || `${LABELS[stage]} failed; waiting for the repair and recheck.` }
|
|
28
|
+
if (stage === 'testing' && status === 'done') reason = `${(wf.evidence || []).filter(item => item.status === 'passed').length} checks passed on the recorded commits.`
|
|
29
|
+
if (stage === 'testing' && status === 'done') {
|
|
30
|
+
const problems = completionProblems({ plan: task.plan, workflow: wf, upTo: 'review' }).filter(problem =>
|
|
31
|
+
problem !== 'There is no separate review result.' && !/^The (separate review|review head)/.test(problem) && !/^Acceptance criterion/.test(problem))
|
|
32
|
+
if (problems.length) { status = 'failed'; reason = problems.join(' ') }
|
|
33
|
+
}
|
|
34
|
+
if (stage === 'reviewing' && status === 'done' && (!wf.review || wf.review.verdict !== 'passed' || !(wf.heads || []).every(head => (wf.review.heads || []).some(item => item.name === head.name && item.headSha === head.headSha)))) {
|
|
35
|
+
status = 'failed'; reason = 'A passing separate review for the current commits is missing.'
|
|
36
|
+
}
|
|
37
|
+
if (stage === 'reviewing' && status === 'done' && wf.review) reason = `Separate review ${wf.review.verdict}${wf.review.engine ? ` (${wf.review.engine})` : ''}.`
|
|
38
|
+
if (status === 'running' && saved.reason && wf.stage !== 'repairing') reason = saved.reason
|
|
39
|
+
return { stage, label: LABELS[stage], status, reason, startedAt: saved.startedAt || null, finishedAt: saved.finishedAt || null, command: stage === 'awaiting-delivery' ? `tspl task run ${task.key} --stage awaiting-delivery --confirm-delivery` : `tspl task run ${task.key} --stage ${stage}` }
|
|
40
|
+
})
|
|
41
|
+
return { stage: current, steps, current: steps.find(step => step.stage === current) || null }
|
|
42
|
+
}
|
|
43
|
+
/** Retry real work only; forward jumps must satisfy the same evidence gates as normal progression. */
|
|
44
|
+
const retryWorkflow = (workflow, stage, plan) => {
|
|
45
|
+
if (!STAGES.includes(stage)) throw new Error(`Unknown retry stage: ${stage}`)
|
|
46
|
+
const current = effectiveStage(workflow)
|
|
47
|
+
if (workflow.stage === 'ready') throw new Error('This task is already ready; start a new task for further changes')
|
|
48
|
+
if (stage === 'implementing' && current !== 'implementing') throw new Error('Implementation has already completed; amend the plan for further code changes')
|
|
49
|
+
if (STAGES.indexOf(stage) > STAGES.indexOf(current)) {
|
|
50
|
+
const manualImplementation = current === 'implementing' && stage === 'testing' && workflow.heads?.some(head => head.baseSha && head.baseSha !== head.headSha) && workflow.heads.every(head => head.clean)
|
|
51
|
+
if (!manualImplementation) throw new Error(`Complete ${current} before retrying ${stage}; prerequisites cannot be skipped`)
|
|
52
|
+
}
|
|
53
|
+
if (stage === 'reviewing') {
|
|
54
|
+
const problems = completionProblems({ plan, workflow: { ...workflow, review: null }, upTo: 'review' }).filter(problem => problem !== 'There is no separate review result.' && !/^Acceptance criterion/.test(problem))
|
|
55
|
+
if (problems.length) throw new Error(`Run checks before review: ${problems.join(' ')}`)
|
|
56
|
+
}
|
|
57
|
+
if (['awaiting-delivery', 'creating-pr', 'awaiting-ci'].includes(stage)) {
|
|
58
|
+
const problems = completionProblems({ plan, workflow, upTo: 'review' })
|
|
59
|
+
if (problems.length) throw new Error(`Complete the required checks before delivery: ${problems.join(' ')}`)
|
|
60
|
+
}
|
|
61
|
+
const next = { ...workflow, stage, blocker: null, resumeStage: null }
|
|
62
|
+
if (stage === 'testing') { next.evidence = []; next.review = null; next.manualVerifications = [] }
|
|
63
|
+
if (stage === 'reviewing') next.review = null
|
|
64
|
+
if (stage === 'creating-pr' && !workflow.deliveryAuthorization) throw new Error('Confirm delivery before retrying pull request creation')
|
|
65
|
+
return next
|
|
66
|
+
}
|
|
67
|
+
module.exports = { workflowProgress, retryWorkflow, RETRY_STAGES: STAGES }
|
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const Crypto = require('crypto')
|
|
4
|
+
|
|
5
|
+
const SCHEMA_VERSION = 1
|
|
6
|
+
|
|
7
|
+
const CHECK_CATEGORIES = ['unit', 'integration', 'e2e', 'lint', 'typecheck', 'build']
|
|
8
|
+
|
|
9
|
+
const TEST_CATEGORIES = ['unit', 'integration', 'e2e']
|
|
10
|
+
|
|
11
|
+
const CHECK_PARSERS = ['node-tap', 'jest-json', 'playwright-json', 'custom', 'exit-code']
|
|
12
|
+
|
|
13
|
+
const CI_POLICIES = ['discover', 'explicit', 'none']
|
|
14
|
+
|
|
15
|
+
const WORKFLOW_STAGES = [
|
|
16
|
+
'planning',
|
|
17
|
+
'awaiting-approval',
|
|
18
|
+
'implementing',
|
|
19
|
+
'testing',
|
|
20
|
+
'repairing',
|
|
21
|
+
'reviewing',
|
|
22
|
+
'awaiting-delivery',
|
|
23
|
+
'creating-pr',
|
|
24
|
+
'awaiting-ci',
|
|
25
|
+
'ready',
|
|
26
|
+
'blocked',
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
/** Where each stage may go next. A blocked workflow resumes only at the stage it was blocked in. */
|
|
30
|
+
const STAGE_TRANSITIONS = {
|
|
31
|
+
planning: ['awaiting-approval', 'blocked'],
|
|
32
|
+
'awaiting-approval': ['implementing', 'blocked'],
|
|
33
|
+
implementing: ['testing', 'blocked'],
|
|
34
|
+
testing: ['reviewing', 'repairing', 'blocked'],
|
|
35
|
+
repairing: ['testing', 'blocked'],
|
|
36
|
+
reviewing: ['awaiting-delivery', 'repairing', 'ready', 'blocked'],
|
|
37
|
+
'awaiting-delivery': ['creating-pr', 'testing', 'blocked'],
|
|
38
|
+
'creating-pr': ['awaiting-ci', 'testing', 'blocked'],
|
|
39
|
+
'awaiting-ci': ['ready', 'creating-pr', 'testing', 'blocked'],
|
|
40
|
+
ready: [],
|
|
41
|
+
blocked: [],
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const BLOCKER_REASONS = ['stopped', 'tests-failed', 'review-failed', 'repairs-exhausted', 'out-of-plan', 'lease-lost', 'provider', 'ci-failed', 'other']
|
|
45
|
+
|
|
46
|
+
/** Steps a task can switch off when it is created. Approvals can only be switched off by an owner, admin or PM. */
|
|
47
|
+
const TASK_OPTIONS = {
|
|
48
|
+
scope: true,
|
|
49
|
+
planApproval: true,
|
|
50
|
+
pmApproval: true,
|
|
51
|
+
review: true,
|
|
52
|
+
pullRequest: true,
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const APPROVAL_OPTIONS = ['scope', 'planApproval', 'pmApproval']
|
|
56
|
+
|
|
57
|
+
const taskOptions = value => Object.fromEntries(Object.entries(TASK_OPTIONS).map(([key, fallback]) => [key, value && typeof value[key] === 'boolean' ? value[key] : fallback]))
|
|
58
|
+
|
|
59
|
+
const EVIDENCE_STATUS = ['passed', 'failed', 'skipped', 'missing', 'cancelled', 'timed-out', 'stale']
|
|
60
|
+
|
|
61
|
+
const WORKFLOW_DEFAULTS = { maxRepairRounds: 2, engineTimeoutMs: 120000, checkTimeoutMs: 600000 }
|
|
62
|
+
|
|
63
|
+
const canonical = value => {
|
|
64
|
+
if (Array.isArray(value)) return value.map(canonical)
|
|
65
|
+
if (value && typeof value === 'object') {
|
|
66
|
+
return Object.fromEntries(
|
|
67
|
+
Object.keys(value)
|
|
68
|
+
.sort()
|
|
69
|
+
.filter(key => value[key] !== undefined)
|
|
70
|
+
.map(key => [key, canonical(value[key])]),
|
|
71
|
+
)
|
|
72
|
+
}
|
|
73
|
+
return value
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const hashValue = value => Crypto.createHash('sha256').update(JSON.stringify(canonical(value))).digest('hex')
|
|
77
|
+
|
|
78
|
+
const checkHash = check =>
|
|
79
|
+
hashValue({
|
|
80
|
+
repo: check.repo,
|
|
81
|
+
cwd: check.cwd,
|
|
82
|
+
category: check.category,
|
|
83
|
+
command: check.command,
|
|
84
|
+
parser: check.parser,
|
|
85
|
+
timeoutMs: check.timeoutMs,
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
const isObject = value => Boolean(value) && typeof value === 'object' && !Array.isArray(value)
|
|
89
|
+
|
|
90
|
+
const text = value => typeof value === 'string' && value.trim() !== ''
|
|
91
|
+
|
|
92
|
+
const relativeInside = value => {
|
|
93
|
+
if (!text(value) || value.includes('\0') || value.startsWith('/') || /^[a-z]:[\\/]/i.test(value) || value.startsWith('~')) return false
|
|
94
|
+
return !value.split(/[\\/]/).includes('..')
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const validateRepo = (repo, index) => {
|
|
98
|
+
const at = `repos[${index}]`
|
|
99
|
+
if (!isObject(repo)) throw new Error(`${at} must be an object`)
|
|
100
|
+
;['id', 'name'].forEach(field => {
|
|
101
|
+
if (!text(repo[field])) throw new Error(`${at}.${field} is required`)
|
|
102
|
+
})
|
|
103
|
+
if (!repo.fingerprint && !repo.remoteHash) throw new Error(`${at} needs a fingerprint or remoteHash`)
|
|
104
|
+
if (!relativeInside(repo.path)) throw new Error(`${at}.path must be relative to the workspace and stay inside it`)
|
|
105
|
+
const delivery = repo.delivery
|
|
106
|
+
if (!isObject(delivery)) throw new Error(`${at}.delivery is required`)
|
|
107
|
+
if (!text(delivery.provider)) throw new Error(`${at}.delivery.provider is required`)
|
|
108
|
+
if (!text(delivery.repository)) throw new Error(`${at}.delivery.repository is required`)
|
|
109
|
+
if (!CI_POLICIES.includes(delivery.ciPolicy)) throw new Error(`${at}.delivery.ciPolicy must be one of ${CI_POLICIES.join(', ')}`)
|
|
110
|
+
const required = delivery.requiredChecks === undefined ? [] : delivery.requiredChecks
|
|
111
|
+
if (!Array.isArray(required) || required.some(item => !isObject(item) || !text(item.name))) {
|
|
112
|
+
throw new Error(`${at}.delivery.requiredChecks must be a list of { name, appId? }`)
|
|
113
|
+
}
|
|
114
|
+
if (delivery.ciPolicy === 'explicit' && !required.length) throw new Error(`${at}: an explicit CI policy needs at least one required check`)
|
|
115
|
+
if (delivery.ciPolicy === 'none' && delivery.noCiConfirmed !== true) {
|
|
116
|
+
throw new Error(`${at}: a no-CI policy must be confirmed (noCiConfirmed: true) in the project file`)
|
|
117
|
+
}
|
|
118
|
+
return { ...repo, delivery: { ...delivery, requiredChecks: required } }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const validateCheck = (check, repoNames, at = 'check') => {
|
|
122
|
+
if (!isObject(check)) throw new Error(`${at} must be an object`)
|
|
123
|
+
if (!text(check.id)) throw new Error(`${at}.id is required`)
|
|
124
|
+
const label = `check ${check.id}`
|
|
125
|
+
if (!repoNames.includes(check.repo)) throw new Error(`${label}: repo "${check.repo}" is not one of the project repos`)
|
|
126
|
+
if (!relativeInside(check.cwd)) throw new Error(`${label}: cwd must be an explicit relative path inside the repo`)
|
|
127
|
+
if (!CHECK_CATEGORIES.includes(check.category)) throw new Error(`${label}: category must be one of ${CHECK_CATEGORIES.join(', ')}`)
|
|
128
|
+
if (!text(check.command)) throw new Error(`${label}: command is required`)
|
|
129
|
+
if (!Number.isInteger(check.timeoutMs) || check.timeoutMs <= 0) throw new Error(`${label}: timeoutMs must be a positive integer`)
|
|
130
|
+
if (typeof check.required !== 'boolean') throw new Error(`${label}: required must be true or false`)
|
|
131
|
+
if (!CHECK_PARSERS.includes(check.parser)) throw new Error(`${label}: parser must be one of ${CHECK_PARSERS.join(', ')}`)
|
|
132
|
+
const isTest = TEST_CATEGORIES.includes(check.category)
|
|
133
|
+
if (check.parser === 'exit-code' && isTest) {
|
|
134
|
+
throw new Error(`${label}: exit-code is only allowed for non-test checks; test checks need a result parser or an attested custom command`)
|
|
135
|
+
}
|
|
136
|
+
if (check.parser === 'custom' && isTest && check.attested !== true) {
|
|
137
|
+
throw new Error(`${label}: a custom test command must be attested (attested: true) because tspl cannot count its results`)
|
|
138
|
+
}
|
|
139
|
+
if (check.artifacts !== undefined && check.artifacts !== null && !relativeInside(check.artifacts)) {
|
|
140
|
+
throw new Error(`${label}: artifacts must be a folder inside the check's working directory`)
|
|
141
|
+
}
|
|
142
|
+
const criteria = check.criteria === undefined ? [] : check.criteria
|
|
143
|
+
if (!Array.isArray(criteria) || criteria.some(n => !Number.isInteger(n) || n < 1)) {
|
|
144
|
+
throw new Error(`${label}: criteria must be a list of done-when numbers`)
|
|
145
|
+
}
|
|
146
|
+
return { ...check, attested: check.attested === true, criteria }
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const validateProjectConfig = value => {
|
|
150
|
+
if (!isObject(value)) throw new Error('Project config must be an object')
|
|
151
|
+
if (value.schemaVersion === undefined) throw new Error('Project config has no schemaVersion')
|
|
152
|
+
if (value.schemaVersion > SCHEMA_VERSION) {
|
|
153
|
+
throw new Error(`Project config schemaVersion ${value.schemaVersion} is newer than this tspl understands (${SCHEMA_VERSION}). Update tspl.`)
|
|
154
|
+
}
|
|
155
|
+
if (value.schemaVersion !== SCHEMA_VERSION) throw new Error(`Unsupported project config schemaVersion ${value.schemaVersion}`)
|
|
156
|
+
if (!text(value.organization)) throw new Error('Project config needs an organization')
|
|
157
|
+
if (!isObject(value.project) || !text(value.project.id) || !text(value.project.slug)) {
|
|
158
|
+
throw new Error('Project config needs project.id and project.slug')
|
|
159
|
+
}
|
|
160
|
+
if (!Array.isArray(value.repos) || !value.repos.length) throw new Error('Project config needs at least one repo')
|
|
161
|
+
const repos = value.repos.map(validateRepo)
|
|
162
|
+
;['id', 'name', 'path'].forEach(field => {
|
|
163
|
+
const seen = new Set()
|
|
164
|
+
repos.forEach(repo => {
|
|
165
|
+
if (seen.has(repo[field])) throw new Error(`Duplicate repo ${field} "${repo[field]}"`)
|
|
166
|
+
seen.add(repo[field])
|
|
167
|
+
})
|
|
168
|
+
})
|
|
169
|
+
const names = repos.map(repo => repo.name)
|
|
170
|
+
const rawChecks = value.checks === undefined ? [] : value.checks
|
|
171
|
+
if (!Array.isArray(rawChecks)) throw new Error('checks must be a list')
|
|
172
|
+
const checks = rawChecks.map((check, i) => validateCheck(check, names, `checks[${i}]`))
|
|
173
|
+
const ids = new Set()
|
|
174
|
+
checks.forEach(check => {
|
|
175
|
+
if (ids.has(check.id)) throw new Error(`Duplicate check id "${check.id}"`)
|
|
176
|
+
ids.add(check.id)
|
|
177
|
+
})
|
|
178
|
+
const workflow = { ...WORKFLOW_DEFAULTS, ...(value.workflow || {}) }
|
|
179
|
+
;['maxRepairRounds', 'engineTimeoutMs', 'checkTimeoutMs'].forEach(field => {
|
|
180
|
+
if (!Number.isInteger(workflow[field]) || workflow[field] < 0) throw new Error(`workflow.${field} must be a non-negative integer`)
|
|
181
|
+
})
|
|
182
|
+
const instructions = value.instructions === undefined ? [] : value.instructions
|
|
183
|
+
if (!Array.isArray(instructions) || instructions.some(file => !relativeInside(file))) {
|
|
184
|
+
throw new Error('instructions must be a list of relative paths inside the project')
|
|
185
|
+
}
|
|
186
|
+
if (value.home !== undefined && (!relativeInside(value.home) || /[\\/]/.test(value.home))) {
|
|
187
|
+
throw new Error('home must be the name of the repository (inside the workspace folder) that holds the .tspl files')
|
|
188
|
+
}
|
|
189
|
+
return {
|
|
190
|
+
schemaVersion: SCHEMA_VERSION,
|
|
191
|
+
...(value.home ? { home: value.home } : {}),
|
|
192
|
+
organization: value.organization,
|
|
193
|
+
project: { id: value.project.id, slug: value.project.slug },
|
|
194
|
+
repos,
|
|
195
|
+
checks,
|
|
196
|
+
workflow,
|
|
197
|
+
instructions,
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Shape check for a plan's frozen snapshot; the API refuses a plan whose snapshot is incomplete. */
|
|
202
|
+
const validateSnapshot = snapshot => {
|
|
203
|
+
if (!isObject(snapshot) || snapshot.version !== 1) throw new Error('The workflow snapshot must be an object with version 1')
|
|
204
|
+
if (!text(snapshot.configHash) || !text(snapshot.instructionsHash)) throw new Error('The workflow snapshot needs configHash and instructionsHash')
|
|
205
|
+
if (!Array.isArray(snapshot.repos) || !snapshot.repos.length) throw new Error('The workflow snapshot needs the project repos')
|
|
206
|
+
const repos = snapshot.repos.map(validateRepo)
|
|
207
|
+
const names = repos.map(repo => repo.name)
|
|
208
|
+
if (!Array.isArray(snapshot.checks)) throw new Error('The workflow snapshot needs a list of checks')
|
|
209
|
+
const checks = snapshot.checks.map((check, i) => validateCheck(check, names, `checks[${i}]`))
|
|
210
|
+
const ids = new Set()
|
|
211
|
+
checks.forEach(check => {
|
|
212
|
+
if (ids.has(check.id)) throw new Error(`Duplicate check id "${check.id}"`)
|
|
213
|
+
ids.add(check.id)
|
|
214
|
+
})
|
|
215
|
+
if (!checks.some(check => check.required && TEST_CATEGORIES.includes(check.category))) {
|
|
216
|
+
throw new Error('The project has no required test check; add one to .tspl/project.json before planning a tested pull request')
|
|
217
|
+
}
|
|
218
|
+
const acceptance = snapshot.acceptance === undefined ? 0 : snapshot.acceptance
|
|
219
|
+
const manualCriteria = snapshot.manualCriteria === undefined ? [] : snapshot.manualCriteria
|
|
220
|
+
if (!Number.isInteger(acceptance) || acceptance < 0) throw new Error('acceptance must be a count')
|
|
221
|
+
if (!Array.isArray(manualCriteria) || manualCriteria.some(n => !Number.isInteger(n) || n < 1 || n > acceptance)) {
|
|
222
|
+
throw new Error('manualCriteria must be done-when numbers within the acceptance count')
|
|
223
|
+
}
|
|
224
|
+
const options = taskOptions(snapshot.options)
|
|
225
|
+
const maxRepairRounds = snapshot.maxRepairRounds === undefined ? WORKFLOW_DEFAULTS.maxRepairRounds : snapshot.maxRepairRounds
|
|
226
|
+
if (!Number.isInteger(maxRepairRounds) || maxRepairRounds < 0) throw new Error('maxRepairRounds must be a non-negative integer')
|
|
227
|
+
return { ...snapshot, repos, checks, acceptance, manualCriteria, maxRepairRounds, options }
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const isTestCheck = check => TEST_CATEGORIES.includes(check.category)
|
|
231
|
+
|
|
232
|
+
const requirementMet = (requirement, observations, headSha) =>
|
|
233
|
+
observations.some(
|
|
234
|
+
observation =>
|
|
235
|
+
observation.name === requirement.name &&
|
|
236
|
+
(requirement.appId === undefined || requirement.appId === null || observation.appId === requirement.appId) &&
|
|
237
|
+
observation.headSha === headSha &&
|
|
238
|
+
observation.status === 'completed' &&
|
|
239
|
+
observation.conclusion === 'success',
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
const sameHeads = (recorded, heads) =>
|
|
243
|
+
Array.isArray(recorded) &&
|
|
244
|
+
heads.every(head => recorded.some(item => item.name === head.name && item.headSha === head.headSha))
|
|
245
|
+
|
|
246
|
+
const evidenceProblems = (snapshot, workflow, headOf) => {
|
|
247
|
+
const problems = []
|
|
248
|
+
const evidence = Array.isArray(workflow.evidence) ? workflow.evidence : []
|
|
249
|
+
snapshot.checks
|
|
250
|
+
.filter(check => check.required)
|
|
251
|
+
.forEach(check => {
|
|
252
|
+
const found = evidence.filter(item => item.checkId === check.id)
|
|
253
|
+
if (!found.length) {
|
|
254
|
+
problems.push(`Required check ${check.id} is missing: it has no executed result.`)
|
|
255
|
+
return
|
|
256
|
+
}
|
|
257
|
+
if (found.length > 1) {
|
|
258
|
+
problems.push(`Required check ${check.id} has duplicate results.`)
|
|
259
|
+
return
|
|
260
|
+
}
|
|
261
|
+
const [item] = found
|
|
262
|
+
const head = headOf(check.repo)
|
|
263
|
+
if (!head) problems.push(`Required check ${check.id}: no recorded head for repo ${check.repo}.`)
|
|
264
|
+
else if (item.headSha !== head.headSha) problems.push(`Check ${check.id} ran on head ${item.headSha}, not the current head ${head.headSha} of ${check.repo}.`)
|
|
265
|
+
if (item.configHash !== snapshot.configHash) problems.push(`Check ${check.id} ran under a different approved config.`)
|
|
266
|
+
if (item.commandHash !== checkHash(check)) problems.push(`Check ${check.id} ran a different command than the approved one.`)
|
|
267
|
+
if (item.executed !== true) problems.push(`Check ${check.id} was not executed.`)
|
|
268
|
+
if (item.status !== 'passed') problems.push(`Check ${check.id} did not pass (status ${item.status}).`)
|
|
269
|
+
if (isTestCheck(check) && check.parser !== 'custom' && !(item.testCount > 0)) {
|
|
270
|
+
problems.push(`Check ${check.id} ran zero tests; a test count above zero is required.`)
|
|
271
|
+
}
|
|
272
|
+
})
|
|
273
|
+
const known = new Set(snapshot.checks.map(check => check.id))
|
|
274
|
+
evidence.filter(item => !known.has(item.checkId)).forEach(item => problems.push(`Result for unknown check ${item.checkId}.`))
|
|
275
|
+
return problems
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const criteriaProblems = (snapshot, workflow, heads) => {
|
|
279
|
+
const problems = []
|
|
280
|
+
const covered = new Set(snapshot.checks.filter(check => check.required).flatMap(check => check.criteria || []))
|
|
281
|
+
const manual = new Set(snapshot.manualCriteria || [])
|
|
282
|
+
const verifications = Array.isArray(workflow.manualVerifications) ? workflow.manualVerifications : []
|
|
283
|
+
for (let n = 1; n <= (snapshot.acceptance || 0); n++) {
|
|
284
|
+
if (covered.has(n)) continue
|
|
285
|
+
if (manual.has(n) && verifications.some(item => item.criterion === n && item.userId && sameHeads(item.heads, heads))) continue
|
|
286
|
+
problems.push(`Acceptance criterion ${n} has no passing check and no human verification on the tested commits.`)
|
|
287
|
+
}
|
|
288
|
+
return problems
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const deliveryProblems = (snapshot, workflow, heads) => {
|
|
292
|
+
const problems = []
|
|
293
|
+
const prs = Array.isArray(workflow.prs) ? workflow.prs : []
|
|
294
|
+
heads
|
|
295
|
+
.filter(head => head.baseSha !== head.headSha)
|
|
296
|
+
.forEach(head => {
|
|
297
|
+
const repo = snapshot.repos.find(item => item.name === head.name)
|
|
298
|
+
const found = prs.filter(item => item.repo === head.name)
|
|
299
|
+
if (!found.length) {
|
|
300
|
+
problems.push(`There is no pull request for ${head.name}.`)
|
|
301
|
+
return
|
|
302
|
+
}
|
|
303
|
+
if (found.length > 1) {
|
|
304
|
+
problems.push(`There are several pull request records for ${head.name}.`)
|
|
305
|
+
return
|
|
306
|
+
}
|
|
307
|
+
const [pr] = found
|
|
308
|
+
if (pr.headSha !== head.headSha) problems.push(`The pull request head ${pr.headSha} for ${head.name} is not the tested head ${head.headSha}.`)
|
|
309
|
+
if (pr.draft) problems.push(`The pull request for ${head.name} is still a draft.`)
|
|
310
|
+
if (pr.state !== 'open') problems.push(`The pull request for ${head.name} is ${pr.state}, not open.`)
|
|
311
|
+
const delivery = repo && repo.delivery
|
|
312
|
+
if (delivery && delivery.ciPolicy === 'none' && delivery.noCiConfirmed === true) return
|
|
313
|
+
const required = Array.isArray(pr.requiredChecks) ? pr.requiredChecks : []
|
|
314
|
+
if (!required.length) {
|
|
315
|
+
problems.push(`The required CI checks for ${head.name} were not determined; an empty set is not success.`)
|
|
316
|
+
return
|
|
317
|
+
}
|
|
318
|
+
const configured = (delivery && delivery.requiredChecks) || []
|
|
319
|
+
configured
|
|
320
|
+
.filter(item => !required.some(r => r.name === item.name && (item.appId === undefined || r.appId === item.appId)))
|
|
321
|
+
.forEach(item => problems.push(`Configured required CI check ${item.name} is missing from the pull request for ${head.name}.`))
|
|
322
|
+
required
|
|
323
|
+
.filter(item => !requirementMet(item, pr.checks || [], head.headSha))
|
|
324
|
+
.forEach(item => problems.push(`Required CI check ${item.name} has not succeeded on ${head.headSha} for ${head.name}.`))
|
|
325
|
+
})
|
|
326
|
+
return problems
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** upTo: 'review' skips the pull request conditions (used before delivery starts). */
|
|
330
|
+
const completionProblems = ({ plan, workflow, upTo = 'ready' }) => {
|
|
331
|
+
const snapshot = plan && plan.workflow
|
|
332
|
+
if (!isObject(snapshot) || !Array.isArray(snapshot.checks)) return ['The approved plan has no workflow snapshot.']
|
|
333
|
+
if (!isObject(workflow)) return ['The task has no workflow record.']
|
|
334
|
+
const problems = []
|
|
335
|
+
if (workflow.configHash !== snapshot.configHash) problems.push('The workflow config does not match the approved config hash.')
|
|
336
|
+
const heads = Array.isArray(workflow.heads) ? workflow.heads : []
|
|
337
|
+
if (!heads.length) {
|
|
338
|
+
problems.push('There are no recorded heads to test.')
|
|
339
|
+
return problems
|
|
340
|
+
}
|
|
341
|
+
heads.filter(head => head.clean !== true).forEach(head => problems.push(`${head.name} has uncommitted changes; checks must run on a clean committed branch.`))
|
|
342
|
+
const headOf = name => heads.find(head => head.name === name)
|
|
343
|
+
problems.push(...evidenceProblems(snapshot, workflow, headOf))
|
|
344
|
+
problems.push(...criteriaProblems(snapshot, workflow, heads))
|
|
345
|
+
const options = taskOptions(snapshot.options)
|
|
346
|
+
const review = workflow.review
|
|
347
|
+
if (!options.review) {
|
|
348
|
+
// the task was created without a separate review
|
|
349
|
+
} else if (!isObject(review)) problems.push('There is no separate review result.')
|
|
350
|
+
else {
|
|
351
|
+
if (review.verdict !== 'passed') problems.push('The separate review did not pass.')
|
|
352
|
+
heads.forEach(head => {
|
|
353
|
+
const seen = (review.heads || []).find(item => item.name === head.name)
|
|
354
|
+
if (!seen || seen.headSha !== head.headSha) problems.push(`The review head for ${head.name} (${seen ? seen.headSha : 'none'}) is not the tested head ${head.headSha}.`)
|
|
355
|
+
})
|
|
356
|
+
}
|
|
357
|
+
if (upTo === 'ready' && options.pullRequest) problems.push(...deliveryProblems(snapshot, workflow, heads))
|
|
358
|
+
return problems
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
module.exports = {
|
|
362
|
+
SCHEMA_VERSION,
|
|
363
|
+
CHECK_CATEGORIES,
|
|
364
|
+
TEST_CATEGORIES,
|
|
365
|
+
CHECK_PARSERS,
|
|
366
|
+
CI_POLICIES,
|
|
367
|
+
WORKFLOW_STAGES,
|
|
368
|
+
STAGE_TRANSITIONS,
|
|
369
|
+
TASK_OPTIONS,
|
|
370
|
+
APPROVAL_OPTIONS,
|
|
371
|
+
taskOptions,
|
|
372
|
+
BLOCKER_REASONS,
|
|
373
|
+
EVIDENCE_STATUS,
|
|
374
|
+
WORKFLOW_DEFAULTS,
|
|
375
|
+
hashValue,
|
|
376
|
+
checkHash,
|
|
377
|
+
validateProjectConfig,
|
|
378
|
+
validateCheck,
|
|
379
|
+
validateSnapshot,
|
|
380
|
+
completionProblems,
|
|
381
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const Fs = require('fs')
|
|
4
|
+
const Path = require('path')
|
|
5
|
+
|
|
6
|
+
const readPackage = dir => {
|
|
7
|
+
try {
|
|
8
|
+
return JSON.parse(Fs.readFileSync(Path.join(dir, 'package.json'), 'utf8'))
|
|
9
|
+
} catch (err) {
|
|
10
|
+
return null
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const PYTHON_MARKERS = ['pyproject.toml', 'requirements.txt', 'setup.py', 'Pipfile']
|
|
15
|
+
|
|
16
|
+
const isPython = dir =>
|
|
17
|
+
PYTHON_MARKERS.some(file => Fs.existsSync(Path.join(dir, file))) ||
|
|
18
|
+
Fs.readdirSync(dir).some(file => file.endsWith('.py'))
|
|
19
|
+
|
|
20
|
+
const detectStacks = dir => {
|
|
21
|
+
const pkg = readPackage(dir)
|
|
22
|
+
if (!pkg) {
|
|
23
|
+
return isPython(dir) ? ['python'] : []
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const deps = { ...pkg.dependencies, ...pkg.devDependencies }
|
|
27
|
+
const has = name => Object.prototype.hasOwnProperty.call(deps, name)
|
|
28
|
+
const stacks = []
|
|
29
|
+
|
|
30
|
+
if (has('@hapi/hapi') || has('hapi')) {
|
|
31
|
+
stacks.push('node-hapi-api')
|
|
32
|
+
}
|
|
33
|
+
if (has('@angular/core')) {
|
|
34
|
+
stacks.push('angular-web')
|
|
35
|
+
}
|
|
36
|
+
if (has('react-native') || has('expo')) {
|
|
37
|
+
stacks.push('react-native')
|
|
38
|
+
} else if (has('react')) {
|
|
39
|
+
stacks.push('react-web')
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return stacks.length ? stacks : ['node']
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
module.exports = {
|
|
46
|
+
detectStacks,
|
|
47
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
const { request } = require('../api-client')
|
|
3
|
+
const { bindApproach, approachBasis } = require('../shared')
|
|
4
|
+
const taskUrl = (projectId, key) => `/api/v1/projects/${projectId}/tasks/${encodeURIComponent(key)}`
|
|
5
|
+
const fetchApproaches = (projectId, key) => request('GET', taskUrl(projectId, key))
|
|
6
|
+
const proposeApproaches = (projectId, key, proposal) => request('POST', `${taskUrl(projectId, key)}/approaches`, proposal)
|
|
7
|
+
const selectApproach = (projectId, key, selection) => request('POST', `${taskUrl(projectId, key)}/approaches/selection`, selection)
|
|
8
|
+
const renderApproaches = approaches => {
|
|
9
|
+
if (!approaches) return 'Read the relevant code, then call propose_approaches with two or three options and one recommendation.'
|
|
10
|
+
if (approaches.selection) {
|
|
11
|
+
const option = approaches.options.find(item => item.id === approaches.selection.optionId)
|
|
12
|
+
if (!option) throw new Error('Selected approach not found; refresh the task')
|
|
13
|
+
return [
|
|
14
|
+
`IMPLEMENTATION APPROACHES v${approaches.version}`,
|
|
15
|
+
`Selected: ${option.id} — ${option.title}${option.id === approaches.recommendedId ? ' (Recommended)' : ''}`,
|
|
16
|
+
option.summary, `Benefits: ${option.benefits.join('; ')}`, `Drawbacks: ${option.drawbacks.join('; ')}`,
|
|
17
|
+
...(option.files.length ? [`Files: ${option.files.join(', ')}`] : []),
|
|
18
|
+
`Create the plan from this choice using approach {version: ${approaches.version}, optionId: "${option.id}"}. Existing plan approvals still apply.`,
|
|
19
|
+
].join('\n')
|
|
20
|
+
}
|
|
21
|
+
const lines = [`IMPLEMENTATION APPROACHES v${approaches.version}`]
|
|
22
|
+
approaches.options.forEach((option, i) => lines.push(
|
|
23
|
+
`${i + 1}. ${option.title}${option.id === approaches.recommendedId ? ' (Recommended)' : ''} [${option.id}]`,
|
|
24
|
+
` ${option.summary}`, ` Benefits: ${option.benefits.join('; ')}`, ` Drawbacks: ${option.drawbacks.join('; ')}`,
|
|
25
|
+
...(option.files.length ? [` Files: ${option.files.join(', ')}`] : []),
|
|
26
|
+
))
|
|
27
|
+
lines.push(`Recommendation: ${approaches.reason}`)
|
|
28
|
+
lines.push('Show these options using your question tool, or a numbered chat list. Wait for an explicit user answer; never select automatically. Then call select_approach with the version and chosen option ID.')
|
|
29
|
+
return lines.join('\n')
|
|
30
|
+
}
|
|
31
|
+
/** Refresh, persist and ask before invoking the final planner. Dependencies keep terminal input injectable. */
|
|
32
|
+
const chooseForPlanning = async (projectId, key, { generate, choose, fetch = fetchApproaches, propose = proposeApproaches, select = selectApproach, signal }) => {
|
|
33
|
+
signal?.throwIfAborted()
|
|
34
|
+
let task = await fetch(projectId, key)
|
|
35
|
+
signal?.throwIfAborted()
|
|
36
|
+
const approaches = task.approaches
|
|
37
|
+
if (!approaches || approaches.basis !== approachBasis(task.card)) {
|
|
38
|
+
const proposal = await generate(task)
|
|
39
|
+
signal?.throwIfAborted()
|
|
40
|
+
task = await propose(projectId, key, { ...proposal, expectedVersion: approaches ? approaches.version : 0 })
|
|
41
|
+
signal?.throwIfAborted()
|
|
42
|
+
}
|
|
43
|
+
if (!task.approaches.selection) {
|
|
44
|
+
if (!choose) return { waitingForSelection: true, task, note: `${renderApproaches(task.approaches)}\nChoose explicitly: tspl task choose ${key} <option-id> --version ${task.approaches.version}` }
|
|
45
|
+
const optionId = await choose(task.approaches)
|
|
46
|
+
signal?.throwIfAborted()
|
|
47
|
+
task = await select(projectId, key, { version: task.approaches.version, optionId, via: 'cli' })
|
|
48
|
+
}
|
|
49
|
+
signal?.throwIfAborted()
|
|
50
|
+
bindApproach(task.approaches, task.card, { version: task.approaches.version, optionId: task.approaches.selection.optionId })
|
|
51
|
+
return { task }
|
|
52
|
+
}
|
|
53
|
+
module.exports = { fetchApproaches, proposeApproaches, selectApproach, renderApproaches, chooseForPlanning }
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const { execFileSync } = require('child_process')
|
|
4
|
+
|
|
5
|
+
const FETCH_TIMEOUT = 60 * 1000
|
|
6
|
+
|
|
7
|
+
const tryGit = (dir, args, timeout) => {
|
|
8
|
+
try {
|
|
9
|
+
return execFileSync('git', ['-C', dir, ...args], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout }).trim()
|
|
10
|
+
} catch (err) {
|
|
11
|
+
return null
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const validBranch = name => tryGit('.', ['check-ref-format', '--branch', name]) !== null
|
|
16
|
+
|
|
17
|
+
/** Local and origin branches of a repo, one entry per name, after a fetch so new remote branches show up. */
|
|
18
|
+
const listBranches = dir => {
|
|
19
|
+
tryGit(dir, ['fetch', 'origin', '--prune'], FETCH_TIMEOUT)
|
|
20
|
+
const refs = (tryGit(dir, ['for-each-ref', '--format=%(refname)', 'refs/heads', 'refs/remotes/origin']) || '').split('\n').filter(Boolean)
|
|
21
|
+
const names = refs
|
|
22
|
+
.map(ref => ref.replace(/^refs\/heads\//, '').replace(/^refs\/remotes\/origin\//, ''))
|
|
23
|
+
.filter(name => name && name !== 'HEAD' && !name.startsWith('refs/'))
|
|
24
|
+
return [...new Set(names)].sort()
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Picks the base branch from the repo's branches; typing filters the list. The suggested base comes first. */
|
|
28
|
+
const pickBase = (repo, search) => {
|
|
29
|
+
const branches = listBranches(repo.path)
|
|
30
|
+
const ordered = [repo.base, repo.current, ...branches].filter((name, i, all) => name && branches.includes(name) && all.indexOf(name) === i)
|
|
31
|
+
const label = name => `${name}${name === repo.base ? ' (project base branch)' : ''}${name === repo.current ? ' (checked out now)' : ''}`
|
|
32
|
+
return search({
|
|
33
|
+
message: `${repo.name}: start from which branch? (type to filter)`,
|
|
34
|
+
pageSize: 12,
|
|
35
|
+
source: async term => {
|
|
36
|
+
const wanted = String(term || '').trim().toLowerCase()
|
|
37
|
+
const matches = ordered.filter(name => name.toLowerCase().includes(wanted)).map(name => ({ name: label(name), value: name }))
|
|
38
|
+
return matches.length ? matches : [{ name: `No branch matches "${term}"`, value: null, disabled: true }]
|
|
39
|
+
},
|
|
40
|
+
})
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Asks, before any code is written, which branch each repo starts from and the name of the new branch.
|
|
45
|
+
* Returns { bases, branch }, or null when the person answers no at the end.
|
|
46
|
+
*/
|
|
47
|
+
const askBranchChoice = async (proposal, { input, confirm, search }) => {
|
|
48
|
+
console.log('\nBefore coding starts, choose the branches.')
|
|
49
|
+
const bases = {}
|
|
50
|
+
for (const repo of proposal.repos) {
|
|
51
|
+
bases[repo.name] = await pickBase(repo, search)
|
|
52
|
+
}
|
|
53
|
+
const branch = await input({
|
|
54
|
+
message: 'Name of the new branch to create for this task:',
|
|
55
|
+
default: proposal.branch,
|
|
56
|
+
validate: value => {
|
|
57
|
+
const name = value.trim()
|
|
58
|
+
if (!name) return 'Give a branch name'
|
|
59
|
+
if (!validBranch(name)) return `"${name}" is not a valid branch name`
|
|
60
|
+
if (Object.values(bases).includes(name)) return 'The new branch must be different from the branch it starts from'
|
|
61
|
+
return true
|
|
62
|
+
},
|
|
63
|
+
}).then(value => value.trim())
|
|
64
|
+
const summary = proposal.repos.map(repo => ` ${repo.name}: ${bases[repo.name]} → ${branch}`).join('\n')
|
|
65
|
+
if (!(await confirm({ message: `Create these branches and start coding?\n${summary}\n`, default: true }))) return null
|
|
66
|
+
return { bases, branch }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
module.exports = { askBranchChoice, listBranches, validBranch }
|