diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d61b2c83d..64ab72b130 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,17 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.8.5] - Unreleased + +### Fixed + +- **The `github/review` composite action can be downloaded by GitHub Actions again.** The release tree contained three VS Code image symlinks whose removed targets caused GitHub's action downloader to reject the entire archive before the review step started. The images are now self-contained files and a release-critical test prevents dangling links from returning. +- **A valid dbt manifest is no longer mislabeled as a lint-only run.** Manifest availability is now checked independently from changed-model lookup, so new models and other valid manifests receive the correct full-run status. + +### Added + +- **Direct GitHub onboarding and a live dbt review demo.** The GitHub App installer now opens GitHub's repository-selection screen directly, and the README/docs link to the public `dbt-pr-review-demo` pull requests. + ## [0.8.4] - 2026-06-05 A trace-durability patch. Open `/traces` mid-session and you'd see a rich waterfall — then the moment the agent finished its turn the view collapsed to a single "system-prompt" span, the Summary tab's *"What was asked"* showed *"No prompt recorded"*, and the Chat tab dropped every user turn but the last. The data was genuinely gone from disk, not just hidden in the viewer. This release stops the on-disk trace from being overwritten after each turn and makes the file authoritative across worker restarts. A five-persona pre-release review drove a follow-up wording fix so a reconstructed trace isn't misread as a failed run. diff --git a/README.md b/README.md index 28754bd76d..9a4db181b7 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,8 @@ into CI pipelines and orchestration DAGs. Precision data tooling for any LLM. [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](./LICENSE) [![Slack](https://img.shields.io/badge/Slack-Join%20Community-4A154B?logo=slack)](https://altimate.studio/join-agentic-data-engineering-slack) [![Docs](https://img.shields.io/badge/docs-docs.altimate.sh-blue)](https://docs.altimate.sh) +[![Install GitHub App](https://img.shields.io/badge/GitHub-Install%20App-24292f?logo=github)](https://github.com/apps/altimate-code-agent/installations/new) +[![Live dbt review](https://img.shields.io/badge/dbt-Live%20PR%20Demo-ff694b?logo=dbt)](https://github.com/AltimateAI/dbt-pr-review-demo/pulls) @@ -36,6 +38,12 @@ curl -fsSL https://www.altimate.sh/install | bash The curl install drops a single self-contained binary named `altimate`. The npm install exposes both `altimate` and `altimate-code` on PATH; the curl install only exposes `altimate`. Alpine Linux (musl) and Windows on ARM64 are not currently supported by the standalone binary — use `apk add gcompat` on Alpine, or use WSL on Windows-on-ARM. +For GitHub, [install the Altimate Code App](https://github.com/apps/altimate-code-agent/installations/new) +to select repositories for interactive agent tasks. Automatic dbt pull-request +reviews use the deterministic GitHub Action documented below; see the +[public demo PRs](https://github.com/AltimateAI/dbt-pr-review-demo/pulls) before +installing it in your own repository. + Then — in order: **Step 1: Configure your LLM provider** (required before anything works): diff --git a/docs/docs/usage/dbt-pr-review.md b/docs/docs/usage/dbt-pr-review.md index 5373d98931..e4b63774ba 100644 --- a/docs/docs/usage/dbt-pr-review.md +++ b/docs/docs/usage/dbt-pr-review.md @@ -1,5 +1,14 @@ # dbt PR Review +[**See live review PRs**](https://github.com/AltimateAI/dbt-pr-review-demo/pulls) +· +[**Install the GitHub App**](https://github.com/apps/altimate-code-agent/installations/new) + +The public demo is a zero-secret DuckDB project with open PRs for broken joins, +removed tests, PII exposure, `SELECT *`, unsafe incremental models, and a safe +refactor. The GitHub App handles interactive repository tasks; the automatic +review on every pull request is installed with the Action below. + AI code review specialized for dbt/SQL. `dbt-pr-review` produces a single, **signed** verdict on a pull request — `APPROVE`, `COMMENT`, or `REQUEST_CHANGES` — where every **blocking** finding is backed by a deterministic engine call, not @@ -172,7 +181,7 @@ jobs: with: { fetch-depth: 0 } # Produce target/manifest.json for the full verdict (adapter-specific). - run: pip install dbt-core dbt-bigquery && dbt deps && dbt compile - - uses: AltimateAI/altimate-code/github/review@v1 + - uses: AltimateAI/altimate-code/github/review@v0.8.5 with: mode: comment # `gate` to block merges manifest_path: target/manifest.json @@ -270,7 +279,7 @@ In GitHub Actions, supply the connection from a secret — both sides of the dif run against the **same** warehouse (base-compiled vs head-compiled SQL): ```yaml - - uses: AltimateAI/altimate-code/github/review@v1 + - uses: AltimateAI/altimate-code/github/review@v0.8.5 with: mode: comment manifest_path: target/manifest.json diff --git a/github/README.md b/github/README.md index 3feb5a01c3..dcd2244db4 100644 --- a/github/README.md +++ b/github/README.md @@ -60,7 +60,7 @@ This will walk you through installing the GitHub app, creating the workflow, and ### Manual Setup -1. Install the GitHub app https://github.com/apps/altimate-code-agent. Make sure it is installed on the target repository. +1. [Install the GitHub app](https://github.com/apps/altimate-code-agent/installations/new) and select the target repository. 2. Add the following workflow file to `.github/workflows/altimate-code.yml` in your repo. Set the appropriate `model` and required API keys in `env`. ```yml diff --git a/github/review/action.yml b/github/review/action.yml index 480145649c..bb0b9e4db6 100644 --- a/github/review/action.yml +++ b/github/review/action.yml @@ -57,9 +57,20 @@ runs: - name: Get altimate-code version id: version shell: bash + env: + ACTION_REF: ${{ github.action_ref }} run: | - VERSION=$(curl -sf https://api.github.com/repos/AltimateAI/altimate-code/releases/latest | grep -o '"tag_name": *"[^"]*"' | cut -d'"' -f4) - VERSION="${VERSION#v}" # the installer's --version expects no leading 'v' + set -euo pipefail + if [[ "${ACTION_REF:-}" =~ ^v?([0-9]+\.[0-9]+\.[0-9]+)$ ]]; then + VERSION="${BASH_REMATCH[1]}" + else + VERSION=$( + curl -sf --connect-timeout 5 --max-time 15 \ + https://api.github.com/repos/AltimateAI/altimate-code/releases/latest \ + | grep -o '"tag_name": *"[^"]*"' | cut -d'"' -f4 || true + ) + VERSION="${VERSION#v}" # the installer's --version expects no leading 'v' + fi echo "version=${VERSION:-latest}" >> $GITHUB_OUTPUT - name: Cache altimate-code @@ -107,6 +118,7 @@ runs: echo "::error::altimate_api_key is set but altimate_instance is empty — provide the tenant/instance name." exit 1 fi + umask 077 mkdir -p "$HOME/.altimate" jq -nc \ --arg url "${IN_ALT_URL:-https://api.myaltimate.com}" \ diff --git a/github/review/examples/altimate-ingestion.yml b/github/review/examples/altimate-ingestion.yml index 2527451b3f..dc52867909 100644 --- a/github/review/examples/altimate-ingestion.yml +++ b/github/review/examples/altimate-ingestion.yml @@ -64,7 +64,7 @@ jobs: - name: altimate dbt PR review # Pin to the altimate-code release that ships `altimate review` # (the first release cut after the dbt-pr-review feature merges). - uses: AltimateAI/altimate-code/github/review@v0.8.0 + uses: AltimateAI/altimate-code/github/review@v0.8.5 with: mode: comment # start non-blocking; switch to `gate` once trusted manifest_path: target/manifest.json diff --git a/packages/opencode/src/altimate/review/orchestrate.ts b/packages/opencode/src/altimate/review/orchestrate.ts index 7d20e41381..a72778a642 100644 --- a/packages/opencode/src/altimate/review/orchestrate.ts +++ b/packages/opencode/src/altimate/review/orchestrate.ts @@ -112,6 +112,8 @@ export interface CheckResult { /** High-level engine surface the orchestrator depends on. */ export interface ReviewRunner { + /** True when the configured dbt manifest loaded, independent of model lookup. */ + manifestAvailable?(): Promise impact(model: string): Promise grade(sql: string, dialect: string): Promise check(sql: string, dialect: string, baseSql?: string): Promise @@ -1002,6 +1004,9 @@ export async function runReview(input: OrchestrateInput): Promise f.kind === "model_sql" || f.kind === "python_model") const ctxByPath = new Map() let anyManifest = false + if (input.runner.manifestAvailable) { + anyManifest = await input.runner.manifestAvailable().catch(() => false) + } await Promise.all( modelFiles.map(async (file) => { const model = modelNameFromPath(file.path) diff --git a/packages/opencode/src/altimate/review/runner.ts b/packages/opencode/src/altimate/review/runner.ts index adb61b962c..1ffda22e6b 100644 --- a/packages/opencode/src/altimate/review/runner.ts +++ b/packages/opencode/src/altimate/review/runner.ts @@ -1,4 +1,5 @@ import { Dispatcher } from "../native" +import { parseManifest } from "../native/dbt/manifest" import type { CheckResult, EquivalenceResult, GradeResult, ImpactResult, ReviewRunner } from "./orchestrate" import { buildReviewSchemaContext, type SchemaContext } from "./schema-context" @@ -116,7 +117,10 @@ export function createDispatcherRunner(opts: DispatcherRunnerOptions): ReviewRun if (!manifestPromise) { manifestPromise = (async () => { try { - const res = await Dispatcher.call("dbt.manifest", { path: opts.manifestPath }) + // Manifest parsing is pure TypeScript. Keep it independent from the + // native dispatcher registration path so a core-loading failure + // cannot incorrectly downgrade a valid dbt run to lint-only. + const res = await parseManifest({ path: opts.manifestPath }) const models = new Map() const byName = new Map() const children = new Map() @@ -239,6 +243,10 @@ export function createDispatcherRunner(opts: DispatcherRunnerOptions): ReviewRun } return { + async manifestAvailable(): Promise { + return (await loadManifest()).ok + }, + async impact(model: string): Promise { const mf = await loadManifest() if (!mf.ok) { diff --git a/packages/opencode/src/cli/cmd/github.ts b/packages/opencode/src/cli/cmd/github.ts index 10914b22a2..8230ab7916 100644 --- a/packages/opencode/src/cli/cmd/github.ts +++ b/packages/opencode/src/cli/cmd/github.ts @@ -141,6 +141,7 @@ type IssueQueryResponse = { const AGENT_USERNAME = "altimate-code-agent[bot]" const AGENT_REACTION = "eyes" const WORKFLOW_FILE = ".github/workflows/altimate-code.yml" +export const GITHUB_APP_INSTALL_URL = "https://github.com/apps/altimate-code-agent/installations/new" // altimate_change end // Event categories for routing @@ -333,7 +334,7 @@ export const GithubInstallCommand = cmd({ // Open browser // altimate_change start — upstream_fix: GitHub App slug is altimate-code-agent - const url = "https://github.com/apps/altimate-code-agent" + const url = GITHUB_APP_INSTALL_URL // altimate_change end const command = process.platform === "darwin" diff --git a/packages/opencode/test/altimate/review-runner.test.ts b/packages/opencode/test/altimate/review-runner.test.ts new file mode 100644 index 0000000000..f055149fb8 --- /dev/null +++ b/packages/opencode/test/altimate/review-runner.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, test } from "bun:test" +import { writeFileSync } from "node:fs" +import path from "node:path" +import { createDispatcherRunner } from "../../src/altimate/review/runner" +import { tmpdir } from "../fixture/fixture" + +describe("review manifest loading", () => { + test("loads a valid manifest without initializing the native dispatcher", async () => { + await using tmp = await tmpdir() + const manifestPath = path.join(tmp.path, "manifest.json") + writeFileSync( + manifestPath, + JSON.stringify({ + metadata: { adapter_type: "duckdb" }, + nodes: { + "model.demo.orders": { + resource_type: "model", + name: "orders", + original_file_path: "models/orders.sql", + config: { materialized: "table" }, + depends_on: { nodes: [] }, + columns: {}, + }, + }, + sources: {}, + }), + ) + + const runner = createDispatcherRunner({ manifestPath }) + expect(await runner.manifestAvailable?.()).toBe(true) + expect(await runner.impact("orders")).toEqual({ + hasManifest: true, + severity: "SAFE", + directCount: 0, + transitiveCount: 0, + testCount: 0, + }) + }) +}) diff --git a/packages/opencode/test/altimate/review.test.ts b/packages/opencode/test/altimate/review.test.ts index 5a7eb099c2..0faf6a191e 100644 --- a/packages/opencode/test/altimate/review.test.ts +++ b/packages/opencode/test/altimate/review.test.ts @@ -1079,7 +1079,7 @@ describe("orchestrate", () => { const runner: ReviewRunner = { ...fakeRunner({}), async impact() { - return { hasManifest: true, severity: "SAFE", directCount: 0, transitiveCount: 0, testCount: 0 } + return { hasManifest: false, severity: "UNKNOWN", directCount: 0, transitiveCount: 0, testCount: 0 } }, async equivalence() { return { decided: true, equivalent: false, differences: ["filter changed"], confidence: "high" } @@ -1229,6 +1229,50 @@ describe("orchestrate", () => { expect(["APPROVE", "COMMENT"]).toContain(env.verdict) }) + test("loaded manifest is not marked lint-only when a changed model is absent from it", async () => { + const files: ChangedFile[] = [{ path: "models/staging/new_model.sql", status: "added", diff: "+select 1\n" }] + const runner: ReviewRunner = { + ...fakeRunner({}), + async manifestAvailable() { + return true + }, + async impact() { + return { hasManifest: false, severity: "UNKNOWN", directCount: 0, transitiveCount: 0, testCount: 0 } + }, + } + const env = await runReview({ + changedFiles: files, + config: { ...DEFAULT_REVIEW_CONFIG, reviewers: ["sql_quality"] }, + rubric: DEFAULT_RUBRIC, + mode: "comment", + runner, + getContent: content("select 1 as value"), + }) + expect(env.summary.degraded).toBe(false) + }) + + test("manifest availability errors degrade safely instead of aborting the review", async () => { + const files: ChangedFile[] = [{ path: "models/staging/stg_x.sql", status: "modified", diff: "+select 1\n" }] + const runner: ReviewRunner = { + ...fakeRunner({}), + async manifestAvailable() { + throw new Error("manifest unreadable") + }, + async impact() { + return { hasManifest: false, severity: "UNKNOWN", directCount: 0, transitiveCount: 0, testCount: 0 } + }, + } + const env = await runReview({ + changedFiles: files, + config: { ...DEFAULT_REVIEW_CONFIG, reviewers: ["sql_quality"] }, + rubric: DEFAULT_RUBRIC, + mode: "comment", + runner, + getContent: content("select 1 as value"), + }) + expect(env.summary.degraded).toBe(true) + }) + test("renderSummary + inlineComments produce marker + structured output", async () => { const env = buildEnvelope({ findings: [ diff --git a/packages/opencode/test/cli/github-action.test.ts b/packages/opencode/test/cli/github-action.test.ts index 279ed27d08..a9e8ed97e5 100644 --- a/packages/opencode/test/cli/github-action.test.ts +++ b/packages/opencode/test/cli/github-action.test.ts @@ -1,8 +1,12 @@ import { test, expect, describe } from "bun:test" -import { extractResponseText, formatPromptTooLargeError } from "../../src/cli/cmd/github" +import { extractResponseText, formatPromptTooLargeError, GITHUB_APP_INSTALL_URL } from "../../src/cli/cmd/github" import type { MessageV2 } from "../../src/session/message-v2" import { SessionID, MessageID, PartID } from "../../src/session/schema" +test("GitHub App install URL opens the repository-selection flow", () => { + expect(GITHUB_APP_INSTALL_URL).toBe("https://github.com/apps/altimate-code-agent/installations/new") +}) + // Helper to create minimal valid parts function createTextPart(text: string): MessageV2.Part { return { diff --git a/packages/opencode/test/install/repository-symlinks.test.ts b/packages/opencode/test/install/repository-symlinks.test.ts new file mode 100644 index 0000000000..465efb708f --- /dev/null +++ b/packages/opencode/test/install/repository-symlinks.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, test } from "bun:test" +import { existsSync, lstatSync, statSync } from "node:fs" +import path from "node:path" + +const root = path.resolve(import.meta.dir, "../../../..") +const vscodeImages = ["button-dark.svg", "button-light.svg", "icon.png"] + +describe("release archive assets", () => { + test("VS Code images are self-contained regular files", () => { + for (const name of vscodeImages) { + const asset = path.join(root, "sdks/vscode/images", name) + expect(existsSync(asset)).toBe(true) + expect(lstatSync(asset).isSymbolicLink()).toBe(false) + expect(statSync(asset).size).toBeGreaterThan(0) + } + }) +}) diff --git a/packages/opencode/test/skill/release-v0.8.5-adversarial.test.ts b/packages/opencode/test/skill/release-v0.8.5-adversarial.test.ts new file mode 100644 index 0000000000..b4b72eddce --- /dev/null +++ b/packages/opencode/test/skill/release-v0.8.5-adversarial.test.ts @@ -0,0 +1,314 @@ +/** + * Adversarial and end-to-end coverage for the v0.8.5 composite-action repair. + */ + +import { $ } from "bun" +import { describe, expect, test } from "bun:test" +import fs from "node:fs/promises" +import path from "node:path" +import { pathToFileURL } from "node:url" +import YAML from "yaml" +import { tmpdir } from "../fixture/fixture" + +const repoRoot = path.resolve(import.meta.dir, "../../../..") +const actionPath = path.join(repoRoot, "github/review/action.yml") + +type ActionStep = { + name?: string + run?: string + shell?: string + env?: Record +} + +async function actionSteps(): Promise { + const action = YAML.parse(await fs.readFile(actionPath, "utf8")) + return action.runs.steps +} + +async function actionScript(name: string): Promise { + const step = (await actionSteps()).find((item) => item.name === name) + expect(step?.run).toBeString() + return step!.run! +} + +async function runBash(script: string, env: Record) { + const proc = Bun.spawn(["bash", "-c", script], { + env: { ...process.env, ...env }, + stdout: "pipe", + stderr: "pipe", + }) + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]) + return { stdout, stderr, exitCode } +} + +describe("v0.8.5 adversarial - composite action", () => { + test("the action is valid composite-action YAML with bash run steps", async () => { + const action = YAML.parse(await fs.readFile(actionPath, "utf8")) + expect(action.runs.using).toBe("composite") + expect(action.runs.steps.length).toBeGreaterThan(0) + for (const step of action.runs.steps as ActionStep[]) { + if (step.run) expect(step.shell).toBe("bash") + } + }) + + test("a semver action ref pins the matching binary without consulting latest", async () => { + await using tmp = await tmpdir() + const bin = path.join(tmp.path, "bin") + const output = path.join(tmp.path, "github-output") + const curlMarker = path.join(tmp.path, "curl-called") + await fs.mkdir(bin) + await Bun.write( + path.join(bin, "curl"), + `#!/usr/bin/env bash\ntouch "$CURL_MARKER"\nexit 99\n`, + ) + await fs.chmod(path.join(bin, "curl"), 0o755) + + const result = await runBash(await actionScript("Get altimate-code version"), { + ACTION_REF: "v0.8.5", + GITHUB_OUTPUT: output, + CURL_MARKER: curlMarker, + PATH: `${bin}:${process.env.PATH}`, + }) + + expect(result.exitCode).toBe(0) + expect(await fs.readFile(output, "utf8")).toBe("version=0.8.5\n") + expect(await fs.stat(curlMarker).then(() => true).catch(() => false)).toBe(false) + }) + + test("the non-semver action ref fallback bounds the release lookup", async () => { + await using tmp = await tmpdir() + const bin = path.join(tmp.path, "bin") + const output = path.join(tmp.path, "github-output") + const argsPath = path.join(tmp.path, "curl-args") + await fs.mkdir(bin) + await Bun.write( + path.join(bin, "curl"), + `#!/usr/bin/env bash\nprintf '%s\\0' "$@" > "$CURL_ARGS"\nprintf '{"tag_name":"v0.8.5"}\\n'\n`, + ) + await fs.chmod(path.join(bin, "curl"), 0o755) + + const result = await runBash(await actionScript("Get altimate-code version"), { + ACTION_REF: "main", + CURL_ARGS: argsPath, + GITHUB_OUTPUT: output, + PATH: `${bin}:${process.env.PATH}`, + }) + + expect(result.exitCode, result.stderr).toBe(0) + expect(await fs.readFile(output, "utf8")).toBe("version=0.8.5\n") + const curlArgs = (await fs.readFile(argsPath, "utf8")).split("\0").filter(Boolean) + expect(curlArgs).toContain("--connect-timeout") + expect(curlArgs).toContain("5") + expect(curlArgs).toContain("--max-time") + expect(curlArgs).toContain("15") + }) + + test("hostile refs and paths are forwarded as data, never evaluated by bash", async () => { + await using tmp = await tmpdir() + const bin = path.join(tmp.path, "bin") + const capture = path.join(tmp.path, "args") + const sentinel = path.join(tmp.path, "injected") + await fs.mkdir(bin) + await Bun.write( + path.join(bin, "altimate"), + `#!/usr/bin/env bash\nprintf '%s\\0' "$@" > "$CAPTURE"\n`, + ) + await fs.chmod(path.join(bin, "altimate"), 0o755) + + const manifest = `target/manifest $(touch ${sentinel}) .json` + const base = `main; touch ${sentinel}` + const head = `HEAD && touch ${sentinel}` + const result = await runBash(await actionScript("Run dbt PR review"), { + CAPTURE: capture, + PATH: `${bin}:${process.env.PATH}`, + IN_MODE: "comment", + IN_MANIFEST: manifest, + IN_SEVERITY: "suggestion", + IN_BASE: base, + IN_HEAD: head, + IN_POST: "true", + GITHUB_TOKEN: "", + GITHUB_REPOSITORY: "AltimateAI/example", + GITHUB_EVENT_PATH: "", + ALTIMATE_REVIEW_SIGNING_KEY: "", + }) + + expect(result.exitCode).toBe(0) + expect(await fs.stat(sentinel).then(() => true).catch(() => false)).toBe(false) + const args = (await fs.readFile(capture, "utf8")).split("\0").filter(Boolean) + expect(args).toEqual([ + "review", + "--mode", + "comment", + "--manifest", + manifest, + "--severity", + "suggestion", + "--base", + base, + "--head", + head, + "--post", + ]) + }) + + test("hosted credentials are written owner-only and never printed", async () => { + await using tmp = await tmpdir() + const githubEnv = path.join(tmp.path, "github-env") + const secret = "alt-secret-that-must-not-leak" + const result = await runBash(await actionScript("Configure advisory reviewer model + credentials"), { + HOME: tmp.path, + GITHUB_ENV: githubEnv, + IN_ALT_KEY: secret, + IN_ALT_INSTANCE: "demo", + IN_ALT_URL: "https://api.example.test", + IN_MODEL: "", + IN_MODEL_API_KEY: "", + }) + + expect(result.exitCode).toBe(0) + expect(result.stdout + result.stderr).not.toContain(secret) + const credentialPath = path.join(tmp.path, ".altimate/altimate.json") + expect((await fs.stat(credentialPath)).mode & 0o777).toBe(0o600) + expect(JSON.parse(await fs.readFile(credentialPath, "utf8"))).toEqual({ + altimateUrl: "https://api.example.test", + altimateInstanceName: "demo", + altimateApiKey: secret, + }) + }) + + test("invalid credential combinations fail without leaking secrets", async () => { + await using tmp = await tmpdir() + const secret = "must-not-appear-in-logs" + const result = await runBash(await actionScript("Configure advisory reviewer model + credentials"), { + HOME: tmp.path, + GITHUB_ENV: path.join(tmp.path, "github-env"), + IN_ALT_KEY: secret, + IN_ALT_INSTANCE: "", + IN_ALT_URL: "https://api.example.test", + IN_MODEL: "", + IN_MODEL_API_KEY: "", + }) + + expect(result.exitCode).toBe(1) + expect(result.stdout + result.stderr).not.toContain(secret) + expect(result.stdout + result.stderr).toContain("altimate_instance is empty") + expect(await fs.stat(path.join(tmp.path, ".altimate/altimate.json")).then(() => true).catch(() => false)).toBe( + false, + ) + }) + + test("the committed archive contains regular, non-empty action dependencies", async () => { + await using tmp = await tmpdir() + const archive = path.join(tmp.path, "release.tar") + const extracted = path.join(tmp.path, "extracted") + await fs.mkdir(extracted) + await $`git archive --format=tar --output=${archive} HEAD`.cwd(repoRoot).quiet() + await $`tar -xf ${archive} -C ${extracted}`.quiet() + + for (const name of ["button-dark.svg", "button-light.svg", "icon.png"]) { + const asset = path.join(extracted, "sdks/vscode/images", name) + const stat = await fs.lstat(asset) + expect(stat.isSymbolicLink()).toBe(false) + expect(stat.isFile()).toBe(true) + expect(stat.size).toBeGreaterThan(0) + } + expect((await fs.stat(path.join(extracted, "github/review/action.yml"))).size).toBeGreaterThan(0) + }) + + test("docs point at the unreleased action patch, not the already-published broken tag", async () => { + const changelog = await fs.readFile(path.join(repoRoot, "CHANGELOG.md"), "utf8") + const version = changelog.match(/^## \[(\d+\.\d+\.\d+)\] - Unreleased$/m)?.[1] + expect(version).toBe("0.8.5") + + for (const relative of [ + "docs/docs/usage/dbt-pr-review.md", + "github/review/examples/altimate-ingestion.yml", + ]) { + const content = await fs.readFile(path.join(repoRoot, relative), "utf8") + expect(content).toContain(`AltimateAI/altimate-code/github/review@v${version}`) + expect(content).not.toContain("AltimateAI/altimate-code/github/review@v0.8.4") + } + }) +}) + +describe("v0.8.5 end-to-end - real review pipeline", () => { + test("reviews a changed dbt model from git with a valid manifest", async () => { + await using tmp = await tmpdir({ git: true }) + const modelPath = path.join(tmp.path, "models/orders.sql") + const manifestPath = path.join(tmp.path, "target/manifest.json") + await fs.mkdir(path.dirname(modelPath), { recursive: true }) + await fs.mkdir(path.dirname(manifestPath), { recursive: true }) + await Bun.write(path.join(tmp.path, "dbt_project.yml"), "name: demo\nversion: 1.0.0\nprofile: demo\n") + await Bun.write(modelPath, "select 1 as order_id\n") + await Bun.write( + manifestPath, + JSON.stringify({ + metadata: { adapter_type: "duckdb" }, + nodes: { + "model.demo.orders": { + unique_id: "model.demo.orders", + resource_type: "model", + name: "orders", + original_file_path: "models/orders.sql", + config: { materialized: "table" }, + depends_on: { nodes: [] }, + columns: { order_id: { name: "order_id", data_type: "integer" } }, + }, + }, + sources: {}, + }), + ) + await $`git add dbt_project.yml models/orders.sql target/manifest.json`.cwd(tmp.path).quiet() + await $`git commit -m base`.cwd(tmp.path).quiet() + await Bun.write(modelPath, "select 1 as order_id, 'paid' as status\n") + + const runnerPath = path.join(tmp.path, "run-review-e2e.ts") + await Bun.write( + runnerPath, + ` + import { collectChangedFiles } from ${JSON.stringify(pathToFileURL(path.join(repoRoot, "packages/opencode/src/altimate/review/git.ts")).href)} + import { reviewPullRequest } from ${JSON.stringify(pathToFileURL(path.join(repoRoot, "packages/opencode/src/altimate/review/run.ts")).href)} + + const cwd = process.argv[2] + const changed = await collectChangedFiles({ cwd, base: "HEAD" }) + if (JSON.stringify(changed.map((file) => file.path)) !== JSON.stringify(["models/orders.sql"])) { + throw new Error("changed files mismatch: " + JSON.stringify(changed)) + } + const result = await reviewPullRequest({ + cwd, + base: "HEAD", + manifestPath: "target/manifest.json", + mode: "comment", + noAi: true, + }) + console.log(JSON.stringify({ + degraded: result.summary.degraded, + manifestHash: result.manifestHash, + verdict: result.verdict, + })) + `, + ) + const proc = Bun.spawn(["bun", runnerPath, tmp.path], { + cwd: path.join(repoRoot, "packages/opencode"), + stdout: "pipe", + stderr: "pipe", + env: { ...process.env }, + }) + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]) + + expect(exitCode, stderr).toBe(0) + const result = JSON.parse(stdout) + expect(result.degraded).toBe(false) + expect(result.manifestHash).toMatch(/^[a-f0-9]{16}$/) + expect(["APPROVE", "COMMENT"]).toContain(result.verdict) + }) +}) diff --git a/sdks/vscode/images/button-dark.svg b/sdks/vscode/images/button-dark.svg deleted file mode 120000 index c0e444a520..0000000000 --- a/sdks/vscode/images/button-dark.svg +++ /dev/null @@ -1 +0,0 @@ -../../../packages/identity/mark.svg \ No newline at end of file diff --git a/sdks/vscode/images/button-dark.svg b/sdks/vscode/images/button-dark.svg new file mode 100644 index 0000000000..157edc4d75 --- /dev/null +++ b/sdks/vscode/images/button-dark.svg @@ -0,0 +1,7 @@ + + + + + \ No newline at end of file diff --git a/sdks/vscode/images/button-light.svg b/sdks/vscode/images/button-light.svg deleted file mode 120000 index 4120d51f62..0000000000 --- a/sdks/vscode/images/button-light.svg +++ /dev/null @@ -1 +0,0 @@ -../../../packages/identity/mark-light.svg \ No newline at end of file diff --git a/sdks/vscode/images/button-light.svg b/sdks/vscode/images/button-light.svg new file mode 100644 index 0000000000..ac619f1b2f --- /dev/null +++ b/sdks/vscode/images/button-light.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/sdks/vscode/images/icon.png b/sdks/vscode/images/icon.png deleted file mode 120000 index d6bfa6e7ca..0000000000 --- a/sdks/vscode/images/icon.png +++ /dev/null @@ -1 +0,0 @@ -../../../packages/identity/mark-512x512.png \ No newline at end of file diff --git a/sdks/vscode/images/icon.png b/sdks/vscode/images/icon.png new file mode 100644 index 0000000000..48f38fc8c2 Binary files /dev/null and b/sdks/vscode/images/icon.png differ