Repository navigation
Expand file tree
/
Copy pathsetup_question_renderer.ts
More file actions
157 lines (147 loc) · 11.4 KB
/
Copy pathsetup_question_renderer.ts
File metadata and controls
157 lines (147 loc) · 11.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
import type { SetupQuestionRenderer } from '../application/ports/setup_terminal_ports';
import type { SetupQuestion, SetupQuestionnaireProgress } from '../domain/setup_questionnaire';
import { color, renderBox } from './setup_prompt_rendering';
import { setupQuestionnaireStateLabel } from '../application/policies/setup_questionnaire_policy';
import { setupQuestionPresentation } from '../application/policies/setup_question_guidance_policy';
export class ConsoleSetupQuestionRenderer implements SetupQuestionRenderer {
constructor(
private readonly phase: 'full' | 'permission-intent' = 'full',
private readonly choiceReviewPass = 1,
) {}
showIntroduction(): void {
if (this.phase === 'permission-intent') {
console.log(renderBox(
this.choiceReviewPass > 1
? [
`Reviewing your setup choices again (pass ${this.choiceReviewPass}).`,
'This is the same setup run. Your answers are saved as defaults.',
'Press Enter to keep each answer, or enter a new value.',
'After this pass you return to the setup PAT permission review.',
'No setup changes have been applied.',
].join('\n')
: [
'First, choose the setup options that affect your temporary PAT permissions.',
'For full workflow contents, start with copilot setup --web. Terminal logs show routes and file actions.',
'These answers carry into the later full wizard and are not asked there again',
'unless you choose to review them here. No GitHub changes happen in this step.',
].join('\n'),
this.choiceReviewPass > 1 ? 'Review saved setup choices' : 'Setup PAT permission intent',
));
return;
}
console.log(renderBox(
'This wizard configures repository workflows, GitHub Actions resources, AI agents, and operational defaults.\n\nThe setup PAT is used in memory only. Runtime credentials are collected separately after the plan is approved.',
'Copilot Setup',
));
}
showState(stateId: SetupQuestion['stateId']): void {
console.log(color(`\n${setupQuestionnaireStateLabel(stateId)}\n`, 36));
}
renderPrompt(question: SetupQuestion, progress?: SetupQuestionnaireProgress): string {
const help = setupQuestionPresentation(question).en;
const step = progress ? `${setupQuestionnaireStateLabel(progress.group)} — question ${progress.groupPosition} of ${progress.groupTotal} (overall ${progress.position} of ${progress.total}).\n` : '';
const source = question.suggestionSource === 'github' ? ' (observed from authenticated GitHub repository metadata)'
: question.suggestionSource === 'local' ? ' (observed in this local checkout; confirm it exists on GitHub)'
: question.suggestionSource === 'configuration' ? ' (provided by your configuration)'
: question.suggestionSource === 'default' ? ' (product default; not verified against GitHub)' : '';
const fixedWorkflowNote = question.id === 'features.issues' && (question.fixedWorkflowFeatures?.release || question.fixedWorkflowFeatures?.hotfix)
? ' Configuration explicitly enables release or hotfix; keep Issues enabled unless you edit --config/flags.'
: (['release', 'hotfix'] as const).flatMap(kind => {
const fixed = question.fixedWorkflowFeatures?.[kind];
return fixed === undefined ? [] : [` Configuration fixes features.${kind}=${fixed}; ${fixed ? 'keep' : 'leave'} ${kind} ${fixed ? 'selected' : 'unselected'} unless you edit --config/flags.`];
}).join('\n');
const runnerNote = question.id.startsWith('runners.') && question.id !== 'runners.discover' ? [
`Registry: ${question.runnerRegistryStatus}${question.runnerRegistryScope ? ` (${question.runnerRegistryScope})` : ''}. Inventory: ${question.runnerInventory?.status ?? 'unavailable'}. No access is granted by selection.`,
...(question.runnerProfiles ?? []).map(profile => `${profile.id}: group ${profile.group || '(labels only)'}; ${profile.labels.join(', ')}; validation ${profile.validation}`),
...(question.runnerInventory?.groups ?? []).map(group => `Observed group ${group.name}: ${group.access}`),
...(question.runnerInventory?.candidates ?? []).slice(0, 10).map(candidate => `Observed labels: ${candidate.labels.join(', ')} · ${candidate.status}${candidate.busy ? ' · busy' : ''}`),
].join('\n ') : '';
const heading = `${step}${help.label}\n ${help.summary}\n Suggested: ${formatDefault(question.defaultValue)}${source}. ${help.documentation.title}: ${help.documentation.url}\n Type ? for detailed help; type :back to return to the previous question without clearing saved answers.${fixedWorkflowNote ? `\n${fixedWorkflowNote}` : ''}${discoveryNote(question)}${runnerNote ? `\n ${runnerNote}` : ''}`;
const reviewedStatuses = question.projectStatusValues?.map(item => ` ${item.transition}: ${item.value}`).join('\n');
const fallback = formatDefault(question.defaultValue);
if (question.kind === 'choice') {
const choices = question.choices ?? [];
const lines = choices.map((choice, index) =>
` ${index + 1}) ${choice}${question.id === 'pullRequestApproval.coverage.checkName'
? (() => { const producer = question.trustedProducers?.find(item => item.name === choice);
return producer ? ` — ${producer.workflowName} · App ${producer.sourceAppId}` : ''; })() : ''}${choice === question.defaultValue ? color(' (default)', 90) : ''}`);
return [heading, ...lines, `Select 1-${choices.length} ${color(`[${choices.indexOf(String(question.defaultValue)) + 1}]`, 90)}: `].join('\n');
}
if (question.kind === 'multi-select') {
return [heading, 'Choose from the options below. In a selector use ↑/↓ and Space; in text mode enter IDs separated by commas. Enter confirms; ? shows help and B goes back.'].join('\n');
}
if (question.kind === 'producer-select') {
const choices = question.producerCandidates ?? [];
const lines = choices.map((candidate, index) =>
` ${index + 1}) ${candidate.name} · App ${candidate.sourceAppId} · ${candidate.workflowName} · ${candidate.conclusion} · ${candidate.headSha.slice(0, 7)} · ${candidate.observedAt ?? 'date unavailable'}\n ${candidate.runUrl}\n ${candidate.requiredByRuleset ? `Required on ${candidate.requiredByRuleset.branch} by active ruleset: ${candidate.requiredByRuleset.sourceUrl}` : 'Required by branch rule: not checked'}`);
return [heading, ...lines, 'Enter check numbers separated by commas (for example 1,2), or exact name|App ID|workflow tuples separated by semicolons.',
'A listed ruleset proves only the exact required check/App pair on that target branch. "Not checked" is not evidence that the check is optional; inspect branch protection too.',
'A suggested check is not proof of coverage. Inspect its workflow and required step before attesting.',
` ${color(`[${fallback}]`, 90)}: `].join('\n');
}
if (question.kind === 'project-select') {
return [heading,
'Choose Projects from the list below. In a selector use ↑/↓ and Space; in text mode enter their URL numbers separated by commas. B returns to the previous question.',
'Project numbers come from GitHub URLs, not PVT_ node IDs. Use manual if a Project is missing, or retry to query GitHub again without restarting setup.',
'All selected Projects must share each chosen Status value; this setup cannot map different values per Project.'].join('\n');
}
if (question.kind === 'scope-overrides' && question.allowedNames?.length) {
return `${heading}\n Available: ${question.allowedNames.join(', ')}; enter "none" to inherit all\n ${color(`[${fallback}]`, 90)}: `;
}
return `${heading}${reviewedStatuses ? `\n Verify these exact Status values in every selected Project:\n${reviewedStatuses}` : ''}${question.statusOptionState === 'unavailable'
? '\n Status options could not be verified for every Project. Check the exact existing value in every selected Project before continuing.' : ''}${question.statusOptionState === 'incompatible'
? '\n Selected Projects have no common Status values. Return to Project selection and choose compatible Projects.' : ''}\n ${color(`[${fallback}]`, 90)}: `;
}
renderHelp(question: SetupQuestion): string {
const help = setupQuestionPresentation(question).en;
return [
`What: ${help.summary}`,
`When: ${help.when}`,
`Where: ${help.where}`,
`How: ${help.how}`,
`Why: ${help.why}`,
`Example: ${help.example}`,
`Effect: ${help.effect}`,
`Verify: ${help.verify}`,
`Read more — ${help.documentation.title}: ${help.documentation.url}`,
].join('\n');
}
showHelp(question: SetupQuestion): void {
console.log(renderBox(this.renderHelp(question), 'About this setup choice'));
}
showValidation(message: string): void {
console.log(color(message, 33));
}
showCancelled(): void {
console.log('Setup cancelled. No changes were applied.');
}
}
function formatDefault(value: string | number | boolean): string {
if (typeof value === 'boolean') return value ? 'Y' : 'N';
return String(value) || 'none';
}
function discoveryNote(question: SetupQuestion): string {
const status = question.discoveryStatus;
if (!status) return '';
const check: Record<string, string> = {
observed: 'Recent CI jobs were found. Inspect the linked runs before trusting a producer.',
'no-recent-runs': 'No recent PR CI runs were found. Run normal CI or enter an exact producer manually.',
'no-verifiable-checks': 'Recent runs exist, but exact job/App identity could not be verified. Use manual entry after inspecting GitHub.',
'permission-denied': 'GitHub denied CI discovery. Give the setup PAT Actions: read and Checks: read, or enter a verified producer manually.',
unavailable: 'CI discovery failed; this does not mean there are no checks. Retry or use verified manual entry.',
};
const project: Record<string, string> = {
observed: 'Existing organization Projects are listed below. Inspect each GitHub URL before selecting it.',
empty: 'The bounded GitHub query returned no open, accessible Projects; this does not prove none exist. Check organization access or enter a verified number manually.',
'permission-denied': 'GitHub denied Project discovery. Check organization Projects: read on the setup PAT, or enter numbers manually.',
unavailable: 'Project discovery failed; this does not mean no Projects exist. Use a verified number or retry.',
unsupported: 'Fine-grained PATs cannot list personal Projects through this GitHub API. Use the number in an existing Project URL.',
};
const note = question.id === 'projects.ids' ? project[status] : check[status];
const sample = status === 'observed' || status === 'empty' || status === 'no-recent-runs' || status === 'no-verifiable-checks'
? question.id === 'projects.ids'
? '\n Search scope: at most 30 open, accessible organization Projects from two pages; up to 100 fields per Project. Closed Projects are excluded.'
: '\n Search scope: select up to 20 PR runs from the latest 1,000 workflow runs (up to 10 pages) within 90 days; at most 15 runs and 100 checks per commit are inspected.'
: '';
return note ? `\n ${note}${sample}${question.discoveryTruncated ? '\n Only a bounded sample was inspected; use manual entry for missing items.' : ''}${question.discoveryRetryRemaining ? `\n Type r to retry GitHub discovery (${question.discoveryRetryRemaining} read-only attempts left).` : ''}` : '';
}