@fromeroc9/testform 1.0.3 → 1.0.5
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/dist/action/index.js +1 -1
- package/dist/action.js +60 -0
- package/dist/adapters/github.js +467 -0
- package/dist/adapters/resources.js +363 -0
- package/dist/cli/index.js +3 -3
- package/dist/commands/apply.js +390 -0
- package/dist/commands/destroy.js +85 -0
- package/dist/commands/diff.js +131 -0
- package/dist/commands/fmt.js +166 -0
- package/dist/commands/force-unlock.js +55 -0
- package/dist/commands/generate.js +143 -0
- package/dist/commands/graph.js +159 -0
- package/dist/commands/import.js +222 -0
- package/dist/commands/init.js +167 -0
- package/dist/commands/login.js +71 -0
- package/dist/commands/logout.js +20 -0
- package/dist/commands/plan.js +250 -0
- package/dist/commands/refresh.js +165 -0
- package/dist/commands/report.js +724 -0
- package/dist/commands/show.js +61 -0
- package/dist/commands/state.js +197 -0
- package/dist/commands/taint.js +49 -0
- package/dist/commands/validate.js +128 -0
- package/dist/commands/workspace.js +102 -0
- package/dist/const.js +105 -0
- package/dist/core/backends/azurerm.js +201 -0
- package/dist/core/backends/backend.js +2 -0
- package/dist/core/backends/gcs.js +200 -0
- package/dist/core/backends/local.js +162 -0
- package/dist/core/backends/s3.js +224 -0
- package/dist/core/command-context.js +59 -0
- package/dist/core/config.js +131 -0
- package/dist/core/credentials.js +53 -0
- package/dist/core/parser.js +62 -0
- package/dist/core/parsers/base-parser.js +215 -0
- package/dist/core/parsers/testcase-parser.js +115 -0
- package/dist/core/parsers/testplan-parser.js +41 -0
- package/dist/core/parsers/testrun-parser.js +43 -0
- package/dist/core/policy.js +341 -0
- package/dist/core/prompt.js +109 -0
- package/dist/core/state.js +185 -0
- package/dist/core/utils.js +94 -0
- package/dist/core/variables.js +108 -0
- package/dist/core/workspace.js +56 -0
- package/dist/help.js +797 -0
- package/dist/index.js +650 -0
- package/dist/logger.js +134 -0
- package/dist/notify.js +36 -0
- package/dist/types.js +2 -0
- package/package.json +1 -1
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.applyCmd = void 0;
|
|
4
|
+
const const_1 = require("../const");
|
|
5
|
+
const fs_1 = require("fs");
|
|
6
|
+
const chalk_1 = require("chalk");
|
|
7
|
+
const resources_1 = require("../adapters/resources");
|
|
8
|
+
const state_1 = require("../core/state");
|
|
9
|
+
const logger_1 = require("../logger");
|
|
10
|
+
const notify_1 = require("../notify");
|
|
11
|
+
const plan_1 = require("./plan");
|
|
12
|
+
const refresh_1 = require("./refresh");
|
|
13
|
+
const prompt_1 = require("../core/prompt");
|
|
14
|
+
const utils_1 = require("../core/utils");
|
|
15
|
+
const command_context_1 = require("../core/command-context");
|
|
16
|
+
const applyCmd = async (options) => {
|
|
17
|
+
const { dir = '.', autoApprove = false, verbose = false, scope, planFile, lock = true, lockTimeout = '0s', input = true, variables, statePath, backupPath, target, refresh = true, refreshOnly = false, setStatus, replaceTargets, parallelism, compactWarnings, testDirectory } = options;
|
|
18
|
+
const logger = new logger_1.Logger(verbose);
|
|
19
|
+
const stateObj = new state_1.State(dir, statePath, backupPath);
|
|
20
|
+
await stateObj.init();
|
|
21
|
+
await stateObj.acquireLock(lock, lockTimeout);
|
|
22
|
+
try {
|
|
23
|
+
let plan;
|
|
24
|
+
if (planFile) {
|
|
25
|
+
const path = require('path');
|
|
26
|
+
const resolvedPlanFile = path.resolve(dir, planFile);
|
|
27
|
+
if (!(0, fs_1.existsSync)(resolvedPlanFile)) {
|
|
28
|
+
logger.error(`Failed to load "${planFile}" as a plan file\n\nstat ${resolvedPlanFile}: no such file or directory`);
|
|
29
|
+
}
|
|
30
|
+
try {
|
|
31
|
+
const planContent = (0, fs_1.readFileSync)(resolvedPlanFile, 'utf-8');
|
|
32
|
+
const planData = JSON.parse(planContent);
|
|
33
|
+
plan = {
|
|
34
|
+
changes: planData.changes || [],
|
|
35
|
+
hasChanges: planData.changes && planData.changes.length > 0,
|
|
36
|
+
state: new state_1.State(dir)
|
|
37
|
+
};
|
|
38
|
+
await plan.state.init();
|
|
39
|
+
}
|
|
40
|
+
catch (e) {
|
|
41
|
+
logger.error(`Failed to load "${planFile}" as a plan file\n\nError parsing JSON: ${e.message}`);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
else {
|
|
46
|
+
if (setStatus && scope === 'testrun') {
|
|
47
|
+
const ctx = await (0, command_context_1.createCommandContext)({ dir, verbose, lock: false });
|
|
48
|
+
if (!ctx)
|
|
49
|
+
return;
|
|
50
|
+
let targetId = setStatus;
|
|
51
|
+
let newStatus = '';
|
|
52
|
+
if (setStatus.includes('=')) {
|
|
53
|
+
[targetId, newStatus] = setStatus.split('=').map(s => s.trim());
|
|
54
|
+
}
|
|
55
|
+
else {
|
|
56
|
+
newStatus = await (0, prompt_1.askStatus)();
|
|
57
|
+
}
|
|
58
|
+
targetId = targetId.replace(/^github_testcase\./, '').replace(/^github_testrun\./, '');
|
|
59
|
+
const rawResources = stateObj.getResources('github_testrun');
|
|
60
|
+
let foundRun = null;
|
|
61
|
+
for (const r of rawResources) {
|
|
62
|
+
const commentIds = r.attributes?.testcaseCommentIds || {};
|
|
63
|
+
const matchingKey = Object.keys(commentIds).find(k => k === targetId || k.endsWith(`::${targetId}`));
|
|
64
|
+
if (matchingKey) {
|
|
65
|
+
foundRun = r;
|
|
66
|
+
targetId = matchingKey;
|
|
67
|
+
break;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
if (!foundRun) {
|
|
71
|
+
notify_1.notify.push({ type: 'error', title: `Could not find testcase '${targetId}' in any testrun state.`, detail: [] });
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
const commentId = foundRun.attributes.testcaseCommentIds[targetId];
|
|
75
|
+
if (commentId) {
|
|
76
|
+
const tcResource = stateObj.getResources('github_testcase').find(r => r.identity === targetId);
|
|
77
|
+
const tcTitle = tcResource?.attributes?.title || targetId;
|
|
78
|
+
const keys = Object.keys(foundRun.attributes.testcaseCommentIds);
|
|
79
|
+
const i = keys.indexOf(targetId) + 1;
|
|
80
|
+
const commentBody = `| # | Test Case | Status |\n|---|-----------|--------|\n| ${i} | ${tcTitle} | ${newStatus} |`;
|
|
81
|
+
await ctx.github.updateIssueComment(commentId, commentBody);
|
|
82
|
+
console.log(` -> Updated status comment for ${targetId} to '${newStatus}'`);
|
|
83
|
+
if (!foundRun.attributes.testcaseStatuses)
|
|
84
|
+
foundRun.attributes.testcaseStatuses = {};
|
|
85
|
+
foundRun.attributes.testcaseStatuses[targetId] = newStatus;
|
|
86
|
+
await stateObj.save();
|
|
87
|
+
console.log((0, chalk_1.green)((0, chalk_1.bold)(`Status successfully updated!`)));
|
|
88
|
+
}
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
if (refresh && !refreshOnly) {
|
|
92
|
+
console.log(const_1.MSG_ACQUIRING_LOCK);
|
|
93
|
+
await (0, refresh_1.refreshState)({ dir, scope, state: stateObj, logger, silent: false, parallelismRaw: parallelism, target });
|
|
94
|
+
}
|
|
95
|
+
// Calculate plan (read-only)
|
|
96
|
+
plan = await (0, plan_1.calculatePlan)({
|
|
97
|
+
dir, scope, variables, statePath, backupPath, target, destroyPlan: false, refreshOnly, preLoadedState: stateObj, lock, lockTimeout, replaceTargets, compactWarnings, testDirectory
|
|
98
|
+
});
|
|
99
|
+
// Show plan summary
|
|
100
|
+
resources_1.resource.summary(plan.changes, false, { state: plan.state });
|
|
101
|
+
if (!plan.hasChanges) {
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
// Ask for approval
|
|
105
|
+
if (!autoApprove) {
|
|
106
|
+
if (!input) {
|
|
107
|
+
const error = new Error(const_1.ERR_NO_INPUT_ALLOWED + '\nUse the -auto-approve flag to bypass approval.');
|
|
108
|
+
error.name = 'No input allowed';
|
|
109
|
+
throw error;
|
|
110
|
+
}
|
|
111
|
+
const approved = await (0, prompt_1.askApproval)();
|
|
112
|
+
if (!approved) {
|
|
113
|
+
notify_1.notify.push({
|
|
114
|
+
type: 'error',
|
|
115
|
+
title: 'error asking for approval: interrupted',
|
|
116
|
+
detail: [],
|
|
117
|
+
});
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
if (planFile && !plan.hasChanges) {
|
|
123
|
+
console.log('No changes found in the provided plan file.');
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
// Initialize context AFTER approval to connect to GitHub.
|
|
127
|
+
// We pass lock: false because stateObj already holds the lock.
|
|
128
|
+
const ctx = await (0, command_context_1.createCommandContext)({ dir, verbose, lock: false });
|
|
129
|
+
if (!ctx)
|
|
130
|
+
return;
|
|
131
|
+
const { github } = ctx;
|
|
132
|
+
let added = 0;
|
|
133
|
+
let changed = 0;
|
|
134
|
+
let destroyed = 0;
|
|
135
|
+
// Helper to resolve milestone titles to IDs
|
|
136
|
+
const resolvePayloadMilestone = async (payload) => {
|
|
137
|
+
if (typeof payload.milestone === 'string' && payload.milestone) {
|
|
138
|
+
payload.milestone = await github.getMilestoneByTitle(payload.milestone);
|
|
139
|
+
}
|
|
140
|
+
else if (!payload.milestone) {
|
|
141
|
+
delete payload.milestone;
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
// Helper to link testruns as sub-issues to testplans
|
|
145
|
+
const linkSubIssues = async (change, parentIssueNumber) => {
|
|
146
|
+
if (change.resourceType === 'github_testplan' && change.scenario.custom?.testruns) {
|
|
147
|
+
for (const tr of change.scenario.custom.testruns) {
|
|
148
|
+
const runResources = stateObj.getResources('github_testrun').filter(r => r.identity.endsWith(tr) || r.identity.includes(tr));
|
|
149
|
+
if (runResources.length > 1) {
|
|
150
|
+
const uris = Array.from(new Set(runResources.map(r => r.identity)));
|
|
151
|
+
logger.warn(`Rule '${tr}' in Testplan matches multiple Testruns. Linking all of them:\n` + uris.map(u => ` - ${u}`).join('\n') + `\nIf this was unintentional, specify the full file path.`);
|
|
152
|
+
}
|
|
153
|
+
for (const runResource of runResources) {
|
|
154
|
+
if (runResource?.attributes?.issueNumber) {
|
|
155
|
+
try {
|
|
156
|
+
const issueDetails = await github.getIssue(runResource.attributes.issueNumber);
|
|
157
|
+
if (issueDetails && issueDetails.id) {
|
|
158
|
+
await github.addSubIssue(parentIssueNumber, issueDetails.id);
|
|
159
|
+
console.log(` -> Linked testrun ${runResource.identity} as sub-issue to testplan`);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
catch (e) {
|
|
163
|
+
console.log(` -> Failed to link testrun ${runResource.identity} as sub-issue: ${e.message}`);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
};
|
|
170
|
+
// Helper to sync status comments for testruns
|
|
171
|
+
const syncTestrunComments = async (change, issueNumber, existingAttributes) => {
|
|
172
|
+
if (change.resourceType !== 'github_testrun' || !change.scenario.custom?.testcases)
|
|
173
|
+
return {};
|
|
174
|
+
const testcaseCommentIds = existingAttributes?.testcaseCommentIds || {};
|
|
175
|
+
const testcaseStatuses = existingAttributes?.testcaseStatuses || {};
|
|
176
|
+
let i = 1;
|
|
177
|
+
for (const tc of change.scenario.custom.testcases) {
|
|
178
|
+
const parts = tc.split('::');
|
|
179
|
+
const scenarioName = parts.pop();
|
|
180
|
+
const ruleName = parts.pop() || '';
|
|
181
|
+
const baseRule = ruleName.replace('.case.feature', '').replace('.feature', '');
|
|
182
|
+
const tcResources = stateObj.getResources('github_testcase').filter((r) => r.identity.includes(baseRule) && (scenarioName === '*' || r.identity.endsWith(`::${scenarioName}`)));
|
|
183
|
+
for (const tcResource of tcResources) {
|
|
184
|
+
const tcIdentity = tcResource.identity;
|
|
185
|
+
const groupScenario = change.scenario.custom.groupScenarios.find((s) => {
|
|
186
|
+
if (!s.rule || !s.name)
|
|
187
|
+
return false;
|
|
188
|
+
const sBaseRule = s.rule.name.replace('.case.feature', '').replace('.feature', '');
|
|
189
|
+
return tcIdentity.includes(sBaseRule) && (s.name === '*' || tcIdentity.endsWith(`::${s.name}`));
|
|
190
|
+
});
|
|
191
|
+
const localStatus = groupScenario?.custom?.fields?.status || groupScenario?.custom?.fields?.Status || existingAttributes?.testcaseStatuses?.[tcIdentity] || 'pending';
|
|
192
|
+
const tcTitle = tcResource.attributes?.title || tcIdentity;
|
|
193
|
+
if (!testcaseCommentIds[tcIdentity] || testcaseStatuses[tcIdentity] !== localStatus) {
|
|
194
|
+
const commentBody = `| # | Test Case | Status |\n|---|-----------|--------|\n| ${i} | ${tcTitle} | ${localStatus} |`;
|
|
195
|
+
if (testcaseCommentIds[tcIdentity]) {
|
|
196
|
+
await github.updateIssueComment(testcaseCommentIds[tcIdentity], commentBody);
|
|
197
|
+
console.log(` -> Updated status comment for ${tcIdentity} to '${localStatus}'`);
|
|
198
|
+
}
|
|
199
|
+
else {
|
|
200
|
+
const result = await github.createIssueComment(issueNumber, commentBody);
|
|
201
|
+
testcaseCommentIds[tcIdentity] = result.id;
|
|
202
|
+
console.log(` -> Created status comment for ${tcIdentity} as '${localStatus}'`);
|
|
203
|
+
}
|
|
204
|
+
testcaseStatuses[tcIdentity] = localStatus;
|
|
205
|
+
}
|
|
206
|
+
i++;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return { testcaseCommentIds, testcaseStatuses };
|
|
210
|
+
};
|
|
211
|
+
// Execute changes in parallel batches
|
|
212
|
+
const parallelismNum = parallelism ? parseInt(String(parallelism), 10) || 10 : 10;
|
|
213
|
+
for (let i = 0; i < plan.changes.length; i += parallelismNum) {
|
|
214
|
+
const batch = plan.changes.slice(i, i + parallelismNum);
|
|
215
|
+
await Promise.all(batch.map(async (change) => {
|
|
216
|
+
try {
|
|
217
|
+
if (change.action === 'add') {
|
|
218
|
+
const address = (0, utils_1.formatResourceAddress)(change.resourceType, change.identity);
|
|
219
|
+
console.log(`${address}: Creating...`);
|
|
220
|
+
const startTime = Date.now();
|
|
221
|
+
const payload = resources_1.resource.evaluate(change.resourceType, change.scenario, { state: stateObj });
|
|
222
|
+
await resolvePayloadMilestone(payload);
|
|
223
|
+
const result = await github.createIssue(payload);
|
|
224
|
+
if (result.node_id) {
|
|
225
|
+
const itemId = await github.addToProject(result.node_id);
|
|
226
|
+
if (itemId && payload.custom_fields) {
|
|
227
|
+
await github.updateProjectItemFields(itemId, payload.custom_fields);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
const elapsed = (0, utils_1.elapsedSeconds)(startTime);
|
|
231
|
+
const remoteId = github.formatRemoteId(result.number);
|
|
232
|
+
console.log((0, chalk_1.green)(`${address}: Creation complete after ${elapsed}s [id=${remoteId}]`));
|
|
233
|
+
// Link sub-issues
|
|
234
|
+
await linkSubIssues(change, result.number);
|
|
235
|
+
// Sync status comments
|
|
236
|
+
const { testcaseCommentIds, testcaseStatuses } = await syncTestrunComments(change, result.number);
|
|
237
|
+
// Update state
|
|
238
|
+
stateObj.upsertResource({
|
|
239
|
+
type: change.resourceType,
|
|
240
|
+
identity: change.identity,
|
|
241
|
+
attributes: {
|
|
242
|
+
localHash: change.localHash,
|
|
243
|
+
remoteId,
|
|
244
|
+
issueNumber: result.number,
|
|
245
|
+
title: payload.title,
|
|
246
|
+
body: payload.body,
|
|
247
|
+
labels: payload.labels,
|
|
248
|
+
assignees: payload.assignees,
|
|
249
|
+
milestone: payload.milestone ?? '',
|
|
250
|
+
custom_fields: payload.custom_fields,
|
|
251
|
+
createdAt: result.created_at,
|
|
252
|
+
updatedAt: result.updated_at,
|
|
253
|
+
...(testcaseCommentIds ? { testcaseCommentIds, testcaseStatuses } : {}),
|
|
254
|
+
},
|
|
255
|
+
lastApplied: new Date().toISOString(),
|
|
256
|
+
});
|
|
257
|
+
// Link sub-issues
|
|
258
|
+
await linkSubIssues(change, result.number);
|
|
259
|
+
added++;
|
|
260
|
+
}
|
|
261
|
+
else if (change.action === 'replace') {
|
|
262
|
+
const address = (0, utils_1.formatResourceAddress)(change.resourceType, change.identity);
|
|
263
|
+
const remoteId = change.remoteId ?? '';
|
|
264
|
+
console.log(`${address}: Replacing... [id=${remoteId}]`);
|
|
265
|
+
const startTime = Date.now();
|
|
266
|
+
// 1. Destroy (close issue)
|
|
267
|
+
if (change.issueNumber) {
|
|
268
|
+
await github.updateIssue(change.issueNumber, { state: 'closed' });
|
|
269
|
+
}
|
|
270
|
+
// 2. Add (create new issue)
|
|
271
|
+
const payload = resources_1.resource.evaluate(change.resourceType, change.scenario, { state: stateObj });
|
|
272
|
+
await resolvePayloadMilestone(payload);
|
|
273
|
+
const result = await github.createIssue(payload);
|
|
274
|
+
if (result.node_id) {
|
|
275
|
+
const itemId = await github.addToProject(result.node_id);
|
|
276
|
+
if (itemId && payload.custom_fields) {
|
|
277
|
+
await github.updateProjectItemFields(itemId, payload.custom_fields);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
const elapsed = (0, utils_1.elapsedSeconds)(startTime);
|
|
281
|
+
const newRemoteId = github.formatRemoteId(result.number);
|
|
282
|
+
console.log((0, chalk_1.green)(`${address}: Replacement complete after ${elapsed}s [id=${newRemoteId}]`));
|
|
283
|
+
// Link sub-issues
|
|
284
|
+
await linkSubIssues(change, result.number);
|
|
285
|
+
// Sync status comments (existingAttributes is empty because it's a replacement)
|
|
286
|
+
const { testcaseCommentIds, testcaseStatuses } = await syncTestrunComments(change, result.number);
|
|
287
|
+
// 3. Update state
|
|
288
|
+
stateObj.upsertResource({
|
|
289
|
+
type: change.resourceType,
|
|
290
|
+
identity: change.identity,
|
|
291
|
+
attributes: {
|
|
292
|
+
localHash: change.localHash,
|
|
293
|
+
remoteId: newRemoteId,
|
|
294
|
+
issueNumber: result.number,
|
|
295
|
+
title: payload.title,
|
|
296
|
+
body: payload.body,
|
|
297
|
+
labels: payload.labels,
|
|
298
|
+
assignees: payload.assignees,
|
|
299
|
+
milestone: payload.milestone ?? '',
|
|
300
|
+
custom_fields: payload.custom_fields,
|
|
301
|
+
createdAt: result.created_at,
|
|
302
|
+
updatedAt: result.updated_at,
|
|
303
|
+
...(testcaseCommentIds ? { testcaseCommentIds, testcaseStatuses } : {}),
|
|
304
|
+
},
|
|
305
|
+
lastApplied: new Date().toISOString(),
|
|
306
|
+
});
|
|
307
|
+
destroyed++;
|
|
308
|
+
added++;
|
|
309
|
+
}
|
|
310
|
+
else if (change.action === 'change') {
|
|
311
|
+
const address = (0, utils_1.formatResourceAddress)(change.resourceType, change.identity);
|
|
312
|
+
const remoteId = change.remoteId ?? '';
|
|
313
|
+
console.log(`${address}: Modifying... [id=${remoteId}]`);
|
|
314
|
+
const startTime = Date.now();
|
|
315
|
+
const payload = resources_1.resource.evaluate(change.resourceType, change.scenario, { state: stateObj });
|
|
316
|
+
await resolvePayloadMilestone(payload);
|
|
317
|
+
const result = await github.updateIssue(change.issueNumber, payload);
|
|
318
|
+
if (result.node_id) {
|
|
319
|
+
const itemId = await github.addToProject(result.node_id);
|
|
320
|
+
if (itemId && payload.custom_fields) {
|
|
321
|
+
await github.updateProjectItemFields(itemId, payload.custom_fields);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
// Sub-issues linking removed: testcases are now embedded in the testrun body.
|
|
325
|
+
const elapsed = (0, utils_1.elapsedSeconds)(startTime);
|
|
326
|
+
console.log((0, chalk_1.green)(`${address}: Modifications complete after ${elapsed}s [id=${remoteId}]`));
|
|
327
|
+
// Link sub-issues
|
|
328
|
+
await linkSubIssues(change, result.number);
|
|
329
|
+
// Sync status comments
|
|
330
|
+
const existingAttributes = stateObj.getResources(change.resourceType).find((r) => r.identity === change.identity)?.attributes;
|
|
331
|
+
const { testcaseCommentIds, testcaseStatuses } = await syncTestrunComments(change, result.number, existingAttributes);
|
|
332
|
+
// Update state
|
|
333
|
+
stateObj.upsertResource({
|
|
334
|
+
type: change.resourceType,
|
|
335
|
+
identity: change.identity,
|
|
336
|
+
attributes: {
|
|
337
|
+
localHash: change.localHash,
|
|
338
|
+
remoteId,
|
|
339
|
+
issueNumber: change.issueNumber,
|
|
340
|
+
title: payload.title,
|
|
341
|
+
body: payload.body,
|
|
342
|
+
labels: payload.labels,
|
|
343
|
+
assignees: payload.assignees,
|
|
344
|
+
milestone: payload.milestone ?? '',
|
|
345
|
+
custom_fields: payload.custom_fields,
|
|
346
|
+
createdAt: result.created_at,
|
|
347
|
+
updatedAt: result.updated_at,
|
|
348
|
+
...(testcaseCommentIds ? { testcaseCommentIds, testcaseStatuses } : {}),
|
|
349
|
+
},
|
|
350
|
+
lastApplied: new Date().toISOString(),
|
|
351
|
+
});
|
|
352
|
+
changed++;
|
|
353
|
+
}
|
|
354
|
+
else if (change.action === 'destroy') {
|
|
355
|
+
const address = (0, utils_1.formatResourceAddress)(change.resourceType, change.identity);
|
|
356
|
+
const remoteId = change.remoteId ?? '';
|
|
357
|
+
console.log(`${address}: Destroying... [id=${remoteId}]`);
|
|
358
|
+
const startTime = Date.now();
|
|
359
|
+
if (change.issueNumber) {
|
|
360
|
+
await github.closeIssue(change.issueNumber);
|
|
361
|
+
}
|
|
362
|
+
const elapsed = (0, utils_1.elapsedSeconds)(startTime);
|
|
363
|
+
console.log((0, chalk_1.green)(`${address}: Destruction complete after ${elapsed}s [id=${remoteId}]`));
|
|
364
|
+
// Remove from state
|
|
365
|
+
stateObj.removeResource(change.identity);
|
|
366
|
+
destroyed++;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
catch (error) {
|
|
370
|
+
notify_1.notify.push({
|
|
371
|
+
type: 'error',
|
|
372
|
+
title: `${error.message}`,
|
|
373
|
+
detail: [
|
|
374
|
+
` with ${change.resourceType}.${change.identity}`,
|
|
375
|
+
],
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
}));
|
|
379
|
+
}
|
|
380
|
+
// Save state once after all operations
|
|
381
|
+
// Final empty line for better UX
|
|
382
|
+
console.log("");
|
|
383
|
+
await stateObj.save();
|
|
384
|
+
console.log((0, chalk_1.green)((0, chalk_1.bold)(`Apply complete! Resources: ${added} added, ${changed} changed, ${destroyed} destroyed.`)));
|
|
385
|
+
}
|
|
386
|
+
finally {
|
|
387
|
+
await stateObj.releaseLock();
|
|
388
|
+
}
|
|
389
|
+
};
|
|
390
|
+
exports.applyCmd = applyCmd;
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @fileoverview `testform destroy` command.
|
|
4
|
+
*
|
|
5
|
+
* Destroys all resources currently tracked in the state by closing their
|
|
6
|
+
* corresponding GitHub issues. Requires explicit user confirmation unless
|
|
7
|
+
* `-auto-approve` is passed.
|
|
8
|
+
*/
|
|
9
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
10
|
+
exports.destroyCmd = void 0;
|
|
11
|
+
const chalk_1 = require("chalk");
|
|
12
|
+
const state_1 = require("../core/state");
|
|
13
|
+
const logger_1 = require("../logger");
|
|
14
|
+
const notify_1 = require("../notify");
|
|
15
|
+
const const_1 = require("../const");
|
|
16
|
+
const command_context_1 = require("../core/command-context");
|
|
17
|
+
const prompt_1 = require("../core/prompt");
|
|
18
|
+
const utils_1 = require("../core/utils");
|
|
19
|
+
const destroyCmd = async (options) => {
|
|
20
|
+
const { dir = '.', verbose = false, scope, lock = true, lockTimeout = '0s', input = true, statePath, backupPath } = options;
|
|
21
|
+
const logger = new logger_1.Logger(verbose);
|
|
22
|
+
const stateObj = new state_1.State(dir, statePath, backupPath);
|
|
23
|
+
await stateObj.init();
|
|
24
|
+
await stateObj.acquireLock(lock, lockTimeout);
|
|
25
|
+
try {
|
|
26
|
+
if (!Object.prototype.hasOwnProperty.call(const_1.SCOPE_RESOURCE_MAP, scope)) {
|
|
27
|
+
throw new Error(`Invalid scope: ${scope}`);
|
|
28
|
+
}
|
|
29
|
+
const resources = stateObj.getResources(const_1.SCOPE_RESOURCE_MAP[scope]);
|
|
30
|
+
if (resources.length === 0) {
|
|
31
|
+
console.log('No resources to destroy. State is empty.');
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
console.log((0, chalk_1.bold)(`\n${const_1.TITLE_APP} will destroy the following resources:\n`));
|
|
35
|
+
for (const res of resources) {
|
|
36
|
+
const remoteId = res.attributes.remoteId ?? '';
|
|
37
|
+
console.log(` ${(0, chalk_1.red)('-')} ${(0, utils_1.formatResourceAddress)(res.type, res.identity)} [id=${remoteId}]`);
|
|
38
|
+
}
|
|
39
|
+
console.log(`\n${(0, chalk_1.bold)('Plan:')} 0 to add, 0 to change, ${resources.length} to destroy.\n`);
|
|
40
|
+
// Require interactive approval unless disabled
|
|
41
|
+
if (!input) {
|
|
42
|
+
const error = new Error('This command requires manual approval, but input is disabled. Use the\n-auto-approve flag to bypass approval.');
|
|
43
|
+
error.name = 'No input allowed';
|
|
44
|
+
throw error;
|
|
45
|
+
}
|
|
46
|
+
const approved = await (0, prompt_1.askDestroyApproval)(resources.length);
|
|
47
|
+
if (!approved) {
|
|
48
|
+
notify_1.notify.push({ type: 'error', title: 'error asking for approval: interrupted', detail: [] });
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
// Create GitHub context AFTER approval to avoid locking for nothing if user declines
|
|
52
|
+
const ctx = await (0, command_context_1.createCommandContext)({ dir, verbose, statePath, backupPath, lock: false, silent: false });
|
|
53
|
+
if (!ctx)
|
|
54
|
+
return;
|
|
55
|
+
let destroyed = 0;
|
|
56
|
+
for (const res of resources) {
|
|
57
|
+
try {
|
|
58
|
+
const remoteId = res.attributes.remoteId ?? '';
|
|
59
|
+
const address = (0, utils_1.formatResourceAddress)(res.type, res.identity);
|
|
60
|
+
console.log(`${address}: Destroying... [id=${remoteId}]`);
|
|
61
|
+
const startTime = Date.now();
|
|
62
|
+
if (res.attributes.issueNumber) {
|
|
63
|
+
await ctx.github.closeIssue(res.attributes.issueNumber);
|
|
64
|
+
}
|
|
65
|
+
console.log((0, chalk_1.green)(`${address}: Destruction complete after ${(0, utils_1.elapsedSeconds)(startTime)}s [id=${remoteId}]`));
|
|
66
|
+
stateObj.removeResource(res.identity);
|
|
67
|
+
destroyed++;
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
notify_1.notify.push({
|
|
71
|
+
type: 'error',
|
|
72
|
+
title: error.message,
|
|
73
|
+
detail: [` with ${(0, utils_1.formatResourceAddress)(res.type, res.identity)}`],
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
await stateObj.save();
|
|
78
|
+
console.log('');
|
|
79
|
+
console.log((0, chalk_1.green)((0, chalk_1.bold)(`Destroy complete! Resources: ${destroyed} destroyed.`)));
|
|
80
|
+
}
|
|
81
|
+
finally {
|
|
82
|
+
await stateObj.releaseLock();
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
exports.destroyCmd = destroyCmd;
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.diffCmd = void 0;
|
|
4
|
+
const crypto_1 = require("crypto");
|
|
5
|
+
const chalk_1 = require("chalk");
|
|
6
|
+
const config_1 = require("../core/config");
|
|
7
|
+
const parser_1 = require("../core/parser");
|
|
8
|
+
const state_1 = require("../core/state");
|
|
9
|
+
const logger_1 = require("../logger");
|
|
10
|
+
const const_1 = require("../const");
|
|
11
|
+
function hashScenario(scenario) {
|
|
12
|
+
return (0, crypto_1.createHash)('sha256').update(JSON.stringify(scenario)).digest('hex');
|
|
13
|
+
}
|
|
14
|
+
function getStatusIcon(status) {
|
|
15
|
+
switch (status) {
|
|
16
|
+
case 'synced': return (0, chalk_1.green)('✓');
|
|
17
|
+
case 'modified_locally': return (0, chalk_1.yellow)('~');
|
|
18
|
+
case 'new_local': return (0, chalk_1.cyan)('+');
|
|
19
|
+
case 'orphaned_remote': return (0, chalk_1.red)('-');
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
function getStatusLabel(status) {
|
|
23
|
+
switch (status) {
|
|
24
|
+
case 'synced': return 'synced';
|
|
25
|
+
case 'modified_locally': return 'modified locally';
|
|
26
|
+
case 'new_local': return 'new (not applied)';
|
|
27
|
+
case 'orphaned_remote': return 'orphaned (not in config)';
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
const diffCmd = async (options) => {
|
|
31
|
+
const { dir = '.', verbose = false, scope } = options;
|
|
32
|
+
const logger = new logger_1.Logger(verbose);
|
|
33
|
+
// Load config and parse features
|
|
34
|
+
const config = new config_1.Config(dir);
|
|
35
|
+
const parser = new parser_1.Parser(dir);
|
|
36
|
+
const documents = parser.content();
|
|
37
|
+
const data = {
|
|
38
|
+
identity: config.getIdentity(scope),
|
|
39
|
+
fields: config.getFields(scope),
|
|
40
|
+
};
|
|
41
|
+
const filtered = parser.filter(documents, data, scope) || [];
|
|
42
|
+
// Load state
|
|
43
|
+
const state = new state_1.State(dir);
|
|
44
|
+
await state.init();
|
|
45
|
+
const resourceType = const_1.SCOPE_RESOURCE_MAP[scope];
|
|
46
|
+
const resources = state.getResources(resourceType);
|
|
47
|
+
const stateMap = new Map(resources.map(r => [r.identity, r]));
|
|
48
|
+
// Calculate diff
|
|
49
|
+
const entries = [];
|
|
50
|
+
const localIds = new Set();
|
|
51
|
+
for (const scenario of filtered) {
|
|
52
|
+
const rawIdentity = scenario.custom?.identity;
|
|
53
|
+
if (!rawIdentity)
|
|
54
|
+
continue;
|
|
55
|
+
let identity;
|
|
56
|
+
if (rawIdentity.includes('::')) {
|
|
57
|
+
identity = rawIdentity;
|
|
58
|
+
}
|
|
59
|
+
else if (rawIdentity === scenario.uri) {
|
|
60
|
+
identity = rawIdentity;
|
|
61
|
+
}
|
|
62
|
+
else {
|
|
63
|
+
identity = `${scenario.uri}::${rawIdentity}`;
|
|
64
|
+
}
|
|
65
|
+
localIds.add(identity);
|
|
66
|
+
const localHash = hashScenario(scenario);
|
|
67
|
+
const stateRes = stateMap.get(identity);
|
|
68
|
+
if (!stateRes) {
|
|
69
|
+
entries.push({ identity, status: 'new_local', localHash });
|
|
70
|
+
}
|
|
71
|
+
else if (stateRes.attributes.localHash !== localHash) {
|
|
72
|
+
entries.push({
|
|
73
|
+
identity,
|
|
74
|
+
status: 'modified_locally',
|
|
75
|
+
localHash,
|
|
76
|
+
stateHash: stateRes.attributes.localHash,
|
|
77
|
+
remoteId: stateRes.attributes.remoteId,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
else {
|
|
81
|
+
entries.push({
|
|
82
|
+
identity,
|
|
83
|
+
status: 'synced',
|
|
84
|
+
localHash,
|
|
85
|
+
stateHash: stateRes.attributes.localHash,
|
|
86
|
+
remoteId: stateRes.attributes.remoteId,
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
// Check for orphaned resources (in state but not in local)
|
|
91
|
+
for (const res of resources) {
|
|
92
|
+
if (!localIds.has(res.identity)) {
|
|
93
|
+
entries.push({
|
|
94
|
+
identity: res.identity,
|
|
95
|
+
status: 'orphaned_remote',
|
|
96
|
+
stateHash: res.attributes.localHash,
|
|
97
|
+
remoteId: res.attributes.remoteId,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
// Output
|
|
102
|
+
console.log('');
|
|
103
|
+
console.log((0, chalk_1.bold)('Drift Detection Report'));
|
|
104
|
+
console.log('═'.repeat(60));
|
|
105
|
+
console.log('');
|
|
106
|
+
const synced = entries.filter(e => e.status === 'synced').length;
|
|
107
|
+
const modified = entries.filter(e => e.status === 'modified_locally').length;
|
|
108
|
+
const newLocal = entries.filter(e => e.status === 'new_local').length;
|
|
109
|
+
const orphaned = entries.filter(e => e.status === 'orphaned_remote').length;
|
|
110
|
+
for (const entry of entries) {
|
|
111
|
+
const icon = getStatusIcon(entry.status);
|
|
112
|
+
const label = getStatusLabel(entry.status);
|
|
113
|
+
const remoteInfo = entry.remoteId ? (0, chalk_1.dim)(` [id=${entry.remoteId}]`) : '';
|
|
114
|
+
console.log(` ${icon} ${(0, chalk_1.bold)(entry.identity)}: ${label}${remoteInfo}`);
|
|
115
|
+
if (verbose && entry.status === 'modified_locally' && entry.localHash && entry.stateHash) {
|
|
116
|
+
console.log(` Local: ${(0, chalk_1.dim)(entry.localHash.substring(0, 12))}...`);
|
|
117
|
+
console.log(` State: ${(0, chalk_1.dim)(entry.stateHash.substring(0, 12))}...`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
console.log('');
|
|
121
|
+
console.log((0, chalk_1.bold)('Summary:'));
|
|
122
|
+
console.log(` ${(0, chalk_1.green)('✓')} ${synced} synced`);
|
|
123
|
+
if (modified > 0)
|
|
124
|
+
console.log(` ${(0, chalk_1.yellow)('~')} ${modified} modified locally`);
|
|
125
|
+
if (newLocal > 0)
|
|
126
|
+
console.log(` ${(0, chalk_1.cyan)('+')} ${newLocal} new (not applied)`);
|
|
127
|
+
if (orphaned > 0)
|
|
128
|
+
console.log(` ${(0, chalk_1.red)('-')} ${orphaned} orphaned (not in config)`);
|
|
129
|
+
console.log('');
|
|
130
|
+
};
|
|
131
|
+
exports.diffCmd = diffCmd;
|