devvami 1.0.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 +255 -0
- package/bin/dev.cmd +3 -0
- package/bin/dev.js +5 -0
- package/bin/run.cmd +3 -0
- package/bin/run.js +5 -0
- package/oclif.manifest.json +1238 -0
- package/package.json +161 -0
- package/src/commands/auth/login.js +89 -0
- package/src/commands/changelog.js +102 -0
- package/src/commands/costs/get.js +73 -0
- package/src/commands/create/repo.js +196 -0
- package/src/commands/docs/list.js +110 -0
- package/src/commands/docs/projects.js +92 -0
- package/src/commands/docs/read.js +172 -0
- package/src/commands/docs/search.js +103 -0
- package/src/commands/doctor.js +115 -0
- package/src/commands/init.js +222 -0
- package/src/commands/open.js +75 -0
- package/src/commands/pipeline/logs.js +41 -0
- package/src/commands/pipeline/rerun.js +66 -0
- package/src/commands/pipeline/status.js +62 -0
- package/src/commands/pr/create.js +114 -0
- package/src/commands/pr/detail.js +83 -0
- package/src/commands/pr/review.js +51 -0
- package/src/commands/pr/status.js +70 -0
- package/src/commands/repo/list.js +113 -0
- package/src/commands/search.js +62 -0
- package/src/commands/tasks/assigned.js +131 -0
- package/src/commands/tasks/list.js +133 -0
- package/src/commands/tasks/today.js +73 -0
- package/src/commands/upgrade.js +52 -0
- package/src/commands/whoami.js +85 -0
- package/src/formatters/cost.js +54 -0
- package/src/formatters/markdown.js +108 -0
- package/src/formatters/openapi.js +146 -0
- package/src/formatters/status.js +48 -0
- package/src/formatters/table.js +87 -0
- package/src/help.js +312 -0
- package/src/hooks/init.js +9 -0
- package/src/hooks/postrun.js +18 -0
- package/src/index.js +1 -0
- package/src/services/auth.js +83 -0
- package/src/services/aws-costs.js +80 -0
- package/src/services/clickup.js +288 -0
- package/src/services/config.js +59 -0
- package/src/services/docs.js +210 -0
- package/src/services/github.js +377 -0
- package/src/services/platform.js +48 -0
- package/src/services/shell.js +42 -0
- package/src/services/version-check.js +58 -0
- package/src/types.js +228 -0
- package/src/utils/banner.js +48 -0
- package/src/utils/errors.js +61 -0
- package/src/utils/gradient.js +130 -0
- package/src/utils/open-browser.js +29 -0
- package/src/utils/typewriter.js +48 -0
- package/src/validators/repo-name.js +42 -0
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import { Command, Flags } from '@oclif/core'
|
|
2
|
+
import chalk from 'chalk'
|
|
3
|
+
import ora from 'ora'
|
|
4
|
+
import { confirm, input, select } from '@inquirer/prompts'
|
|
5
|
+
import { printBanner } from '../utils/banner.js'
|
|
6
|
+
import { typewriterLine } from '../utils/typewriter.js'
|
|
7
|
+
import { detectPlatform } from '../services/platform.js'
|
|
8
|
+
import { exec, which } from '../services/shell.js'
|
|
9
|
+
import { configExists, loadConfig, saveConfig, CONFIG_PATH } from '../services/config.js'
|
|
10
|
+
import { oauthFlow, storeToken, validateToken, getTeams } from '../services/clickup.js'
|
|
11
|
+
|
|
12
|
+
export default class Init extends Command {
|
|
13
|
+
static description = 'Setup completo ambiente di sviluppo locale'
|
|
14
|
+
|
|
15
|
+
static examples = [
|
|
16
|
+
'<%= config.bin %> init',
|
|
17
|
+
'<%= config.bin %> init --dry-run',
|
|
18
|
+
'<%= config.bin %> init --verbose',
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
static enableJsonFlag = true
|
|
22
|
+
|
|
23
|
+
static flags = {
|
|
24
|
+
verbose: Flags.boolean({ description: 'Mostra output dettagliato', default: false }),
|
|
25
|
+
'dry-run': Flags.boolean({ description: 'Mostra cosa farebbe senza eseguire', default: false }),
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async run() {
|
|
29
|
+
const { flags } = await this.parse(Init)
|
|
30
|
+
const isDryRun = flags['dry-run']
|
|
31
|
+
const isJson = flags.json
|
|
32
|
+
|
|
33
|
+
if (!isJson) await printBanner()
|
|
34
|
+
|
|
35
|
+
const platform = await detectPlatform()
|
|
36
|
+
const steps = []
|
|
37
|
+
|
|
38
|
+
// 1. Check prerequisites
|
|
39
|
+
const spinner = isJson ? null : ora({ spinner: 'arc', color: false, text: chalk.hex('#FF6B2B')('Checking prerequisites...') }).start()
|
|
40
|
+
const prerequisites = [
|
|
41
|
+
{ name: 'Node.js', cmd: 'node', args: ['--version'], required: true },
|
|
42
|
+
{ name: 'nvm', cmd: 'nvm', args: ['--version'], required: false },
|
|
43
|
+
{ name: 'npm', cmd: 'npm', args: ['--version'], required: true },
|
|
44
|
+
{ name: 'Git', cmd: 'git', args: ['--version'], required: true },
|
|
45
|
+
{ name: 'gh CLI', cmd: 'gh', args: ['--version'], required: true },
|
|
46
|
+
{ name: 'Docker', cmd: 'docker', args: ['--version'], required: false },
|
|
47
|
+
{ name: 'AWS CLI', cmd: 'aws', args: ['--version'], required: false },
|
|
48
|
+
{ name: 'aws-vault', cmd: 'aws-vault', args: ['--version'], required: false },
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
for (const prereq of prerequisites) {
|
|
52
|
+
const path = await which(prereq.cmd)
|
|
53
|
+
const status = path ? 'ok' : prereq.required ? 'fail' : 'warn'
|
|
54
|
+
steps.push({ name: prereq.name, status, action: path ? 'found' : 'missing' })
|
|
55
|
+
if (flags.verbose && !isJson) this.log(` ${prereq.name}: ${path ?? 'not found'}`)
|
|
56
|
+
}
|
|
57
|
+
spinner?.succeed('Prerequisites checked')
|
|
58
|
+
|
|
59
|
+
// 2. Configure Git credential helper
|
|
60
|
+
const gitCredSpinner = isJson ? null : ora({ spinner: 'arc', color: false, text: chalk.hex('#FF6B2B')('Configuring Git credential helper...') }).start()
|
|
61
|
+
if (!isDryRun) {
|
|
62
|
+
await exec('git', ['config', '--global', 'credential.helper', platform.credentialHelper])
|
|
63
|
+
}
|
|
64
|
+
steps.push({ name: 'git-credential', status: 'ok', action: isDryRun ? 'would configure' : 'configured' })
|
|
65
|
+
gitCredSpinner?.succeed(`Git credential helper: ${platform.credentialHelper}`)
|
|
66
|
+
|
|
67
|
+
// 3. Configure aws-vault (interactive if not configured)
|
|
68
|
+
const awsVaultInstalled = await which('aws-vault')
|
|
69
|
+
if (awsVaultInstalled) {
|
|
70
|
+
steps.push({ name: 'aws-vault', status: 'ok', action: 'found' })
|
|
71
|
+
} else {
|
|
72
|
+
steps.push({ name: 'aws-vault', status: 'warn', action: 'not installed' })
|
|
73
|
+
if (!isJson) this.log(chalk.yellow(' aws-vault not found. Install: brew install aws-vault'))
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// 4. Create/update config
|
|
77
|
+
const configSpinner = isJson ? null : ora({ spinner: 'arc', color: false, text: chalk.hex('#FF6B2B')('Creating config...') }).start()
|
|
78
|
+
let config = await loadConfig()
|
|
79
|
+
|
|
80
|
+
if (!configExists() && !isDryRun && !isJson) {
|
|
81
|
+
const useOrg = await confirm({ message: 'Do you use a GitHub organization? (y/n)', default: true })
|
|
82
|
+
let org = ''
|
|
83
|
+
if (useOrg) {
|
|
84
|
+
org = await input({ message: 'GitHub org name:', default: config.org || '' })
|
|
85
|
+
}
|
|
86
|
+
const awsProfile = await input({ message: 'AWS profile name:', default: config.awsProfile || 'default' })
|
|
87
|
+
const awsRegion = await input({ message: 'AWS region:', default: config.awsRegion || 'eu-west-1' })
|
|
88
|
+
config = { ...config, org, awsProfile, awsRegion, shell: platform.credentialHelper }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (!isDryRun) {
|
|
92
|
+
await saveConfig(config)
|
|
93
|
+
}
|
|
94
|
+
steps.push({ name: 'config', status: 'ok', action: isDryRun ? 'would create' : 'created' })
|
|
95
|
+
configSpinner?.succeed(`Config: ${CONFIG_PATH}`)
|
|
96
|
+
|
|
97
|
+
// 5. ClickUp wizard (T008: interactive, T009: dry-run, T010: json)
|
|
98
|
+
if (isDryRun) {
|
|
99
|
+
// T009: In dry-run mode report what would happen without any network calls
|
|
100
|
+
steps.push({ name: 'clickup', status: 'would configure' })
|
|
101
|
+
} else if (isJson) {
|
|
102
|
+
// T010: In JSON mode skip wizard, report current ClickUp config status
|
|
103
|
+
config = await loadConfig()
|
|
104
|
+
steps.push({
|
|
105
|
+
name: 'clickup',
|
|
106
|
+
status: config.clickup?.teamId ? 'configured' : 'not_configured',
|
|
107
|
+
teamId: config.clickup?.teamId,
|
|
108
|
+
teamName: config.clickup?.teamName,
|
|
109
|
+
authMethod: config.clickup?.authMethod,
|
|
110
|
+
})
|
|
111
|
+
} else {
|
|
112
|
+
// T008: Full interactive wizard
|
|
113
|
+
const configureClickUp = await confirm({ message: 'Configure ClickUp integration?', default: true })
|
|
114
|
+
if (!configureClickUp) {
|
|
115
|
+
steps.push({ name: 'clickup', status: 'skipped' })
|
|
116
|
+
this.log(chalk.dim(' Skipped. Run `dvmi init` again to configure ClickUp later.'))
|
|
117
|
+
} else {
|
|
118
|
+
// Determine auth method
|
|
119
|
+
const clientId = process.env.CLICKUP_CLIENT_ID
|
|
120
|
+
const clientSecret = process.env.CLICKUP_CLIENT_SECRET
|
|
121
|
+
let authMethod = /** @type {'oauth'|'personal_token'} */ ('personal_token')
|
|
122
|
+
|
|
123
|
+
if (clientId && clientSecret) {
|
|
124
|
+
const choice = await select({
|
|
125
|
+
message: 'Select ClickUp authentication method:',
|
|
126
|
+
choices: [
|
|
127
|
+
{ name: 'Personal API Token (paste from ClickUp Settings > Apps)', value: 'personal_token' },
|
|
128
|
+
{ name: 'OAuth (opens browser)', value: 'oauth' },
|
|
129
|
+
],
|
|
130
|
+
})
|
|
131
|
+
authMethod = /** @type {'oauth'|'personal_token'} */ (choice)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Acquire token
|
|
135
|
+
if (authMethod === 'oauth') {
|
|
136
|
+
try {
|
|
137
|
+
this.log(chalk.dim(' Opening browser for OAuth authorization...'))
|
|
138
|
+
await oauthFlow(/** @type {string} */ (clientId), /** @type {string} */ (clientSecret))
|
|
139
|
+
} catch {
|
|
140
|
+
this.log(chalk.yellow(' OAuth failed. Falling back to Personal API Token.'))
|
|
141
|
+
authMethod = 'personal_token'
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (authMethod === 'personal_token') {
|
|
146
|
+
const token = await input({ message: 'Paste your ClickUp Personal API Token:' })
|
|
147
|
+
await storeToken(token)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Validate token
|
|
151
|
+
const validateSpinner = ora({ spinner: 'arc', color: false, text: chalk.hex('#FF6B2B')('Validating ClickUp credentials...') }).start()
|
|
152
|
+
let tokenValid = false
|
|
153
|
+
try {
|
|
154
|
+
const result = await validateToken()
|
|
155
|
+
tokenValid = result.valid
|
|
156
|
+
validateSpinner.succeed('ClickUp credentials validated')
|
|
157
|
+
} catch {
|
|
158
|
+
validateSpinner.fail('Failed to validate ClickUp credentials')
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (!tokenValid) {
|
|
162
|
+
this.log(chalk.yellow(' Invalid token. Check your ClickUp Personal API Token and try again.'))
|
|
163
|
+
const retry = await confirm({ message: 'Retry ClickUp configuration?', default: false })
|
|
164
|
+
if (!retry) {
|
|
165
|
+
steps.push({ name: 'clickup', status: 'skipped' })
|
|
166
|
+
} else {
|
|
167
|
+
const token = await input({ message: 'Paste your ClickUp Personal API Token:' })
|
|
168
|
+
await storeToken(token)
|
|
169
|
+
tokenValid = (await validateToken()).valid
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (tokenValid) {
|
|
174
|
+
// Fetch teams
|
|
175
|
+
let teamId = ''
|
|
176
|
+
let teamName = ''
|
|
177
|
+
const teamsSpinner = ora({ spinner: 'arc', color: false, text: chalk.hex('#FF6B2B')('Fetching available teams...') }).start()
|
|
178
|
+
try {
|
|
179
|
+
const teams = await getTeams()
|
|
180
|
+
teamsSpinner.stop()
|
|
181
|
+
if (teams.length === 1) {
|
|
182
|
+
teamId = teams[0].id
|
|
183
|
+
teamName = teams[0].name
|
|
184
|
+
this.log(chalk.green('✓') + ` Auto-selected team: ${teamName} (${teamId})`)
|
|
185
|
+
} else if (teams.length > 1) {
|
|
186
|
+
const selected = await select({
|
|
187
|
+
message: 'Select your ClickUp team:',
|
|
188
|
+
choices: teams.map((t) => ({ name: `${t.name} (${t.id})`, value: t.id })),
|
|
189
|
+
})
|
|
190
|
+
teamId = selected
|
|
191
|
+
teamName = teams.find((t) => t.id === selected)?.name ?? ''
|
|
192
|
+
} else {
|
|
193
|
+
teamId = await input({ message: 'Enter ClickUp team ID:' })
|
|
194
|
+
}
|
|
195
|
+
} catch {
|
|
196
|
+
teamsSpinner.fail('Could not fetch teams')
|
|
197
|
+
teamId = await input({ message: 'Enter ClickUp team ID (find in ClickUp Settings > Spaces):' })
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Save ClickUp config
|
|
201
|
+
config = await loadConfig()
|
|
202
|
+
config = { ...config, clickup: { ...config.clickup, teamId, teamName, authMethod } }
|
|
203
|
+
await saveConfig(config)
|
|
204
|
+
this.log(chalk.green('✓') + ' ClickUp configured successfully!')
|
|
205
|
+
steps.push({ name: 'clickup', status: 'configured', teamId, teamName, authMethod })
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// 6. Shell completions
|
|
211
|
+
steps.push({ name: 'shell-completions', status: 'ok', action: 'install via: dvmi autocomplete' })
|
|
212
|
+
|
|
213
|
+
const result = { steps, configPath: CONFIG_PATH }
|
|
214
|
+
|
|
215
|
+
if (isJson) return result
|
|
216
|
+
|
|
217
|
+
await typewriterLine('✓ Setup complete!')
|
|
218
|
+
this.log(chalk.dim(' Run `dvmi doctor` to verify your environment'))
|
|
219
|
+
|
|
220
|
+
return result
|
|
221
|
+
}
|
|
222
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { Command, Args } from '@oclif/core'
|
|
2
|
+
import chalk from 'chalk'
|
|
3
|
+
import { exec } from '../services/shell.js'
|
|
4
|
+
import { openBrowser } from '../utils/open-browser.js'
|
|
5
|
+
import { loadConfig } from '../services/config.js'
|
|
6
|
+
|
|
7
|
+
const VALID_TARGETS = ['repo', 'pr', 'actions', 'aws']
|
|
8
|
+
|
|
9
|
+
export default class Open extends Command {
|
|
10
|
+
static description = 'Apri risorse nel browser (repo, pr, actions, aws)'
|
|
11
|
+
|
|
12
|
+
static examples = [
|
|
13
|
+
'<%= config.bin %> open repo',
|
|
14
|
+
'<%= config.bin %> open pr',
|
|
15
|
+
'<%= config.bin %> open actions',
|
|
16
|
+
'<%= config.bin %> open aws',
|
|
17
|
+
'<%= config.bin %> open repo --json',
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
static enableJsonFlag = true
|
|
21
|
+
|
|
22
|
+
static args = {
|
|
23
|
+
target: Args.string({ description: 'Target: repo, pr, actions, aws', required: true }),
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async run() {
|
|
27
|
+
const { args, flags } = await this.parse(Open)
|
|
28
|
+
const isJson = flags.json
|
|
29
|
+
|
|
30
|
+
if (!VALID_TARGETS.includes(args.target)) {
|
|
31
|
+
this.error(`Invalid target "${args.target}". Allowed: ${VALID_TARGETS.join(', ')}`)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
let url = ''
|
|
35
|
+
|
|
36
|
+
if (args.target === 'aws') {
|
|
37
|
+
const config = await loadConfig()
|
|
38
|
+
const result = await exec('aws-vault', ['login', config.awsProfile, '--stdout'])
|
|
39
|
+
if (result.exitCode !== 0) this.error('AWS login failed. Run `dvmi auth login --aws`')
|
|
40
|
+
url = result.stdout
|
|
41
|
+
} else {
|
|
42
|
+
const remoteResult = await exec('git', ['remote', 'get-url', 'origin'])
|
|
43
|
+
if (remoteResult.exitCode !== 0) this.error('Not in a Git repository.')
|
|
44
|
+
const match = remoteResult.stdout.match(/github\.com[:/]([^/]+)\/([^/.]+?)(\.git)?$/)
|
|
45
|
+
if (!match) this.error('Could not detect GitHub repository.')
|
|
46
|
+
const [, owner, repo] = match
|
|
47
|
+
const baseUrl = `https://github.com/${owner}/${repo}`
|
|
48
|
+
|
|
49
|
+
if (args.target === 'repo') {
|
|
50
|
+
url = baseUrl
|
|
51
|
+
} else if (args.target === 'actions') {
|
|
52
|
+
url = `${baseUrl}/actions`
|
|
53
|
+
} else if (args.target === 'pr') {
|
|
54
|
+
const branchResult = await exec('git', ['rev-parse', '--abbrev-ref', 'HEAD'])
|
|
55
|
+
const branch = branchResult.stdout
|
|
56
|
+
// Try to find open PR for current branch
|
|
57
|
+
const prResult = await exec('gh', ['pr', 'view', '--json', 'url', '-H', branch])
|
|
58
|
+
if (prResult.exitCode === 0) {
|
|
59
|
+
url = JSON.parse(prResult.stdout).url
|
|
60
|
+
} else {
|
|
61
|
+
this.error(`No PR found for branch "${branch}". Create one with \`dvmi pr create\``)
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const result = { target: args.target, url, opened: !isJson }
|
|
67
|
+
|
|
68
|
+
if (isJson) return result
|
|
69
|
+
|
|
70
|
+
await openBrowser(url)
|
|
71
|
+
this.log(chalk.green('✓') + ` Opened ${url}`)
|
|
72
|
+
|
|
73
|
+
return result
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { Command, Args, Flags } from '@oclif/core'
|
|
2
|
+
import { exec } from '../../services/shell.js'
|
|
3
|
+
|
|
4
|
+
export default class PipelineLogs extends Command {
|
|
5
|
+
static description = 'Log di un workflow run specifico'
|
|
6
|
+
|
|
7
|
+
static examples = [
|
|
8
|
+
'<%= config.bin %> pipeline logs 12345',
|
|
9
|
+
'<%= config.bin %> pipeline logs 12345 --job test',
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
static enableJsonFlag = true
|
|
13
|
+
|
|
14
|
+
static args = {
|
|
15
|
+
'run-id': Args.integer({ description: 'ID del workflow run', required: true }),
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
static flags = {
|
|
19
|
+
job: Flags.string({ description: 'Filtra per job name' }),
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async run() {
|
|
23
|
+
const { args, flags } = await this.parse(PipelineLogs)
|
|
24
|
+
const isJson = flags.json
|
|
25
|
+
|
|
26
|
+
const ghArgs = ['run', 'view', String(args['run-id']), '--log']
|
|
27
|
+
if (flags.job) ghArgs.push('--job', flags.job)
|
|
28
|
+
|
|
29
|
+
const result = await exec('gh', ghArgs)
|
|
30
|
+
if (result.exitCode !== 0) {
|
|
31
|
+
this.error(`Run #${args['run-id']} not found or access denied.\n${result.stderr}`)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (isJson) {
|
|
35
|
+
return { runId: args['run-id'], log: result.stdout }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
this.log(result.stdout)
|
|
39
|
+
return { runId: args['run-id'], log: result.stdout }
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { Command, Flags } from '@oclif/core'
|
|
2
|
+
import chalk from 'chalk'
|
|
3
|
+
import ora from 'ora'
|
|
4
|
+
import { confirm } from '@inquirer/prompts'
|
|
5
|
+
import { listWorkflowRuns, rerunWorkflow } from '../../services/github.js'
|
|
6
|
+
import { exec } from '../../services/shell.js'
|
|
7
|
+
|
|
8
|
+
export default class PipelineRerun extends Command {
|
|
9
|
+
static description = 'Rilancia l\'ultimo workflow fallito'
|
|
10
|
+
|
|
11
|
+
static examples = [
|
|
12
|
+
'<%= config.bin %> pipeline rerun',
|
|
13
|
+
'<%= config.bin %> pipeline rerun --failed-only',
|
|
14
|
+
'<%= config.bin %> pipeline rerun --run-id 12345',
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
static enableJsonFlag = true
|
|
18
|
+
|
|
19
|
+
static flags = {
|
|
20
|
+
'run-id': Flags.integer({ description: 'ID specifico del run' }),
|
|
21
|
+
'failed-only': Flags.boolean({ description: 'Rilancia solo i job falliti', default: false }),
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async run() {
|
|
25
|
+
const { flags } = await this.parse(PipelineRerun)
|
|
26
|
+
const isJson = flags.json
|
|
27
|
+
|
|
28
|
+
const remoteResult = await exec('git', ['remote', 'get-url', 'origin'])
|
|
29
|
+
if (remoteResult.exitCode !== 0) this.error('Not in a Git repository.')
|
|
30
|
+
const match = remoteResult.stdout.match(/github\.com[:/]([^/]+)\/([^/.]+)/)
|
|
31
|
+
if (!match) this.error('Could not detect GitHub repository.')
|
|
32
|
+
const [, owner, repo] = match
|
|
33
|
+
|
|
34
|
+
let runId = flags['run-id']
|
|
35
|
+
|
|
36
|
+
if (!runId) {
|
|
37
|
+
const runs = await listWorkflowRuns(owner, repo, { limit: 10 })
|
|
38
|
+
const failed = runs.find((r) => r.conclusion === 'failure')
|
|
39
|
+
if (!failed) {
|
|
40
|
+
this.log(chalk.green('No failed runs found.'))
|
|
41
|
+
return
|
|
42
|
+
}
|
|
43
|
+
runId = failed.id
|
|
44
|
+
if (!isJson) {
|
|
45
|
+
this.log(`Last failed run: ${chalk.bold(failed.name)} (#${failed.id}) on ${failed.branch}`)
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (!isJson) {
|
|
50
|
+
const ok = await confirm({ message: `Rerun workflow #${runId}?` })
|
|
51
|
+
if (!ok) { this.log('Aborted.'); return }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const spinner = isJson ? null : ora({ spinner: 'arc', color: false, text: chalk.hex('#FF6B2B')('Relaunching workflow...') }).start()
|
|
55
|
+
await rerunWorkflow(owner, repo, runId, flags['failed-only'])
|
|
56
|
+
spinner?.succeed(`Workflow #${runId} rerun started`)
|
|
57
|
+
|
|
58
|
+
const result = { rerun: { id: runId, failedOnly: flags['failed-only'], status: 'queued' } }
|
|
59
|
+
|
|
60
|
+
if (!isJson) {
|
|
61
|
+
this.log(chalk.dim('Track with `dvmi pipeline status`'))
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return result
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { Command, Flags } from '@oclif/core'
|
|
2
|
+
import chalk from 'chalk'
|
|
3
|
+
import ora from 'ora'
|
|
4
|
+
import { listWorkflowRuns } from '../../services/github.js'
|
|
5
|
+
import { exec } from '../../services/shell.js'
|
|
6
|
+
import { renderTable, colorStatus } from '../../formatters/table.js'
|
|
7
|
+
|
|
8
|
+
export default class PipelineStatus extends Command {
|
|
9
|
+
static description = 'Stato GitHub Actions per il repo corrente'
|
|
10
|
+
|
|
11
|
+
static examples = [
|
|
12
|
+
'<%= config.bin %> pipeline status',
|
|
13
|
+
'<%= config.bin %> pipeline status --branch main',
|
|
14
|
+
'<%= config.bin %> pipeline status --limit 20 --json',
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
static enableJsonFlag = true
|
|
18
|
+
|
|
19
|
+
static flags = {
|
|
20
|
+
branch: Flags.string({ description: 'Filtra per branch' }),
|
|
21
|
+
limit: Flags.integer({ description: 'Numero di run da mostrare', default: 10 }),
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async run() {
|
|
25
|
+
const { flags } = await this.parse(PipelineStatus)
|
|
26
|
+
const isJson = flags.json
|
|
27
|
+
|
|
28
|
+
// Detect repo from git remote
|
|
29
|
+
const remoteResult = await exec('git', ['remote', 'get-url', 'origin'])
|
|
30
|
+
if (remoteResult.exitCode !== 0) {
|
|
31
|
+
this.error('Not in a Git repository. Navigate to a repo or use `dvmi repo list`')
|
|
32
|
+
}
|
|
33
|
+
const match = remoteResult.stdout.match(/github\.com[:/]([^/]+)\/([^/.]+)/)
|
|
34
|
+
if (!match) this.error('Could not detect GitHub repository.')
|
|
35
|
+
const [, owner, repo] = match
|
|
36
|
+
|
|
37
|
+
const spinner = isJson ? null : ora({ spinner: 'arc', color: false, text: chalk.hex('#FF6B2B')('Fetching pipeline runs...') }).start()
|
|
38
|
+
const runs = await listWorkflowRuns(owner, repo, {
|
|
39
|
+
branch: flags.branch,
|
|
40
|
+
limit: flags.limit,
|
|
41
|
+
})
|
|
42
|
+
spinner?.stop()
|
|
43
|
+
|
|
44
|
+
if (isJson) return { runs }
|
|
45
|
+
|
|
46
|
+
if (runs.length === 0) {
|
|
47
|
+
this.log(chalk.dim('No workflow runs found.'))
|
|
48
|
+
return { runs: [] }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
this.log(chalk.bold('\nGitHub Actions runs:\n'))
|
|
52
|
+
this.log(renderTable(runs, [
|
|
53
|
+
{ header: 'Status', key: 'conclusion', width: 10, format: (v) => colorStatus(v ? String(v) : 'pending') },
|
|
54
|
+
{ header: 'Workflow', key: 'name', width: 25 },
|
|
55
|
+
{ header: 'Branch', key: 'branch', width: 20 },
|
|
56
|
+
{ header: 'Duration', key: 'duration', width: 10, format: (v) => `${v}s` },
|
|
57
|
+
{ header: 'Actor', key: 'actor', width: 15 },
|
|
58
|
+
]))
|
|
59
|
+
|
|
60
|
+
return { runs }
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { Command, Flags } from '@oclif/core'
|
|
2
|
+
import chalk from 'chalk'
|
|
3
|
+
import ora from 'ora'
|
|
4
|
+
import { confirm, input } from '@inquirer/prompts'
|
|
5
|
+
import { createPR } from '../../services/github.js'
|
|
6
|
+
import { exec } from '../../services/shell.js'
|
|
7
|
+
import { readFile } from 'node:fs/promises'
|
|
8
|
+
import { existsSync } from 'node:fs'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* @param {string} branchName
|
|
12
|
+
* @returns {string}
|
|
13
|
+
*/
|
|
14
|
+
function titleFromBranch(branchName) {
|
|
15
|
+
const [type, ...rest] = branchName.split('/')
|
|
16
|
+
const desc = rest.join('/').replace(/-/g, ' ')
|
|
17
|
+
const typeMap = { feature: 'Feature', fix: 'Fix', chore: 'Chore', hotfix: 'Hotfix' }
|
|
18
|
+
return `${typeMap[type] ?? type}: ${desc}`
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @param {string} branchType
|
|
23
|
+
* @returns {string[]}
|
|
24
|
+
*/
|
|
25
|
+
function labelFromType(branchType) {
|
|
26
|
+
const map = { feature: ['feature'], fix: ['bug'], chore: ['chore'], hotfix: ['critical'] }
|
|
27
|
+
return map[branchType] ?? []
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export default class PRCreate extends Command {
|
|
31
|
+
static description = 'Apri Pull Request precompilata con template, label e reviewer'
|
|
32
|
+
|
|
33
|
+
static examples = [
|
|
34
|
+
'<%= config.bin %> pr create',
|
|
35
|
+
'<%= config.bin %> pr create --draft',
|
|
36
|
+
'<%= config.bin %> pr create --title "My PR" --dry-run',
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
static enableJsonFlag = true
|
|
40
|
+
|
|
41
|
+
static flags = {
|
|
42
|
+
title: Flags.string({ description: 'Titolo PR (default: auto-generated)' }),
|
|
43
|
+
draft: Flags.boolean({ description: 'Crea come draft', default: false }),
|
|
44
|
+
'dry-run': Flags.boolean({ description: 'Preview senza eseguire', default: false }),
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async run() {
|
|
48
|
+
const { flags } = await this.parse(PRCreate)
|
|
49
|
+
const isJson = flags.json
|
|
50
|
+
const isDryRun = flags['dry-run']
|
|
51
|
+
// Get current branch
|
|
52
|
+
const branchResult = await exec('git', ['rev-parse', '--abbrev-ref', 'HEAD'])
|
|
53
|
+
if (branchResult.exitCode !== 0) this.error('Not in a Git repository.')
|
|
54
|
+
const branch = branchResult.stdout
|
|
55
|
+
|
|
56
|
+
if (['main', 'master', 'develop'].includes(branch)) {
|
|
57
|
+
this.error(`You're on the default branch "${branch}". Create a feature branch first with \`dvmi branch create\``)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Check for commits
|
|
61
|
+
const repoUrl = await exec('git', ['remote', 'get-url', 'origin'])
|
|
62
|
+
const repoMatch = repoUrl.stdout.match(/github\.com[:/]([^/]+)\/([^/.]+)/)
|
|
63
|
+
if (!repoMatch) this.error('Could not detect GitHub repository from git remote.')
|
|
64
|
+
const [, owner, repo] = repoMatch
|
|
65
|
+
|
|
66
|
+
// Push branch if needed
|
|
67
|
+
const pushSpinner = isJson ? null : ora({ spinner: 'arc', color: false, text: chalk.hex('#FF6B2B')('Pushing branch...') }).start()
|
|
68
|
+
await exec('git', ['push', '-u', 'origin', branch])
|
|
69
|
+
pushSpinner?.stop()
|
|
70
|
+
|
|
71
|
+
// Get PR template
|
|
72
|
+
let body = ''
|
|
73
|
+
const templatePaths = ['.github/pull_request_template.md', '.github/PULL_REQUEST_TEMPLATE.md']
|
|
74
|
+
for (const tp of templatePaths) {
|
|
75
|
+
if (existsSync(tp)) {
|
|
76
|
+
body = await readFile(tp, 'utf8')
|
|
77
|
+
break
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Generate title
|
|
82
|
+
const autoTitle = titleFromBranch(branch)
|
|
83
|
+
const title = flags.title ?? (isJson ? autoTitle : await input({ message: 'PR title:', default: autoTitle }))
|
|
84
|
+
const branchType = branch.split('/')[0]
|
|
85
|
+
const labels = labelFromType(branchType)
|
|
86
|
+
|
|
87
|
+
const preview = { branch, base: 'main', title, labels, draft: flags.draft }
|
|
88
|
+
if (isDryRun) {
|
|
89
|
+
if (isJson) return { pr: preview }
|
|
90
|
+
this.log(chalk.bold('Dry run — would create PR:'))
|
|
91
|
+
this.log(JSON.stringify(preview, null, 2))
|
|
92
|
+
return { pr: preview }
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (!isJson) {
|
|
96
|
+
const ok = await confirm({ message: `Create PR "${title}"?` })
|
|
97
|
+
if (!ok) { this.log('Aborted.'); return }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const spinner = isJson ? null : ora({ spinner: 'arc', color: false, text: chalk.hex('#FF6B2B')('Creating PR...') }).start()
|
|
101
|
+
const pr = await createPR({
|
|
102
|
+
owner, repo, title, body,
|
|
103
|
+
head: branch, base: 'main',
|
|
104
|
+
draft: flags.draft, labels, reviewers: [],
|
|
105
|
+
})
|
|
106
|
+
spinner?.succeed(`PR created: ${pr.htmlUrl}`)
|
|
107
|
+
|
|
108
|
+
const result = { pr: { number: pr.number, title, url: pr.htmlUrl, labels, draft: flags.draft } }
|
|
109
|
+
|
|
110
|
+
if (isJson) return result
|
|
111
|
+
this.log(chalk.green('✓') + ' ' + pr.htmlUrl)
|
|
112
|
+
return result
|
|
113
|
+
}
|
|
114
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { Command, Args, Flags } from '@oclif/core'
|
|
2
|
+
import chalk from 'chalk'
|
|
3
|
+
import ora from 'ora'
|
|
4
|
+
import { getPRDetail } from '../../services/github.js'
|
|
5
|
+
import { exec } from '../../services/shell.js'
|
|
6
|
+
|
|
7
|
+
export default class PRDetail extends Command {
|
|
8
|
+
static description = 'Dettaglio PR con commenti QA e checklist degli step'
|
|
9
|
+
|
|
10
|
+
static examples = [
|
|
11
|
+
'<%= config.bin %> pr detail 42',
|
|
12
|
+
'<%= config.bin %> pr detail 42 --repo devvami/my-api',
|
|
13
|
+
'<%= config.bin %> pr detail 42 --json',
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
static enableJsonFlag = true
|
|
17
|
+
|
|
18
|
+
static args = {
|
|
19
|
+
number: Args.integer({ description: 'Numero della PR', required: true }),
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
static flags = {
|
|
23
|
+
repo: Flags.string({ description: 'Repository nel formato owner/repo (default: rilevato da git remote)' }),
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async run() {
|
|
27
|
+
const { args, flags } = await this.parse(PRDetail)
|
|
28
|
+
const isJson = flags.json
|
|
29
|
+
|
|
30
|
+
let owner, repo
|
|
31
|
+
if (flags.repo) {
|
|
32
|
+
const parts = flags.repo.split('/')
|
|
33
|
+
if (parts.length !== 2) this.error('--repo deve essere nel formato owner/repo')
|
|
34
|
+
;[owner, repo] = parts
|
|
35
|
+
} else {
|
|
36
|
+
const repoUrl = await exec('git', ['remote', 'get-url', 'origin'])
|
|
37
|
+
const match = repoUrl.stdout.match(/github\.com[:/]([^/]+)\/([^/.]+)/)
|
|
38
|
+
if (!match) this.error('Impossibile rilevare il repository GitHub dal git remote. Usa --repo owner/repo')
|
|
39
|
+
;[, owner, repo] = match
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const spinner = isJson ? null : ora({ spinner: 'arc', color: false, text: chalk.hex('#FF6B2B')('Caricamento PR...') }).start()
|
|
43
|
+
const detail = await getPRDetail(owner, repo, args.number)
|
|
44
|
+
spinner?.stop()
|
|
45
|
+
|
|
46
|
+
if (isJson) return detail
|
|
47
|
+
|
|
48
|
+
this.log(chalk.bold(`\nPR #${detail.number}: ${detail.title}`))
|
|
49
|
+
this.log(chalk.dim(`${detail.htmlUrl}\n`))
|
|
50
|
+
|
|
51
|
+
const stateColor = detail.state === 'open' ? chalk.green : chalk.red
|
|
52
|
+
this.log(`Stato: ${stateColor(detail.state)}${detail.isDraft ? chalk.dim(' (draft)') : ''}`)
|
|
53
|
+
this.log(`Autore: ${detail.author}`)
|
|
54
|
+
this.log(`Branch: ${detail.headBranch} → ${detail.baseBranch}`)
|
|
55
|
+
if (detail.labels.length) this.log(`Label: ${detail.labels.join(', ')}`)
|
|
56
|
+
if (detail.reviewers.length) this.log(`Reviewer: ${detail.reviewers.join(', ')}`)
|
|
57
|
+
|
|
58
|
+
if (detail.qaSteps.length > 0) {
|
|
59
|
+
this.log(chalk.bold('\n── QA Steps ─────────────────────'))
|
|
60
|
+
for (const step of detail.qaSteps) {
|
|
61
|
+
const icon = step.checked ? chalk.green('✓') : chalk.yellow('○')
|
|
62
|
+
this.log(` ${icon} ${step.text}`)
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (detail.qaComments.length > 0) {
|
|
67
|
+
this.log(chalk.bold('\n── QA Comments ──────────────────'))
|
|
68
|
+
for (const comment of detail.qaComments) {
|
|
69
|
+
this.log(chalk.dim(`@${comment.author} [${comment.createdAt.slice(0, 10)}]:`))
|
|
70
|
+
const lines = comment.body.split('\n').slice(0, 5)
|
|
71
|
+
this.log(lines.map((l) => ` ${l}`).join('\n'))
|
|
72
|
+
if (comment.body.split('\n').length > 5) this.log(chalk.dim(' ...'))
|
|
73
|
+
this.log('')
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (detail.qaComments.length === 0 && detail.qaSteps.length === 0) {
|
|
78
|
+
this.log(chalk.dim('\nNessun commento o step QA trovato.'))
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return detail
|
|
82
|
+
}
|
|
83
|
+
}
|