diff --git a/.github/workflows/continue-pr-review.yml b/.github/workflows/continue-pr-review.yml new file mode 100644 index 0000000..caff5fd --- /dev/null +++ b/.github/workflows/continue-pr-review.yml @@ -0,0 +1,246 @@ +name: Continue PR Review + +# Security: this workflow uses a repository secret to configure opub/Continue. +# It is intentionally limited to non-fork pull_request events. Do not switch this +# workflow to pull_request_target while checking out or executing PR code. +# This job must not check out or execute PR-head code before secret-bearing steps; +# it reviews metadata/diff only from the trusted base checkout plus GitHub API. +# If fork support is needed later, split trusted/internal and untrusted/fork paths. + +on: + pull_request: + types: + - opened + - synchronize + - reopened + - ready_for_review + +permissions: + contents: read + pull-requests: write + issues: write + +jobs: + fork-notice: + if: github.event.pull_request.draft == false && github.event.pull_request.head.repo.fork == true + runs-on: ubuntu-24.04 + env: + PR_NUMBER: ${{ github.event.pull_request.number }} + GH_TOKEN: ${{ github.token }} + steps: + - name: Upsert fork notice comment + run: | + set -euo pipefail + + COMMENT_FILE="$RUNNER_TEMP/pr-comment.md" + MARKER="" + + { + echo "$MARKER" + echo "## Continue PR Review" + echo + echo "_Continue review is only run automatically for trusted non-fork pull requests because this workflow requires repository secrets._" + echo + echo "- This PR comes from a fork, so the secret-backed review job was skipped by design." + echo "- Maintainers can run an internal review workflow or review locally if needed." + } > "$COMMENT_FILE" + + EXISTING_COMMENT_ID="$({ + gh api \ + "/repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments" \ + --paginate \ + --jq '.[] | select(.user.login == "github-actions[bot]" and (.body | contains(""))) | .id' \ + | tail -n1 + } || true)" + + if [ -n "$EXISTING_COMMENT_ID" ]; then + gh api \ + --method PATCH \ + "/repos/$GITHUB_REPOSITORY/issues/comments/$EXISTING_COMMENT_ID" \ + --field body="$(cat "$COMMENT_FILE")" + else + gh pr comment "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" --body-file "$COMMENT_FILE" + fi + + continue-review: + if: github.event.pull_request.draft == false && github.event.pull_request.head.repo.fork == false + runs-on: ubuntu-24.04 + env: + PR_NUMBER: ${{ github.event.pull_request.number }} + GH_TOKEN: ${{ github.token }} + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.base.sha }} + fetch-depth: 1 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Initialize workflow environment + run: | + echo "OPUB_HOME=$RUNNER_TEMP/opub" >> "$GITHUB_ENV" + echo "REVIEW_FILE=$RUNNER_TEMP/continue-review.md" >> "$GITHUB_ENV" + echo "SANITIZED_REVIEW_FILE=$RUNNER_TEMP/continue-review-sanitized.md" >> "$GITHUB_ENV" + + - name: Install GitHub CLI + run: | + type -p gh >/dev/null || { + sudo apt-get update + sudo apt-get install -y gh + } + + - name: Install opub + run: curl -fsSL https://opub.dev/install.sh | sh + + - name: Add installed tools to PATH + run: | + echo "$HOME/.local/bin" >> "$GITHUB_PATH" + echo "$HOME/go/bin" >> "$GITHUB_PATH" + + - name: Install Continue CLI + run: npm install -g @continuedev/cli@1.5.45 + + - name: Verify installed CLIs + run: | + opub --version + cn --help >/dev/null + gh --version + + - name: Configure opub for Continue + env: + OPUB_PROVIDER_KEY: ${{ secrets.CONTINUE_CI_OPUB }} + run: | + test -n "$OPUB_PROVIDER_KEY" + printf '%s\n' "$OPUB_PROVIDER_KEY" | opub setup continue \ + --project 'opubdev/opub-cli' \ + --project-id 4 \ + --compute-key-id 3 \ + --api-key-hash '231ed9ad...90f1e7d8' \ + --insecure-storage \ + --provider-key-stdin + + + - name: Fetch PR metadata and diff + run: | + set -euo pipefail + + DIFF_FILE="$RUNNER_TEMP/pr.diff" + CHANGED_FILE_LIST="$RUNNER_TEMP/changed-files.txt" + gh api \ + -H "Accept: application/vnd.github.v3.diff" \ + "/repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER" > "$DIFF_FILE" + gh api \ + "/repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/files" \ + --paginate \ + --jq '.[].filename' > "$CHANGED_FILE_LIST" + + - name: Run Continue review + run: | + set -euo pipefail + + DIFF_FILE="$RUNNER_TEMP/pr.diff" + CHANGED_FILE_LIST="$RUNNER_TEMP/changed-files.txt" + REVIEW_INPUT="$RUNNER_TEMP/review-input.txt" + REVIEW_PROMPT_FILE="$RUNNER_TEMP/review-prompt.txt" + MAX_DIFF_BYTES=120000 + MAX_CHANGED_FILES=250 + + DIFF_BYTES=$(wc -c < "$DIFF_FILE" | tr -d '[:space:]') + CHANGED_FILES_COUNT=$(wc -l < "$CHANGED_FILE_LIST" | tr -d '[:space:]') + DIFF_TRUNCATED=false + + if [ "$CHANGED_FILES_COUNT" -gt "$MAX_CHANGED_FILES" ]; then + echo "PR changes $CHANGED_FILES_COUNT files; max supported is $MAX_CHANGED_FILES for automatic review." >&2 + exit 1 + fi + + if [ "$DIFF_BYTES" -gt "$MAX_DIFF_BYTES" ]; then + python3 -c 'import pathlib, sys; path = pathlib.Path(sys.argv[1]); limit = int(sys.argv[2]); data = path.read_text(encoding="utf-8", errors="replace"); path.write_text(data[:limit], encoding="utf-8")' "$DIFF_FILE" "$MAX_DIFF_BYTES" + DIFF_TRUNCATED=true + fi + + cat > "$REVIEW_PROMPT_FILE" <<'PROMPT' + Review this pull request diff for bugs, regressions, security issues, and missing tests. + Focus especially on opub invariants around secrets, MCP response discipline, session linking, and target completeness. + Keep the review concise and practical. + The review input was collected without checking out PR-head code; reason only from the provided diff and changed file list. + If the input says the diff was truncated, mention that limitation in the summary and avoid overclaiming coverage. + Output markdown with these sections exactly: + + ## Summary + + ## Findings + - Use bullets. If there are no issues, write `- No actionable issues found.` + + ## Suggested follow-ups + - Use bullets. If none, write `- None.` + PROMPT + + { + echo "You are reviewing PR #$PR_NUMBER for repository $GITHUB_REPOSITORY." + echo + echo "Base branch: ${{ github.event.pull_request.base.ref }}" + echo "Base SHA checked out in this job: ${{ github.event.pull_request.base.sha }}" + echo "PR head SHA under review: ${{ github.event.pull_request.head.sha }}" + echo + echo "Changed files count: $CHANGED_FILES_COUNT" + echo "Diff bytes before truncation: $DIFF_BYTES" + echo "Diff truncated: $DIFF_TRUNCATED" + echo + echo "Changed files:" + cat "$CHANGED_FILE_LIST" + echo + echo "Diff:" + cat "$DIFF_FILE" + echo + cat "$REVIEW_PROMPT_FILE" + } > "$REVIEW_INPUT" + + opub run continue -- \ + -p "$(cat "$REVIEW_INPUT")" \ + --silent > "$REVIEW_FILE" + + test -s "$REVIEW_FILE" + + - name: Upsert PR comment + run: | + set -euo pipefail + + COMMENT_FILE="$RUNNER_TEMP/pr-comment.md" + MARKER="" + + python3 -c 'import pathlib, re, sys; source = pathlib.Path(sys.argv[1]).read_text(encoding="utf-8", errors="replace"); source = re.sub(r"(^|[^\\w`])@([A-Za-z0-9][A-Za-z0-9_-]*(?:/[A-Za-z0-9][A-Za-z0-9_-]*)?)", lambda m: f"{m.group(1)}@\u200b{m.group(2)}", source); source = re.sub(r"(? "$COMMENT_FILE" + + EXISTING_COMMENT_ID="$({ + gh api \ + "/repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments" \ + --paginate \ + --jq '.[] | select(.user.login == "github-actions[bot]" and (.body | contains(""))) | .id' \ + | tail -n1 + } || true)" + + if [ -n "$EXISTING_COMMENT_ID" ]; then + gh api \ + --method PATCH \ + "/repos/$GITHUB_REPOSITORY/issues/comments/$EXISTING_COMMENT_ID" \ + --field body="$(cat "$COMMENT_FILE")" + else + gh pr comment "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" --body-file "$COMMENT_FILE" + fi diff --git a/main_test.go b/main_test.go index 25c3787..abc5131 100644 --- a/main_test.go +++ b/main_test.go @@ -157,6 +157,116 @@ func TestUnhandledTargetPanics(t *testing.T) { _ = Target(999).name() } +func TestParseTargetSupportedTargetsMessageFollowsAllTargets(t *testing.T) { + _, err := parseTarget("nope") + if err == nil { + t.Fatal("expected unsupported target error") + } + + var names []string + for _, target := range allTargets { + names = append(names, target.name()) + } + want := `unsupported target "nope"; supported targets: ` + strings.Join(names, ", ") + if err.Error() != want { + t.Fatalf("parseTarget error = %q, want %q", err.Error(), want) + } +} + +func TestAllTargetsOrdering(t *testing.T) { + var got []string + for _, target := range allTargets { + got = append(got, target.name()) + } + want := []string{"claude", "codex", "copilot", "vibe", "opencode", "continue"} + if strings.Join(got, "\x00") != strings.Join(want, "\x00") { + t.Fatalf("allTargets ordering = %#v, want %#v", got, want) + } +} + +func TestAllTargetsFullyWired(t *testing.T) { + repoRoot := t.TempDir() + stateDir := t.TempDir() + homeDir := t.TempDir() + binDir := t.TempDir() + stateRoot := t.TempDir() + copilotArgs := filepath.Join(t.TempDir(), "copilot.args") + + t.Setenv("HOME", homeDir) + t.Setenv("OPUB_HOME", stateRoot) + t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH")) + writeExecutable(t, filepath.Join(binDir, "copilot"), "#!/bin/sh\nprintf '%s\\n' \"$*\" > \""+copilotArgs+"\"\n") + + cfg := &LocalConfig{ + Project: ProjectConfig{FullName: "owner/repo", ID: stringPtr("proj_123")}, + ComputeKey: ComputeKeyConfig{ID: "ck_123"}, + Provider: ProviderConfig{Name: "openrouter", KeyHash: stringPtr("hash_123")}, + } + session := &LocalSession{ID: "local-1", LaunchProofToken: "runtime-token"} + assets := launchAssets{ + opubSkill: "skill", + copilotPlugin: "plugin", + copilotPluginMCP: "mcp", + copilotSkill: "copilot-skill", + } + + for _, target := range allTargets { + target := target + t.Run(target.name(), func(t *testing.T) { + cfg.Target = TargetConfig{Name: target.name()} + paths := &RepoPaths{ + Root: repoRoot, + StateDir: filepath.Join(stateDir, target.name()), + GithubOrigin: "git@github.com:owner/repo.git", + } + if err := os.MkdirAll(paths.StateDir, 0755); err != nil { + t.Fatalf("mkdir state dir: %v", err) + } + + parsed, err := parseTarget(target.name()) + if err != nil { + t.Fatalf("parseTarget(%q): %v", target.name(), err) + } + if parsed != target { + t.Fatalf("parseTarget(%q) = %v, want %v", target.name(), parsed, target) + } + + if got := target.name(); got == "" { + t.Fatal("empty target name") + } + if got := target.executable(); got == "" { + t.Fatal("empty executable") + } + + env := target.env(cfg, session, "secret", paths) + if !contains(env, "OPUB_TARGET="+target.name()) { + t.Fatalf("env missing OPUB_TARGET for %s: %#v", target.name(), env) + } + + cmd := target.buildCommand([]string{"--help"}, paths, assets) + if len(cmd.Args) == 0 { + t.Fatalf("empty command args for %s", target.name()) + } + if cmd.Args[0] != target.executable() { + t.Fatalf("command executable = %q, want %q", cmd.Args[0], target.executable()) + } + + if err := target.refresh(nil, paths, assets); err != nil { + t.Fatalf("refresh(%s): %v", target.name(), err) + } + assertTargetRefreshEffects(t, target, paths, assets, stateRoot, copilotArgs) + + _ = skillDir(target) + if got := skillStatus(target); got == "" { + t.Fatalf("empty skill status for %s", target.name()) + } + if got := mcpStatus(target); got == "" { + t.Fatalf("empty mcp status for %s", target.name()) + } + }) + } +} + func TestTargetRunEnvironmentPerTarget(t *testing.T) { cfg := &LocalConfig{ Project: ProjectConfig{FullName: "owner/repo", ID: stringPtr("proj_123")}, @@ -276,18 +386,11 @@ func TestMCPToolListAndSessionCalls(t *testing.T) { if missing["state"] != "missing" || missing["active"] != false { t.Fatalf("missing session state = %#v", missing) } - assertNoArtifactFields(t, missing) + assertMinimalMCPResponse(t, missing) + assertMinimalLinkJSON(t, missing, false) status := computeStatus(state) - assertNoArtifactFields(t, status) - if _, ok := status["workflow_guidance"]; ok { - t.Fatalf("compute status should not include workflow_guidance: %#v", status) - } - if _, ok := status["evidence"]; ok { - t.Fatalf("compute status should not include evidence: %#v", status) - } - if link := status["link"].(map[string]interface{}); len(link) != 1 || link["linked"] != false { - t.Fatalf("compute status link should be minimal: %#v", link) - } + assertMinimalMCPResponse(t, status) + assertMinimalLinkJSON(t, status, false) session, err := writeSession(paths, state.config) if err != nil { @@ -298,8 +401,8 @@ func TestMCPToolListAndSessionCalls(t *testing.T) { if err != nil { t.Fatalf("load active state: %v", err) } - assertNoArtifactFields(t, activeWorkSession(state)) - assertNoArtifactFields(t, computeStatus(state)) + assertMinimalMCPResponse(t, activeWorkSession(state)) + assertMinimalMCPResponse(t, computeStatus(state)) input := strings.Join([]string{ `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}`, @@ -337,15 +440,8 @@ func TestMCPToolListAndSessionCalls(t *testing.T) { t.Fatalf("linked MCP response missing:\n%s", out.String()) } activeResponse := structuredContent(t, responses[2]) - if _, ok := activeResponse["workflow_guidance"]; ok { - t.Fatalf("active session response should not include workflow_guidance: %#v", activeResponse) - } - if _, ok := activeResponse["evidence"]; ok { - t.Fatalf("active session response should not include evidence: %#v", activeResponse) - } - if link := activeResponse["link"].(map[string]interface{}); len(link) != 1 || link["linked"] != true { - t.Fatalf("active session link should be minimal: %#v", link) - } + assertMinimalMCPResponse(t, activeResponse) + assertMinimalLinkJSON(t, activeResponse, true) finished, err := readSession(paths) if err != nil { @@ -506,10 +602,8 @@ func TestLegacyLinkedArtifactsSessionLoadsUnverifiedAndCannotFinish(t *testing.T if _, err := finishWorkSession(state); err == nil || !strings.Contains(err.Error(), "opub work session is unverified") { t.Fatalf("legacy finish error = %v", err) } - resultJSON, _ := json.Marshal(active) - if strings.Contains(string(resultJSON), "linked_artifacts") || strings.Contains(string(resultJSON), "linked_artifacts_count") { - t.Fatalf("legacy artifact fields leaked into active response: %s", string(resultJSON)) - } + assertMinimalMCPResponse(t, active) + assertMinimalLinkJSON(t, active, false) sessionBytes, err := os.ReadFile(paths.Session) if err != nil { @@ -567,8 +661,8 @@ func TestSessionReuseStaleFinishedAndMismatchBehavior(t *testing.T) { if _, err := finishWorkSession(staleState); err == nil || !strings.Contains(err.Error(), "opub work session is stale") { t.Fatalf("stale finish error = %v", err) } - assertNoArtifactFields(t, activeWorkSession(staleState)) - assertNoArtifactFields(t, computeStatus(staleState)) + assertMinimalMCPResponse(t, activeWorkSession(staleState)) + assertMinimalMCPResponse(t, computeStatus(staleState)) now := unixNow() stale.FinishedAtUnix = &now @@ -594,8 +688,8 @@ func TestSessionReuseStaleFinishedAndMismatchBehavior(t *testing.T) { if _, err := finishWorkSession(finishedState); err == nil || !strings.Contains(err.Error(), "opub work session is finished") { t.Fatalf("finished finish error = %v", err) } - assertNoArtifactFields(t, activeWorkSession(finishedState)) - assertNoArtifactFields(t, computeStatus(finishedState)) + assertMinimalMCPResponse(t, activeWorkSession(finishedState)) + assertMinimalMCPResponse(t, computeStatus(finishedState)) fresh.ProjectFullName = "other/repo" if err := persistSession(paths, fresh); err != nil { @@ -620,8 +714,8 @@ func TestFinishWorkSessionRejectsMissingSession(t *testing.T) { if _, err := finishWorkSession(state); err == nil || !strings.Contains(err.Error(), "opub work session is missing") { t.Fatalf("missing finish error = %v", err) } - assertNoArtifactFields(t, activeWorkSession(state)) - assertNoArtifactFields(t, computeStatus(state)) + assertMinimalMCPResponse(t, activeWorkSession(state)) + assertMinimalMCPResponse(t, computeStatus(state)) } func TestVibeEnvironment(t *testing.T) { @@ -772,6 +866,69 @@ func TestRefreshContinueConfigWritesSecretlessYaml(t *testing.T) { } } +func assertTargetRefreshEffects(t *testing.T, target Target, paths *RepoPaths, assets launchAssets, stateRoot, copilotArgs string) { + t.Helper() + + if target != TargetClaude && target != TargetCopilot && target != TargetContinue { + skillPath := filepath.Join(skillDir(target), "SKILL.md") + got, err := os.ReadFile(skillPath) + if err != nil { + t.Fatalf("read skill for %s: %v", target.name(), err) + } + if string(got) != assets.opubSkill { + t.Fatalf("skill content for %s = %q, want %q", target.name(), got, assets.opubSkill) + } + } + + switch target { + case TargetCopilot: + for path, want := range map[string]string{ + filepath.Join(stateRoot, "plugins", "copilot-opub", "plugin.json"): assets.copilotPlugin, + filepath.Join(stateRoot, "plugins", "copilot-opub", ".mcp.json"): assets.copilotPluginMCP, + filepath.Join(stateRoot, "plugins", "copilot-opub", "skills", "opub-funded-work", "SKILL.md"): assets.copilotSkill, + } { + got, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read %s: %v", path, err) + } + if string(got) != want { + t.Fatalf("%s = %q, want %q", path, got, want) + } + } + args, err := os.ReadFile(copilotArgs) + if err != nil { + t.Fatalf("read copilot invocation: %v", err) + } + if !strings.HasPrefix(string(args), "plugin install "+filepath.Join(stateRoot, "plugins", "copilot-opub")) { + t.Fatalf("unexpected copilot invocation: %q", args) + } + case TargetVibe: + got, err := os.ReadFile(vibeConfigPath(paths.StateDir)) + if err != nil { + t.Fatalf("read vibe config: %v", err) + } + if !strings.Contains(string(got), `"--target", "vibe"`) { + t.Fatalf("vibe config missing target marker:\n%s", string(got)) + } + case TargetOpenCode: + got, err := os.ReadFile(filepath.Join(paths.StateDir, "opencode.json")) + if err != nil { + t.Fatalf("read opencode config: %v", err) + } + if !strings.Contains(string(got), `"--target",`) || !strings.Contains(string(got), `"opencode"`) { + t.Fatalf("opencode config missing target marker:\n%s", string(got)) + } + case TargetContinue: + got, err := os.ReadFile(continueConfigPath(paths.StateDir)) + if err != nil { + t.Fatalf("read continue config: %v", err) + } + if !strings.Contains(string(got), "- continue") { + t.Fatalf("continue config missing target marker:\n%s", string(got)) + } + } +} + func TestCommandHelpCoverage(t *testing.T) { root := newRootForTest() for _, args := range [][]string{ @@ -947,14 +1104,37 @@ func structuredContent(t *testing.T, msg map[string]interface{}) map[string]inte return content } -func assertNoArtifactFields(t *testing.T, value interface{}) { +func assertMinimalLinkJSON(t *testing.T, value map[string]interface{}, wantLinked bool) { + t.Helper() + link, ok := value["link"].(map[string]interface{}) + if !ok { + t.Fatalf("missing or invalid link object: %#v", value) + } + if len(link) != 1 { + t.Fatalf("link should be minimal: %#v", link) + } + if linked, ok := link["linked"].(bool); !ok || linked != wantLinked { + t.Fatalf("link.linked = %#v, want %v in %#v", link["linked"], wantLinked, link) + } +} + +func assertMinimalMCPResponse(t *testing.T, value interface{}) { t.Helper() data, err := json.Marshal(value) if err != nil { t.Fatalf("marshal value: %v", err) } - if strings.Contains(string(data), "linked_artifacts") || strings.Contains(string(data), "linked_artifacts_count") { - t.Fatalf("artifact fields present in %s", string(data)) + text := string(data) + for _, bad := range []string{ + "workflow_guidance", + "evidence", + "linked_artifacts", + "linked_artifacts_count", + "runtime_proof", + } { + if strings.Contains(text, bad) { + t.Fatalf("forbidden field %q present in %s", bad, text) + } } } diff --git a/target.go b/target.go index c4f52ff..2092b1b 100644 --- a/target.go +++ b/target.go @@ -28,6 +28,15 @@ const ( TargetContinue Target = iota ) +var allTargets = []Target{ + TargetClaude, + TargetCodex, + TargetCopilot, + TargetVibe, + TargetOpenCode, + TargetContinue, +} + const ( claudeDefaultSonnetModel = "anthropic/claude-sonnet-4.6" claudeDefaultHaikuModel = "anthropic/claude-haiku-4.5" @@ -38,22 +47,17 @@ const ( ) func parseTarget(s string) (Target, error) { - switch s { - case "claude": - return TargetClaude, nil - case "codex": - return TargetCodex, nil - case "copilot": - return TargetCopilot, nil - case "vibe": - return TargetVibe, nil - case "opencode": - return TargetOpenCode, nil - case "continue": - return TargetContinue, nil - default: - return 0, fmt.Errorf("unsupported target %q; supported targets: claude, codex, copilot, vibe, opencode, continue", s) + for _, t := range allTargets { + if t.name() == s { + return t, nil + } + } + + var names []string + for _, t := range allTargets { + names = append(names, t.name()) } + return 0, fmt.Errorf("unsupported target %q; supported targets: %s", s, strings.Join(names, ", ")) } func (t Target) name() string { diff --git a/tmp/blog.md b/tmp/blog.md new file mode 100644 index 0000000..5e6e085 --- /dev/null +++ b/tmp/blog.md @@ -0,0 +1,549 @@ +# Building PR Review on Donated Compute with opub + Continue + +There’s a simple but powerful idea here: + +- developers want useful AI review in CI +- maintainers want project-linked usage +- contributors want a low-friction workflow +- teams want a setup that is explicit, auditable, and easy to reason about + +In this session, we wired up exactly that for `opubdev/opub-cli`: + +- local funded work ran through `opub run continue` +- CI review will also run through `opub` +- spend links to the configured opub project because the execution path goes through the opub CLI +- Continue provides the actual review behavior +- GitHub Actions provides the automation + +This post walks through what we built, why it matters, and how to reproduce it. + +--- + +## Why this integration is compelling + +Using Continue directly in CI is useful. + +Using Continue through `opub` is more interesting. + +That gives you: + +- a project-aware execution path +- local work sessions that can be linked to a project +- CI review that runs through the same opub entrypoint +- provider usage that is associated with the configured compute key/project context + +In short: + +> We are not just “calling an AI in CI.” +> We are running AI review through the opub CLI, so spend links to the project. + +That is the story. + +--- + +## What we accomplished + +We did two things in parallel: + +1. **Hardened opub invariants in tests/code** +2. **Added a Continue-powered PR review workflow in GitHub Actions** + +### 1) Hardening invariants + +We verified and strengthened several important behaviors. + +#### Secrets do not leak +We confirmed the system keeps raw provider credentials and launch proof tokens out of persisted config/session JSON and out of secretless MCP responses. + +Examples of what matters: + +- provider key should not be written into config +- raw launch token should not be written into `session.json` +- token should only be exposed where needed for the launched target environment +- generated Continue config should not contain the actual provider secret + +#### Target completeness is tested exhaustively +We identified a maintenance hazard: adding a new target should require wiring all target-specific behavior everywhere. + +To reduce drift, we introduced a canonical target list and tests that walk every target through all expected operations. + +That now exercises things like: + +- parsing +- display name +- executable resolution +- env generation +- command construction +- refresh/install steps +- skill dir/status +- MCP status + +We also added an explicit ordering test so message generation and iteration stay stable. + +#### MCP response discipline is enforced +We tightened tests around secretless/minimal MCP response behavior. + +In particular: + +- `get_active_work_session` +- `get_compute_status` + +must keep `link` minimal: + +```json +{"linked": true} +``` + +and must not expose rich/session-internal structures like: + +- `workflow_guidance` +- `evidence` +- `linked_artifacts` +- `runtime_proof` + +That matters because these “safe surface area” guarantees are easy to erode unless they are actively tested. + +--- + +## The CI workflow we added + +We created a GitHub Actions workflow that reviews pull requests with Continue, but runs Continue through opub. + +Conceptually, the command looks like this: + +```bash +opub run continue -- -p "Review this PR diff..." --silent +``` + +That’s the key integration point. + +Continue handles the review. +opub handles project-linked execution context. + +--- + +## The workflow design + +The workflow is intentionally conservative. + +### Trigger +It runs on pull request events like: + +- opened +- synchronize +- reopened +- ready_for_review + +### Security posture +We explicitly kept this on `pull_request`, not `pull_request_target`. + +That matters because the workflow uses repository secrets and checks out PR code. + +So the rule is: + +- **trusted non-fork PRs**: run review +- **fork PRs**: skip review and post a notice comment + +That avoids the common footgun of mixing secrets with untrusted PR execution. + +--- + +## How the GitHub Actions flow works + +At a high level, the CI job does this: + +1. checks out the repo +2. sets up Go and Node +3. installs `opub` +4. installs Continue CLI +5. configures opub for Continue using a repo secret +6. computes the PR diff against the base branch +7. builds a review prompt +8. runs Continue through opub +9. posts or updates a PR comment with the review + +--- + +## Example workflow shape + +Here is the core idea in simplified form. + +```yaml +name: Continue PR Review + +on: + pull_request: + types: [opened, synchronize, reopened, ready_for_review] + +permissions: + contents: read + pull-requests: write + issues: write + +jobs: + continue-review: + if: github.event.pull_request.draft == false && github.event.pull_request.head.repo.fork == false + runs-on: ubuntu-24.04 + env: + PR_NUMBER: ${{ github.event.pull_request.number }} + GH_TOKEN: ${{ github.token }} + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Initialize workflow environment + run: | + echo "OPUB_HOME=$RUNNER_TEMP/opub" >> "$GITHUB_ENV" + echo "REVIEW_FILE=$RUNNER_TEMP/continue-review.md" >> "$GITHUB_ENV" + + - name: Install opub + run: curl -fsSL https://opub.dev/install.sh | sh + + - name: Add installed tools to PATH + run: | + echo "$HOME/.local/bin" >> "$GITHUB_PATH" + echo "$HOME/go/bin" >> "$GITHUB_PATH" + + - name: Install Continue CLI + run: npm install -g @continuedev/cli@1.5.45 + + - name: Configure opub for Continue + env: + OPUB_PROVIDER_KEY: ${{ secrets.CONTINUE_CI_OPUB }} + run: | + test -n "$OPUB_PROVIDER_KEY" + printf '%s\n' "$OPUB_PROVIDER_KEY" | opub setup continue \ + --project 'opubdev/opub-cli' \ + --project-id 4 \ + --compute-key-id 3 \ + --api-key-hash '231ed9ad...90f1e7d8' \ + --insecure-storage \ + --provider-key-stdin + + - name: Run Continue review + run: | + opub run continue -- \ + -p "Review this pull request diff for bugs, regressions, security issues, and missing tests." \ + --silent > "$REVIEW_FILE" +``` + +That is the essence. + +--- + +## The opub setup command + +A key part of this integration is that Continue is configured via opub, not by bypassing it. + +Example: + +```bash +printf '%s\n' "$CONTINUE_CI_OPUB" | opub setup continue \ + --project 'opubdev/opub-cli' \ + --project-id 4 \ + --compute-key-id 3 \ + --api-key-hash '231ed9ad...90f1e7d8' \ + --insecure-storage \ + --provider-key-stdin +``` + +Why this matters: + +- the project is explicit +- the compute key is explicit +- the provider key comes from CI secret storage +- the runtime path remains `opub run continue` + +So both local use and CI use flow through the same project-aware mechanism. + +--- + +## Why `opub run continue -- ...` is the important part + +It would be easy to install Continue and just run `cn` directly. + +But that would weaken the story. + +Instead, we use: + +```bash +opub run continue -- -p "..." --silent +``` + +That means: + +- opub launches the target +- opub provides the target environment +- opub injects the linked/project-aware session context +- usage can be associated with the configured project/compute key path + +That is what makes this more than a generic CI AI wrapper. + +--- + +## Local usage example + +The local developer flow is just as important as CI. + +A typical local run looks like: + +```bash +opub setup continue \ + --project 'opubdev/opub-cli' \ + --project-id 4 \ + --compute-key-id 3 \ + --api-key-hash '231ed9ad...90f1e7d8' +``` + +Then: + +```bash +opub run continue -- -p "Review my staged changes for regressions and missing tests" +``` + +Or more generally: + +```bash +opub run continue -- -p "Summarize the design tradeoffs in this refactor" +``` + +That gives a consistent operator model: + +- local work uses opub +- CI work uses opub +- both link back to the same project configuration model + +--- + +## PR review prompt example + +We shaped the CI prompt so the output is predictable and practical. + +Example prompt: + +```text +Review this pull request diff for bugs, regressions, security issues, and missing tests. +Focus especially on opub invariants around secrets, MCP response discipline, session linking, and target completeness. +Keep the review concise and practical. +Output markdown with these sections exactly: + +## Summary + +## Findings +- Use bullets. If there are no issues, write `- No actionable issues found.` + +## Suggested follow-ups +- Use bullets. If none, write `- None.` +``` + +This is a strong pattern because it: + +- narrows the task +- focuses the model on project-specific invariants +- produces machine-friendly, human-readable output +- makes comment upserts stable + +--- + +## Example generated PR comment format + +The workflow posts a single maintained comment, rather than spamming new comments every run. + +Example shape: + +```markdown + +## Continue PR Review + +_Automated review via Continue CLI run through opub._ + +## Summary + +Short overview of the PR quality and risk. + +## Findings +- Missing a test for stale-session behavior in the new target path. +- Error handling in the new workflow may fail silently if the review file is empty. + +## Suggested follow-ups +- Add a regression test for the new target integration. +- Consider validating required secret presence earlier in the workflow. +``` + +The marker comment lets the workflow update the previous review instead of creating a new one every time. + +--- + +## Fork handling + +One of the subtle but important parts of this setup is fork behavior. + +Because the workflow requires repository secrets, it does **not** run the real review job for fork PRs. + +Instead, it posts a neutral notice comment. + +Example message: + +```markdown + +## Continue PR Review + +_Continue review is only run automatically for trusted non-fork pull requests because this workflow requires repository secrets._ + +- This PR comes from a fork, so the secret-backed review job was skipped by design. +- Maintainers can run an internal review workflow or review locally if needed. +``` + +That is better than silently doing nothing. + +It keeps contributor experience clear without weakening the trust model. + +--- + +## Why we used insecure storage in CI + +In GitHub-hosted Linux runners, storing credentials in the system keyring can fail because the expected keyring service may not exist. + +We hit exactly that issue. + +The practical fix was: + +```bash +--insecure-storage +``` + +This is acceptable here because: + +- the runner is ephemeral +- `OPUB_HOME` is placed under `$RUNNER_TEMP` +- the environment is short-lived +- the workflow is secret-scoped already + +This is a good example of making CI-specific tradeoffs explicit rather than pretending desktop assumptions apply unchanged in automation. + +--- + +## Why pin Continue but not opub + +For this setup, Continue CLI was pinned, while opub was intentionally left unpinned. + +That split makes sense when: + +- opub is under direct project/operator control +- Continue is an external dependency where deterministic behavior is useful + +Example pinned install: + +```bash +npm install -g @continuedev/cli@1.5.45 +``` + +Example unpinned opub install: + +```bash +curl -fsSL https://opub.dev/install.sh | sh +``` + +That gives stability where desired without constraining the opub release path. + +--- + +## The test story matters too + +The integration story is stronger because we did not just add CI glue. + +We also made the codebase safer around the core concepts that opub depends on. + +### Example: target completeness +We added a canonical target list and tests that ensure each target participates in all the expected behaviors. + +That protects against “added new target, forgot to wire one method” failures. + +### Example: session proof handling +We validated that launch proof tokens are not persisted raw, but are still passed where they need to go. + +### Example: MCP minimal response shape +We locked down what secretless MCP responses may and may not expose. + +This matters because integration confidence is not just about “the workflow runs.” +It is about preserving the invariants that make the workflow trustworthy. + +--- + +## End-to-end story in one sentence + +Here is the whole value proposition in one line: + +> Continue gives us high-quality PR review, and opub makes that review project-linked so donated compute usage runs through a clear, reusable, project-aware execution path both locally and in CI. + +--- + +## Minimal how-to recap + +If you want to reproduce the pattern, the shortest version is: + +### 1. Install opub +```bash +curl -fsSL https://opub.dev/install.sh | sh +``` + +### 2. Configure Continue through opub +```bash +opub setup continue \ + --project 'opubdev/opub-cli' \ + --project-id 4 \ + --compute-key-id 3 \ + --api-key-hash '231ed9ad...90f1e7d8' +``` + +### 3. Run locally through opub +```bash +opub run continue -- -p "Review the current diff for bugs and missing tests" +``` + +### 4. In CI, provide the provider key as a secret +For this repo, the secret is: + +```text +CONTINUE_CI_OPUB +``` + +### 5. In GitHub Actions, install Continue and invoke it through opub +```bash +printf '%s\n' "$CONTINUE_CI_OPUB" | opub setup continue ... --provider-key-stdin +opub run continue -- -p "$PROMPT" --silent +``` + +### 6. Post the output back to the PR +Use a marker comment and upsert it each run. + +--- + +## Final thoughts + +This integration is compelling because it aligns incentives and mechanics: + +- maintainers get automated review +- projects get linked usage context +- local and CI workflows use the same entrypoint +- the review path is explicit +- the secret/trust boundary is handled deliberately +- the codebase has stronger tests around the invariants that make all of this credible + +Most importantly, this was not just a demo. + +This entire session ran through donated compute, and the CI workflow is designed to do the same. + +Because we are using the opub CLI, spend links to the project. + +That is the story worth telling.