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.
Files changed (58) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +255 -0
  3. package/bin/dev.cmd +3 -0
  4. package/bin/dev.js +5 -0
  5. package/bin/run.cmd +3 -0
  6. package/bin/run.js +5 -0
  7. package/oclif.manifest.json +1238 -0
  8. package/package.json +161 -0
  9. package/src/commands/auth/login.js +89 -0
  10. package/src/commands/changelog.js +102 -0
  11. package/src/commands/costs/get.js +73 -0
  12. package/src/commands/create/repo.js +196 -0
  13. package/src/commands/docs/list.js +110 -0
  14. package/src/commands/docs/projects.js +92 -0
  15. package/src/commands/docs/read.js +172 -0
  16. package/src/commands/docs/search.js +103 -0
  17. package/src/commands/doctor.js +115 -0
  18. package/src/commands/init.js +222 -0
  19. package/src/commands/open.js +75 -0
  20. package/src/commands/pipeline/logs.js +41 -0
  21. package/src/commands/pipeline/rerun.js +66 -0
  22. package/src/commands/pipeline/status.js +62 -0
  23. package/src/commands/pr/create.js +114 -0
  24. package/src/commands/pr/detail.js +83 -0
  25. package/src/commands/pr/review.js +51 -0
  26. package/src/commands/pr/status.js +70 -0
  27. package/src/commands/repo/list.js +113 -0
  28. package/src/commands/search.js +62 -0
  29. package/src/commands/tasks/assigned.js +131 -0
  30. package/src/commands/tasks/list.js +133 -0
  31. package/src/commands/tasks/today.js +73 -0
  32. package/src/commands/upgrade.js +52 -0
  33. package/src/commands/whoami.js +85 -0
  34. package/src/formatters/cost.js +54 -0
  35. package/src/formatters/markdown.js +108 -0
  36. package/src/formatters/openapi.js +146 -0
  37. package/src/formatters/status.js +48 -0
  38. package/src/formatters/table.js +87 -0
  39. package/src/help.js +312 -0
  40. package/src/hooks/init.js +9 -0
  41. package/src/hooks/postrun.js +18 -0
  42. package/src/index.js +1 -0
  43. package/src/services/auth.js +83 -0
  44. package/src/services/aws-costs.js +80 -0
  45. package/src/services/clickup.js +288 -0
  46. package/src/services/config.js +59 -0
  47. package/src/services/docs.js +210 -0
  48. package/src/services/github.js +377 -0
  49. package/src/services/platform.js +48 -0
  50. package/src/services/shell.js +42 -0
  51. package/src/services/version-check.js +58 -0
  52. package/src/types.js +228 -0
  53. package/src/utils/banner.js +48 -0
  54. package/src/utils/errors.js +61 -0
  55. package/src/utils/gradient.js +130 -0
  56. package/src/utils/open-browser.js +29 -0
  57. package/src/utils/typewriter.js +48 -0
  58. 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
+ }