Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 36 additions & 16 deletions dist/index.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -1968,7 +1968,7 @@ var factors_default = {
// package.json
var package_default = {
name: "greenops-cli",
version: "0.5.0",
version: "0.5.2",
description: "Carbon footprint linting for Terraform plans \u2014 AWS, Azure, and GCP. Analyses infrastructure changes for Scope 2, Scope 3, and water impact. Posts recommendations directly on GitHub PRs.",
main: "dist/index.cjs",
bin: {
Expand Down Expand Up @@ -2745,8 +2745,9 @@ async function postSuggestions(result2, ctx) {
for (const { input, recommendation } of resourcesWithRecs) {
if (!recommendation)
continue;
const isDb = input.resourceId.includes("aws_db_instance") || input.instanceType.startsWith("db.");
const attributeKey = isDb ? "instance_class" : "instance_type";
const provider = input.provider ?? "aws";
const isDb = provider === "aws" && (input.resourceId.includes("aws_db_instance") || input.instanceType.startsWith("db."));
const attributeKey = provider === "azure" ? "size" : provider === "gcp" ? "machine_type" : isDb ? "instance_class" : "instance_type";
const currentValue = isDb ? `db.${input.instanceType}` : input.instanceType;
const newValue = recommendation.suggestedInstanceType ? isDb ? `db.${recommendation.suggestedInstanceType}` : recommendation.suggestedInstanceType : input.instanceType;
if (!recommendation.suggestedInstanceType) {
Expand Down Expand Up @@ -2843,7 +2844,8 @@ function formatWater(litres) {
}
function formatMarkdown(result2, options = {}) {
const METHODOLOGY_URL = options.repositoryUrl || "https://github.com/omrdev1/greenops-cli/blob/main/METHODOLOGY.md";
const recsCount = result2.resources.filter((r) => r.recommendation).length;
const analysedForCount = result2.resources.filter((r) => r.baseline.confidence !== "LOW_ASSUMED_DEFAULT");
const recsCount = analysedForCount.filter((r) => r.recommendation).length;
let out = `## \u{1F331} GreenOps Infrastructure Impact

`;
Expand Down Expand Up @@ -2879,32 +2881,40 @@ function formatMarkdown(result2, options = {}) {

`;
}
const analysed = result2.resources.filter((r) => r.baseline.confidence !== "LOW_ASSUMED_DEFAULT");
const unsupportedResources = result2.resources.filter((r) => r.baseline.confidence === "LOW_ASSUMED_DEFAULT");
out += `### Resource Breakdown

`;
out += `| Resource | Type | Region | Scope 2 CO2e | Scope 3 CO2e | Water | Cost/mo | Action |
`;
out += `|---|---|---|---|---|---|---|---|
`;
for (const r of result2.resources) {
for (const r of analysed) {
const action = r.recommendation ? `\u{1F4A1} [View Recommendation](#recommendations)` : `\u2705 Optimal`;
out += `| \`${r.input.resourceId}\` | \`${r.input.instanceType}\` | \`${r.input.region}\` | ${formatGrams(r.baseline.totalCo2eGramsPerMonth)} | ${formatGrams(r.baseline.embodiedCo2eGramsPerMonth)} | ${formatWater(r.baseline.waterLitresPerMonth)} | $${r.baseline.totalCostUsdPerMonth.toFixed(2)} | ${action} |
out += `| \`${r.input.resourceId}\` | \`${r.input.instanceType}\` | \`${r.input.region}\` | ${formatGrams(r.baseline.totalCo2eGramsPerMonth)} | ${formatGrams(r.baseline.embodiedCo2eGramsPerMonth)} | ${formatWater(r.baseline.waterLitresPerMonth)} | ${r.baseline.totalCostUsdPerMonth.toFixed(2)} | ${action} |
`;
}
out += `
`;
if (result2.skipped.length > 0) {
out += `<details><summary>\u26A0\uFE0F <b>${result2.skipped.length} Skipped Resources</b></summary>
const totalSkipped = result2.skipped.length + unsupportedResources.length;
if (totalSkipped > 0) {
out += `<details><summary>\u26A0\uFE0F <b>${totalSkipped} Skipped Resource${totalSkipped !== 1 ? "s" : ""}</b></summary>

`;
out += `The following resources were excluded from analysis (typically due to runtime-resolved attributes). The actual footprint may be higher.
out += `The following resources were excluded from analysis. The actual footprint may be higher.

`;
out += `| Resource | Reason |
|---|---|
out += `| Resource | Instance | Reason |
|---|---|---|
`;
for (const s of result2.skipped) {
out += `| \`${s.resourceId}\` | \`${s.reason}\` |
out += `| \`${s.resourceId}\` | \u2014 | \`${s.reason}\` |
`;
}
for (const r of unsupportedResources) {
const reason = r.baseline.unsupportedReason ?? "Instance type not in ledger";
out += `| \`${r.input.resourceId}\` | \`${r.input.instanceType}\` | ${reason} |
`;
}
out += `
Expand Down Expand Up @@ -2944,7 +2954,7 @@ function formatMarkdown(result2, options = {}) {
`;
out += `*Emissions calculated using the [Open GreenOps Methodology Ledger v${result2.ledgerVersion}](${METHODOLOGY_URL}). `;
out += `Scope 2 (operational) and Scope 3 (embodied) emissions tracked. `;
out += `Water consumption estimated from AWS 2023 WUE data. `;
out += `Water consumption estimated from provider sustainability reports (AWS 2023, Microsoft 2023, Google 2023). `;
out += `Math is MIT-licensed and auditable. Analysed at ${result2.analysedAt}.*
`;
if (options.showUpgradePrompt) {
Expand Down Expand Up @@ -2982,7 +2992,9 @@ function formatTable(result2) {
`;
out += `\u251C${"\u2500".repeat(38)}\u253C${"\u2500".repeat(13)}\u253C${"\u2500".repeat(13)}\u253C${"\u2500".repeat(11)}\u253C${"\u2500".repeat(11)}\u253C${"\u2500".repeat(9)}\u253C${"\u2500".repeat(13)}\u2524
`;
for (const r of result2.resources) {
const analysed = result2.resources.filter((r) => r.baseline.confidence !== "LOW_ASSUMED_DEFAULT");
const unsupportedResources = result2.resources.filter((r) => r.baseline.confidence === "LOW_ASSUMED_DEFAULT");
for (const r of analysed) {
const scope2 = formatGrams(r.baseline.totalCo2eGramsPerMonth);
const scope3 = formatGrams(r.baseline.embodiedCo2eGramsPerMonth);
const water = formatWater2(r.baseline.waterLitresPerMonth);
Expand All @@ -2992,6 +3004,10 @@ function formatTable(result2) {
}
for (const s of result2.skipped) {
out += `\u2502 \x1B[90m${truncate(s.resourceId, 36)}\x1B[0m \u2502 \x1B[90m${truncate("---", 11)}\x1B[0m \u2502 \x1B[90m${truncate("---", 11)}\x1B[0m \u2502 \x1B[90m${truncate("---", 9)}\x1B[0m \u2502 \x1B[90m${truncate("---", 9)}\x1B[0m \u2502 \x1B[90m${truncate("---", 7)}\x1B[0m \u2502 \x1B[33m${truncate("\u26A0 SKIPPED", 11)}\x1B[0m \u2502
`;
}
for (const r of unsupportedResources) {
out += `\u2502 \x1B[90m${truncate(r.input.resourceId, 36)}\x1B[0m \u2502 \x1B[90m${truncate(r.input.instanceType, 11)}\x1B[0m \u2502 \x1B[90m${truncate(r.input.region, 11)}\x1B[0m \u2502 \x1B[90m${truncate("---", 9)}\x1B[0m \u2502 \x1B[90m${truncate("---", 9)}\x1B[0m \u2502 \x1B[90m${truncate("---", 7)}\x1B[0m \u2502 \x1B[33m${truncate("\u26A0 UNKNOWN", 11)}\x1B[0m \u2502
`;
}
out += `\u2514${"\u2500".repeat(38)}\u2534${"\u2500".repeat(13)}\u2534${"\u2500".repeat(13)}\u2534${"\u2500".repeat(11)}\u2534${"\u2500".repeat(11)}\u2534${"\u2500".repeat(9)}\u2534${"\u2500".repeat(13)}\u2518
Expand All @@ -3005,9 +3021,13 @@ function formatTable(result2) {
out += `\x1B[32mScope 2 Savings: ${formatDelta2(-result2.totals.potentialCo2eSavingGramsPerMonth)} | ${formatCostDelta2(-result2.totals.potentialCostSavingUsdPerMonth)}\x1B[0m
`;
}
if (result2.skipped.length > 0) {
const totalSkipped = result2.skipped.length + unsupportedResources.length;
if (totalSkipped > 0) {
const skippedNote = result2.skipped.length > 0 ? `${result2.skipped.length} unresolvable at plan time` : "";
const unknownNote = unsupportedResources.length > 0 ? `${unsupportedResources.length} instance type(s) not yet in ledger` : "";
const parts = [skippedNote, unknownNote].filter(Boolean).join(", ");
out += `
\x1B[90mNote: ${result2.skipped.length} resource(s) were skipped due to runtime abstractions.\x1B[0m
\x1B[90mNote: ${parts}. Actual footprint may be higher.\x1B[0m
`;
}
return out;
Expand Down
24 changes: 24 additions & 0 deletions formatters/markdown.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,30 @@ describe('formatMarkdown', () => {
assert.ok(md.includes('aws_ecs_service'), 'Should list the unsupported type');
});

it('shows LOW_ASSUMED_DEFAULT resources in skipped section with unsupportedReason', () => {
const result = makeMockResult({
resources: [
{
input: { resourceId: 'google_compute_instance.old', instanceType: 'n1-standard-4', region: 'us-central1', provider: 'gcp' as const },
baseline: makeMockBaseline({
confidence: 'LOW_ASSUMED_DEFAULT' as const,
totalCo2eGramsPerMonth: 0,
embodiedCo2eGramsPerMonth: 0,
totalCostUsdPerMonth: 0,
unsupportedReason: 'Instance type "n1-standard-4" is not present in the GCP section of the Open GreenOps Methodology Ledger.',
}),
recommendation: null,
},
],
totals: makeMockTotals(),
});
const md = formatMarkdown(result);
assert.ok(md.includes('Skipped Resource'), 'Should show skipped section for unsupported instances');
assert.ok(md.includes('n1-standard-4'), 'Should show the unsupported instance type in skipped section');
assert.ok(md.includes('not present in the GCP'), 'Should show the unsupportedReason');
assert.ok(!md.includes('✅ Optimal'), 'Should NOT show as optimal resource');
});

it('does not show coverage note when only known_after_apply resources are skipped', () => {
const result = makeMockResult({
skipped: [{ resourceId: 'aws_instance.unknown', reason: 'known_after_apply' }],
Expand Down
28 changes: 19 additions & 9 deletions formatters/markdown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ function formatWater(litres: number): string {

export function formatMarkdown(result: PlanAnalysisResult, options: FormatterOptions = {}): string {
const METHODOLOGY_URL = options.repositoryUrl || 'https://github.com/omrdev1/greenops-cli/blob/main/METHODOLOGY.md';
const recsCount = result.resources.filter(r => r.recommendation).length;
const analysedForCount = result.resources.filter(r => r.baseline.confidence !== 'LOW_ASSUMED_DEFAULT');
const recsCount = analysedForCount.filter(r => r.recommendation).length;

let out = `## 🌱 GreenOps Infrastructure Impact\n\n`;

Expand Down Expand Up @@ -41,21 +42,30 @@ export function formatMarkdown(result: PlanAnalysisResult, options: FormatterOpt
out += `> ✅ **Already optimally configured.** No upgrades recommended.\n\n`;
}

// Separate fully-analysed resources from unsupported (LOW_ASSUMED_DEFAULT)
const analysed = result.resources.filter(r => r.baseline.confidence !== 'LOW_ASSUMED_DEFAULT');
const unsupportedResources = result.resources.filter(r => r.baseline.confidence === 'LOW_ASSUMED_DEFAULT');

out += `### Resource Breakdown\n\n`;
out += `| Resource | Type | Region | Scope 2 CO2e | Scope 3 CO2e | Water | Cost/mo | Action |\n`;
out += `|---|---|---|---|---|---|---|---|\n`;
for (const r of result.resources) {
for (const r of analysed) {
const action = r.recommendation ? `💡 [View Recommendation](#recommendations)` : `✅ Optimal`;
out += `| \`${r.input.resourceId}\` | \`${r.input.instanceType}\` | \`${r.input.region}\` | ${formatGrams(r.baseline.totalCo2eGramsPerMonth)} | ${formatGrams(r.baseline.embodiedCo2eGramsPerMonth)} | ${formatWater(r.baseline.waterLitresPerMonth)} | $${r.baseline.totalCostUsdPerMonth.toFixed(2)} | ${action} |\n`;
out += `| \`${r.input.resourceId}\` | \`${r.input.instanceType}\` | \`${r.input.region}\` | ${formatGrams(r.baseline.totalCo2eGramsPerMonth)} | ${formatGrams(r.baseline.embodiedCo2eGramsPerMonth)} | ${formatWater(r.baseline.waterLitresPerMonth)} | ${r.baseline.totalCostUsdPerMonth.toFixed(2)} | ${action} |\n`;
}
out += `\n`;

if (result.skipped.length > 0) {
out += `<details><summary>⚠️ <b>${result.skipped.length} Skipped Resources</b></summary>\n\n`;
out += `The following resources were excluded from analysis (typically due to runtime-resolved attributes). The actual footprint may be higher.\n\n`;
out += `| Resource | Reason |\n|---|---|\n`;
const totalSkipped = result.skipped.length + unsupportedResources.length;
if (totalSkipped > 0) {
out += `<details><summary>⚠️ <b>${totalSkipped} Skipped Resource${totalSkipped !== 1 ? 's' : ''}</b></summary>\n\n`;
out += `The following resources were excluded from analysis. The actual footprint may be higher.\n\n`;
out += `| Resource | Instance | Reason |\n|---|---|---|\n`;
for (const s of result.skipped) {
out += `| \`${s.resourceId}\` | \`${s.reason}\` |\n`;
out += `| \`${s.resourceId}\` | — | \`${s.reason}\` |\n`;
}
for (const r of unsupportedResources) {
const reason = r.baseline.unsupportedReason ?? 'Instance type not in ledger';
out += `| \`${r.input.resourceId}\` | \`${r.input.instanceType}\` | ${reason} |\n`;
}
out += `\n</details>\n\n`;
}
Expand Down Expand Up @@ -83,7 +93,7 @@ export function formatMarkdown(result: PlanAnalysisResult, options: FormatterOpt
out += `---\n`;
out += `*Emissions calculated using the [Open GreenOps Methodology Ledger v${result.ledgerVersion}](${METHODOLOGY_URL}). `;
out += `Scope 2 (operational) and Scope 3 (embodied) emissions tracked. `;
out += `Water consumption estimated from AWS 2023 WUE data. `;
out += `Water consumption estimated from provider sustainability reports (AWS 2023, Microsoft 2023, Google 2023). `;
out += `Math is MIT-licensed and auditable. Analysed at ${result.analysedAt}.*\n`;

if (options.showUpgradePrompt) {
Expand Down
23 changes: 23 additions & 0 deletions formatters/table.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,29 @@ describe('formatTable', () => {
assert.ok(table.includes('SKIPPED'), 'Should show SKIPPED for skipped resources');
});

it('shows UNKNOWN marker for LOW_ASSUMED_DEFAULT resources instead of OK/UPGRADE', () => {
const result = makeMockResult({
resources: [
{
input: { resourceId: 'azurerm_linux_virtual_machine.big', instanceType: 'Standard_M96ms_v3', region: 'eastus', provider: 'azure' as const },
baseline: makeMockBaseline({
confidence: 'LOW_ASSUMED_DEFAULT' as const,
totalCo2eGramsPerMonth: 0,
embodiedCo2eGramsPerMonth: 0,
totalCostUsdPerMonth: 0,
unsupportedReason: 'Instance type "Standard_M96ms_v3" is not present in the AZURE ledger.',
}),
recommendation: null,
},
],
totals: makeMockTotals(),
});
const table = formatTable(result);
assert.ok(table.includes('UNKNOWN'), 'Should show UNKNOWN for unsupported instance types');
assert.ok(!table.includes('OK'), 'Should NOT show OK for unsupported resources');
assert.ok(table.includes('not yet in ledger'), 'Footer should mention ledger gap');
});

it('shows Scope 2 and Scope 3 columns', () => {
const result = makeMockResult({
resources: [{
Expand Down
19 changes: 16 additions & 3 deletions formatters/table.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,16 +23,25 @@ export function formatTable(result: PlanAnalysisResult): string {
out += `│ ${truncate('Resource', 36)} │ ${truncate('Instance', 11)} │ ${truncate('Region', 11)} │ ${truncate('Scope 2', 9)} │ ${truncate('Scope 3', 9)} │ ${truncate('Water', 7)} │ ${truncate('Action', 11)} │\n`;
out += `├${'─'.repeat(38)}┼${'─'.repeat(13)}┼${'─'.repeat(13)}┼${'─'.repeat(11)}┼${'─'.repeat(11)}┼${'─'.repeat(9)}┼${'─'.repeat(13)}┤\n`;

for (const r of result.resources) {
// Separate analysed resources from LOW_ASSUMED_DEFAULT (unsupported instance/region)
const analysed = result.resources.filter(r => r.baseline.confidence !== 'LOW_ASSUMED_DEFAULT');
const unsupportedResources = result.resources.filter(r => r.baseline.confidence === 'LOW_ASSUMED_DEFAULT');

for (const r of analysed) {
const scope2 = formatGrams(r.baseline.totalCo2eGramsPerMonth);
const scope3 = formatGrams(r.baseline.embodiedCo2eGramsPerMonth);
const water = formatWater(r.baseline.waterLitresPerMonth);
const action = r.recommendation ? `\x1b[33mUPGRADE\x1b[0m` : `\x1b[32mOK\x1b[0m`;
out += `│ ${truncate(r.input.resourceId, 36)} │ ${truncate(r.input.instanceType, 11)} │ ${truncate(r.input.region, 11)} │ ${truncate(scope2, 9)} │ ${truncate(scope3, 9)} │ ${truncate(water, 7)} │ ${truncate(action, 11)} │\n`;
}
// Skipped: known_after_apply and other runtime-unresolvable resources
for (const s of result.skipped) {
out += `│ \x1b[90m${truncate(s.resourceId, 36)}\x1b[0m │ \x1b[90m${truncate('---', 11)}\x1b[0m │ \x1b[90m${truncate('---', 11)}\x1b[0m │ \x1b[90m${truncate('---', 9)}\x1b[0m │ \x1b[90m${truncate('---', 9)}\x1b[0m │ \x1b[90m${truncate('---', 7)}\x1b[0m │ \x1b[33m${truncate('⚠ SKIPPED', 11)}\x1b[0m │\n`;
}
// Skipped: unsupported instance types not in the ledger
for (const r of unsupportedResources) {
out += `│ \x1b[90m${truncate(r.input.resourceId, 36)}\x1b[0m │ \x1b[90m${truncate(r.input.instanceType, 11)}\x1b[0m │ \x1b[90m${truncate(r.input.region, 11)}\x1b[0m │ \x1b[90m${truncate('---', 9)}\x1b[0m │ \x1b[90m${truncate('---', 9)}\x1b[0m │ \x1b[90m${truncate('---', 7)}\x1b[0m │ \x1b[33m${truncate('⚠ UNKNOWN', 11)}\x1b[0m │\n`;
}
out += `└${'─'.repeat(38)}┴${'─'.repeat(13)}┴${'─'.repeat(13)}┴${'─'.repeat(11)}┴${'─'.repeat(11)}┴${'─'.repeat(9)}┴${'─'.repeat(13)}┘\n\n`;

out += `Scope 2: ${formatGrams(result.totals.currentCo2eGramsPerMonth)} | Scope 3: ${formatGrams(result.totals.currentEmbodiedCo2eGramsPerMonth)} | Lifecycle: ${formatGrams(result.totals.currentLifecycleCo2eGramsPerMonth)}\n`;
Expand All @@ -42,8 +51,12 @@ export function formatTable(result: PlanAnalysisResult): string {
out += `\x1b[32mScope 2 Savings: ${formatDelta(-result.totals.potentialCo2eSavingGramsPerMonth)} | ${formatCostDelta(-result.totals.potentialCostSavingUsdPerMonth)}\x1b[0m\n`;
}

if (result.skipped.length > 0) {
out += `\n\x1b[90mNote: ${result.skipped.length} resource(s) were skipped due to runtime abstractions.\x1b[0m\n`;
const totalSkipped = result.skipped.length + unsupportedResources.length;
if (totalSkipped > 0) {
const skippedNote = result.skipped.length > 0 ? `${result.skipped.length} unresolvable at plan time` : '';
const unknownNote = unsupportedResources.length > 0 ? `${unsupportedResources.length} instance type(s) not yet in ledger` : '';
const parts = [skippedNote, unknownNote].filter(Boolean).join(', ');
out += `\n\x1b[90mNote: ${parts}. Actual footprint may be higher.\x1b[0m\n`;
}

return out;
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "greenops-cli",
"version": "0.5.0",
"version": "0.5.2",
"description": "Carbon footprint linting for Terraform plans — AWS, Azure, and GCP. Analyses infrastructure changes for Scope 2, Scope 3, and water impact. Posts recommendations directly on GitHub PRs.",
"main": "dist/index.cjs",
"bin": {
Expand Down
17 changes: 12 additions & 5 deletions suggestions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@
* Design principles:
* - Zero runtime dependencies: uses Node 20's built-in fetch API exclusively.
* - Precise targeting: suggestions are posted on the exact line that contains
* the attribute being changed (instance_type, instance_class).
* the attribute being changed (instance_type/instance_class for AWS,
* size for Azure, machine_type for GCP).
* - Idempotent: existing GreenOps suggestion comments are updated, not duplicated.
* - Fail-open: if the GitHub API is unreachable or the plan file cannot be mapped
* to a source file, the CLI exits 0 with a warning. Never blocks a deployment.
Expand Down Expand Up @@ -254,10 +255,16 @@ export async function postSuggestions(
for (const { input, recommendation } of resourcesWithRecs) {
if (!recommendation) continue;

// Determine which attribute and value we're targeting
const isDb = input.resourceId.includes('aws_db_instance') ||
input.instanceType.startsWith('db.');
const attributeKey = isDb ? 'instance_class' : 'instance_type';
// Determine which attribute and value we're targeting — provider-aware
const provider = input.provider ?? 'aws';
const isDb = provider === 'aws' && (
input.resourceId.includes('aws_db_instance') ||
input.instanceType.startsWith('db.')
);
const attributeKey =
provider === 'azure' ? 'size' :
provider === 'gcp' ? 'machine_type' :
isDb ? 'instance_class' : 'instance_type';
const currentValue = isDb ? `db.${input.instanceType}` : input.instanceType;
const newValue = recommendation.suggestedInstanceType
? (isDb ? `db.${recommendation.suggestedInstanceType}` : recommendation.suggestedInstanceType)
Expand Down
Loading