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
43 changes: 26 additions & 17 deletions action.yml
Original file line number Diff line number Diff line change
@@ -1,35 +1,46 @@
name: "GreenOps PR Analysis"
description: "Analyses a Terraform plan for carbon & cost impact and posts a PR comment"
description: "Analyses a Terraform plan for carbon & cost impact, enforces policy budgets, and posts inline Terraform suggestions on the PR"
branding:
icon: activity
color: green
inputs:
plan-file:
description: "Path to terraform plan json"
description: "Path to terraform plan JSON file"
required: true
github-token:
description: "GitHub token for commenting"
description: "GitHub token for posting PR comments and suggestions"
required: true
post-suggestions:
description: "Post inline Terraform suggestion comments on the PR (one-click committable fixes)"
required: false
default: 'false'
show-upgrade-prompt:
description: "Enable opt-out dashboard upsell trigger"
description: "Show GreenOps Dashboard upsell in the PR comment"
required: false
default: 'true'
api-key:
description: "Optional secret API key for GreenOps Dashboard telemetry (masks automatically as GitHub Secret)"
description: "Optional GreenOps Dashboard API key for telemetry aggregation"
required: false
runs:
using: "composite"
steps:
- name: Run GreenOps CLI
- name: Run GreenOps Analysis
id: run-cli
shell: bash
env:
GREENOPS_PLAN_FILE: ${{ inputs.plan-file }}
GREENOPS_SHOW_UPGRADE_PROMPT: ${{ inputs.show-upgrade-prompt }}
run: |
node "${{ github.action_path }}/dist/index.cjs" diff "$GREENOPS_PLAN_FILE" \
SUGGESTIONS_FLAG=""
if [ "${{ inputs.post-suggestions }}" = "true" ]; then
SUGGESTIONS_FLAG="--post-suggestions \
--github-token=${{ inputs.github-token }} \
--repo=${{ github.repository }} \
--pr-number=${{ github.event.pull_request.number }} \
--commit-sha=${{ github.event.pull_request.head.sha }}"
fi

node "${{ github.action_path }}/dist/index.cjs" diff "${{ inputs.plan-file }}" \
--format markdown \
"--show-upgrade-prompt=$GREENOPS_SHOW_UPGRADE_PROMPT" > greenops-output.md
--show-upgrade-prompt="${{ inputs.show-upgrade-prompt }}" \
$SUGGESTIONS_FLAG > greenops-output.md

EOF=$(dd if=/dev/urandom bs=15 count=1 status=none | base64)
echo "comment_body<<$EOF" >> "$GITHUB_OUTPUT"
Expand All @@ -39,8 +50,6 @@ runs:
- name: Post or Update PR Comment
uses: actions/github-script@v7
env:
# Pass via env to avoid backtick/quote injection when the Markdown output
# contains backtick-wrapped resource identifiers (e.g. `aws_instance.web`)
GREENOPS_COMMENT_BODY: ${{ steps.run-cli.outputs.comment_body }}
with:
github-token: ${{ inputs.github-token }}
Expand All @@ -63,21 +72,21 @@ runs:
await github.rest.issues.createComment({ owner, repo, issue_number: number, body });
}

- name: Run GreenOps CLI (JSON) & Upload Telemetry
- name: Upload Telemetry (optional)
if: inputs.api-key != ''
shell: bash
env:
GREENOPS_API_KEY: ${{ inputs.api-key }}
GREENOPS_PLAN_FILE: ${{ inputs.plan-file }}
run: |
node "${{ github.action_path }}/dist/index.cjs" diff "$GREENOPS_PLAN_FILE" \
node "${{ github.action_path }}/dist/index.cjs" diff "${{ inputs.plan-file }}" \
--format json > result.json

jq --arg repo "$GITHUB_REPOSITORY" \
--arg pr "${{ github.event.pull_request.number }}" \
--arg sha "$GITHUB_SHA" \
--arg ref "$GITHUB_REF" \
'{ repository: $repo, pull_request_number: ($pr | tonumber), commit_sha: $sha, ref: $ref, analysis: . }' result.json > upload.json
'{ repository: $repo, pull_request_number: ($pr | tonumber), commit_sha: $sha, ref: $ref, analysis: . }' \
result.json > upload.json

curl -f -X POST https://api.greenops-cli.dev/ingest \
-H "Authorization: Bearer $GREENOPS_API_KEY" \
Expand Down
128 changes: 107 additions & 21 deletions cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import factorsData from './factors.json';
import pkg from './package.json';
import { extractResourceInputs } from './extractor.js';
import { analysePlan } from './engine.js';
import { loadPolicy, evaluatePolicy } from './policy.js';
import { postSuggestions } from './suggestions.js';
import { formatMarkdown } from './formatters/markdown.js';
import { formatTable } from './formatters/table.js';
import { formatJson } from './formatters/json.js';
Expand All @@ -16,7 +18,12 @@ const { positionals, values } = parseArgs({
help: { type: 'boolean', default: false },
version: { type: 'boolean', default: false },
'show-upgrade-prompt': { type: 'string', default: 'true' },
env: { type: 'string', default: 'production' },
// Policy + suggestions flags (used by GitHub Action)
'github-token': { type: 'string' },
'repo': { type: 'string' },
'pr-number': { type: 'string' },
'commit-sha': { type: 'string' },
'post-suggestions': { type: 'boolean', default: false },
}
});

Expand All @@ -26,17 +33,41 @@ if (values.version) {
}

if (values.help) {
console.log(`GreenOps CLI v${pkg.version}\nUsage: greenops-cli diff <plan.json> [--format markdown|table|json]\n greenops-cli --coverage [--format json]\n greenops-cli --version`);
console.log([
`GreenOps CLI v${pkg.version}`,
``,
`Usage:`,
` greenops-cli diff <plan.json> [options]`,
` greenops-cli --coverage [--format json]`,
` greenops-cli --version`,
``,
`Options:`,
` --format Output format: markdown (default), table, json`,
` --coverage List supported regions and instance types`,
` --github-token GitHub token for posting suggestion comments`,
` --repo Repository full name (e.g. owner/repo)`,
` --pr-number Pull request number`,
` --commit-sha Head commit SHA for suggestion anchoring`,
` --post-suggestions Post inline Terraform suggestion comments on the PR`,
` --show-upgrade-prompt Show dashboard upsell (true/false, default: true)`,
` --version Print version and exit`,
` --help Print this help and exit`,
].join('\n'));
process.exit(0);
}

if (values.coverage) {
const rawFs = Object.assign({}, factorsData);
if (values.format === 'json') {
console.log(JSON.stringify({ regions: Object.keys(rawFs.regions), instances: Object.keys(rawFs.instances) }, null, 2));
console.log(JSON.stringify({
ledgerVersion: rawFs.metadata.ledger_version,
regions: Object.keys(rawFs.regions),
instances: Object.keys(rawFs.instances)
}, null, 2));
} else {
console.log(`Supported Regions: ${Object.keys(rawFs.regions).join(', ')}`);
console.log(`Supported Instances: ${Object.keys(rawFs.instances).join(', ')}`);
console.log(`GreenOps Methodology Ledger v${rawFs.metadata.ledger_version}`);
console.log(`Supported Regions (${Object.keys(rawFs.regions).length}): ${Object.keys(rawFs.regions).join(', ')}`);
console.log(`Supported Instances (${Object.keys(rawFs.instances).length}): ${Object.keys(rawFs.instances).join(', ')}`);
}
process.exit(0);
}
Expand All @@ -45,33 +76,52 @@ const command = positionals[0];
const planFile = positionals[1];

if (command !== 'diff' || !planFile) {
console.error("Error: Missing 'diff' command or plan file parameter.");
console.error("Error: Missing 'diff' command or plan file parameter. Run --help for usage.");
process.exit(1);
}

// Environment profiles: staging environments typically run ~22% of the month
// (weekday business hours only), so we use 160h/month instead of 730h.
const HOURS_BY_ENV: Record<string, number> = {
production: 730,
staging: 160,
};
const hoursPerMonth = HOURS_BY_ENV[values.env ?? 'production'] ?? 730;

const extracted = extractResourceInputs(planFile);

if (extracted.error) {
console.error(`Extraction Error: ${extracted.error}`);
process.exit(1);
}

// Apply the environment's hoursPerMonth to every resource that doesn't already have one set
const resourcesWithEnv = extracted.resources.map(r =>
r.hoursPerMonth !== undefined ? r : { ...r, hoursPerMonth }
);

const result = analysePlan(resourcesWithEnv, extracted.skipped, planFile, undefined, extracted.unsupportedTypes);
const result = analysePlan(extracted.resources, extracted.skipped, planFile, undefined, extracted.unsupportedTypes);
const showUpgradePrompt = values['show-upgrade-prompt'] === 'true';

// --- Policy evaluation ---
let policyExitCode = 0;
try {
const policy = loadPolicy(process.cwd());
if (policy) {
const evaluation = evaluatePolicy(result, policy);
if (!evaluation.isCompliant) {
// Append violations to output regardless of format
const violationLines = evaluation.violations.map(v =>
`⛔ Policy violation [${v.constraint}]: ${v.message}`
).join('\n');

if (values.format === 'json') {
// For JSON format, violations are included in the output object downstream
// We write them to stderr so they don't corrupt the JSON pipe
process.stderr.write(`\n${violationLines}\n`);
} else {
// Append to stdout for markdown/table formats
process.stdout.write(`\n${violationLines}\n`);
}

if (evaluation.shouldBlock) {
policyExitCode = 1;
}
}
}
} catch (err) {
// Policy file parse errors are warnings, not fatal
process.stderr.write(`[WARN] .greenops.yml parse error: ${err instanceof Error ? err.message : String(err)}\n`);
}

// --- Format and output ---
if (values.format === 'table') {
console.log(formatTable(result));
} else if (values.format === 'json') {
Expand All @@ -80,4 +130,40 @@ if (values.format === 'table') {
console.log(formatMarkdown(result, { showUpgradePrompt }));
}

process.exit(0);
// --- Post GitHub suggestion comments (async, fail-open) ---
if (values['post-suggestions']) {
const token = values['github-token'];
const repo = values['repo'];
const prNumber = values['pr-number'];
const commitSha = values['commit-sha'];

if (!token || !repo || !prNumber || !commitSha) {
process.stderr.write(
'[WARN] --post-suggestions requires --github-token, --repo, --pr-number, and --commit-sha. Skipping.\n'
);
} else {
postSuggestions(result, {
token,
repoFullName: repo,
pullNumber: parseInt(prNumber, 10),
commitSha,
planFilePath: planFile,
}).then(suggestionResult => {
if (suggestionResult.posted > 0 || suggestionResult.updated > 0) {
process.stderr.write(
`[GreenOps] Suggestions: ${suggestionResult.posted} posted, ${suggestionResult.updated} updated, ${suggestionResult.skipped} skipped\n`
);
}
for (const warn of suggestionResult.warnings) {
process.stderr.write(`[WARN] ${warn}\n`);
}
}).catch(err => {
// Fail-open: suggestion posting errors never block the CLI
process.stderr.write(
`[WARN] GreenOps suggestion engine error: ${err instanceof Error ? err.message : String(err)}. Continuing.\n`
);
});
}
}

process.exit(policyExitCode);
Loading
Loading