diff --git a/.ruby-version b/.ruby-version new file mode 100644 index 0000000..a0891f5 --- /dev/null +++ b/.ruby-version @@ -0,0 +1 @@ +3.3.4 diff --git a/README.md b/README.md index cf6294d..79cc284 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,7 @@ Set these in a `.env` file in your project root: | `DIFFDASH_GRAFANA_FOLDER_ID` | No | Target folder ID for dashboards | | `DIFFDASH_OUTPUTS` | No | Comma-separated outputs (default: `grafana`) | | `DIFFDASH_DRY_RUN` | No | Set to `true` to force dry-run mode | +| `DIFFDASH_PR_DEPLOY_ANNOTATION_EXPR` | No | PromQL expr for PR deployment annotation | Legacy `GRAFANA_*` env vars are still supported as fallbacks for now. diff --git a/lib/diffdash/cli/runner.rb b/lib/diffdash/cli/runner.rb index 7f490b0..6458799 100644 --- a/lib/diffdash/cli/runner.rb +++ b/lib/diffdash/cli/runner.rb @@ -283,11 +283,12 @@ def print_help --help Show this help message Environment Variables (set in .env file): - DIFFDASH_GRAFANA_URL Grafana instance URL (required) - DIFFDASH_GRAFANA_TOKEN Grafana API token (required) - DIFFDASH_GRAFANA_FOLDER_ID Target folder ID (optional) - DIFFDASH_OUTPUTS Comma-separated outputs (default: grafana) - DIFFDASH_DRY_RUN Set to 'true' to force dry-run mode + DIFFDASH_GRAFANA_URL Grafana instance URL (required) + DIFFDASH_GRAFANA_TOKEN Grafana API token (required) + DIFFDASH_GRAFANA_FOLDER_ID Target folder ID (optional) + DIFFDASH_OUTPUTS Comma-separated outputs (default: grafana) + DIFFDASH_DRY_RUN Set to 'true' to force dry-run mode + DIFFDASH_PR_DEPLOY_ANNOTATION_EXPR PromQL for PR deployment annotation Output: Prints output JSON to STDOUT (Grafana first if configured). diff --git a/lib/diffdash/outputs/grafana.rb b/lib/diffdash/outputs/grafana.rb index 701c9fe..a651ff4 100644 --- a/lib/diffdash/outputs/grafana.rb +++ b/lib/diffdash/outputs/grafana.rb @@ -53,7 +53,7 @@ def build_dashboard(signal_bundle) }, templating: build_templating, panels: build_panels(signal_bundle), - annotations: build_annotations + annotations: build_annotations(signal_bundle) } end @@ -107,19 +107,12 @@ def app_variable } end - def build_annotations - { - list: [ - { - name: "Deployments", - datasource: { type: "prometheus", uid: "${datasource}" }, - enable: true, - expr: "changes(deploy_timestamp[5m]) > 0", - tagKeys: "app,env", - titleFormat: "Deploy" - } - ] - } + def build_annotations(signal_bundle) + annotations = [deployment_annotation] + pr_annotation = pr_deployment_annotation(signal_bundle) + annotations << pr_annotation if pr_annotation + + { list: annotations } end def build_panels(signal_bundle) @@ -323,6 +316,10 @@ def escape_log_value(value) value.to_s.gsub("\\", "\\\\").gsub("\"", "\\\"") end + def escape_promql_label(value) + value.to_s.gsub("\\", "\\\\").gsub("\"", "\\\"") + end + def sanitize_metric_name(name) name.to_s.gsub(/[^a-zA-Z0-9_:]/, "_") end @@ -338,6 +335,41 @@ def relative_path(path) def log_verbose(message) warn "[diffdash] #{message}" if @verbose end + + def deployment_annotation + { + name: "Deployments", + datasource: { type: "prometheus", uid: "${datasource}" }, + enable: true, + expr: "changes(deploy_timestamp[5m]) > 0", + tagKeys: "app,env", + titleFormat: "Deploy" + } + end + + def pr_deployment_annotation(signal_bundle) + expr = pr_deployment_expr(signal_bundle) + return nil if expr.nil? || expr.empty? + + { + name: "PR Deployments", + datasource: { type: "prometheus", uid: "${datasource}" }, + enable: true, + expr: expr, + tagKeys: "app,env,branch", + titleFormat: "PR Deploy" + } + end + + def pr_deployment_expr(signal_bundle) + override = ENV["DIFFDASH_PR_DEPLOY_ANNOTATION_EXPR"].to_s.strip + return override unless override.empty? + + branch = signal_bundle.metadata.dig(:change_set, :branch_name).to_s.strip + return nil if branch.empty? + + "changes(deploy_timestamp{branch=\"#{escape_promql_label(branch)}\"}[5m]) > 0" + end end end end diff --git a/spec/diffdash/config_spec.rb b/spec/diffdash/config_spec.rb index 544cfd5..abf8669 100644 --- a/spec/diffdash/config_spec.rb +++ b/spec/diffdash/config_spec.rb @@ -22,26 +22,55 @@ end describe "#grafana_url" do - it "reads from GRAFANA_URL environment variable" do + it "prefers DIFFDASH_GRAFANA_URL when set" do + allow(ENV).to receive(:[]).and_call_original + allow(ENV).to receive(:[]).with("DIFFDASH_GRAFANA_URL").and_return("https://diffdash.example.com") + allow(ENV).to receive(:[]).with("GRAFANA_URL").and_return("https://grafana.example.com") + expect(config.grafana_url).to eq("https://diffdash.example.com") + end + + it "falls back to GRAFANA_URL when DIFFDASH_GRAFANA_URL is not set" do + allow(ENV).to receive(:[]).and_call_original + allow(ENV).to receive(:[]).with("DIFFDASH_GRAFANA_URL").and_return(nil) allow(ENV).to receive(:[]).with("GRAFANA_URL").and_return("https://grafana.example.com") expect(config.grafana_url).to eq("https://grafana.example.com") end it "returns nil when not set" do + allow(ENV).to receive(:[]).and_call_original + allow(ENV).to receive(:[]).with("DIFFDASH_GRAFANA_URL").and_return(nil) allow(ENV).to receive(:[]).with("GRAFANA_URL").and_return(nil) expect(config.grafana_url).to be_nil end end describe "#grafana_token" do - it "reads from GRAFANA_TOKEN environment variable" do + it "prefers DIFFDASH_GRAFANA_TOKEN when set" do + allow(ENV).to receive(:[]).and_call_original + allow(ENV).to receive(:[]).with("DIFFDASH_GRAFANA_TOKEN").and_return("diffdash-token") + allow(ENV).to receive(:[]).with("GRAFANA_TOKEN").and_return("secret-token") + expect(config.grafana_token).to eq("diffdash-token") + end + + it "falls back to GRAFANA_TOKEN when DIFFDASH_GRAFANA_TOKEN is not set" do + allow(ENV).to receive(:[]).and_call_original + allow(ENV).to receive(:[]).with("DIFFDASH_GRAFANA_TOKEN").and_return(nil) allow(ENV).to receive(:[]).with("GRAFANA_TOKEN").and_return("secret-token") expect(config.grafana_token).to eq("secret-token") end end describe "#grafana_folder_id" do - it "reads from GRAFANA_FOLDER_ID environment variable" do + it "prefers DIFFDASH_GRAFANA_FOLDER_ID when set" do + allow(ENV).to receive(:[]).and_call_original + allow(ENV).to receive(:[]).with("DIFFDASH_GRAFANA_FOLDER_ID").and_return("456") + allow(ENV).to receive(:[]).with("GRAFANA_FOLDER_ID").and_return("123") + expect(config.grafana_folder_id).to eq("456") + end + + it "falls back to GRAFANA_FOLDER_ID when DIFFDASH_GRAFANA_FOLDER_ID is not set" do + allow(ENV).to receive(:[]).and_call_original + allow(ENV).to receive(:[]).with("DIFFDASH_GRAFANA_FOLDER_ID").and_return(nil) allow(ENV).to receive(:[]).with("GRAFANA_FOLDER_ID").and_return("123") expect(config.grafana_folder_id).to eq("123") end diff --git a/spec/diffdash/renderers/grafana_contract_spec.rb b/spec/diffdash/renderers/grafana_contract_spec.rb index 802c2a2..b954571 100644 --- a/spec/diffdash/renderers/grafana_contract_spec.rb +++ b/spec/diffdash/renderers/grafana_contract_spec.rb @@ -29,7 +29,10 @@ def stringify_keys(value) logs: [signal], metrics: [], traces: [], - metadata: { time_range: { from: "now-1h", to: "now" } } + metadata: { + time_range: { from: "now-1h", to: "now" }, + change_set: { branch_name: "contract-dashboard" } + } ) renderer = Diffdash::Outputs::Grafana.new( title: "contract-dashboard", diff --git a/spec/diffdash/renderers/grafana_spec.rb b/spec/diffdash/renderers/grafana_spec.rb index 89add6d..7c59ed8 100644 --- a/spec/diffdash/renderers/grafana_spec.rb +++ b/spec/diffdash/renderers/grafana_spec.rb @@ -4,7 +4,14 @@ describe "#render" do context "with empty signals" do subject(:renderer) { described_class.new(title: "Empty Dashboard", folder_id: nil) } - let(:bundle) { Diffdash::Engine::SignalBundle.new(metadata: { time_range: { from: "now-1h", to: "now" } }) } + let(:bundle) do + Diffdash::Engine::SignalBundle.new( + metadata: { + time_range: { from: "now-1h", to: "now" }, + change_set: { branch_name: "feature/pr-123" } + } + ) + end it "returns valid Grafana JSON structure" do result = renderer.render(bundle) @@ -296,7 +303,14 @@ context "dashboard metadata" do subject(:renderer) { described_class.new(title: "Test Dashboard", folder_id: nil) } - let(:bundle) { Diffdash::Engine::SignalBundle.new(metadata: { time_range: { from: "now-1h", to: "now" } }) } + let(:bundle) do + Diffdash::Engine::SignalBundle.new( + metadata: { + time_range: { from: "now-1h", to: "now" }, + change_set: { branch_name: "feature/pr-123" } + } + ) + end it "includes Grafana tags" do result = renderer.render(bundle) @@ -321,8 +335,23 @@ result = renderer.render(bundle) annotations = result[:dashboard][:annotations][:list] - expect(annotations.size).to eq(1) - expect(annotations.first[:name]).to eq("Deployments") + expect(annotations.size).to eq(2) + base = annotations.first + pr = annotations.last + + expect(base[:name]).to eq("Deployments") + expect(base[:datasource]).to eq(type: "prometheus", uid: "${datasource}") + expect(base[:enable]).to be true + expect(base[:expr]).to eq("changes(deploy_timestamp[5m]) > 0") + expect(base[:tagKeys]).to eq("app,env") + expect(base[:titleFormat]).to eq("Deploy") + + expect(pr[:name]).to eq("PR Deployments") + expect(pr[:datasource]).to eq(type: "prometheus", uid: "${datasource}") + expect(pr[:enable]).to be true + expect(pr[:expr]).to eq("changes(deploy_timestamp{branch=\"feature/pr-123\"}[5m]) > 0") + expect(pr[:tagKeys]).to eq("app,env,branch") + expect(pr[:titleFormat]).to eq("PR Deploy") end it "sets schema version" do diff --git a/spec/fixtures/grafana/dashboard_v1_fixture.json b/spec/fixtures/grafana/dashboard_v1_fixture.json index d22da28..ed6d058 100644 --- a/spec/fixtures/grafana/dashboard_v1_fixture.json +++ b/spec/fixtures/grafana/dashboard_v1_fixture.json @@ -101,6 +101,17 @@ "expr": "changes(deploy_timestamp[5m]) > 0", "tagKeys": "app,env", "titleFormat": "Deploy" + }, + { + "name": "PR Deployments", + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "enable": true, + "expr": "changes(deploy_timestamp{branch=\"contract-dashboard\"}[5m]) > 0", + "tagKeys": "app,env,branch", + "titleFormat": "PR Deploy" } ] }