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,115 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const { taskText } = require('./check')
|
|
4
|
+
const { conventionsFor } = require('./conventions')
|
|
5
|
+
|
|
6
|
+
const LEVEL = { high: 'HIGH', medium: 'MEDIUM', low: 'LOW' }
|
|
7
|
+
|
|
8
|
+
const briefFrom = (result, { keywords = [], basis = null, source = 'wizard', refined = null } = {}) => ({
|
|
9
|
+
source,
|
|
10
|
+
confidence: result.confidence,
|
|
11
|
+
keywords,
|
|
12
|
+
files: result.files.map(file => ({
|
|
13
|
+
repo: file.repo,
|
|
14
|
+
path: file.path,
|
|
15
|
+
symbols: file.symbols.map(symbol => ({ name: symbol.name, line: symbol.line || null })),
|
|
16
|
+
why: file.why.slice(0, 3),
|
|
17
|
+
similar: file.similar || [],
|
|
18
|
+
copies: file.copies || [],
|
|
19
|
+
})),
|
|
20
|
+
chains: result.chains.map(chain => chain.map(step => ({ repo: step.repo, path: step.path, detail: step.detail || null }))),
|
|
21
|
+
placement: result.placement,
|
|
22
|
+
searchSteps: result.searchSteps,
|
|
23
|
+
unknown: result.unknown,
|
|
24
|
+
basis,
|
|
25
|
+
refined,
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
const basisOf = index =>
|
|
29
|
+
Object.fromEntries(Object.values(index.repos).map(repo => [repo.name, { branch: repo.branch, commit: repo.commitSha }]))
|
|
30
|
+
|
|
31
|
+
const buildBrief = (loaded, task, { keywords = [], source = 'wizard', refined = null } = {}) => {
|
|
32
|
+
const card = task.card
|
|
33
|
+
const known = new Set(loaded.graph.repos.map(repo => repo.name))
|
|
34
|
+
const scope = (card.apps || []).filter(name => known.has(name))
|
|
35
|
+
const words = keywords.map(word => String(word).trim()).filter(Boolean).slice(0, 20)
|
|
36
|
+
const result = loaded.search.find([taskText(card), ...words].join(' '), {
|
|
37
|
+
limit: 10,
|
|
38
|
+
repos: scope,
|
|
39
|
+
title: [card.what, ...words].join(' '),
|
|
40
|
+
})
|
|
41
|
+
const brief = briefFrom(result, { keywords: words, basis: basisOf(loaded.index), source, refined })
|
|
42
|
+
const seen = [...new Set([...scope, ...result.files.slice(0, 5).map(file => file.repo)])]
|
|
43
|
+
brief.conventions = conventionsFor(loaded.graph, loaded.index, { focus: seen })
|
|
44
|
+
return { result, scope, brief }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const renderFiles = files => {
|
|
48
|
+
const lines = []
|
|
49
|
+
files.forEach((file, i) => {
|
|
50
|
+
lines.push(`${i + 1}. ${file.repo}: ${file.path}`)
|
|
51
|
+
if (file.symbols.length) {
|
|
52
|
+
lines.push(` look at: ${file.symbols.map(symbol => (symbol.line ? `${symbol.name} (line ${symbol.line})` : symbol.name)).join(', ')}`)
|
|
53
|
+
}
|
|
54
|
+
if (file.why.length) lines.push(` why: ${file.why.slice(0, 3).join(' · ')}`)
|
|
55
|
+
if (file.similar && file.similar.length) lines.push(` similar files, often changed the same way: ${file.similar.join(', ')}`)
|
|
56
|
+
if (file.copies && file.copies.length) lines.push(` same file in other repos (check whether they change too): ${file.copies.slice(0, 4).join(', ')}`)
|
|
57
|
+
})
|
|
58
|
+
return lines
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const scopeLine = scope => {
|
|
62
|
+
if (!scope) return 'Scope: not confirmed by the PM yet.'
|
|
63
|
+
const who = scope.userName || 'the PM'
|
|
64
|
+
const when = scope.at ? ` on ${new Date(scope.at).toLocaleString()}` : ''
|
|
65
|
+
if (scope.confirmed) return `Scope: confirmed by ${who}${when}.${scope.note ? ` "${scope.note}"` : ''}`
|
|
66
|
+
return `Scope: sent back by ${who}${when}: "${scope.note || 'no note'}"`
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const renderBrief = (task, counts, instructions = '') => {
|
|
70
|
+
const brief = task.brief
|
|
71
|
+
const { testCaseLines, TEST_NAMING } = require('../workflow/agents')
|
|
72
|
+
const cases = testCaseLines(task.card)
|
|
73
|
+
const lines = [
|
|
74
|
+
`Task ${task.id}${task.card.ticket ? ` · ${task.card.ticket}` : ''}`,
|
|
75
|
+
task.summary || taskText(task.card),
|
|
76
|
+
scopeLine(task.scope),
|
|
77
|
+
...(cases.length ? ['', 'TEST CASES (done-when items; plan a test for each, named after its id)', ...cases, TEST_NAMING] : []),
|
|
78
|
+
]
|
|
79
|
+
if (!brief) return (instructions ? [...lines, '', 'PROJECT RULES', instructions] : lines).join('\n')
|
|
80
|
+
lines.push('')
|
|
81
|
+
if (counts) lines.push(`tspl searched ${counts.files} files in ${counts.repos} repos (0 AI tokens).`)
|
|
82
|
+
lines.push(`Confidence: ${LEVEL[brief.confidence.level]} (${brief.confidence.reasons.join('; ')})`)
|
|
83
|
+
if (brief.keywords.length) lines.push(`Keywords: ${brief.keywords.join(', ')}`)
|
|
84
|
+
lines.push(brief.files.length ? 'Files, most relevant first:' : 'No matching files.')
|
|
85
|
+
lines.push(...renderFiles(brief.files))
|
|
86
|
+
if (brief.searchSteps && brief.searchSteps.length) {
|
|
87
|
+
lines.push('', 'Names to search for:')
|
|
88
|
+
brief.searchSteps.forEach(step => lines.push(` ${step.pattern} (${step.why})`))
|
|
89
|
+
}
|
|
90
|
+
if (brief.chains.length) {
|
|
91
|
+
lines.push('', 'How they connect:')
|
|
92
|
+
brief.chains.forEach(chain => {
|
|
93
|
+
lines.push(` ${chain.map((step, i) => `${i ? ` → ${step.detail ? `${step.detail} → ` : ''}` : ''}${step.repo}: ${step.path}`).join('')}`)
|
|
94
|
+
})
|
|
95
|
+
}
|
|
96
|
+
if (brief.placement.length) {
|
|
97
|
+
lines.push('', 'New files, if needed, usually go:')
|
|
98
|
+
brief.placement.forEach(place => lines.push(` ${place.repo}: ${place.pattern} (${place.count} like this)`))
|
|
99
|
+
}
|
|
100
|
+
if (brief.conventions && brief.conventions.length) lines.push('', ...brief.conventions)
|
|
101
|
+
if (brief.basis) {
|
|
102
|
+
lines.push('', `Based on the last fetched commit of: ${Object.entries(brief.basis).map(([name, at]) => `${name}@${at.branch}`).join(', ')}. Local, uncommitted changes are not included.`)
|
|
103
|
+
}
|
|
104
|
+
if (brief.refined && brief.refined.note) lines.push(`AI note (${brief.refined.engine}): ${brief.refined.note}`)
|
|
105
|
+
if (instructions) lines.push('', 'PROJECT RULES (.tspl/instructions.md and the files it lists)', instructions)
|
|
106
|
+
return lines.join('\n')
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
module.exports = {
|
|
110
|
+
LEVEL,
|
|
111
|
+
briefFrom,
|
|
112
|
+
buildBrief,
|
|
113
|
+
renderFiles,
|
|
114
|
+
renderBrief,
|
|
115
|
+
}
|
|
@@ -0,0 +1,549 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const Fs = require('fs')
|
|
4
|
+
const Path = require('path')
|
|
5
|
+
const { execFileSync, spawnSync } = require('child_process')
|
|
6
|
+
|
|
7
|
+
const { request } = require('../api-client')
|
|
8
|
+
const { readActive, writeActive, readTask, writeTask } = require('../config-store')
|
|
9
|
+
const { remoteUrl, normalizeGitUrl } = require('../git-helper')
|
|
10
|
+
const { TEST_FILE, checkFiles } = require('./gate')
|
|
11
|
+
const { installHooks, removeHooks } = require('./hooks')
|
|
12
|
+
|
|
13
|
+
const FETCH_TIMEOUT = 60 * 1000
|
|
14
|
+
const TEST_TIMEOUT = 10 * 60 * 1000
|
|
15
|
+
const TEST_TAIL = 12
|
|
16
|
+
const BRANCH_WORDS = 6
|
|
17
|
+
const BRANCH_LENGTH = 60
|
|
18
|
+
|
|
19
|
+
const git = (dir, args, timeout) =>
|
|
20
|
+
execFileSync('git', ['-C', dir, ...args], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout }).trim()
|
|
21
|
+
|
|
22
|
+
const tryGit = (dir, args, timeout) => {
|
|
23
|
+
try {
|
|
24
|
+
return git(dir, args, timeout)
|
|
25
|
+
} catch (err) {
|
|
26
|
+
return null
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const planFiles = plan =>
|
|
31
|
+
['edit', 'create', 'remove']
|
|
32
|
+
.flatMap(kind => (plan && plan[kind]) || [])
|
|
33
|
+
.map(item => item.file)
|
|
34
|
+
.concat(((plan && plan.tests) || []).map(test => test.file).filter(Boolean))
|
|
35
|
+
|
|
36
|
+
const planRepos = plan => [...new Set(['edit', 'create', 'remove'].flatMap(kind => (plan && plan[kind]) || []).map(item => item.file.split('/')[0]))]
|
|
37
|
+
|
|
38
|
+
const suggestBranch = (card, taskKey) => {
|
|
39
|
+
const prefix = (card.ticket || taskKey).replace(/[^A-Za-z0-9-]+/g, '-')
|
|
40
|
+
const words = String(card.what || '')
|
|
41
|
+
.toLowerCase()
|
|
42
|
+
.replace(/[^a-z0-9\s]+/g, ' ')
|
|
43
|
+
.split(/\s+/)
|
|
44
|
+
.filter(word => word.length > 1)
|
|
45
|
+
.slice(0, BRANCH_WORDS)
|
|
46
|
+
return [prefix, ...words].join('-').slice(0, BRANCH_LENGTH).replace(/-+$/, '')
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const validBranch = name => tryGit('.', ['check-ref-format', '--branch', name]) !== null
|
|
50
|
+
|
|
51
|
+
const statusLines = dir => {
|
|
52
|
+
try {
|
|
53
|
+
return execFileSync('git', ['-C', dir, 'status', '--porcelain'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] })
|
|
54
|
+
.split('\n')
|
|
55
|
+
.filter(Boolean)
|
|
56
|
+
} catch (err) {
|
|
57
|
+
return []
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const repoState = dir => {
|
|
62
|
+
const lines = statusLines(dir)
|
|
63
|
+
return {
|
|
64
|
+
current: tryGit(dir, ['rev-parse', '--abbrev-ref', 'HEAD']),
|
|
65
|
+
changed: lines.filter(line => !line.startsWith('??')).map(line => line.slice(3)),
|
|
66
|
+
untracked: lines.filter(line => line.startsWith('??')).length,
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const prepareBuild = ({ taskKey, task, project, paths, branch, base: everyBase, bases = {}, fromCurrent = false }) => {
|
|
71
|
+
const repos = planRepos(task.plan).map(name => {
|
|
72
|
+
const path = paths.get(name)
|
|
73
|
+
if (!path) throw new Error(`${name} is on the plan but is not linked on this computer; run tspl in the project folder`)
|
|
74
|
+
const state = repoState(path)
|
|
75
|
+
const saved = ((project && project.repos) || []).find(repo => repo.name === name)
|
|
76
|
+
const base = bases[name] || everyBase || (fromCurrent ? state.current : (saved && saved.defaultBaseBranch) || 'master')
|
|
77
|
+
return { name, path, base, ...state, moved: moved({ name, path, base }, task.plan) }
|
|
78
|
+
})
|
|
79
|
+
return {
|
|
80
|
+
branch: branch || (task.build && task.build.branch) || suggestBranch(task.card || {}, taskKey),
|
|
81
|
+
repos,
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const moved = (repo, plan) => {
|
|
86
|
+
const basis = plan && plan.basis && plan.basis[repo.name]
|
|
87
|
+
if (!basis || !basis.commit) return 0
|
|
88
|
+
const tip = tryGit(repo.path, ['rev-parse', '--verify', '--quiet', `refs/remotes/origin/${repo.base}`])
|
|
89
|
+
if (!tip || tip === basis.commit) return 0
|
|
90
|
+
return Number(tryGit(repo.path, ['rev-list', '--count', `${basis.commit}..${tip}`])) || 0
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const describeProposal = proposal =>
|
|
94
|
+
[
|
|
95
|
+
`Branch: ${proposal.branch}`,
|
|
96
|
+
...proposal.repos.map(repo =>
|
|
97
|
+
` ${repo.name}: from ${repo.base} (now on ${repo.current})` +
|
|
98
|
+
`${repo.changed.length ? ` · ${repo.changed.length} uncommitted change(s): ${repo.changed.slice(0, 3).join(', ')}` : ''}` +
|
|
99
|
+
`${repo.untracked ? ` · ${repo.untracked} untracked file(s)` : ''}` +
|
|
100
|
+
`${repo.moved ? ` · ${repo.base} has ${repo.moved} new commit(s) since the plan was made; check the plan still fits` : ''}`),
|
|
101
|
+
].join('\n')
|
|
102
|
+
|
|
103
|
+
const blockers = proposal => {
|
|
104
|
+
const problems = []
|
|
105
|
+
if (!validBranch(proposal.branch)) problems.push(`"${proposal.branch}" is not a valid branch name`)
|
|
106
|
+
proposal.repos
|
|
107
|
+
.filter(repo => repo.changed.length && repo.current !== proposal.branch)
|
|
108
|
+
.forEach(repo => problems.push(`${repo.name} has uncommitted changes (${repo.changed.slice(0, 3).join(', ')}); commit or stash them first`))
|
|
109
|
+
return problems
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const startBranch = (repo, branch) => {
|
|
113
|
+
const notes = []
|
|
114
|
+
if (tryGit(repo.path, ['fetch', 'origin', repo.base], FETCH_TIMEOUT) === null) notes.push('could not fetch, used the local copy')
|
|
115
|
+
const ref = tryGit(repo.path, ['rev-parse', '--verify', '--quiet', `refs/remotes/origin/${repo.base}`])
|
|
116
|
+
? `origin/${repo.base}`
|
|
117
|
+
: tryGit(repo.path, ['rev-parse', '--verify', '--quiet', `refs/heads/${repo.base}`])
|
|
118
|
+
? repo.base
|
|
119
|
+
: null
|
|
120
|
+
if (!ref) throw new Error(`${repo.name}: base branch "${repo.base}" not found`)
|
|
121
|
+
if (tryGit(repo.path, ['rev-parse', '--verify', '--quiet', `refs/heads/${branch}`])) {
|
|
122
|
+
if (repo.current !== branch) git(repo.path, ['switch', branch])
|
|
123
|
+
notes.push('branch already existed, switched to it')
|
|
124
|
+
} else {
|
|
125
|
+
git(repo.path, ['switch', '-c', branch, ref])
|
|
126
|
+
}
|
|
127
|
+
return { name: repo.name, path: repo.path, base: repo.base, baseSha: git(repo.path, ['rev-parse', ref]), notes }
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const runBuild = async ({ projectId, taskKey, task, proposal }) => {
|
|
131
|
+
const problems = blockers(proposal)
|
|
132
|
+
if (problems.length) {
|
|
133
|
+
return { started: false, problems }
|
|
134
|
+
}
|
|
135
|
+
const repos = proposal.repos.map(repo => startBranch(repo, proposal.branch))
|
|
136
|
+
const build = {
|
|
137
|
+
branch: proposal.branch,
|
|
138
|
+
repos: repos.map(({ name, base, baseSha }) => ({ name, base, baseSha })),
|
|
139
|
+
startedAt: new Date().toISOString(),
|
|
140
|
+
}
|
|
141
|
+
const saved = await request('PUT', `/api/v1/projects/${projectId}/tasks/${taskKey}`, {
|
|
142
|
+
card: task.card,
|
|
143
|
+
assumptions: task.assumptions || [],
|
|
144
|
+
found: task.found || [],
|
|
145
|
+
used: task.used || [],
|
|
146
|
+
build,
|
|
147
|
+
})
|
|
148
|
+
const active = readActive()
|
|
149
|
+
active[taskKey] = {
|
|
150
|
+
projectId: String(projectId),
|
|
151
|
+
ticket: (task.card && task.card.ticket) || null,
|
|
152
|
+
branch: proposal.branch,
|
|
153
|
+
repos: repos.map(({ name, path, base, baseSha }) => ({ name, path, base, baseSha })),
|
|
154
|
+
files: planFiles(task.plan),
|
|
155
|
+
startedAt: build.startedAt,
|
|
156
|
+
}
|
|
157
|
+
writeActive(active)
|
|
158
|
+
repos.forEach(repo => {
|
|
159
|
+
try {
|
|
160
|
+
installHooks(repo.path)
|
|
161
|
+
repo.notes.push('commits and pushes are checked against the checklist')
|
|
162
|
+
} catch (err) {
|
|
163
|
+
repo.notes.push(`could not install the git hooks: ${err.message}`)
|
|
164
|
+
}
|
|
165
|
+
})
|
|
166
|
+
const local = readTask(taskKey)
|
|
167
|
+
if (local.id) writeTask({ ...local, status: saved.status, build })
|
|
168
|
+
return { started: true, repos }
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const checkBuild = ({ taskKey, build, paths }) =>
|
|
172
|
+
(build.repos || []).map(entry => {
|
|
173
|
+
const dir = paths.get(entry.name)
|
|
174
|
+
if (!dir) return { name: entry.name, missing: true, allow: false, reasons: [`${entry.name} is not linked on this computer`], files: [] }
|
|
175
|
+
const files = (tryGit(dir, ['-c', 'core.quotePath=false', 'diff', '--name-only', `${entry.baseSha || entry.base}..HEAD`]) || '').split('\n').filter(Boolean)
|
|
176
|
+
const branch = tryGit(dir, ['rev-parse', '--abbrev-ref', 'HEAD'])
|
|
177
|
+
return { name: entry.name, files, ...checkFiles({ taskKey, build, repoName: entry.name, branch, files }) }
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
const tail = text => String(text || '').trim().split('\n').filter(Boolean).slice(-TEST_TAIL).join('\n')
|
|
181
|
+
|
|
182
|
+
const runPlanTests = ({ task, paths }) => {
|
|
183
|
+
const build = task.build || {}
|
|
184
|
+
const tests = ((task.plan && task.plan.tests) || []).filter(test => test.command)
|
|
185
|
+
return tests.map(test => {
|
|
186
|
+
const repo = (test.file && test.file.split('/')[0]) || ((build.repos || [])[0] || {}).name
|
|
187
|
+
const dir = paths.get(repo)
|
|
188
|
+
if (!dir) return { name: test.name, passed: false, how: `${test.command} → ${repo || 'repo'} is not linked on this computer`, command: test.command, exitCode: null, executed: true }
|
|
189
|
+
const run = spawnSync('sh', ['-c', test.command], { cwd: dir, encoding: 'utf8', timeout: TEST_TIMEOUT, env: { ...process.env, CI: '1' } })
|
|
190
|
+
const exitCode = run.status === null ? -1 : run.status
|
|
191
|
+
const output = tail(`${run.stdout || ''}\n${run.stderr || ''}`)
|
|
192
|
+
return {
|
|
193
|
+
name: test.name,
|
|
194
|
+
passed: exitCode === 0,
|
|
195
|
+
how: `${test.command} → exit ${exitCode}${run.error ? ` (${run.error.message})` : ''}${output ? `\n${output}` : ''}`,
|
|
196
|
+
command: test.command,
|
|
197
|
+
exitCode,
|
|
198
|
+
executed: true,
|
|
199
|
+
}
|
|
200
|
+
})
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const STYLE_FILE = /\.(js|jsx|ts|tsx|mjs|cjs|json|scss|css|html|md|vue)$/
|
|
204
|
+
const LINT_FILE = /\.(js|jsx|ts|tsx|mjs|cjs|vue)$/
|
|
205
|
+
const ESLINT_CONFIG = /^(eslint\.config\.[cm]?js|\.eslintrc(\.(js|cjs|json|ya?ml))?)$/
|
|
206
|
+
const PRETTIER_CONFIG = /^(\.prettierrc(\.(js|cjs|json|ya?ml))?|prettier\.config\.[cm]?js)$/
|
|
207
|
+
|
|
208
|
+
const hasConfig = (dir, pattern) => {
|
|
209
|
+
try {
|
|
210
|
+
return Fs.readdirSync(dir).some(name => pattern.test(name))
|
|
211
|
+
} catch (err) {
|
|
212
|
+
return false
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const styleTool = (dir, name, pattern) => {
|
|
217
|
+
const bin = Path.join(dir, 'node_modules', '.bin', name)
|
|
218
|
+
return Fs.existsSync(bin) && hasConfig(dir, pattern) ? bin : null
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const lintMessages = (bin, dir, args, input) => {
|
|
222
|
+
const run = spawnSync(bin, ['--format', 'json', ...args], { cwd: dir, encoding: 'utf8', timeout: TEST_TIMEOUT, input, maxBuffer: 64 * 1024 * 1024 })
|
|
223
|
+
try {
|
|
224
|
+
return JSON.parse(run.stdout || '[]').flatMap(result =>
|
|
225
|
+
(result.messages || []).filter(message => message.severity === 2).map(message => ({ key: `${message.ruleId}|${message.message}`, line: message.line, text: `${message.ruleId || 'error'}: ${message.message}` })))
|
|
226
|
+
} catch (err) {
|
|
227
|
+
return null
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const newMessages = (current, base) => {
|
|
232
|
+
const seen = new Map()
|
|
233
|
+
base.forEach(message => seen.set(message.key, (seen.get(message.key) || 0) + 1))
|
|
234
|
+
return current.filter(message => {
|
|
235
|
+
const left = seen.get(message.key) || 0
|
|
236
|
+
if (left > 0) {
|
|
237
|
+
seen.set(message.key, left - 1)
|
|
238
|
+
return false
|
|
239
|
+
}
|
|
240
|
+
return true
|
|
241
|
+
})
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const baseContent = (dir, baseSha, file) => {
|
|
245
|
+
try {
|
|
246
|
+
return execFileSync('git', ['-C', dir, 'show', `${baseSha}:${file}`], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 64 * 1024 * 1024 })
|
|
247
|
+
} catch (err) {
|
|
248
|
+
return null
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const lintCheck = ({ repo, dir, baseSha, files, bin }) => {
|
|
253
|
+
const fresh = []
|
|
254
|
+
let old = 0
|
|
255
|
+
let broken = null
|
|
256
|
+
files.forEach(file => {
|
|
257
|
+
const current = lintMessages(bin, dir, [file])
|
|
258
|
+
if (current === null) {
|
|
259
|
+
broken = file
|
|
260
|
+
return
|
|
261
|
+
}
|
|
262
|
+
const before = baseContent(dir, baseSha, file)
|
|
263
|
+
const base = before === null ? [] : lintMessages(bin, dir, ['--stdin', '--stdin-filename', file], before) || []
|
|
264
|
+
const added = newMessages(current, base)
|
|
265
|
+
old += current.length - added.length
|
|
266
|
+
fresh.push(...added.map(message => `${file}:${message.line} ${message.text}`))
|
|
267
|
+
})
|
|
268
|
+
const passed = !fresh.length && !broken
|
|
269
|
+
return {
|
|
270
|
+
name: `lint (${repo}, ${files.length} changed file${files.length === 1 ? '' : 's'})`,
|
|
271
|
+
passed,
|
|
272
|
+
how: broken
|
|
273
|
+
? `eslint could not check ${broken}`
|
|
274
|
+
: `eslint on the changed files → ${fresh.length} new error${fresh.length === 1 ? '' : 's'}${old ? `, ${old} already there before the change (ignored)` : ''}${fresh.length ? `\n${fresh.slice(0, TEST_TAIL).join('\n')}` : ''}`,
|
|
275
|
+
command: `eslint ${files.join(' ')}`.slice(0, 200),
|
|
276
|
+
exitCode: passed ? 0 : 1,
|
|
277
|
+
executed: true,
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const formatCheck = ({ repo, dir, baseSha, files, bin }) => {
|
|
282
|
+
const unformatted = []
|
|
283
|
+
const legacy = []
|
|
284
|
+
files.forEach(file => {
|
|
285
|
+
const check = spawnSync(bin, ['--check', file], { cwd: dir, encoding: 'utf8', timeout: TEST_TIMEOUT })
|
|
286
|
+
if (check.status === 0) return
|
|
287
|
+
const before = baseContent(dir, baseSha, file)
|
|
288
|
+
if (before === null) {
|
|
289
|
+
unformatted.push(file)
|
|
290
|
+
return
|
|
291
|
+
}
|
|
292
|
+
const formatted = spawnSync(bin, ['--stdin-filepath', file], { cwd: dir, encoding: 'utf8', timeout: TEST_TIMEOUT, input: before, maxBuffer: 64 * 1024 * 1024 })
|
|
293
|
+
if (formatted.status === 0 && formatted.stdout.trim() === before.trim()) unformatted.push(file)
|
|
294
|
+
else legacy.push(file)
|
|
295
|
+
})
|
|
296
|
+
return {
|
|
297
|
+
name: `format (${repo}, ${files.length} changed file${files.length === 1 ? '' : 's'})`,
|
|
298
|
+
passed: !unformatted.length,
|
|
299
|
+
how: `prettier --check on the changed files → ${unformatted.length ? `not formatted: ${unformatted.join(', ')}` : 'all formatted'}${legacy.length ? ` (${legacy.length} file${legacy.length === 1 ? ' was' : 's were'} not formatted before the change either; left as is)` : ''}`,
|
|
300
|
+
command: `prettier --check ${files.join(' ')}`.slice(0, 200),
|
|
301
|
+
exitCode: unformatted.length ? 1 : 0,
|
|
302
|
+
executed: true,
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const runStyleChecks = ({ task, paths }) => {
|
|
307
|
+
const build = task.build || {}
|
|
308
|
+
return (build.repos || []).flatMap(entry => {
|
|
309
|
+
const dir = paths.get(entry.name)
|
|
310
|
+
if (!dir) return []
|
|
311
|
+
const baseSha = entry.baseSha || entry.base
|
|
312
|
+
const changed = [...changedFiles(dir, baseSha).entries()]
|
|
313
|
+
.filter(([file, status]) => status !== 'D' && STYLE_FILE.test(file) && Fs.existsSync(Path.join(dir, file)))
|
|
314
|
+
.map(([file]) => file)
|
|
315
|
+
if (!changed.length) return []
|
|
316
|
+
const checks = []
|
|
317
|
+
const eslint = styleTool(dir, 'eslint', ESLINT_CONFIG)
|
|
318
|
+
const lintable = changed.filter(file => LINT_FILE.test(file))
|
|
319
|
+
if (eslint && lintable.length) checks.push(lintCheck({ repo: entry.name, dir, baseSha, files: lintable, bin: eslint }))
|
|
320
|
+
const prettier = styleTool(dir, 'prettier', PRETTIER_CONFIG)
|
|
321
|
+
if (prettier) checks.push(formatCheck({ repo: entry.name, dir, baseSha, files: changed, bin: prettier }))
|
|
322
|
+
return checks
|
|
323
|
+
})
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const mergeTests = (executed, reported) => [
|
|
327
|
+
...executed,
|
|
328
|
+
...reported.filter(test => !executed.some(item => item.name === test.name)).map(test => ({ ...test, executed: false })),
|
|
329
|
+
]
|
|
330
|
+
|
|
331
|
+
const renderBuildBrief = (task, repos, conventions = [], instructions = '') => {
|
|
332
|
+
const plan = task.plan || {}
|
|
333
|
+
const card = task.card || {}
|
|
334
|
+
const build = task.build || {}
|
|
335
|
+
const lines = [
|
|
336
|
+
`BUILD ${task.key || task.id}${card.ticket ? ` · ${card.ticket}` : ''} · ${card.what || ''}`,
|
|
337
|
+
`Branch ${build.branch}. Only the files below (and test files) may change; anything else is refused at commit and push.`,
|
|
338
|
+
`Stay on ${build.branch} in every repo: do not create, switch, reset or delete branches. Work on another branch is refused.`,
|
|
339
|
+
'',
|
|
340
|
+
]
|
|
341
|
+
;['edit', 'create', 'remove'].forEach(kind => {
|
|
342
|
+
;(plan[kind] || []).forEach(item => lines.push(` ${kind}: ${item.file}${item.why ? ` — ${item.why}` : ''}`))
|
|
343
|
+
})
|
|
344
|
+
if ((plan.tests || []).length) {
|
|
345
|
+
lines.push('', 'TESTS')
|
|
346
|
+
plan.tests.forEach(test => lines.push(` ${test.name}${test.file ? ` (${test.file})` : ''}${test.command ? ` $ ${test.command}` : ''}${(test.checks || []).length ? ` → done-when ${test.checks.join(', ')}` : ''}`))
|
|
347
|
+
}
|
|
348
|
+
if ((card.doneWhen || []).length) {
|
|
349
|
+
const { testCaseLines, TEST_NAMING } = require('../workflow/agents')
|
|
350
|
+
lines.push('', 'TEST CASES (done-when items)', ...testCaseLines(card), TEST_NAMING)
|
|
351
|
+
}
|
|
352
|
+
if (plan.notes) lines.push('', `NOTES: ${plan.notes}`)
|
|
353
|
+
if (conventions.length) lines.push('', ...conventions)
|
|
354
|
+
if (instructions) lines.push('', 'PROJECT RULES', instructions)
|
|
355
|
+
lines.push('', 'REPOS')
|
|
356
|
+
;(repos || build.repos || []).forEach(repo => lines.push(` ${repo.name}: ${repo.path || ''} (from ${repo.base}${repo.baseSha ? `@${String(repo.baseSha).slice(0, 7)}` : ''})`))
|
|
357
|
+
lines.push(
|
|
358
|
+
'',
|
|
359
|
+
'Build the checklist the way the surrounding code is written. Run the tests. Check every "done when" item.',
|
|
360
|
+
`Do not commit or push. When the checklist is done, call the tspl tool finish_build with taskId "${task.key || task.id}": tspl runs the test commands itself on the files as they are now and compares the changes with the checklist. Committing and pushing is the developer's own choice afterwards.`,
|
|
361
|
+
)
|
|
362
|
+
return lines.join('\n')
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const KINDS = { edit: 'M', create: 'A', remove: 'D' }
|
|
366
|
+
|
|
367
|
+
const changedFiles = (dir, baseSha) => {
|
|
368
|
+
const changed = new Map()
|
|
369
|
+
;(tryGit(dir, ['-c', 'core.quotePath=false', 'diff', '--name-status', '--no-renames', baseSha]) || '')
|
|
370
|
+
.split('\n')
|
|
371
|
+
.filter(Boolean)
|
|
372
|
+
.forEach(line => {
|
|
373
|
+
const [status, file] = line.split('\t')
|
|
374
|
+
changed.set(file, status[0])
|
|
375
|
+
})
|
|
376
|
+
;(tryGit(dir, ['-c', 'core.quotePath=false', 'ls-files', '--others', '--exclude-standard']) || '')
|
|
377
|
+
.split('\n')
|
|
378
|
+
.filter(Boolean)
|
|
379
|
+
.forEach(file => changed.set(file, 'A'))
|
|
380
|
+
return changed
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
const pullRequestLink = (dir, branch, base) => {
|
|
384
|
+
const url = remoteUrl(dir)
|
|
385
|
+
if (!url) return null
|
|
386
|
+
const path = normalizeGitUrl(url)
|
|
387
|
+
const [host, ...rest] = path.split('/')
|
|
388
|
+
const repo = rest.join('/')
|
|
389
|
+
if (host === 'github.com') return `https://github.com/${repo}/compare/${encodeURIComponent(base)}...${encodeURIComponent(branch)}?expand=1`
|
|
390
|
+
if (host === 'bitbucket.org') return `https://bitbucket.org/${repo}/pull-requests/new?source=${encodeURIComponent(branch)}&dest=${encodeURIComponent(base)}`
|
|
391
|
+
if (host.includes('gitlab')) return `https://${host}/${repo}/-/merge_requests/new?merge_request[source_branch]=${encodeURIComponent(branch)}&merge_request[target_branch]=${encodeURIComponent(base)}`
|
|
392
|
+
return null
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
const reviewBuild = ({ task, paths, checks = [], tests = [] }) => {
|
|
396
|
+
const build = task.build || {}
|
|
397
|
+
const plan = task.plan || {}
|
|
398
|
+
const doneWhen = (task.card && task.card.doneWhen) || []
|
|
399
|
+
const planned = new Map(['edit', 'create', 'remove'].flatMap(kind => (plan[kind] || []).map(item => [item.file, kind])))
|
|
400
|
+
const plannedTests = new Set((plan.tests || []).map(test => test.file).filter(Boolean))
|
|
401
|
+
|
|
402
|
+
const repos = (build.repos || []).map(entry => {
|
|
403
|
+
const dir = paths.get(entry.name)
|
|
404
|
+
if (!dir) return { name: entry.name, missing: true, changed: [], commits: [] }
|
|
405
|
+
const changed = [...changedFiles(dir, entry.baseSha || entry.base).entries()].map(([file, status]) => ({ file: `${entry.name}/${file}`, status }))
|
|
406
|
+
const commits = (tryGit(dir, ['log', '--format=%h %s', `${entry.baseSha || entry.base}..HEAD`]) || '').split('\n').filter(Boolean)
|
|
407
|
+
const current = tryGit(dir, ['rev-parse', '--abbrev-ref', 'HEAD'])
|
|
408
|
+
const pushed = tryGit(dir, ['rev-parse', '--verify', '--quiet', `refs/remotes/origin/${build.branch}`])
|
|
409
|
+
const head = tryGit(dir, ['rev-parse', 'HEAD'])
|
|
410
|
+
return {
|
|
411
|
+
name: entry.name,
|
|
412
|
+
base: entry.base,
|
|
413
|
+
baseSha: entry.baseSha,
|
|
414
|
+
onBranch: current === build.branch,
|
|
415
|
+
uncommitted: statusLines(dir).length,
|
|
416
|
+
pushed: Boolean(pushed) && pushed === head,
|
|
417
|
+
push: `git -C ${dir} push -u origin ${build.branch}`,
|
|
418
|
+
pullRequest: pullRequestLink(dir, build.branch, entry.base),
|
|
419
|
+
changed,
|
|
420
|
+
commits,
|
|
421
|
+
}
|
|
422
|
+
})
|
|
423
|
+
|
|
424
|
+
const changed = new Map(repos.flatMap(repo => repo.changed.map(item => [item.file, item.status])))
|
|
425
|
+
const offPlan = [...changed.keys()].filter(file => !planned.has(file) && !plannedTests.has(file) && !TEST_FILE.test(file))
|
|
426
|
+
const untouched = [...planned.entries()].filter(([file]) => !changed.has(file)).map(([file, kind]) => `${kind} ${file}`)
|
|
427
|
+
const wrongKind = [...planned.entries()]
|
|
428
|
+
.filter(([file, kind]) => changed.has(file) && changed.get(file) !== KINDS[kind])
|
|
429
|
+
.map(([file, kind]) => `${file}: planned to ${kind}, but it was ${{ A: 'added', M: 'changed', D: 'deleted' }[changed.get(file)] || 'changed'}`)
|
|
430
|
+
const testFiles = [...changed.keys()].filter(file => plannedTests.has(file) || TEST_FILE.test(file))
|
|
431
|
+
|
|
432
|
+
const checkResults = doneWhen.map((check, i) => {
|
|
433
|
+
const reported = checks.find(item => item.n === i + 1)
|
|
434
|
+
return { n: i + 1, check, passed: Boolean(reported && reported.passed), how: reported ? reported.how || '' : 'not checked' }
|
|
435
|
+
})
|
|
436
|
+
const testResults = tests.map(test => ({ name: test.name, passed: Boolean(test.passed), how: test.how || '', ...(test.command ? { command: test.command, exitCode: test.exitCode } : {}), executed: Boolean(test.executed) }))
|
|
437
|
+
|
|
438
|
+
const problems = [
|
|
439
|
+
...checkResults.filter(item => !item.passed).map(item => `Done-when ${item.n} "${item.check}" ${item.how === 'not checked' ? 'was not checked' : 'did not pass'}`),
|
|
440
|
+
...testResults.filter(test => !test.passed).map(test => `Test "${test.name}" failed`),
|
|
441
|
+
...(testResults.length ? [] : ['No test results were reported']),
|
|
442
|
+
...offPlan.map(file => `${file} changed but is not on the approved checklist`),
|
|
443
|
+
...repos.filter(repo => repo.missing).map(repo => `${repo.name} is not linked on this computer`),
|
|
444
|
+
...repos.filter(repo => !repo.missing && !repo.onBranch).map(repo => `${repo.name} is not on the task branch ${build.branch}; switch back with: git -C ${paths.get(repo.name)} switch ${build.branch}`),
|
|
445
|
+
]
|
|
446
|
+
const warnings = [
|
|
447
|
+
...untouched.map(item => `Planned but not changed: ${item}`),
|
|
448
|
+
...wrongKind,
|
|
449
|
+
...repos.filter(repo => repo.uncommitted).map(repo => `${repo.name}: ${repo.uncommitted} change(s) not committed yet; tested as they are (commit and push when you want)`),
|
|
450
|
+
]
|
|
451
|
+
|
|
452
|
+
return {
|
|
453
|
+
passed: problems.length === 0,
|
|
454
|
+
branch: build.branch,
|
|
455
|
+
checks: checkResults,
|
|
456
|
+
tests: testResults,
|
|
457
|
+
repos,
|
|
458
|
+
offPlan,
|
|
459
|
+
untouched,
|
|
460
|
+
testFiles,
|
|
461
|
+
problems,
|
|
462
|
+
warnings,
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
const describeReport = report =>
|
|
467
|
+
[
|
|
468
|
+
`REPORT · branch ${report.branch} · ${report.passed ? 'PASSED' : 'NOT DONE YET'}`,
|
|
469
|
+
'',
|
|
470
|
+
'DONE WHEN',
|
|
471
|
+
...report.checks.map(item => ` ${item.passed ? '✓' : '✗'} ${item.n}. ${item.check}${item.how ? ` — ${item.how}` : ''}`),
|
|
472
|
+
'TESTS',
|
|
473
|
+
...(report.tests.length ? report.tests.map(test => ` ${test.passed ? '✓' : '✗'} ${test.name}${test.how ? ` — ${test.how}` : ''}`) : [' (none reported)']),
|
|
474
|
+
'CHANGES',
|
|
475
|
+
...report.repos.flatMap(repo => [
|
|
476
|
+
` ${repo.name}: ${repo.changed.length} file(s), ${repo.commits.length} commit(s) since ${repo.base}`,
|
|
477
|
+
...repo.changed.map(item => ` ${item.status} ${item.file.slice(repo.name.length + 1)}`),
|
|
478
|
+
]),
|
|
479
|
+
...(report.problems.length ? ['', 'PROBLEMS', ...report.problems.map(item => ` - ${item}`)] : []),
|
|
480
|
+
...(report.warnings.length ? ['', 'WARNINGS', ...report.warnings.map(item => ` - ${item}`)] : []),
|
|
481
|
+
].join('\n')
|
|
482
|
+
|
|
483
|
+
const finishBuild = async ({ projectId, taskKey, task, report, summary }) => {
|
|
484
|
+
const saved = await request('PUT', `/api/v1/projects/${projectId}/tasks/${taskKey}`, {
|
|
485
|
+
card: task.card,
|
|
486
|
+
assumptions: task.assumptions || [],
|
|
487
|
+
found: task.found || [],
|
|
488
|
+
used: task.used || [],
|
|
489
|
+
report: { ...report, summary: summary || '', finishedAt: new Date().toISOString() },
|
|
490
|
+
})
|
|
491
|
+
if (saved.status === 'done') {
|
|
492
|
+
const active = readActive()
|
|
493
|
+
;((active[taskKey] || {}).repos || []).forEach(repo => removeHooks(repo.path))
|
|
494
|
+
delete active[taskKey]
|
|
495
|
+
writeActive(active)
|
|
496
|
+
}
|
|
497
|
+
const local = readTask(taskKey)
|
|
498
|
+
if (local.id) writeTask({ ...local, status: saved.status })
|
|
499
|
+
return saved
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
const stopBuild = taskKey => {
|
|
503
|
+
const active = readActive()
|
|
504
|
+
const found = Boolean(active[taskKey])
|
|
505
|
+
;((active[taskKey] || {}).repos || []).forEach(repo => removeHooks(repo.path))
|
|
506
|
+
delete active[taskKey]
|
|
507
|
+
writeActive(active)
|
|
508
|
+
const local = readTask(taskKey)
|
|
509
|
+
if (local.id) writeTask({ ...local, status: 'stopped' })
|
|
510
|
+
return found || Boolean(local.id)
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* Keeps this computer's edit lock on the plan that is approved now. A build started under an earlier plan version
|
|
515
|
+
* keeps its branch, so it is not started again after an amendment; without this the hooks enforce the old checklist.
|
|
516
|
+
*/
|
|
517
|
+
const refreshChecklist = (taskKey, plan) => {
|
|
518
|
+
const active = readActive()
|
|
519
|
+
if (!active[taskKey] || !plan) return false
|
|
520
|
+
const files = planFiles(plan)
|
|
521
|
+
if (JSON.stringify(active[taskKey].files) === JSON.stringify(files)) return false
|
|
522
|
+
active[taskKey] = { ...active[taskKey], files, planVersion: plan.version }
|
|
523
|
+
writeActive(active)
|
|
524
|
+
return true
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
module.exports = {
|
|
528
|
+
planFiles,
|
|
529
|
+
refreshChecklist,
|
|
530
|
+
planRepos,
|
|
531
|
+
suggestBranch,
|
|
532
|
+
repoState,
|
|
533
|
+
prepareBuild,
|
|
534
|
+
describeProposal,
|
|
535
|
+
blockers,
|
|
536
|
+
runBuild,
|
|
537
|
+
startBranch,
|
|
538
|
+
stopBuild,
|
|
539
|
+
checkBuild,
|
|
540
|
+
runPlanTests,
|
|
541
|
+
runStyleChecks,
|
|
542
|
+
newMessages,
|
|
543
|
+
mergeTests,
|
|
544
|
+
renderBuildBrief,
|
|
545
|
+
reviewBuild,
|
|
546
|
+
describeReport,
|
|
547
|
+
finishBuild,
|
|
548
|
+
pullRequestLink,
|
|
549
|
+
}
|