diff --git a/.github/workflows/frontend-pr-ci.yml b/.github/workflows/frontend-pr-ci.yml deleted file mode 100644 index d378789..0000000 --- a/.github/workflows/frontend-pr-ci.yml +++ /dev/null @@ -1,53 +0,0 @@ -name: frontend-pr-ci - -on: - pull_request: - paths: - - "frontend/**" - - ".github/workflows/frontend-pr-ci.yml" - workflow_dispatch: - -permissions: - contents: read - -concurrency: - group: frontend-pr-ci-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true - -jobs: - web-quality: - name: Typecheck and build frontend - runs-on: ubuntu-latest - timeout-minutes: 20 - - defaults: - run: - working-directory: frontend - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - with: - fetch-depth: 1 - - - name: Setup Bun - uses: oven-sh/setup-bun@v2 - with: - bun-version: "1.2.23" - - - name: Cache Bun packages - uses: actions/cache@v4 - with: - path: ~/.bun/install/cache - key: ${{ runner.os }}-bun-${{ hashFiles('frontend/bun.lock') }} - restore-keys: | - ${{ runner.os }}-bun- - - - name: Install dependencies - run: bun install --frozen-lockfile - - - name: Typecheck - run: bun run typecheck - - - name: Build production bundle - run: bun run build \ No newline at end of file diff --git a/.github/workflows/mcp-pr-ci.yml b/.github/workflows/mcp-pr-ci.yml deleted file mode 100644 index 43c8ad9..0000000 --- a/.github/workflows/mcp-pr-ci.yml +++ /dev/null @@ -1,53 +0,0 @@ -name: mcp-pr-ci - -on: - pull_request: - paths: - - "mcp/**" - - ".github/workflows/mcp-pr-ci.yml" - workflow_dispatch: - -permissions: - contents: read - -concurrency: - group: mcp-pr-ci-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true - -jobs: - mcp-quality: - name: Typecheck and build MCP server - runs-on: ubuntu-latest - timeout-minutes: 10 - - defaults: - run: - working-directory: mcp - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - with: - fetch-depth: 1 - - - name: Setup Bun - uses: oven-sh/setup-bun@v2 - with: - bun-version: "1.2.23" - - - name: Cache Bun packages - uses: actions/cache@v4 - with: - path: ~/.bun/install/cache - key: ${{ runner.os }}-bun-mcp-${{ hashFiles('mcp/bun.lock') }} - restore-keys: | - ${{ runner.os }}-bun-mcp- - - - name: Install dependencies - run: bun install --frozen-lockfile - - - name: Typecheck - run: bun run typecheck - - - name: Build - run: bun run build \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 32a2b54..4d4f40d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -28,11 +28,6 @@ jobs: curl -sL -o /tmp/tinygo.deb https://github.com/tinygo-org/tinygo/releases/download/v0.41.1/tinygo_0.41.1_amd64.deb sudo dpkg -i /tmp/tinygo.deb - - name: Setup Bun - uses: oven-sh/setup-bun@v2 - with: - bun-version: latest - - name: Build backend WASM working-directory: backend run: | @@ -40,55 +35,27 @@ jobs: mkdir -p ../release/backend tinygo build -target=wasip1 -buildmode=c-shared -o ../release/backend/github.wasm . - - name: Build frontend - working-directory: frontend - run: | - set -euo pipefail - bun install --frozen-lockfile - bun run build - - - name: Build MCP - working-directory: mcp - run: | - set -euo pipefail - bun install --frozen-lockfile - bun run build - + # The plugin is provider-only: a backend, its migrations and the manifest. - name: Collect release files run: | set -euo pipefail - mkdir -p release/frontend - cp -R frontend/dist release/frontend/dist - - mkdir -p release/mcp - cp -R mcp/dist release/mcp/dist - mkdir -p release/migrations cp -R backend/migrations/. release/migrations/ - mkdir -p release/skills - cp -R skills/. release/skills/ - cp plugin.json release/plugin.json - name: Create archives run: | set -euo pipefail tar -czf github-backend-wasm.tar.gz -C release/backend github.wasm - tar -czf github-frontend-dist.tar.gz -C release/frontend dist - tar -czf github-mcp-dist.tar.gz -C release/mcp dist tar -czf github-migrations.tar.gz -C release migrations - tar -czf github-skills-bundle.tar.gz -C release skills tar -czf github-plugin-manifest.tar.gz -C release plugin.json - name: Generate checksums run: | set -euo pipefail sha256sum github-backend-wasm.tar.gz \ - github-frontend-dist.tar.gz \ - github-mcp-dist.tar.gz \ github-migrations.tar.gz \ - github-skills-bundle.tar.gz \ github-plugin-manifest.tar.gz > checksums.txt - name: Publish GitHub Release assets @@ -97,9 +64,6 @@ jobs: generate_release_notes: true files: | github-backend-wasm.tar.gz - github-frontend-dist.tar.gz - github-mcp-dist.tar.gz github-migrations.tar.gz - github-skills-bundle.tar.gz github-plugin-manifest.tar.gz checksums.txt diff --git a/README.md b/README.md index fc6fb70..bb4d257 100644 --- a/README.md +++ b/README.md @@ -1,243 +1,37 @@ # com.paca.github -First-party Paca plugin that integrates GitHub repositories, pull requests, and branches with Paca projects and tasks. +First-party Paca plugin that is the GitHub provider for Paca's platform-neutral Git integration (`gitprovider.v1`, see `docs/plugins/git-provider-spi.md`). ---- +Core owns repositories, project links, identities, change requests and audit. This plugin only adapts GitHub to the provider SPI: -## Architecture +- Identity setup through the GitHub App manifest flow (one App per Paca instance or agent). +- Per-owner installation grants, requested when a repository is linked. +- Short-lived installation tokens for read/write repository credentials (never stored in remotes). +- Repository listing, webhook verification and event normalisation. -The plugin follows the standard three-part plugin structure: +## Structure ```text github/ -├── backend/ - Go WASM plugin (runs inside the API host) -├── frontend/ - React micro-frontend (Module Federation remote) -└── mcp/ - MCP tools bundle for AI/tooling integrations +└── backend/ - Go WASM plugin (runs inside the API host) ``` -### Backend (backend/) - -- Written in Go, compiled to wasip1/wasm for production. -- Registered as com.paca.github in the plugin registry. -- Owns its database schema (plugin_data_com_paca_github) and runs its own migration on startup. -- Stores GitHub PATs and webhook secrets encrypted with AES-256-GCM. -- Creates and verifies GitHub webhooks for linked repositories. -- Handles task/project cleanup on task.deleted and project.deleted events. - -### Frontend (frontend/) - -- Vite + React + TanStack Query. -- Exposed as a Module Federation remote (com_paca_github). -- Mounted at: - - project.settings.tab (GitHubSettingsTab) - - task.detail.section (GitHubTaskSection) -- Communicates with backend via plugin API routes under /api/v1/plugins/com.paca.github/projects/:projectId/... - -### MCP (mcp/) - -- Built with @paca-ai/plugin-sdk-mcp. -- Published as an ESM bundle (mcp.js). -- Exposes tools for integration management, repository linking, PR linking, and branch operations. - ---- - -## Required Configuration - -The backend uses the following config keys: - -- ENCRYPTION_KEY: 64 hex chars (32-byte key) used for AES-256-GCM encryption of tokens/secrets. -- PUBLIC_URL: public base URL of the Paca server, used to build the webhook callback URL. - -Without PUBLIC_URL, linking repositories will fail because webhook creation requires a reachable callback URL. - ---- - -## API Endpoints - -All routes are prefixed with /projects/:projectId by the host. -The caller must be an authenticated member of the project unless noted otherwise. - -| Method | Path | Description | -|--------|------|-------------| -| GET | /github | Get integration status for the current project | -| POST | /github/token | Set or replace GitHub PAT | -| DELETE | /github/token | Delete GitHub PAT and integration | -| GET | /github/repositories | List repositories accessible by the token | -| GET | /github/linked-repositories | List repositories linked to the project | -| POST | /github/linked-repositories | Link a repository and create webhook | -| DELETE | /github/linked-repositories/:repoId | Unlink a repository | -| GET | /tasks/:taskId/github/pull-requests | List pull requests linked to a task | -| POST | /tasks/:taskId/github/pull-requests | Link a pull request to a task | -| DELETE | /tasks/:taskId/github/pull-requests/:prId | Unlink a pull request from a task | -| POST | /tasks/:taskId/github/branches | Create branch and link it to a task | -| GET | /tasks/:taskId/github/branches | List branches linked to a task | -| POST | /webhook (public) | Receive GitHub webhook events | - -### Example requests - -Set token: - -```json -{ "token": "ghp_xxx" } -``` - -Link repository: - -```json -{ "owner": "Paca-AI", "repo_name": "paca" } -``` - -Link PR to task: - -```json -{ "repo_id": "550e8400-e29b-41d4-a716-446655440000", "pr_number": 42 } -``` - -Create branch for task: - -```json -{ - "repo_id": "550e8400-e29b-41d4-a716-446655440000", - "branch_name": "feat/PROJ-42-github-integration", - "source_branch": "main" -} -``` - ---- - -## Webhook Behavior - -On linked repositories, GitHub webhooks are configured for: - -- push -- pull_request - -Current behavior includes: - -- Dropping any event other than `pull_request`/`push` before the repository - lookup, so a hook left subscribed to `check_run` (one delivery per CI job - per state) costs nothing. -- Verifying X-Hub-Signature-256 with the stored webhook secret. -- Caching/updating pull request state in plugin tables. -- Auto-linking PRs when opened/reopened from a branch already linked to a task. -- Auto-linking created branches when branch names include task references like PROJ-42. -- Emitting plugin events: - - github.pr_linked - - github.pr_updated - - github.branch_linked - ---- - -## Database Schema - -Tables live in plugin_data_com_paca_github and are created by backend/migrations/0001_create_github_tables.sql. - -```text -github_integrations - id UUID PK - project_id UUID UNIQUE -> projects(id) ON DELETE CASCADE - access_token_enc TEXT - created_at TIMESTAMPTZ - updated_at TIMESTAMPTZ - -github_repositories - id UUID PK - project_id UUID -> projects(id) ON DELETE CASCADE - integration_id UUID -> github_integrations(id) ON DELETE CASCADE - owner TEXT - repo_name TEXT - full_name TEXT - webhook_id BIGINT - webhook_secret_enc TEXT - default_branch TEXT - created_at TIMESTAMPTZ - updated_at TIMESTAMPTZ - -github_pull_requests - id UUID PK - project_id UUID -> projects(id) ON DELETE CASCADE - repo_id UUID -> github_repositories(id) ON DELETE CASCADE - pr_number INT - github_pr_id BIGINT - title TEXT - state TEXT - html_url TEXT - head_branch TEXT - base_branch TEXT - author TEXT - merged_at TIMESTAMPTZ - created_at TIMESTAMPTZ - updated_at TIMESTAMPTZ - -github_task_pr_links - id UUID PK - task_id UUID -> tasks(id) ON DELETE CASCADE - pull_request_id UUID -> github_pull_requests(id) ON DELETE CASCADE - created_at TIMESTAMPTZ - -github_task_branches - id UUID PK - task_id UUID -> tasks(id) ON DELETE CASCADE - repo_id UUID -> github_repositories(id) ON DELETE CASCADE - branch_name TEXT - created_at TIMESTAMPTZ -``` - ---- - -## Development +The plugin has no HTTP routes of its own: core calls the internal-only `POST /_git/:op` operations. It has no frontend and no MCP tools; the Git UI and agent tools live in core and `apps/mcp`. ### Backend -```bash -cd backend - -# Run tests -go test -v ./... - -# Build WASM binary (requires TinyGo — see https://tinygo.org/getting-started/install/) -tinygo build -target=wasip1 -buildmode=c-shared -o github.wasm . -``` - -### Frontend - -```bash -cd frontend +- `spi_*.go` implement the provider operations; the conformance suite (`spi_conformance_test.go`) runs them against a fake GitHub. +- Owns the schema `plugin_data_com_paca_github` (`github_apps`, `github_app_installations`, `github_manifest_sessions`); App private keys and webhook secrets are stored encrypted with AES-256-GCM using the `ENCRYPTION_KEY` config value. +- Personal access tokens are not supported: every connection is a GitHub App installation. -# Install dependencies -bun install +### Upgrading from 0.4.x -# Development build (watch) -bun run dev +Migration `0002_git_provider.sql` **drops** the previous release's tables (`github_integrations`, `github_repositories`, `github_pull_requests`, `github_task_branches`, `github_task_pr_links`). Nothing is migrated, because a personal access token cannot back a provider identity. Export anything you still need from `plugin_data_com_paca_github` before upgrading, then set up a GitHub App identity and relink repositories in Paca. -# Production build -bun run build -``` - -### MCP +### Build ```bash -cd mcp - -# Install dependencies -bun install - -# Typecheck -bun run typecheck - -# Production build (outputs mcp.js) -bun run build +cd backend +go vet ./... && go test -race ./... +tinygo build -target=wasip1 -buildmode=c-shared -o backend.wasm . ``` - ---- - -## Release (CD) - -The release workflow is at .github/workflows/release.yml. - -It runs on: - -- tag push matching v* -- manual workflow_dispatch - -The workflow builds backend/frontend/mcp assets, packages migrations and plugin.json, generates SHA256 checksums, and uploads all artifacts to a GitHub Release. diff --git a/backend/appjwt.go b/backend/appjwt.go new file mode 100644 index 0000000..e65e4e4 --- /dev/null +++ b/backend/appjwt.go @@ -0,0 +1,62 @@ +package main + +import ( + "crypto" + "crypto/rsa" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/json" + "encoding/pem" + "errors" + "fmt" + "strconv" + "time" +) + +// ─── App JWT ───────────────────────────────────────────────────────────────── + +// parseRSAPrivateKey accepts the PEM GitHub hands out (PKCS#1, "RSA PRIVATE +// KEY") as well as PKCS#8, which keys re-exported by other tools often use. +func parseRSAPrivateKey(pemStr string) (*rsa.PrivateKey, error) { + block, _ := pem.Decode([]byte(pemStr)) + if block == nil { + return nil, errors.New("github app: private key is not valid PEM") + } + if key, err := x509.ParsePKCS1PrivateKey(block.Bytes); err == nil { + return key, nil + } + parsed, err := x509.ParsePKCS8PrivateKey(block.Bytes) + if err != nil { + return nil, errors.New("github app: private key is neither PKCS#1 nor PKCS#8") + } + key, ok := parsed.(*rsa.PrivateKey) + if !ok { + return nil, errors.New("github app: private key is not an RSA key") + } + return key, nil +} + +// signAppJWT builds the short-lived RS256 JWT GitHub requires to authenticate +// as the App itself. iat is backdated a minute to absorb clock drift; GitHub +// caps exp at 10 minutes. +func signAppJWT(githubAppID int64, pemStr string, now time.Time) (string, error) { + key, err := parseRSAPrivateKey(pemStr) + if err != nil { + return "", err + } + header, _ := json.Marshal(map[string]string{"alg": "RS256", "typ": "JWT"}) + claims, _ := json.Marshal(map[string]any{ + "iat": now.Add(-60 * time.Second).Unix(), + "exp": now.Add(9 * time.Minute).Unix(), + "iss": strconv.FormatInt(githubAppID, 10), + }) + enc := base64.RawURLEncoding + signingInput := enc.EncodeToString(header) + "." + enc.EncodeToString(claims) + digest := sha256.Sum256([]byte(signingInput)) + sig, err := rsa.SignPKCS1v15(nil, key, crypto.SHA256, digest[:]) + if err != nil { + return "", fmt.Errorf("github app: sign jwt: %w", err) + } + return signingInput + "." + enc.EncodeToString(sig), nil +} diff --git a/backend/automation.go b/backend/automation.go deleted file mode 100644 index eed9ba6..0000000 --- a/backend/automation.go +++ /dev/null @@ -1,187 +0,0 @@ -package main - -import ( - "context" - "encoding/json" - - plugin "github.com/Paca-AI/plugin-sdk-go" -) - -// This file implements the automation-graph Condition and Action node -// types this plugin contributes, registered in Init via ctx.Condition and -// ctx.Action. Node types must match exactly what's declared in plugin.json -// under "automation" (see AutomationManifest in the core's -// domain/plugin/entity.go) — namespaced under the plugin's short name, -// "github" (the last dot-separated segment of the plugin ID "com.paca.github"), -// not the full reverse-DNS ID. -// -// Both handlers resolve the calling project from req.ProjectID — supplied -// directly by the host (the automation graph's own project), not read from -// the node's config — since a task by itself doesn't carry which GitHub -// repository/PR it's linked to; that's resolved through -// github_task_pr_links the same way the HTTP handlers in pull_requests.go -// do it, keyed by (task_id, project_id). - -const ( - // automationConditionPRState checks the linked pull request's state - // (open/closed/merged) against a configured expected value. - automationConditionPRState = "github.pr_state" - - // automationActionMergePR merges the linked pull request. - automationActionMergePR = "github.merge_pr" - // automationActionCommentPR posts a comment on the linked pull request. - automationActionCommentPR = "github.comment_pr" -) - -// registerAutomationNodes wires this plugin's Condition/Action handlers -// into ctx. Called once from Init. -func (p *githubPlugin) registerAutomationNodes(ctx *plugin.Context) { - ctx.Condition(automationConditionPRState, p.conditionPRState) - ctx.Action(automationActionMergePR, p.actionMergePR) - ctx.Action(automationActionCommentPR, p.actionCommentPR) -} - -// ─── shared: resolve the most recently linked PR for a task ────────────────── - -// pluginLinkedPR is what resolveLinkedPRForAutomation returns: enough to -// call the GitHub API plus the plugin's own repo_id/pr row id for logging. -type pluginLinkedPR struct { - Owner string - RepoName string - PRNumber int -} - -// resolveLinkedPRForAutomation finds the most recently linked PR for a -// task, the same many-PRs-per-task relationship listTaskPRs exposes over -// HTTP — automation nodes act on the newest link since that's virtually -// always the PR the automation graph author means ("the PR for this task"). -func (p *githubPlugin) resolveLinkedPRForAutomation(projectID, taskID string) (*pluginLinkedPR, error) { - result, err := p.db.Query(` - SELECT r.owner, r.repo_name, pr.pr_number - FROM github_pull_requests pr - JOIN github_task_pr_links l ON l.pull_request_id = pr.id - JOIN github_repositories r ON r.id = pr.repo_id - WHERE l.task_id = $1 AND pr.project_id = $2 - ORDER BY l.created_at DESC - LIMIT 1 - `, taskID, projectID) - if err != nil { - return nil, err - } - if len(result.Rows) == 0 { - return nil, &appError{code: "GITHUB_PR_LINK_NOT_FOUND", status: 404, msg: "No pull request linked to this task"} - } - sc := newRowScanner(result.Columns, result.Rows[0]) - return &pluginLinkedPR{ - Owner: sc.str("owner"), - RepoName: sc.str("repo_name"), - PRNumber: sc.intVal("pr_number"), - }, nil -} - -// ─── Condition: github.pr_state ─────────────────────────────────────────────── - -func (p *githubPlugin) conditionPRState(req *plugin.ConditionRequest) plugin.ConditionResult { - var cfg struct { - ExpectedState string `json:"expected_state"` // "open" | "closed" | "merged" - } - if err := json.Unmarshal(req.Config, &cfg); err != nil || req.ProjectID == "" || cfg.ExpectedState == "" { - p.log.Error("github: pr_state condition: invalid config") - return plugin.ConditionResult{Matched: false} - } - - linked, err := p.resolveLinkedPRForAutomation(req.ProjectID, req.Task.ID) - if err != nil { - p.log.Info("github: pr_state condition: " + err.Error()) - return plugin.ConditionResult{Matched: false} - } - - token, err := p.decryptToken(req.ProjectID) - if err != nil { - p.log.Error("github: pr_state condition: decrypt token: " + err.Error()) - return plugin.ConditionResult{Matched: false} - } - - ghc := newGHClient(token) - ghPR, err := ghc.getPullRequest(context.Background(), linked.Owner, linked.RepoName, linked.PRNumber) - if err != nil { - p.log.Error("github: pr_state condition: fetch PR: " + err.Error()) - return plugin.ConditionResult{Matched: false} - } - - state := ghPR.State - if ghPR.Merged { - state = "merged" - } - return plugin.ConditionResult{Matched: state == cfg.ExpectedState} -} - -// ─── Action: github.merge_pr ────────────────────────────────────────────────── - -func (p *githubPlugin) actionMergePR(req *plugin.ActionRequest) plugin.ActionResult { - var cfg struct { - MergeMethod string `json:"merge_method"` // "merge" | "squash" | "rebase"; defaults to "merge" - } - if err := json.Unmarshal(req.Config, &cfg); err != nil || req.ProjectID == "" { - return plugin.ActionResult{Applied: false, Error: "invalid config"} - } - if cfg.MergeMethod == "" { - cfg.MergeMethod = "merge" - } - - linked, err := p.resolveLinkedPRForAutomation(req.ProjectID, req.Task.ID) - if err != nil { - return plugin.ActionResult{Applied: false, Error: err.Error()} - } - - token, err := p.decryptToken(req.ProjectID) - if err != nil { - return plugin.ActionResult{Applied: false, Error: "decrypt token: " + err.Error()} - } - ghc := newGHClient(token) - ctx := context.Background() - - // Idempotency: a plugin action can be retried by the automation - // engine, so check current state first — merging an already-merged PR - // is a no-op success, not an error, mirroring how built-in actions - // treat "already at the desired state". - ghPR, err := ghc.getPullRequest(ctx, linked.Owner, linked.RepoName, linked.PRNumber) - if err != nil { - return plugin.ActionResult{Applied: false, Error: "fetch PR: " + err.Error()} - } - if ghPR.Merged { - return plugin.ActionResult{Applied: false} - } - - if err := ghc.mergePullRequest(ctx, linked.Owner, linked.RepoName, linked.PRNumber, cfg.MergeMethod); err != nil { - return plugin.ActionResult{Applied: false, Error: "merge PR: " + err.Error()} - } - return plugin.ActionResult{Applied: true} -} - -// ─── Action: github.comment_pr ──────────────────────────────────────────────── - -func (p *githubPlugin) actionCommentPR(req *plugin.ActionRequest) plugin.ActionResult { - var cfg struct { - Body string `json:"body"` - } - if err := json.Unmarshal(req.Config, &cfg); err != nil || req.ProjectID == "" || cfg.Body == "" { - return plugin.ActionResult{Applied: false, Error: "invalid config: body is required"} - } - - linked, err := p.resolveLinkedPRForAutomation(req.ProjectID, req.Task.ID) - if err != nil { - return plugin.ActionResult{Applied: false, Error: err.Error()} - } - - token, err := p.decryptToken(req.ProjectID) - if err != nil { - return plugin.ActionResult{Applied: false, Error: "decrypt token: " + err.Error()} - } - ghc := newGHClient(token) - - if err := ghc.createIssueComment(context.Background(), linked.Owner, linked.RepoName, linked.PRNumber, cfg.Body); err != nil { - return plugin.ActionResult{Applied: false, Error: "comment PR: " + err.Error()} - } - return plugin.ActionResult{Applied: true} -} diff --git a/backend/automation_test.go b/backend/automation_test.go deleted file mode 100644 index 88bb01d..0000000 --- a/backend/automation_test.go +++ /dev/null @@ -1,105 +0,0 @@ -package main - -import ( - "testing" - - plugin "github.com/Paca-AI/plugin-sdk-go" - "github.com/Paca-AI/plugin-sdk-go/plugintest" -) - -// These cover the paths reachable without an outbound GitHub API call: -// config validation and "no PR linked to this task". The GitHub-API-backed -// happy path (like other ghClient-dependent handlers in this plugin) isn't -// unit-testable outside a WASM build — see plugin_test.go's note on this. -// -// ProjectID is supplied the same way the host always supplies it — as a -// top-level request field, not folded into Config — mirroring how -// pluginNodePayload builds a real automation run's request in the core. - -func conditionReqWithConfig(cfg any) plugintest.ConditionRequest { - return plugintest.ConditionRequest{ - Task: plugin.TaskSnapshot{ID: testTaskID}, - ProjectID: testProjectID, - }.WithJSONConfig(cfg) -} - -func actionReqWithConfig(cfg any) plugintest.ActionRequest { - return plugintest.ActionRequest{ - Task: plugin.TaskSnapshot{ID: testTaskID}, - ProjectID: testProjectID, - }.WithJSONConfig(cfg) -} - -func TestConditionPRState_MissingConfig(t *testing.T) { - tc := setupPlugin(t) - result := tc.EvaluateCondition(automationConditionPRState, conditionReqWithConfig(map[string]string{})) - if result.Matched { - t.Fatal("expected Matched=false for missing config") - } -} - -func TestConditionPRState_MissingProjectID(t *testing.T) { - tc := setupPlugin(t) - req := plugintest.ConditionRequest{Task: plugin.TaskSnapshot{ID: testTaskID}}. - WithJSONConfig(map[string]string{"expected_state": "merged"}) - result := tc.EvaluateCondition(automationConditionPRState, req) - if result.Matched { - t.Fatal("expected Matched=false when the host supplies no project_id") - } -} - -func TestConditionPRState_NoLinkedPR(t *testing.T) { - tc := setupPlugin(t) - cfg := map[string]string{"expected_state": "merged"} - result := tc.EvaluateCondition(automationConditionPRState, conditionReqWithConfig(cfg)) - if result.Matched { - t.Fatal("expected Matched=false when no PR is linked to the task") - } -} - -func TestActionMergePR_MissingProjectID(t *testing.T) { - tc := setupPlugin(t) - req := plugintest.ActionRequest{Task: plugin.TaskSnapshot{ID: testTaskID}}. - WithJSONConfig(map[string]string{}) - result := tc.RunAction(automationActionMergePR, req) - if result.Applied { - t.Fatal("expected Applied=false when the host supplies no project_id") - } - if result.Error == "" { - t.Fatal("expected an error message for missing project_id") - } -} - -func TestActionMergePR_NoLinkedPR(t *testing.T) { - tc := setupPlugin(t) - result := tc.RunAction(automationActionMergePR, actionReqWithConfig(map[string]string{})) - if result.Applied { - t.Fatal("expected Applied=false when no PR is linked to the task") - } - if result.Error == "" { - t.Fatal("expected an error message when no PR is linked") - } -} - -func TestActionCommentPR_MissingBody(t *testing.T) { - tc := setupPlugin(t) - result := tc.RunAction(automationActionCommentPR, actionReqWithConfig(map[string]string{})) - if result.Applied { - t.Fatal("expected Applied=false for missing body") - } - if result.Error == "" { - t.Fatal("expected an error message for missing body") - } -} - -func TestActionCommentPR_NoLinkedPR(t *testing.T) { - tc := setupPlugin(t) - cfg := map[string]string{"body": "looks good"} - result := tc.RunAction(automationActionCommentPR, actionReqWithConfig(cfg)) - if result.Applied { - t.Fatal("expected Applied=false when no PR is linked to the task") - } - if result.Error == "" { - t.Fatal("expected an error message when no PR is linked") - } -} diff --git a/backend/branches.go b/backend/branches.go deleted file mode 100644 index 4f591de..0000000 --- a/backend/branches.go +++ /dev/null @@ -1,255 +0,0 @@ -package main - -import ( - "context" - "errors" - "fmt" - - plugin "github.com/Paca-AI/plugin-sdk-go" -) - -// ─── DTOs ──────────────────────────────────────────────────────────────────── - -type taskBranchResponse struct { - ID string `json:"id"` - TaskID string `json:"task_id"` - RepoID string `json:"repo_id"` - BranchName string `json:"branch_name"` - CreatedAt string `json:"created_at"` -} - -type createBranchResponse struct { - BranchName string `json:"branch_name"` -} - -// ─── POST /tasks/:taskId/github/branches ────────────────────────────────────── - -func (p *githubPlugin) createBranch(req *plugin.Request, res *plugin.Response) { - projectID := req.Caller.ProjectID - taskID := req.PathParam("taskId") - if !p.taskBelongsToProject(taskID, projectID, res) { - return - } - - type createBranchBody struct { - RepoID string `json:"repo_id"` - BranchName string `json:"branch_name"` - SourceBranch string `json:"source_branch"` - } - b, err := plugin.JSONBody[createBranchBody](req) - if err != nil || b.RepoID == "" || b.BranchName == "" { - apiError(res, 400, "BAD_REQUEST", "repo_id and branch_name are required") - return - } - - token, err := p.decryptToken(projectID) - if err != nil { - writeAppError(res, err) - return - } - - // Get repository details. - repoResult, rErr := p.db.Query( - `SELECT owner, repo_name, default_branch FROM github_repositories WHERE id = $1 AND project_id = $2`, - b.RepoID, projectID, - ) - if rErr != nil { - apiError(res, 500, "INTERNAL_ERROR", rErr.Error()) - return - } - if len(repoResult.Rows) == 0 { - apiError(res, 404, "GITHUB_REPOSITORY_NOT_FOUND", "Repository not found") - return - } - rSc := newRowScanner(repoResult.Columns, repoResult.Rows[0]) - owner := rSc.str("owner") - repoName := rSc.str("repo_name") - defaultBranch := rSc.str("default_branch") - - sourceBranch := b.SourceBranch - if sourceBranch == "" { - sourceBranch = defaultBranch - } - - ghc := newGHClient(token) - if err := ghc.createBranch(context.Background(), owner, repoName, b.BranchName, sourceBranch); err != nil { - var apiErr *ghAPIError - if errors.As(err, &apiErr) { - if apiErr.StatusCode == 403 { - apiError(res, 403, "GITHUB_TOKEN_INSUFFICIENT_PERMISSIONS", "Token does not have permission to create branches") - return - } - apiError(res, 400, "BAD_REQUEST", fmt.Sprintf("GitHub API error: %s", apiErr.Message)) - return - } - apiError(res, 502, "INTERNAL_ERROR", fmt.Sprintf("failed to create branch: %s", err)) - return - } - - // Link the branch to the task. - now := nowStr() - rowsAffected, dbErr := p.db.Exec(` - INSERT INTO github_task_branches (task_id, repo_id, branch_name, created_at) - VALUES ($1,$2,$3,$4) - ON CONFLICT (task_id, repo_id, branch_name) DO NOTHING - `, taskID, b.RepoID, b.BranchName, now) - if dbErr != nil { - p.log.Error("failed to link branch to task: " + dbErr.Error()) - } else if rowsAffected == 0 { - apiError(res, 409, "GITHUB_BRANCH_ALREADY_LINKED", "Branch is already linked to this task") - return - } - - plugin.EmitEvent("github.branch_linked", map[string]any{ - "project_id": projectID, - "task_id": taskID, - "repo_id": b.RepoID, - "branch_name": b.BranchName, - }) - - created(res, createBranchResponse{BranchName: b.BranchName}) -} - -// ─── POST /tasks/:taskId/branches/link ──────────────────────────────────────── - -func (p *githubPlugin) linkBranchToTask(req *plugin.Request, res *plugin.Response) { - projectID := req.Caller.ProjectID - taskID := req.PathParam("taskId") - if !p.taskBelongsToProject(taskID, projectID, res) { - return - } - - type linkBranchToTaskBody struct { - RepoID string `json:"repo_id"` - BranchName string `json:"branch_name"` - } - b, err := plugin.JSONBody[linkBranchToTaskBody](req) - if err != nil || b.RepoID == "" || b.BranchName == "" { - apiError(res, 400, "BAD_REQUEST", "repo_id and branch_name are required") - return - } - - token, err := p.decryptToken(projectID) - if err != nil { - writeAppError(res, err) - return - } - - repoResult, rErr := p.db.Query( - `SELECT owner, repo_name FROM github_repositories WHERE id = $1 AND project_id = $2`, - b.RepoID, projectID, - ) - if rErr != nil { - apiError(res, 500, "INTERNAL_ERROR", rErr.Error()) - return - } - if len(repoResult.Rows) == 0 { - apiError(res, 404, "GITHUB_REPOSITORY_NOT_FOUND", "Repository not found") - return - } - rSc := newRowScanner(repoResult.Columns, repoResult.Rows[0]) - owner := rSc.str("owner") - repoName := rSc.str("repo_name") - - ghc := newGHClient(token) - if err := ghc.branchExists(context.Background(), owner, repoName, b.BranchName); err != nil { - var apiErr *ghAPIError - if errors.As(err, &apiErr) { - if apiErr.StatusCode == 404 { - apiError(res, 404, "GITHUB_BRANCH_NOT_FOUND", - fmt.Sprintf("Branch %q not found in %s/%s", b.BranchName, owner, repoName)) - return - } - if apiErr.StatusCode == 403 { - apiError(res, 403, "GITHUB_TOKEN_INSUFFICIENT_PERMISSIONS", "Token does not have permission to read branches") - return - } - } - apiError(res, 502, "INTERNAL_ERROR", fmt.Sprintf("failed to verify branch: %s", err)) - return - } - - now := nowStr() - inserted, dbErr := p.db.Query(` - INSERT INTO github_task_branches (task_id, repo_id, branch_name, created_at) - VALUES ($1,$2,$3,$4) - ON CONFLICT (task_id, repo_id, branch_name) DO NOTHING - RETURNING id, task_id, repo_id, branch_name, created_at - `, taskID, b.RepoID, b.BranchName, now) - if dbErr != nil { - apiError(res, 500, "INTERNAL_ERROR", dbErr.Error()) - return - } - if len(inserted.Rows) == 0 { - apiError(res, 409, "GITHUB_BRANCH_ALREADY_LINKED", "Branch is already linked to this task") - return - } - - plugin.EmitEvent("github.branch_linked", map[string]any{ - "project_id": projectID, - "task_id": taskID, - "repo_id": b.RepoID, - "branch_name": b.BranchName, - }) - - sc := newRowScanner(inserted.Columns, inserted.Rows[0]) - created(res, taskBranchResponse{ - ID: sc.str("id"), - TaskID: sc.str("task_id"), - RepoID: sc.str("repo_id"), - BranchName: sc.str("branch_name"), - CreatedAt: sc.str("created_at"), - }) -} - -// ─── GET /tasks/:taskId/github/branches ─────────────────────────────────────── - -func (p *githubPlugin) listTaskBranches(req *plugin.Request, res *plugin.Response) { - projectID := req.Caller.ProjectID - taskID := req.PathParam("taskId") - if !p.taskBelongsToProject(taskID, projectID, res) { - return - } - - result, err := p.db.Query( - `SELECT id, task_id, repo_id, branch_name, created_at FROM github_task_branches WHERE task_id = $1 ORDER BY created_at ASC`, - taskID, - ) - if err != nil { - apiError(res, 500, "INTERNAL_ERROR", err.Error()) - return - } - - // github_task_branches has no project_id column of its own — only - // repo_id, which links to github_repositories (which does). Re-verify - // each branch's repo against the caller's project as defense-in-depth: - // taskBelongsToProject above already closes the main vector (a foreign - // taskId), but this also protects against any row a pre-fix caller - // might have already linked across projects. No SQL JOIN here (kept - // consistent with resolvePRForTask's style elsewhere in this plugin, - // which also resolves through separate single-table queries). - items := make([]taskBranchResponse, 0, len(result.Rows)) - for _, row := range result.Rows { - sc := newRowScanner(result.Columns, row) - repoID := sc.str("repo_id") - repoResult, rErr := p.db.Query( - `SELECT id FROM github_repositories WHERE id = $1 AND project_id = $2`, - repoID, projectID, - ) - if rErr != nil { - apiError(res, 500, "INTERNAL_ERROR", rErr.Error()) - return - } - if len(repoResult.Rows) == 0 { - continue - } - items = append(items, taskBranchResponse{ - ID: sc.str("id"), - TaskID: sc.str("task_id"), - RepoID: repoID, - BranchName: sc.str("branch_name"), - CreatedAt: sc.str("created_at"), - }) - } - ok(res, items) -} diff --git a/backend/client.go b/backend/client.go deleted file mode 100644 index 0427fb5..0000000 --- a/backend/client.go +++ /dev/null @@ -1,443 +0,0 @@ -package main - -import ( - "context" - "encoding/json" - "fmt" - "net/url" - "strings" - "time" - - plugin "github.com/Paca-AI/plugin-sdk-go" -) - -const ( - ghBaseURL = "https://api.github.com" - ghAPIVersion = "2022-11-28" -) - -// ghRepository is a minimal GitHub repository representation. -type ghRepository struct { - ID int64 `json:"id"` - FullName string `json:"full_name"` - Name string `json:"name"` - Owner struct { - Login string `json:"login"` - } `json:"owner"` - DefaultBranch string `json:"default_branch"` - Private bool `json:"private"` -} - -// ghPullRequest is a minimal representation of a GitHub pull request. -type ghPullRequest struct { - ID int64 `json:"id"` - Number int `json:"number"` - Title string `json:"title"` - State string `json:"state"` // "open" | "closed" - HTMLURL string `json:"html_url"` - Head struct { - Ref string `json:"ref"` - SHA string `json:"sha"` - } `json:"head"` - Base struct { - Ref string `json:"ref"` - } `json:"base"` - User struct { - Login string `json:"login"` - } `json:"user"` - Merged bool `json:"merged"` - MergedAt *time.Time `json:"merged_at"` -} - -// ghCombinedStatus is the response of GET /commits/{ref}/status — the legacy -// Status API used by CI integrations that post plain commit statuses rather -// than check runs (e.g. classic CircleCI/Travis integrations). -type ghCombinedStatus struct { - State string `json:"state"` // "pending" | "success" | "failure" | "error" - Statuses []struct { - Context string `json:"context"` - State string `json:"state"` - Description string `json:"description"` - TargetURL string `json:"target_url"` - } `json:"statuses"` -} - -// ghCheckRunsResponse is the response of GET /commits/{ref}/check-runs — the -// modern Checks API used by GitHub Actions and most GitHub Apps. -type ghCheckRunsResponse struct { - CheckRuns []struct { - Name string `json:"name"` - Status string `json:"status"` // "queued" | "in_progress" | "completed" - Conclusion *string `json:"conclusion"` // "success" | "failure" | ... | null while not completed - HTMLURL string `json:"html_url"` - } `json:"check_runs"` -} - -// ghAPIError carries a non-2xx HTTP status and the GitHub error message. -type ghAPIError struct { - StatusCode int - Message string - Details string -} - -func (e *ghAPIError) Error() string { - if e.Details != "" { - return fmt.Sprintf("github: API error %d: %s (%s)", e.StatusCode, e.Message, e.Details) - } - return fmt.Sprintf("github: API error %d: %s", e.StatusCode, e.Message) -} - -// ghClient is a GitHub REST API v3 client authenticated via a PAT. -// All outbound HTTP is proxied through the paca.fetch WASM host function, -// which enforces domain allowlisting configured in plugin.json. -type ghClient struct { - token string -} - -func newGHClient(token string) *ghClient { - return &ghClient{token: token} -} - -func (c *ghClient) headers() map[string]string { - return map[string]string{ - "Authorization": "Bearer " + c.token, - "Accept": "application/vnd.github+json", - "X-GitHub-Api-Version": ghAPIVersion, - } -} - -func (c *ghClient) get(_ context.Context, rawURL string, out any) error { - resp, err := plugin.Fetch("GET", rawURL, c.headers(), "") - if err != nil { - return fmt.Errorf("githubclient: execute request: %w", err) - } - if resp.Status >= 400 { - return ghParseAPIError(resp.Status, resp.Body) - } - if out != nil { - if err := json.Unmarshal([]byte(resp.Body), out); err != nil { - return fmt.Errorf("githubclient: decode response: %w", err) - } - } - return nil -} - -func (c *ghClient) post(_ context.Context, rawURL string, body, out any) error { - bodyJSON, err := json.Marshal(body) - if err != nil { - return fmt.Errorf("githubclient: encode body: %w", err) - } - hdrs := c.headers() - hdrs["Content-Type"] = "application/json" - resp, err := plugin.Fetch("POST", rawURL, hdrs, string(bodyJSON)) - if err != nil { - return fmt.Errorf("githubclient: execute request: %w", err) - } - if resp.Status >= 400 { - return ghParseAPIError(resp.Status, resp.Body) - } - if out != nil { - if err := json.Unmarshal([]byte(resp.Body), out); err != nil { - return fmt.Errorf("githubclient: decode response: %w", err) - } - } - return nil -} - -func (c *ghClient) doDelete(_ context.Context, rawURL string) error { - resp, err := plugin.Fetch("DELETE", rawURL, c.headers(), "") - if err != nil { - return fmt.Errorf("githubclient: execute request: %w", err) - } - if resp.Status == 404 || resp.Status == 204 { - return nil - } - if resp.Status >= 400 { - return ghParseAPIError(resp.Status, resp.Body) - } - return nil -} - -func ghParseAPIError(statusCode int, body string) error { - var errBody struct { - Message string `json:"message"` - Errors []struct { - Field string `json:"field"` - Message string `json:"message"` - } `json:"errors"` - } - _ = json.Unmarshal([]byte(body), &errBody) - if errBody.Message == "" { - errBody.Message = fmt.Sprintf("HTTP %d", statusCode) - } - details := make([]string, 0, len(errBody.Errors)) - for _, e := range errBody.Errors { - msg := strings.TrimSpace(e.Message) - if msg == "" { - continue - } - if e.Field != "" { - details = append(details, fmt.Sprintf("%s: %s", e.Field, msg)) - } else { - details = append(details, msg) - } - } - return &ghAPIError{ - StatusCode: statusCode, - Message: errBody.Message, - Details: strings.Join(details, "; "), - } -} - -// ─── API methods ───────────────────────────────────────────────────────────── - -func (c *ghClient) validateToken(ctx context.Context) error { - var user struct { - Login string `json:"login"` - } - return c.get(ctx, ghBaseURL+"/user", &user) -} - -func (c *ghClient) listRepositories(ctx context.Context) ([]ghRepository, error) { - seen := make(map[string]struct{}) - var all []ghRepository - - addRepo := func(r ghRepository) { - if _, ok := seen[r.FullName]; ok { - return - } - seen[r.FullName] = struct{}{} - all = append(all, r) - } - - // 1. Repos directly accessible to the user (owned + collaborator + org member). - for page := 1; ; page++ { - url := fmt.Sprintf("%s/user/repos?visibility=all&affiliation=owner,collaborator,organization_member&per_page=100&page=%d", ghBaseURL, page) - var batch []ghRepository - if err := c.get(ctx, url, &batch); err != nil { - return nil, err - } - for _, r := range batch { - addRepo(r) - } - if len(batch) < 100 { - break - } - } - - // 2. List all orgs the user belongs to, then fetch each org's repos - // explicitly. This catches orgs where the membership is private or - // where the token has "read:org" but not full org repo visibility. - var orgs []struct { - Login string `json:"login"` - } - for page := 1; ; page++ { - url := fmt.Sprintf("%s/user/orgs?per_page=100&page=%d", ghBaseURL, page) - var batch []struct { - Login string `json:"login"` - } - if err := c.get(ctx, url, &batch); err != nil { - // Non-fatal: proceed with what we have from /user/repos. - break - } - orgs = append(orgs, batch...) - if len(batch) < 100 { - break - } - } - - for _, org := range orgs { - for page := 1; ; page++ { - url := fmt.Sprintf("%s/orgs/%s/repos?type=all&per_page=100&page=%d", ghBaseURL, org.Login, page) - var batch []ghRepository - if err := c.get(ctx, url, &batch); err != nil { - // Skip orgs where the token lacks access. - break - } - for _, r := range batch { - addRepo(r) - } - if len(batch) < 100 { - break - } - } - } - - return all, nil -} - -func (c *ghClient) getRepository(ctx context.Context, owner, repo string) (*ghRepository, error) { - url := fmt.Sprintf("%s/repos/%s/%s", ghBaseURL, owner, repo) - var r ghRepository - if err := c.get(ctx, url, &r); err != nil { - return nil, err - } - return &r, nil -} - -func (c *ghClient) createWebhook(ctx context.Context, owner, repo, webhookURL, secret string, events []string) (int64, error) { - url := fmt.Sprintf("%s/repos/%s/%s/hooks", ghBaseURL, owner, repo) - body := map[string]any{ - "name": "web", - "active": true, - "events": events, - "config": map[string]string{ - "url": webhookURL, - "content_type": "json", - "secret": secret, - }, - } - var resp struct { - ID int64 `json:"id"` - } - if err := c.post(ctx, url, body, &resp); err != nil { - return 0, err - } - return resp.ID, nil -} - -func (c *ghClient) deleteWebhook(ctx context.Context, owner, repo string, hookID int64) error { - url := fmt.Sprintf("%s/repos/%s/%s/hooks/%d", ghBaseURL, owner, repo, hookID) - return c.doDelete(ctx, url) -} - -func (c *ghClient) getPullRequest(ctx context.Context, owner, repo string, prNumber int) (*ghPullRequest, error) { - url := fmt.Sprintf("%s/repos/%s/%s/pulls/%d", ghBaseURL, owner, repo, prNumber) - var pr ghPullRequest - if err := c.get(ctx, url, &pr); err != nil { - return nil, err - } - return &pr, nil -} - -func (c *ghClient) createPullRequest(ctx context.Context, owner, repo, title, head, base, body string) (*ghPullRequest, error) { - url := fmt.Sprintf("%s/repos/%s/%s/pulls", ghBaseURL, owner, repo) - reqBody := map[string]string{ - "title": title, - "head": head, - "base": base, - } - if body != "" { - reqBody["body"] = body - } - var pr ghPullRequest - if err := c.post(ctx, url, reqBody, &pr); err != nil { - return nil, err - } - return &pr, nil -} - -// mergePullRequest merges a pull request via PUT /pulls/{number}/merge. -// mergeMethod is one of "merge" | "squash" | "rebase" (GitHub defaults to -// "merge" if empty, but callers should always pass an explicit value). -func (c *ghClient) mergePullRequest(ctx context.Context, owner, repo string, prNumber int, mergeMethod string) error { - url := fmt.Sprintf("%s/repos/%s/%s/pulls/%d/merge", ghBaseURL, owner, repo, prNumber) - body := map[string]string{"merge_method": mergeMethod} - bodyJSON, err := json.Marshal(body) - if err != nil { - return fmt.Errorf("githubclient: encode body: %w", err) - } - hdrs := c.headers() - hdrs["Content-Type"] = "application/json" - resp, err := plugin.Fetch("PUT", url, hdrs, string(bodyJSON)) - if err != nil { - return fmt.Errorf("githubclient: execute request: %w", err) - } - if resp.Status >= 400 { - return ghParseAPIError(resp.Status, resp.Body) - } - return nil -} - -// getPullRequestDiff fetches the unified diff for a pull request via GitHub's -// diff media type. Unlike get(), the response body is raw diff text, not -// JSON, so it bypasses get()'s json.Unmarshal step. -func (c *ghClient) getPullRequestDiff(_ context.Context, owner, repo string, prNumber int) (string, error) { - url := fmt.Sprintf("%s/repos/%s/%s/pulls/%d", ghBaseURL, owner, repo, prNumber) - hdrs := c.headers() - hdrs["Accept"] = "application/vnd.github.v3.diff" - resp, err := plugin.Fetch("GET", url, hdrs, "") - if err != nil { - return "", fmt.Errorf("githubclient: execute request: %w", err) - } - if resp.Status >= 400 { - return "", ghParseAPIError(resp.Status, resp.Body) - } - return resp.Body, nil -} - -// createPullRequestReview submits a review on a pull request. event must be -// one of "APPROVE", "REQUEST_CHANGES", or "COMMENT" (GitHub requires a -// non-empty body for the latter two). -func (c *ghClient) createPullRequestReview(ctx context.Context, owner, repo string, prNumber int, event, body string) error { - url := fmt.Sprintf("%s/repos/%s/%s/pulls/%d/reviews", ghBaseURL, owner, repo, prNumber) - reqBody := map[string]string{"event": event} - if body != "" { - reqBody["body"] = body - } - return c.post(ctx, url, reqBody, nil) -} - -// createIssueComment adds a general (non-review) comment to a pull request. -// GitHub represents PR comments as issue comments under /issues/{number}/comments. -func (c *ghClient) createIssueComment(ctx context.Context, owner, repo string, prNumber int, body string) error { - url := fmt.Sprintf("%s/repos/%s/%s/issues/%d/comments", ghBaseURL, owner, repo, prNumber) - return c.post(ctx, url, map[string]string{"body": body}, nil) -} - -// getCombinedStatus fetches legacy commit statuses posted for ref (a SHA, -// branch, or tag name). -func (c *ghClient) getCombinedStatus(ctx context.Context, owner, repo, ref string) (*ghCombinedStatus, error) { - url := fmt.Sprintf("%s/repos/%s/%s/commits/%s/status", ghBaseURL, owner, repo, ref) - var status ghCombinedStatus - if err := c.get(ctx, url, &status); err != nil { - return nil, err - } - return &status, nil -} - -// getCheckRuns fetches check runs (GitHub Actions and other Checks-API-based -// CI) for ref (a SHA, branch, or tag name). -func (c *ghClient) getCheckRuns(ctx context.Context, owner, repo, ref string) (*ghCheckRunsResponse, error) { - url := fmt.Sprintf("%s/repos/%s/%s/commits/%s/check-runs", ghBaseURL, owner, repo, ref) - var result ghCheckRunsResponse - if err := c.get(ctx, url, &result); err != nil { - return nil, err - } - return &result, nil -} - -// ghBranchHeadRefURL builds GET /repos/{owner}/{repo}/git/ref/heads/{branch}. -// Branch names may contain slashes (e.g. fix/foo); encode the ref segment so -// the path is not split into extra URL segments. -func ghBranchHeadRefURL(owner, repo, branch string) string { - return fmt.Sprintf( - "%s/repos/%s/%s/git/ref/heads/%s", - ghBaseURL, - url.PathEscape(owner), - url.PathEscape(repo), - url.PathEscape(branch), - ) -} - -func (c *ghClient) branchExists(ctx context.Context, owner, repo, branch string) error { - return c.get(ctx, ghBranchHeadRefURL(owner, repo, branch), &struct{}{}) -} - -func (c *ghClient) createBranch(ctx context.Context, owner, repo, newBranch, sourceBranch string) error { - refURL := ghBranchHeadRefURL(owner, repo, sourceBranch) - var refResp struct { - Object struct { - SHA string `json:"sha"` - } `json:"object"` - } - if err := c.get(ctx, refURL, &refResp); err != nil { - return fmt.Errorf("resolve source branch %q: %w", sourceBranch, err) - } - - createURL := fmt.Sprintf("%s/repos/%s/%s/git/refs", ghBaseURL, url.PathEscape(owner), url.PathEscape(repo)) - return c.post(ctx, createURL, map[string]string{ - "ref": "refs/heads/" + newBranch, - "sha": refResp.Object.SHA, - }, nil) -} diff --git a/backend/client_url_test.go b/backend/client_url_test.go deleted file mode 100644 index d750f89..0000000 --- a/backend/client_url_test.go +++ /dev/null @@ -1,67 +0,0 @@ -package main - -import "testing" - -func TestGhBranchHeadRefURL_EncodesSlashes(t *testing.T) { - got := ghBranchHeadRefURL("torvalds", "linux", "fix/sched-fair") - want := "https://api.github.com/repos/torvalds/linux/git/ref/heads/fix%2Fsched-fair" - if got != want { - t.Fatalf("got %q, want %q", got, want) - } -} - -func TestGhBranchHeadRefURL_PlainBranch(t *testing.T) { - got := ghBranchHeadRefURL("golang", "go", "master") - want := "https://api.github.com/repos/golang/go/git/ref/heads/master" - if got != want { - t.Fatalf("got %q, want %q", got, want) - } -} - -func TestGhBranchHeadRefURL_TableDriven(t *testing.T) { - tests := []struct { - name string - owner string - repo string - branch string - want string - }{ - { - name: "release branch", - owner: "nodejs", - repo: "node", - branch: "v20.x", - want: "https://api.github.com/repos/nodejs/node/git/ref/heads/v20.x", - }, - { - name: "nested feature branch", - owner: "rust-lang", - repo: "rust", - branch: "feature/const-traits", - want: "https://api.github.com/repos/rust-lang/rust/git/ref/heads/feature%2Fconst-traits", - }, - { - name: "dependabot style branch", - owner: "django", - repo: "django", - branch: "dependabot/pip/wheel-0.43.0", - want: "https://api.github.com/repos/django/django/git/ref/heads/dependabot%2Fpip%2Fwheel-0.43.0", - }, - { - name: "owner repo with hyphen", - owner: "home-assistant", - repo: "core", - branch: "dev", - want: "https://api.github.com/repos/home-assistant/core/git/ref/heads/dev", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := ghBranchHeadRefURL(tt.owner, tt.repo, tt.branch) - if got != tt.want { - t.Fatalf("got %q, want %q", got, tt.want) - } - }) - } -} diff --git a/backend/github.go b/backend/github.go new file mode 100644 index 0000000..4044aac --- /dev/null +++ b/backend/github.go @@ -0,0 +1,128 @@ +package main + +import ( + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "net/netip" + "net/url" + "regexp" + "strings" + "time" + + plugin "github.com/Paca-AI/plugin-sdk-go" +) + +const ( + githubPluginID = "com.paca.github" + ghBaseURL = "https://api.github.com" + ghAPIVersion = "2022-11-28" + + // githubAppNameMaxLen is GitHub's limit on an App's name. + githubAppNameMaxLen = 34 +) + +// fetch is the outbound HTTP call; a variable so tests can stub GitHub. +var fetch = plugin.Fetch + +var githubLoginRe = regexp.MustCompile(`^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?$`) + +// manifestPermissions is requested up front as a superset of what the provider +// uses: widening an App's permissions later forces an org owner to re-approve it. +var manifestPermissions = map[string]string{ + "metadata": "read", + "contents": "write", + "pull_requests": "write", + "issues": "write", + "checks": "write", // the SPI reports agent progress as check runs + "statuses": "read", + "actions": "read", +} + +var manifestEvents = []string{ + "push", + "pull_request", + "pull_request_review", + "pull_request_review_comment", + "pull_request_review_thread", + "issue_comment", + "issues", + "check_run", + "check_suite", +} + +// ghInstallation is a minimal GET /app/installations/{id} response. +type ghInstallation struct { + ID int64 `json:"id"` + RepositorySelection string `json:"repository_selection"` + SuspendedAt *time.Time `json:"suspended_at"` + Account struct { + Login string `json:"login"` + Type string `json:"type"` + } `json:"account"` +} + +// ghManifestConversion is the response of POST /app-manifests/{code}/conversions. +// It is the only time GitHub ever returns the App's private key and webhook secret. +type ghManifestConversion struct { + ID int64 `json:"id"` + Slug string `json:"slug"` + Name string `json:"name"` + HTMLURL string `json:"html_url"` + ClientID string `json:"client_id"` + ClientSecret string `json:"client_secret"` + WebhookSecret string `json:"webhook_secret"` + PEM string `json:"pem"` + Owner struct { + Login string `json:"login"` + } `json:"owner"` +} + +// nowStr returns the current UTC time as an RFC3339Nano string. +func nowStr() string { return time.Now().UTC().Format(time.RFC3339Nano) } + +func randomHex(nBytes int) (string, error) { + b := make([]byte, nBytes) + if _, err := rand.Read(b); err != nil { + return "", err + } + return hex.EncodeToString(b), nil +} + +// isLocalURL reports whether rawURL points at this machine or a private +// network, where GitHub cannot deliver a webhook. +func isLocalURL(rawURL string) bool { + u, err := url.Parse(rawURL) + if err != nil { + return false + } + host := strings.ToLower(u.Hostname()) + switch { + case host == "", host == "localhost": + return true + case strings.HasSuffix(host, ".local"), strings.HasSuffix(host, ".internal"), + strings.HasSuffix(host, ".localhost"), strings.HasSuffix(host, ".lan"): + return true + } + ip, err := netip.ParseAddr(host) + if err != nil { + return false + } + ip = ip.Unmap() + return ip.IsUnspecified() || ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || + cgnat.Contains(ip) +} + +// cgnat is the carrier-grade NAT range (RFC 6598), which is not routable either. +var cgnat = netip.MustParsePrefix("100.64.0.0/10") + +func verifyHMAC(payload []byte, secret, signatureHeader string) bool { + const prefix = "sha256=" + if !strings.HasPrefix(signatureHeader, prefix) { + return false + } + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write(payload) + return hmac.Equal([]byte(hex.EncodeToString(mac.Sum(nil))), []byte(signatureHeader[len(prefix):])) +} diff --git a/backend/go.mod b/backend/go.mod index c0bba7e..147f1c6 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -2,4 +2,4 @@ module github.com/Paca-AI/first-party/github go 1.24 -require github.com/Paca-AI/plugin-sdk-go v0.3.3 +require github.com/Paca-AI/plugin-sdk-go v0.4.0 diff --git a/backend/go.sum b/backend/go.sum index 970bd41..78f3884 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -1,2 +1,2 @@ -github.com/Paca-AI/plugin-sdk-go v0.3.3 h1:nW2DwJatraYfxo4MpwNkfQEp+9jmj7QHaU16bOXzBpM= -github.com/Paca-AI/plugin-sdk-go v0.3.3/go.mod h1:5WeC6cSEf2wM1ovICZbDaVky9oi5id/Qpdfc5LDAQnw= +github.com/Paca-AI/plugin-sdk-go v0.4.0 h1:u3xDC+VGfHrgjI2e+XCgVMdnnKo5cedX36KPg6X0+/4= +github.com/Paca-AI/plugin-sdk-go v0.4.0/go.mod h1:5WeC6cSEf2wM1ovICZbDaVky9oi5id/Qpdfc5LDAQnw= diff --git a/backend/integration.go b/backend/integration.go deleted file mode 100644 index c4d046b..0000000 --- a/backend/integration.go +++ /dev/null @@ -1,500 +0,0 @@ -package main - -import ( - "context" - "crypto/rand" - "encoding/hex" - "errors" - "fmt" - "strings" - - plugin "github.com/Paca-AI/plugin-sdk-go" -) - -// ─── DTOs ──────────────────────────────────────────────────────────────────── - -type integrationResponse struct { - ProjectID string `json:"project_id"` - Connected bool `json:"connected"` - CreatedAt *string `json:"created_at,omitempty"` - UpdatedAt *string `json:"updated_at,omitempty"` -} - -// accessibleRepoResponse is returned by GET /integration/accessible-repos. -// It reflects data fetched live from the GitHub API. -type accessibleRepoResponse struct { - FullName string `json:"full_name"` - Owner string `json:"owner"` - RepoName string `json:"repo_name"` - DefaultBranch string `json:"default_branch"` - Private bool `json:"private"` -} - -// repositoryResponse is the canonical DTO for a project-linked repository. -// Returned by GET /repositories and POST /repositories. -type repositoryResponse struct { - ID string `json:"id"` - ProjectID string `json:"project_id"` - Owner string `json:"owner"` - RepoName string `json:"repo_name"` - FullName string `json:"full_name"` - DefaultBranch string `json:"default_branch"` - CloneURL string `json:"clone_url"` - WebhookActive bool `json:"webhook_active"` - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` -} - -// repoCloneInfo is returned by GET /repositories/:repoId/clone-info. -// It includes a short-lived token for cloning. -type repoCloneInfo struct { - ID string `json:"id"` - FullName string `json:"full_name"` - Owner string `json:"owner"` - RepoName string `json:"repo_name"` - CloneURL string `json:"clone_url"` - Token string `json:"token"` - ExpiresAt float64 `json:"expires_at"` -} - -const githubPluginID = "com.paca.github" - -func webhookURLFromPublicURL(cfg *plugin.Config, projectID string) (string, error) { - publicURL, ok := cfg.Get("PUBLIC_URL") - if !ok || strings.TrimSpace(publicURL) == "" { - return "", errors.New("PUBLIC_URL is not configured") - } - base := strings.TrimRight(strings.TrimSpace(publicURL), "/") - return fmt.Sprintf("%s/api/v1/plugins/%s/projects/%s/webhook", base, githubPluginID, projectID), nil -} - -// ─── Helper: decrypt PAT for a project ─────────────────────────────────────── - -func (p *githubPlugin) decryptToken(projectID string) (string, error) { - result, err := p.db.Query( - `SELECT access_token_enc FROM github_integrations WHERE project_id = $1`, - projectID, - ) - if err != nil { - return "", err - } - if len(result.Rows) == 0 { - return "", &appError{code: "GITHUB_INTEGRATION_NOT_FOUND", status: 404, msg: "GitHub integration not found"} - } - enc := newRowScanner(result.Columns, result.Rows[0]).str("access_token_enc") - return p.decrypt(enc) -} - -// ─── GET /integration ──────────────────────────────────────────────────────── - -func (p *githubPlugin) getIntegration(req *plugin.Request, res *plugin.Response) { - projectID := req.Caller.ProjectID - result, err := p.db.Query( - `SELECT project_id, created_at, updated_at FROM github_integrations WHERE project_id = $1`, - projectID, - ) - if err != nil { - apiError(res, 500, "INTERNAL_ERROR", err.Error()) - return - } - if len(result.Rows) == 0 { - ok(res, integrationResponse{ProjectID: projectID, Connected: false}) - return - } - sc := newRowScanner(result.Columns, result.Rows[0]) - ca := sc.str("created_at") - ua := sc.str("updated_at") - ok(res, integrationResponse{ - ProjectID: sc.str("project_id"), - Connected: true, - CreatedAt: &ca, - UpdatedAt: &ua, - }) -} - -// ─── POST /integration/token ───────────────────────────────────────────────── - -func (p *githubPlugin) setToken(req *plugin.Request, res *plugin.Response) { - projectID := req.Caller.ProjectID - - type setTokenBody struct { - Token string `json:"token"` - } - b, err := plugin.JSONBody[setTokenBody](req) - if err != nil || b.Token == "" { - apiError(res, 400, "BAD_REQUEST", "token is required") - return - } - - // Validate against GitHub API. - ghc := newGHClient(b.Token) - if err := ghc.validateToken(context.Background()); err != nil { - var apiErr *ghAPIError - if errors.As(err, &apiErr) && (apiErr.StatusCode == 401 || apiErr.StatusCode == 403) { - apiError(res, 422, "GITHUB_INVALID_TOKEN", "GitHub token is invalid or expired") - return - } - apiError(res, 502, "INTERNAL_ERROR", fmt.Sprintf("failed to validate token: %s", err)) - return - } - - enc, err := p.encrypt(b.Token) - if err != nil { - apiError(res, 500, "INTERNAL_ERROR", "failed to encrypt token") - return - } - - now := nowStr() - _, err = p.db.Exec(` - INSERT INTO github_integrations (project_id, access_token_enc, created_at, updated_at) - VALUES ($1, $2, $3, $4) - ON CONFLICT (project_id) DO UPDATE SET access_token_enc = EXCLUDED.access_token_enc, updated_at = EXCLUDED.updated_at - `, projectID, enc, now, now) - if err != nil { - apiError(res, 500, "INTERNAL_ERROR", err.Error()) - return - } - - // Fetch back the record. - result, qErr := p.db.Query( - `SELECT project_id, created_at, updated_at FROM github_integrations WHERE project_id = $1`, - projectID, - ) - if qErr != nil || len(result.Rows) == 0 { - ok(res, integrationResponse{ProjectID: projectID, Connected: true}) - return - } - sc := newRowScanner(result.Columns, result.Rows[0]) - ca := sc.str("created_at") - ua := sc.str("updated_at") - ok(res, integrationResponse{ - ProjectID: sc.str("project_id"), - Connected: true, - CreatedAt: &ca, - UpdatedAt: &ua, - }) -} - -// ─── DELETE /integration/token ─────────────────────────────────────────────── - -func (p *githubPlugin) deleteToken(req *plugin.Request, res *plugin.Response) { - projectID := req.Caller.ProjectID - - // Best-effort: delete webhooks for all linked repositories first. - token, tokenErr := p.decryptToken(projectID) - if tokenErr == nil { - ghc := newGHClient(token) - rows, qErr := p.db.Query( - `SELECT owner, repo_name, webhook_id FROM github_repositories WHERE project_id = $1`, - projectID, - ) - if qErr == nil { - for _, row := range rows.Rows { - sc := newRowScanner(rows.Columns, row) - whID := sc.int64Val("webhook_id") - if whID > 0 { - _ = ghc.deleteWebhook(context.Background(), sc.str("owner"), sc.str("repo_name"), whID) - } - } - } - } - - _, execErr := p.db.Exec(`DELETE FROM github_integrations WHERE project_id = $1`, projectID) - if execErr != nil { - apiError(res, 500, "INTERNAL_ERROR", execErr.Error()) - return - } - noContent(res) -} - -// ─── GET /integration/accessible-repos ────────────────────────────────────── - -func (p *githubPlugin) listAccessibleRepos(req *plugin.Request, res *plugin.Response) { - projectID := req.Caller.ProjectID - - token, err := p.decryptToken(projectID) - if err != nil { - writeAppError(res, err) - return - } - - ghc := newGHClient(token) - repos, err := ghc.listRepositories(context.Background()) - if err != nil { - apiError(res, 502, "INTERNAL_ERROR", fmt.Sprintf("failed to list repositories: %s", err)) - return - } - - items := make([]accessibleRepoResponse, len(repos)) - for i, r := range repos { - items[i] = accessibleRepoResponse{ - FullName: r.FullName, - Owner: r.Owner.Login, - RepoName: r.Name, - DefaultBranch: r.DefaultBranch, - Private: r.Private, - } - } - ok(res, items) -} - -// ─── GET /repositories ─────────────────────────────────────────────────────── - -func (p *githubPlugin) listRepositories(req *plugin.Request, res *plugin.Response) { - projectID := req.Caller.ProjectID - - result, err := p.db.Query(` - SELECT id, project_id, owner, repo_name, full_name, default_branch, webhook_id, created_at, updated_at - FROM github_repositories WHERE project_id = $1 ORDER BY created_at ASC - `, projectID) - if err != nil { - apiError(res, 500, "INTERNAL_ERROR", err.Error()) - return - } - - items := make([]repositoryResponse, 0, len(result.Rows)) - for _, row := range result.Rows { - sc := newRowScanner(result.Columns, row) - fullName := sc.str("full_name") - items = append(items, repositoryResponse{ - ID: sc.str("id"), - ProjectID: sc.str("project_id"), - Owner: sc.str("owner"), - RepoName: sc.str("repo_name"), - FullName: fullName, - DefaultBranch: sc.str("default_branch"), - CloneURL: "https://github.com/" + fullName + ".git", - WebhookActive: sc.int64Val("webhook_id") > 0, - CreatedAt: sc.str("created_at"), - UpdatedAt: sc.str("updated_at"), - }) - } - ok(res, items) -} - -// ─── POST /repositories ─────────────────────────────────────────────────────── - -func (p *githubPlugin) linkRepository(req *plugin.Request, res *plugin.Response) { - projectID := req.Caller.ProjectID - - type linkRepositoryBody struct { - Owner string `json:"owner"` - RepoName string `json:"repo_name"` - } - b, err := plugin.JSONBody[linkRepositoryBody](req) - if err != nil || b.Owner == "" || b.RepoName == "" { - apiError(res, 400, "BAD_REQUEST", "owner and repo_name are required") - return - } - - webhookURL, err := webhookURLFromPublicURL(p.cfg, projectID) - if err != nil { - apiError(res, 422, "GITHUB_WEBHOOK_URL_REQUIRED", "PUBLIC_URL is not configured") - return - } - - token, err := p.decryptToken(projectID) - if err != nil { - writeAppError(res, err) - return - } - - ghc := newGHClient(token) - ghRepo, err := ghc.getRepository(context.Background(), b.Owner, b.RepoName) - if err != nil { - var apiErr *ghAPIError - if errors.As(err, &apiErr) && (apiErr.StatusCode == 403 || apiErr.StatusCode == 404) { - apiError(res, 422, "GITHUB_REPO_NOT_ACCESSIBLE", "Repository not accessible with the provided token") - return - } - apiError(res, 502, "INTERNAL_ERROR", fmt.Sprintf("failed to get repository: %s", err)) - return - } - - // Check if already linked. - existResult, _ := p.db.Query( - `SELECT id FROM github_repositories WHERE project_id = $1 AND full_name = $2`, - projectID, ghRepo.FullName, - ) - if existResult != nil && len(existResult.Rows) > 0 { - apiError(res, 409, "GITHUB_REPO_ALREADY_LINKED", "Repository is already linked to this project") - return - } - - // Fetch integration ID. - integResult, iErr := p.db.Query(`SELECT id FROM github_integrations WHERE project_id = $1`, projectID) - if iErr != nil || len(integResult.Rows) == 0 { - apiError(res, 404, "GITHUB_INTEGRATION_NOT_FOUND", "GitHub integration not found") - return - } - integrationID := newRowScanner(integResult.Columns, integResult.Rows[0]).str("id") - - // Generate webhook secret. - secretBytes := make([]byte, 32) - if _, randErr := rand.Read(secretBytes); randErr != nil { - apiError(res, 500, "INTERNAL_ERROR", "failed to generate webhook secret") - return - } - webhookSecret := hex.EncodeToString(secretBytes) - - webhookID, err := ghc.createWebhook(context.Background(), ghRepo.Owner.Login, ghRepo.Name, webhookURL, webhookSecret, - []string{"push", "pull_request"}) - if err != nil { - var apiErr *ghAPIError - if errors.As(err, &apiErr) && isWebhookURLNotPublic(apiErr) { - apiError(res, 422, "GITHUB_WEBHOOK_URL_NOT_PUBLIC", "Webhook URL is not publicly accessible") - return - } - apiError(res, 502, "GITHUB_WEBHOOK_CREATION_FAILED", fmt.Sprintf("failed to create webhook: %s", err)) - return - } - - encSecret, err := p.encrypt(webhookSecret) - if err != nil { - _ = ghc.deleteWebhook(context.Background(), ghRepo.Owner.Login, ghRepo.Name, webhookID) - apiError(res, 500, "INTERNAL_ERROR", "failed to encrypt webhook secret") - return - } - - now := nowStr() - inserted, dbErr := p.db.Query(` - INSERT INTO github_repositories - (project_id, integration_id, owner, repo_name, full_name, webhook_id, webhook_secret_enc, default_branch, created_at, updated_at) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$9) - RETURNING id - `, projectID, integrationID, ghRepo.Owner.Login, ghRepo.Name, ghRepo.FullName, webhookID, encSecret, ghRepo.DefaultBranch, now) - if dbErr != nil || len(inserted.Rows) == 0 { - _ = ghc.deleteWebhook(context.Background(), ghRepo.Owner.Login, ghRepo.Name, webhookID) - if dbErr != nil { - apiError(res, 500, "INTERNAL_ERROR", dbErr.Error()) - } else { - apiError(res, 500, "INTERNAL_ERROR", "insert returned no rows") - } - return - } - repoID := newRowScanner(inserted.Columns, inserted.Rows[0]).str("id") - - created(res, repositoryResponse{ - ID: repoID, - ProjectID: projectID, - Owner: ghRepo.Owner.Login, - RepoName: ghRepo.Name, - FullName: ghRepo.FullName, - DefaultBranch: ghRepo.DefaultBranch, - CloneURL: "https://github.com/" + ghRepo.FullName + ".git", - WebhookActive: webhookID > 0, - CreatedAt: now, - UpdatedAt: now, - }) -} - -// ─── DELETE /repositories/:repoId ──────────────────────────────────────────── - -func (p *githubPlugin) unlinkRepository(req *plugin.Request, res *plugin.Response) { - projectID := req.Caller.ProjectID - repoID := req.PathParam("repoId") - - // Fetch repository details to delete webhook. - result, err := p.db.Query( - `SELECT owner, repo_name, webhook_id FROM github_repositories WHERE id = $1 AND project_id = $2`, - repoID, projectID, - ) - if err != nil { - apiError(res, 500, "INTERNAL_ERROR", err.Error()) - return - } - if len(result.Rows) == 0 { - apiError(res, 404, "GITHUB_REPOSITORY_NOT_FOUND", "Repository not found") - return - } - sc := newRowScanner(result.Columns, result.Rows[0]) - owner := sc.str("owner") - repoName := sc.str("repo_name") - webhookID := sc.int64Val("webhook_id") - - // Best-effort: delete the webhook. - if webhookID > 0 { - if token, tErr := p.decryptToken(projectID); tErr == nil { - ghc := newGHClient(token) - _ = ghc.deleteWebhook(context.Background(), owner, repoName, webhookID) - } - } - - _, err = p.db.Exec(`DELETE FROM github_repositories WHERE id = $1`, repoID) - if err != nil { - apiError(res, 500, "INTERNAL_ERROR", err.Error()) - return - } - noContent(res) -} - -// ─── GET /repositories/:repoId/clone-info ──────────────────────────────────── - -func (p *githubPlugin) getRepoCloneInfo(req *plugin.Request, res *plugin.Response) { - projectID := req.Caller.ProjectID - repoID := req.PathParam("repoId") - if repoID == "" { - apiError(res, 400, "BAD_REQUEST", "repoId path parameter is required") - return - } - result, err := p.db.Query( - `SELECT id, full_name, owner, repo_name FROM github_repositories WHERE project_id = $1 AND id = $2`, - projectID, repoID, - ) - if err != nil { - apiError(res, 500, "INTERNAL_ERROR", err.Error()) - return - } - if len(result.Rows) == 0 { - apiError(res, 404, "GITHUB_REPOSITORY_NOT_FOUND", "repository not found") - return - } - token, err := p.decryptToken(projectID) - if err != nil { - writeAppError(res, err) - return - } - sc := newRowScanner(result.Columns, result.Rows[0]) - fullName := sc.str("full_name") - ok(res, repoCloneInfo{ - ID: sc.str("id"), - FullName: fullName, - Owner: sc.str("owner"), - RepoName: sc.str("repo_name"), - CloneURL: "https://github.com/" + fullName + ".git", - Token: token, - ExpiresAt: 0, - }) -} - -// ─── GET /github/repo-info (REMOVED) ───────────────────────────────────────── -// Replaced by GET /repositories (list all) and GET /repositories/:repoId/clone-info - -// ─── Error helpers ──────────────────────────────────────────────────────────── - -type appError struct { - code string - status int - msg string -} - -func (e *appError) Error() string { return e.msg } - -func writeAppError(res *plugin.Response, err error) { - var ae *appError - if errors.As(err, &ae) { - apiError(res, ae.status, ae.code, ae.msg) - return - } - apiError(res, 500, "INTERNAL_ERROR", err.Error()) -} - -func isWebhookURLNotPublic(apiErr *ghAPIError) bool { - if apiErr == nil || apiErr.StatusCode != 422 { - return false - } - msg := strings.ToLower(apiErr.Message + " " + apiErr.Details) - return strings.Contains(msg, "isn't reachable over the public internet") || - strings.Contains(msg, "not publicly reachable") || - strings.Contains(msg, "localhost") -} diff --git a/backend/migrations/0002_git_provider.sql b/backend/migrations/0002_git_provider.sql new file mode 100644 index 0000000..1eab48e --- /dev/null +++ b/backend/migrations/0002_git_provider.sql @@ -0,0 +1,64 @@ +-- 0002_git_provider.sql +-- The GitHub provider's own state: instance- or agent-level GitHub Apps +-- (created through the GitHub App manifest flow, key material encrypted), the +-- accounts they are installed on, and the setup sessions that tie the +-- manifest flow's redirects together. Drops the previous release's +-- project-bound tables (see the end). +-- Run with search_path = plugin_data_com_paca_github, public. + +CREATE TABLE IF NOT EXISTS github_apps ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + github_app_id BIGINT NOT NULL, + slug TEXT NOT NULL, + name TEXT NOT NULL, + owner_login TEXT NOT NULL DEFAULT '', + html_url TEXT NOT NULL DEFAULT '', + webhook_secret_enc TEXT NOT NULL DEFAULT '', + private_key_enc TEXT NOT NULL, + webhook_active BOOLEAN NOT NULL DEFAULT TRUE, + created_by TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS github_app_installations ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + app_id UUID NOT NULL REFERENCES github_apps(id) ON DELETE CASCADE, + installation_id BIGINT NOT NULL, + account_login TEXT NOT NULL DEFAULT '', + account_type TEXT NOT NULL DEFAULT '', + repository_selection TEXT NOT NULL DEFAULT 'selected', + suspended_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (app_id, installation_id) +); + +-- One row per manifest-flow attempt. state is the single-use CSRF nonce that +-- GitHub echoes back on both redirects. +CREATE TABLE IF NOT EXISTS github_manifest_sessions ( + state TEXT PRIMARY KEY, + created_by TEXT NOT NULL, + app_name TEXT NOT NULL DEFAULT '', + owner_org TEXT NOT NULL DEFAULT '', + -- Whom the identity is for: the shared one, or one agent's own. + scope TEXT NOT NULL DEFAULT 'shared', + agent_id TEXT NOT NULL DEFAULT '', + -- Set once the manifest code has been exchanged; the second leg + -- (installation) then runs against this app. + app_id UUID REFERENCES github_apps(id) ON DELETE CASCADE, + code_used_at TIMESTAMPTZ, + completed_at TIMESTAMPTZ, + expires_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- The plugin is a Git provider: repositories, pull requests, task links and +-- the per-project connection now live in Paca core (git_* tables). The +-- project-bound tables of the previous release are dropped; PAT connections +-- cannot back a provider identity, so no data is migrated. +DROP TABLE IF EXISTS github_task_pr_links; +DROP TABLE IF EXISTS github_task_branches; +DROP TABLE IF EXISTS github_pull_requests; +DROP TABLE IF EXISTS github_repositories; +DROP TABLE IF EXISTS github_integrations; diff --git a/backend/plugin.go b/backend/plugin.go index 0010b64..3aa3bb3 100644 --- a/backend/plugin.go +++ b/backend/plugin.go @@ -1,149 +1,32 @@ -// Package main implements the com.paca.github backend WASM plugin. -// -// It manages GitHub integration for projects: PAT storage, repository linking -// with automatic webhook creation, pull-request caching, branch creation, and -// webhook event processing. +// Package main implements the com.paca.github backend WASM plugin: the GitHub +// provider of Paca's Git provider SPI (spi_*.go). Core drives it through the +// internal-only POST /_git/:op routes; the plugin has no routes of its own, no +// per-project data and no frontend. Connections are GitHub App installations +// only; personal access tokens are not supported. package main import ( - "encoding/json" - "time" - plugin "github.com/Paca-AI/plugin-sdk-go" + "github.com/Paca-AI/plugin-sdk-go/gitprovider/serve" ) -// nowStr returns the current UTC time as an RFC3339Nano string. -func nowStr() string { - return time.Now().UTC().Format(time.RFC3339Nano) -} - // githubPlugin implements plugin.Plugin. type githubPlugin struct { - db *plugin.DB - log *plugin.Logger - cfg *plugin.Config + db *plugin.DB + log *plugin.Logger + cfg *plugin.Config + cache *plugin.Cache } -// Init registers all routes and event handlers on the provided context. +// Init mounts the provider operations. func (p *githubPlugin) Init(ctx *plugin.Context) error { p.db = ctx.DB() p.log = ctx.Log() p.cfg = ctx.Config() - - // Event handlers - ctx.On("task.deleted", p.handleTaskDeleted) - ctx.On("project.deleted", p.handleProjectDeleted) - - // ── Integration (GitHub token / connection) ─────────────────────────────── - ctx.Route("GET", "/integration", p.getIntegration) - ctx.Route("POST", "/integration/token", p.setToken) - ctx.Route("DELETE", "/integration/token", p.deleteToken) - ctx.Route("GET", "/integration/accessible-repos", p.listAccessibleRepos) - - // ── Repositories (repos linked to the project) ──────────────────────────── - ctx.Route("GET", "/repositories", p.listRepositories) - ctx.Route("POST", "/repositories", p.linkRepository) - ctx.Route("DELETE", "/repositories/:repoId", p.unlinkRepository) - ctx.Route("GET", "/repositories/:repoId/clone-info", p.getRepoCloneInfo) - - // ── Task resources ──────────────────────────────────────────────────────── - ctx.Route("GET", "/tasks/:taskId/pull-requests", p.listTaskPRs) - ctx.Route("POST", "/tasks/:taskId/pull-requests", p.createPullRequest) - ctx.Route("POST", "/tasks/:taskId/pull-requests/link", p.linkPRToTask) - ctx.Route("DELETE", "/tasks/:taskId/pull-requests/:prId", p.unlinkPRFromTask) - ctx.Route("GET", "/tasks/:taskId/pull-requests/:prId", p.getPullRequestDetails) - ctx.Route("GET", "/tasks/:taskId/pull-requests/:prId/ci-status", p.getPullRequestCIStatus) - ctx.Route("POST", "/tasks/:taskId/pull-requests/:prId/comments", p.addPullRequestComment) - ctx.Route("POST", "/tasks/:taskId/pull-requests/:prId/reviews", p.createReview) - ctx.Route("GET", "/tasks/:taskId/branches", p.listTaskBranches) - ctx.Route("POST", "/tasks/:taskId/branches", p.createBranch) - ctx.Route("POST", "/tasks/:taskId/branches/link", p.linkBranchToTask) - - // ── Webhook ─────────────────────────────────────────────────────────────── - ctx.Route("POST", "/webhook", p.receiveWebhook) - - // ── Automation graph nodes (Condition/Action) ───────────────────────────── - p.registerAutomationNodes(ctx) - + p.cache = ctx.Cache() + serve.Register(ctx, newGHProvider(newDBStore(p))) return nil } // Shutdown is a no-op for this plugin. func (p *githubPlugin) Shutdown() {} - -// ─── envelope helpers ──────────────────────────────────────────────────────── - -type envelope struct { - Success bool `json:"success"` - Data any `json:"data"` -} - -func ok(res *plugin.Response, data any) { - res.JSON(200, envelope{Success: true, Data: data}) -} - -func created(res *plugin.Response, data any) { - res.JSON(201, envelope{Success: true, Data: data}) -} - -func noContent(res *plugin.Response) { - res.NoContent() -} - -func apiError(res *plugin.Response, code int, errCode, message string) { - res.JSON(code, map[string]any{ - "success": false, - "error": message, - "error_code": errCode, - }) -} - -// taskBelongsToProject verifies taskID exists, is not deleted, and belongs -// to projectID — writing a 404 and returning false otherwise. Every handler -// that accepts a :taskId path param and uses it to read or write -// project-scoped GitHub data (PRs, branches) must call this first: the -// host's route-level permission check only verifies the caller belongs to -// the project in the URL, it has no way to also verify an arbitrary path -// param like :taskId belongs to that same project — that's this plugin's -// job, the same role resolvePRForTask plays for PR-specific resources. -func (p *githubPlugin) taskBelongsToProject(taskID, projectID string, res *plugin.Response) bool { - result, err := p.db.Query( - `SELECT id FROM tasks WHERE id = $1 AND project_id = $2 AND deleted_at IS NULL`, - taskID, projectID, - ) - if err != nil { - apiError(res, 500, "INTERNAL_ERROR", err.Error()) - return false - } - if len(result.Rows) == 0 { - apiError(res, 404, "TASK_NOT_FOUND", "Task not found") - return false - } - return true -} - -// ─── event handlers ────────────────────────────────────────────────────────── - -// handleTaskDeleted cleans up branches and PR links when a task is deleted. -func (p *githubPlugin) handleTaskDeleted(evt *plugin.Event) { - var payload struct { - TaskID string `json:"task_id"` - } - if err := json.Unmarshal(evt.Payload, &payload); err != nil || payload.TaskID == "" { - return - } - _, _ = p.db.Exec(`DELETE FROM github_task_branches WHERE task_id = $1`, payload.TaskID) - _, _ = p.db.Exec(`DELETE FROM github_task_pr_links WHERE task_id = $1`, payload.TaskID) -} - -// handleProjectDeleted cleans up the full integration when a project is deleted. -func (p *githubPlugin) handleProjectDeleted(evt *plugin.Event) { - var payload struct { - ProjectID string `json:"project_id"` - } - if err := json.Unmarshal(evt.Payload, &payload); err != nil || payload.ProjectID == "" { - return - } - // Cascade via FK, but also be explicit. - _, _ = p.db.Exec(`DELETE FROM github_integrations WHERE project_id = $1`, payload.ProjectID) -} diff --git a/backend/plugin_test.go b/backend/plugin_test.go deleted file mode 100644 index 9891e86..0000000 --- a/backend/plugin_test.go +++ /dev/null @@ -1,581 +0,0 @@ -package main - -import ( - "crypto/hmac" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "testing" - - plugin "github.com/Paca-AI/plugin-sdk-go" - "github.com/Paca-AI/plugin-sdk-go/plugintest" -) - -// ── Helpers ─────────────────────────────────────────────────────────────────── - -const ( - testProjectID = "project-1" - testTaskID = "task-1" -) - -func setupPlugin(t *testing.T) *plugintest.Context { - t.Helper() - tc := plugintest.NewContext(t) - - // Seed the shared tables referenced by FK. - tc.DB.SeedRows("tasks", []string{"id", "project_id", "deleted_at"}, [][]any{ - {testTaskID, testProjectID, nil}, - }) - - // Seed empty plugin tables so queries return empty result sets instead of errors. - tc.DB.SeedRows("github_integrations", - []string{"id", "project_id", "access_token_enc", "created_at", "updated_at"}, - nil) - tc.DB.SeedRows("github_repositories", - []string{"id", "project_id", "integration_id", "owner", "repo_name", "full_name", - "webhook_id", "webhook_secret_enc", "default_branch", "created_at", "updated_at"}, - nil) - tc.DB.SeedRows("github_pull_requests", - []string{"id", "project_id", "repo_id", "pr_number", "github_pr_id", "title", - "state", "html_url", "head_branch", "base_branch", "author", "merged_at", "created_at", "updated_at"}, - nil) - tc.DB.SeedRows("github_task_pr_links", - []string{"id", "task_id", "pull_request_id", "created_at"}, - nil) - tc.DB.SeedRows("github_task_branches", - []string{"id", "task_id", "repo_id", "branch_name", "created_at"}, - nil) - - var p githubPlugin - if err := p.Init(tc.PluginContext()); err != nil { - t.Fatal("Init failed:", err) - } - return tc -} - -func callerReq() plugintest.Request { - return plugintest.Request{ - Caller: plugin.CallerIdentity{ - ProjectID: testProjectID, - CallerID: "member-1", - CallerRole: "PROJECT_MEMBER", - }, - PathParams: map[string]string{}, - } -} - -// ── Integration tests ───────────────────────────────────────────────────────── - -func TestGetIntegration_NotConnected(t *testing.T) { - tc := setupPlugin(t) - res := tc.Call("GET", "/integration", callerReq()) - - if res.StatusCode != 200 { - t.Fatalf("expected 200, got %d: %s", res.StatusCode, res.BodyString()) - } - var env struct { - Success bool `json:"success"` - Data integrationResponse `json:"data"` - } - if err := json.Unmarshal(res.Body, &env); err != nil { - t.Fatal(err) - } - if !env.Success { - t.Fatal("expected success=true") - } - if env.Data.Connected { - t.Fatal("expected Connected=false when no token is set") - } - if env.Data.ProjectID != testProjectID { - t.Fatalf("expected project_id=%s, got %s", testProjectID, env.Data.ProjectID) - } -} - -// ── PR review/comment tests ──────────────────────────────────────────────────── -// -// These cover the paths reachable without an outbound GitHub API call -// (validation and "PR not linked to this task"). The actual GitHub call -// (ghClient -> plugin.Fetch) always errors outside a WASM build — see -// plugin-sdk-go's native_backends.go — so the happy path for these three -// handlers, like the existing createPullRequest handler, isn't unit-testable -// here and is covered manually / by integration testing instead. - -func reqWithPathParams(params map[string]string) plugintest.Request { - r := callerReq() - for k, v := range params { - r.PathParams[k] = v - } - return r -} - -func TestGetPullRequestDetails_NotLinkedToTask(t *testing.T) { - tc := setupPlugin(t) - req := reqWithPathParams(map[string]string{"taskId": testTaskID, "prId": "pr-1"}) - res := tc.Call("GET", "/tasks/:taskId/pull-requests/:prId", req) - - if res.StatusCode != 404 { - t.Fatalf("expected 404, got %d: %s", res.StatusCode, res.BodyString()) - } -} - -func TestAddPullRequestComment_MissingBody(t *testing.T) { - tc := setupPlugin(t) - req := reqWithPathParams(map[string]string{"taskId": testTaskID, "prId": "pr-1"}). - WithJSONBody(map[string]string{}) - res := tc.Call("POST", "/tasks/:taskId/pull-requests/:prId/comments", req) - - if res.StatusCode != 400 { - t.Fatalf("expected 400, got %d: %s", res.StatusCode, res.BodyString()) - } -} - -func TestAddPullRequestComment_NotLinkedToTask(t *testing.T) { - tc := setupPlugin(t) - req := reqWithPathParams(map[string]string{"taskId": testTaskID, "prId": "pr-1"}). - WithJSONBody(map[string]string{"body": "looks good"}) - res := tc.Call("POST", "/tasks/:taskId/pull-requests/:prId/comments", req) - - if res.StatusCode != 404 { - t.Fatalf("expected 404, got %d: %s", res.StatusCode, res.BodyString()) - } -} - -func TestCreateReview_InvalidEvent(t *testing.T) { - tc := setupPlugin(t) - req := reqWithPathParams(map[string]string{"taskId": testTaskID, "prId": "pr-1"}). - WithJSONBody(map[string]string{"event": "NOT_A_REAL_EVENT"}) - res := tc.Call("POST", "/tasks/:taskId/pull-requests/:prId/reviews", req) - - if res.StatusCode != 400 { - t.Fatalf("expected 400, got %d: %s", res.StatusCode, res.BodyString()) - } -} - -func TestCreateReview_NotLinkedToTask(t *testing.T) { - tc := setupPlugin(t) - req := reqWithPathParams(map[string]string{"taskId": testTaskID, "prId": "pr-1"}). - WithJSONBody(map[string]string{"event": "APPROVE"}) - res := tc.Call("POST", "/tasks/:taskId/pull-requests/:prId/reviews", req) - - if res.StatusCode != 404 { - t.Fatalf("expected 404, got %d: %s", res.StatusCode, res.BodyString()) - } -} - -func TestGetPullRequestCIStatus_NotLinkedToTask(t *testing.T) { - tc := setupPlugin(t) - req := reqWithPathParams(map[string]string{"taskId": testTaskID, "prId": "pr-1"}) - res := tc.Call("GET", "/tasks/:taskId/pull-requests/:prId/ci-status", req) - - if res.StatusCode != 404 { - t.Fatalf("expected 404, got %d: %s", res.StatusCode, res.BodyString()) - } -} - -func TestLinkBranchToTask_MissingBody(t *testing.T) { - tc := setupPlugin(t) - req := reqWithPathParams(map[string]string{"taskId": testTaskID}). - WithJSONBody(map[string]string{}) - res := tc.Call("POST", "/tasks/:taskId/branches/link", req) - - if res.StatusCode != 400 { - t.Fatalf("expected 400, got %d: %s", res.StatusCode, res.BodyString()) - } -} - -func TestLinkBranchToTask_MissingRepoID(t *testing.T) { - tc := setupPlugin(t) - req := reqWithPathParams(map[string]string{"taskId": testTaskID}). - WithJSONBody(map[string]string{"branch_name": "dev"}) - res := tc.Call("POST", "/tasks/:taskId/branches/link", req) - - if res.StatusCode != 400 { - t.Fatalf("expected 400, got %d: %s", res.StatusCode, res.BodyString()) - } -} - -func TestLinkBranchToTask_MissingBranchName(t *testing.T) { - tc := setupPlugin(t) - req := reqWithPathParams(map[string]string{"taskId": testTaskID}). - WithJSONBody(map[string]string{"repo_id": "00000000-0000-0000-0000-000000000001"}) - res := tc.Call("POST", "/tasks/:taskId/branches/link", req) - - if res.StatusCode != 400 { - t.Fatalf("expected 400, got %d: %s", res.StatusCode, res.BodyString()) - } -} - -func TestCreateBranch_MissingBranchName(t *testing.T) { - tc := setupPlugin(t) - req := reqWithPathParams(map[string]string{"taskId": testTaskID}). - WithJSONBody(map[string]string{"repo_id": "00000000-0000-0000-0000-000000000001"}) - res := tc.Call("POST", "/tasks/:taskId/branches", req) - - if res.StatusCode != 400 { - t.Fatalf("expected 400, got %d: %s", res.StatusCode, res.BodyString()) - } -} - -func TestCreateBranch_MissingRepoID(t *testing.T) { - tc := setupPlugin(t) - req := reqWithPathParams(map[string]string{"taskId": testTaskID}). - WithJSONBody(map[string]string{"branch_name": "dev"}) - res := tc.Call("POST", "/tasks/:taskId/branches", req) - - if res.StatusCode != 400 { - t.Fatalf("expected 400, got %d: %s", res.StatusCode, res.BodyString()) - } -} - -// ── Cross-project task ownership guards ────────────────────────────────────── -// -// Every handler below previously trusted the URL's :taskId without -// verifying it belongs to the caller's own project (req.Caller.ProjectID) — -// only the request body's repo_id/pr_number were project-checked. A caller -// with tasks.read/write on their own project could substitute a foreign -// taskId to read or attach records to a completely different project's -// task. taskBelongsToProject now runs before any of that, so these all -// resolve to a 404 before ever reaching a GitHub API call or a DB write — -// which is also what keeps them unit-testable, since the outbound GitHub -// call itself always errors outside a real WASM build (see the comment -// above the PR review/comment tests). - -const otherTaskID = "task-2" // seeded under a project the default caller is not a member of - -func foreignTaskReq() plugintest.Request { - return plugintest.Request{ - Caller: plugin.CallerIdentity{ - ProjectID: testProjectID, // the default caller's own project - CallerID: "member-1", - CallerRole: "PROJECT_MEMBER", - }, - PathParams: map[string]string{"taskId": otherTaskID}, // but a foreign task - } -} - -// setupWithForeignTask re-seeds tasks with both the default task and a -// second task belonging to a different project. -func setupWithForeignTask(t *testing.T) *plugintest.Context { - t.Helper() - tc := setupPlugin(t) - tc.DB.SeedRows("tasks", []string{"id", "project_id", "deleted_at"}, [][]any{ - {testTaskID, testProjectID, nil}, - {otherTaskID, "other-project", nil}, - }) - return tc -} - -func TestListTaskPRs_CrossProjectTaskRejected(t *testing.T) { - tc := setupWithForeignTask(t) - res := tc.Call("GET", "/tasks/:taskId/pull-requests", foreignTaskReq()) - if res.StatusCode != 404 { - t.Fatalf("expected 404, got %d: %s", res.StatusCode, res.BodyString()) - } -} - -func TestListTaskBranches_CrossProjectTaskRejected(t *testing.T) { - tc := setupWithForeignTask(t) - res := tc.Call("GET", "/tasks/:taskId/branches", foreignTaskReq()) - if res.StatusCode != 404 { - t.Fatalf("expected 404, got %d: %s", res.StatusCode, res.BodyString()) - } -} - -// TestListTaskBranches_ReturnsOwnProjectBranches is the happy-path -// counterpart to the cross-project test above — it exercises the handler's -// main query past taskBelongsToProject, which a rejection test alone -// wouldn't reach. -func TestListTaskBranches_ReturnsOwnProjectBranches(t *testing.T) { - tc := setupPlugin(t) - tc.DB.SeedRows("github_repositories", - []string{"id", "project_id", "integration_id", "owner", "repo_name", "full_name", - "webhook_id", "webhook_secret_enc", "default_branch", "created_at", "updated_at"}, - [][]any{{"repo-1", testProjectID, "integration-1", "octocat", "hello-world", "octocat/hello-world", - "wh-1", "enc-secret", "main", "now", "now"}}) - tc.DB.SeedRows("github_task_branches", - []string{"id", "task_id", "repo_id", "branch_name", "created_at"}, - [][]any{{"branch-1", testTaskID, "repo-1", "feature/x", "now"}}) - - res := tc.Call("GET", "/tasks/:taskId/branches", reqWithPathParams(map[string]string{"taskId": testTaskID})) - if res.StatusCode != 200 { - t.Fatalf("expected 200, got %d: %s", res.StatusCode, res.BodyString()) - } - var env struct { - Data []taskBranchResponse `json:"data"` - } - if err := json.Unmarshal(res.Body, &env); err != nil { - t.Fatal(err) - } - if len(env.Data) != 1 || env.Data[0].BranchName != "feature/x" { - t.Fatalf("expected the seeded branch to be returned, got %+v", env.Data) - } -} - -// TestListTaskBranches_SkipsRepoFromAnotherProject mirrors -// TestListTaskPRs_SkipsLinkFromAnotherProject: github_task_branches has no -// project_id of its own, only repo_id, so a branch row pointing at a repo -// that belongs to a different project must be filtered out even when the -// task itself is the caller's own. -func TestListTaskBranches_SkipsRepoFromAnotherProject(t *testing.T) { - tc := setupPlugin(t) - tc.DB.SeedRows("github_repositories", - []string{"id", "project_id", "integration_id", "owner", "repo_name", "full_name", - "webhook_id", "webhook_secret_enc", "default_branch", "created_at", "updated_at"}, - [][]any{{"repo-foreign", "other-project", "integration-1", "octocat", "other-repo", "octocat/other-repo", - "wh-1", "enc-secret", "main", "now", "now"}}) - tc.DB.SeedRows("github_task_branches", - []string{"id", "task_id", "repo_id", "branch_name", "created_at"}, - [][]any{{"branch-1", testTaskID, "repo-foreign", "feature/x", "now"}}) - - res := tc.Call("GET", "/tasks/:taskId/branches", reqWithPathParams(map[string]string{"taskId": testTaskID})) - if res.StatusCode != 200 { - t.Fatalf("expected 200, got %d: %s", res.StatusCode, res.BodyString()) - } - var env struct { - Data []taskBranchResponse `json:"data"` - } - if err := json.Unmarshal(res.Body, &env); err != nil { - t.Fatal(err) - } - if len(env.Data) != 0 { - t.Fatalf("expected the cross-project repo's branch to be filtered out, got %+v", env.Data) - } -} - -func TestCreateBranch_CrossProjectTaskRejected(t *testing.T) { - tc := setupWithForeignTask(t) - res := tc.Call("POST", "/tasks/:taskId/branches", - foreignTaskReq().WithJSONBody(map[string]string{ - "repo_id": "00000000-0000-0000-0000-000000000001", "branch_name": "dev", - })) - if res.StatusCode != 404 { - t.Fatalf("expected 404, got %d: %s", res.StatusCode, res.BodyString()) - } -} - -func TestLinkBranchToTask_CrossProjectTaskRejected(t *testing.T) { - tc := setupWithForeignTask(t) - res := tc.Call("POST", "/tasks/:taskId/branches/link", - foreignTaskReq().WithJSONBody(map[string]string{ - "repo_id": "00000000-0000-0000-0000-000000000001", "branch_name": "dev", - })) - if res.StatusCode != 404 { - t.Fatalf("expected 404, got %d: %s", res.StatusCode, res.BodyString()) - } -} - -func TestCreatePullRequest_CrossProjectTaskRejected(t *testing.T) { - tc := setupWithForeignTask(t) - res := tc.Call("POST", "/tasks/:taskId/pull-requests", - foreignTaskReq().WithJSONBody(map[string]string{ - "repo_id": "00000000-0000-0000-0000-000000000001", "title": "x", "head_branch": "a", "base_branch": "main", - })) - if res.StatusCode != 404 { - t.Fatalf("expected 404, got %d: %s", res.StatusCode, res.BodyString()) - } -} - -func TestLinkPRToTask_CrossProjectTaskRejected(t *testing.T) { - tc := setupWithForeignTask(t) - res := tc.Call("POST", "/tasks/:taskId/pull-requests/link", - foreignTaskReq().WithJSONBody(map[string]any{ - "repo_id": "00000000-0000-0000-0000-000000000001", "pr_number": 1, - })) - if res.StatusCode != 404 { - t.Fatalf("expected 404, got %d: %s", res.StatusCode, res.BodyString()) - } -} - -func TestUnlinkPRFromTask_CrossProjectTaskRejected(t *testing.T) { - tc := setupWithForeignTask(t) - req := foreignTaskReq() - req.PathParams["prId"] = "pr-1" - res := tc.Call("DELETE", "/tasks/:taskId/pull-requests/:prId", req) - if res.StatusCode != 404 { - t.Fatalf("expected 404, got %d: %s", res.StatusCode, res.BodyString()) - } -} - -// TestListTaskPRs_SkipsLinkFromAnotherProject covers the defense-in-depth -// layer on top of taskBelongsToProject: even for the caller's own, -// legitimate task, a github_task_pr_links row pointing at a PR that -// actually belongs to a different project (e.g. one created by a pre-fix -// caller exploiting the taskId substitution above) must not be returned. -func TestListTaskPRs_SkipsLinkFromAnotherProject(t *testing.T) { - tc := setupPlugin(t) - tc.DB.SeedRows("github_pull_requests", - []string{"id", "project_id", "repo_id", "pr_number", "github_pr_id", "title", - "state", "html_url", "head_branch", "base_branch", "author", "merged_at", "created_at", "updated_at"}, - [][]any{ - {"pr-foreign", "other-project", "repo-1", 1, int64(1), "Someone else's PR", - "open", "https://example.com", "feature", "main", "octocat", nil, "now", "now"}, - }) - tc.DB.SeedRows("github_task_pr_links", - []string{"id", "task_id", "pull_request_id", "created_at"}, - [][]any{{"link-1", testTaskID, "pr-foreign", "now"}}) - - res := tc.Call("GET", "/tasks/:taskId/pull-requests", reqWithPathParams(map[string]string{"taskId": testTaskID})) - if res.StatusCode != 200 { - t.Fatalf("expected 200, got %d: %s", res.StatusCode, res.BodyString()) - } - var env struct { - Data []pullRequestResponse `json:"data"` - } - if err := json.Unmarshal(res.Body, &env); err != nil { - t.Fatal(err) - } - if len(env.Data) != 0 { - t.Fatalf("expected the cross-project-linked PR to be filtered out, got %+v", env.Data) - } -} - -// ── Webhook: project scoping + signature verification ──────────────────────── -// -// receiveWebhook always responds 204 regardless of outcome (so GitHub -// doesn't retry on an application-level rejection), so the status code -// can't distinguish "accepted" from "rejected" the way it can for the -// authenticated API routes elsewhere in this file. These tests instead -// assert on tc.Log (plugintest.CapturingLogger), using the distinct log -// line each outcome takes — "handling pull_request event" is only logged by -// handleWebhookEvent, which runs after signature verification succeeds, so it -// is a reliable positive signal that the delivery was actually dispatched. - -const testEncryptionKey = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" // 64 hex chars = 32 bytes - -func signedWebhookRequest(projectID string, payload []byte, secret string) plugintest.Request { - mac := hmac.New(sha256.New, []byte(secret)) - mac.Write(payload) - sig := "sha256=" + hex.EncodeToString(mac.Sum(nil)) - return plugintest.Request{ - PathParams: map[string]string{"projectId": projectID}, - Headers: map[string]string{ - "X-Github-Event": "pull_request", - "X-Hub-Signature-256": sig, - }, - Body: payload, - } -} - -func seedEncryptedRepo(t *testing.T, tc *plugintest.Context, repoID, projectID, fullName, secret string) { - t.Helper() - tc.Config.Set("ENCRYPTION_KEY", testEncryptionKey) - encSecret, err := encryptAES(secret, testEncryptionKey) - if err != nil { - t.Fatalf("failed to encrypt test secret: %v", err) - } - tc.DB.SeedRows("github_repositories", - []string{"id", "project_id", "integration_id", "owner", "repo_name", "full_name", - "webhook_id", "webhook_secret_enc", "default_branch", "created_at", "updated_at"}, - append(tc.DB.AllRows("github_repositories"), []any{ - repoID, projectID, "integration-1", "octocat", "hello-world", fullName, - "wh-1", encSecret, "main", "now", "now", - })) -} - -// TestReceiveWebhook_ScopesRepositoryLookupToURLProject pins the fix for a -// real cross-tenant interference bug: github_repositories.full_name is only -// unique per-project, so two projects can legitimately link the same repo. -// The old lookup (`WHERE full_name = $1`, no project_id) could resolve to -// whichever row Postgres happened to return first — using a DIFFERENT -// project's secret to verify a delivery meant for this one, which then -// fails signature verification and silently drops a legitimate delivery. -// This seeds the same full_name under two different projects with two -// different secrets, and confirms a delivery signed with project-1's own -// secret, addressed to project-1's URL, still verifies successfully even -// though project-2's row for the same repo full_name exists and was seeded -// first (so a naive full_name-only lookup would find it before project-1's). -func TestReceiveWebhook_ScopesRepositoryLookupToURLProject(t *testing.T) { - tc := setupPlugin(t) - seedEncryptedRepo(t, tc, "repo-other", "other-project", "octocat/hello-world", "other-projects-secret") - seedEncryptedRepo(t, tc, "repo-mine", testProjectID, "octocat/hello-world", "my-projects-secret") - - payload := []byte(`{"repository":{"full_name":"octocat/hello-world"}}`) - res := tc.Call("POST", "/webhook", signedWebhookRequest(testProjectID, payload, "my-projects-secret")) - if res.StatusCode != 204 { - t.Fatalf("expected 204, got %d: %s", res.StatusCode, res.BodyString()) - } - if tc.Log.HasMessage("invalid webhook signature") { - t.Fatal("signature verified against the wrong project's secret — repository lookup is not scoped to the URL's project") - } - if !tc.Log.HasMessage("handling pull_request event") { - t.Fatalf("expected signature verification to succeed and reach event dispatch; log entries: %+v", tc.Log.Entries()) - } -} - -// TestReceiveWebhook_RejectsMissingSecret pins the fix for a fail-open path: -// a repository row with no webhook secret configured previously skipped -// HMAC verification entirely and trusted the payload; it must now be -// refused instead, without ever reaching event dispatch. -func TestReceiveWebhook_RejectsMissingSecret(t *testing.T) { - tc := setupPlugin(t) - tc.DB.SeedRows("github_repositories", - []string{"id", "project_id", "integration_id", "owner", "repo_name", "full_name", - "webhook_id", "webhook_secret_enc", "default_branch", "created_at", "updated_at"}, - [][]any{{"repo-1", testProjectID, "integration-1", "octocat", "hello-world", "octocat/hello-world", - "wh-1", "", "main", "now", "now"}}) - - payload := []byte(`{"repository":{"full_name":"octocat/hello-world"}}`) - req := plugintest.Request{ - PathParams: map[string]string{"projectId": testProjectID}, - Headers: map[string]string{"X-Github-Event": "pull_request", "X-Hub-Signature-256": "sha256=deadbeef"}, - Body: payload, - } - res := tc.Call("POST", "/webhook", req) - if res.StatusCode != 204 { - t.Fatalf("expected 204, got %d: %s", res.StatusCode, res.BodyString()) - } - if !tc.Log.HasMessage("no webhook secret configured") { - t.Fatalf("expected the missing-secret delivery to be refused before dispatch; log entries: %+v", tc.Log.Entries()) - } - if tc.Log.HasMessage("handling pull_request event") { - t.Fatal("delivery with no configured secret reached event dispatch — HMAC verification was skipped instead of failing closed") - } -} - -// ── overallCIState ───────────────────────────────────────────────────────────── - -func TestOverallCIState_NoChecks(t *testing.T) { - if got := overallCIState(nil); got != "unknown" { - t.Fatalf("expected unknown, got %s", got) - } -} - -func TestOverallCIState_AllSuccess(t *testing.T) { - checks := []ciCheckResponse{ - {Status: "completed", Conclusion: "success"}, - {Status: "completed", Conclusion: "neutral"}, - } - if got := overallCIState(checks); got != "success" { - t.Fatalf("expected success, got %s", got) - } -} - -func TestOverallCIState_OneFailureWins(t *testing.T) { - checks := []ciCheckResponse{ - {Status: "completed", Conclusion: "success"}, - {Status: "completed", Conclusion: "failure"}, - } - if got := overallCIState(checks); got != "failure" { - t.Fatalf("expected failure, got %s", got) - } -} - -func TestOverallCIState_StillRunningIsPending(t *testing.T) { - checks := []ciCheckResponse{ - {Status: "completed", Conclusion: "success"}, - {Status: "in_progress", Conclusion: ""}, - } - if got := overallCIState(checks); got != "pending" { - t.Fatalf("expected pending, got %s", got) - } -} - -func TestOverallCIState_FailureBeatsPending(t *testing.T) { - checks := []ciCheckResponse{ - {Status: "in_progress", Conclusion: ""}, - {Status: "completed", Conclusion: "failure"}, - } - if got := overallCIState(checks); got != "failure" { - t.Fatalf("expected failure, got %s", got) - } -} diff --git a/backend/pull_requests.go b/backend/pull_requests.go deleted file mode 100644 index 00b4652..0000000 --- a/backend/pull_requests.go +++ /dev/null @@ -1,692 +0,0 @@ -package main - -import ( - "context" - "errors" - "fmt" - - plugin "github.com/Paca-AI/plugin-sdk-go" -) - -// ─── DTOs ──────────────────────────────────────────────────────────────────── - -type pullRequestResponse struct { - ID string `json:"id"` - ProjectID string `json:"project_id"` - RepoID string `json:"repo_id"` - PRNumber int `json:"pr_number"` - GitHubPRID int64 `json:"github_pr_id"` - Title string `json:"title"` - State string `json:"state"` - HTMLURL string `json:"html_url"` - HeadBranch string `json:"head_branch"` - BaseBranch string `json:"base_branch"` - Author string `json:"author"` - MergedAt *string `json:"merged_at"` - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` -} - -// ─── GET /tasks/:taskId/github/pull-requests ────────────────────────────────── - -func (p *githubPlugin) listTaskPRs(req *plugin.Request, res *plugin.Response) { - projectID := req.Caller.ProjectID - taskID := req.PathParam("taskId") - if !p.taskBelongsToProject(taskID, projectID, res) { - return - } - - linkResult, err := p.db.Query( - `SELECT pull_request_id FROM github_task_pr_links WHERE task_id = $1 ORDER BY created_at ASC`, - taskID, - ) - if err != nil { - apiError(res, 500, "INTERNAL_ERROR", err.Error()) - return - } - - // No SQL JOIN here (resolved via a separate query per link instead) — - // consistent with resolvePRForTask's style elsewhere in this plugin. - // taskBelongsToProject above already closes the main vector (a foreign - // taskId); re-verifying each linked PR's own project_id here is - // defense-in-depth against any row a pre-fix caller might have already - // linked across projects. - items := make([]pullRequestResponse, 0, len(linkResult.Rows)) - for _, linkRow := range linkResult.Rows { - prID := newRowScanner(linkResult.Columns, linkRow).str("pull_request_id") - prResult, pErr := p.db.Query( - `SELECT id, project_id, repo_id, pr_number, github_pr_id, title, state, html_url, head_branch, base_branch, author, merged_at, created_at, updated_at FROM github_pull_requests WHERE id = $1 AND project_id = $2`, - prID, projectID, - ) - if pErr != nil { - apiError(res, 500, "INTERNAL_ERROR", pErr.Error()) - return - } - if len(prResult.Rows) == 0 { - continue - } - sc := newRowScanner(prResult.Columns, prResult.Rows[0]) - items = append(items, pullRequestResponse{ - ID: sc.str("id"), - ProjectID: sc.str("project_id"), - RepoID: sc.str("repo_id"), - PRNumber: sc.intVal("pr_number"), - GitHubPRID: sc.int64Val("github_pr_id"), - Title: sc.str("title"), - State: sc.str("state"), - HTMLURL: sc.str("html_url"), - HeadBranch: sc.str("head_branch"), - BaseBranch: sc.str("base_branch"), - Author: sc.str("author"), - MergedAt: sc.strPtr("merged_at"), - CreatedAt: sc.str("created_at"), - UpdatedAt: sc.str("updated_at"), - }) - } - ok(res, items) -} - -// ─── POST /tasks/:taskId/github/pull-requests/link ─────────────────────────── - -func (p *githubPlugin) linkPRToTask(req *plugin.Request, res *plugin.Response) { - projectID := req.Caller.ProjectID - taskID := req.PathParam("taskId") - if !p.taskBelongsToProject(taskID, projectID, res) { - return - } - - type linkPRToTaskBody struct { - RepoID string `json:"repo_id"` - PRNumber int `json:"pr_number"` - } - b, err := plugin.JSONBody[linkPRToTaskBody](req) - if err != nil || b.RepoID == "" || b.PRNumber == 0 { - apiError(res, 400, "BAD_REQUEST", "repo_id and pr_number are required") - return - } - - token, err := p.decryptToken(projectID) - if err != nil { - writeAppError(res, err) - return - } - - // Get repository details. - repoResult, rErr := p.db.Query( - `SELECT owner, repo_name FROM github_repositories WHERE id = $1 AND project_id = $2`, - b.RepoID, projectID, - ) - if rErr != nil { - apiError(res, 500, "INTERNAL_ERROR", rErr.Error()) - return - } - if len(repoResult.Rows) == 0 { - apiError(res, 404, "GITHUB_REPOSITORY_NOT_FOUND", "Repository not found") - return - } - rSc := newRowScanner(repoResult.Columns, repoResult.Rows[0]) - owner := rSc.str("owner") - repoName := rSc.str("repo_name") - - ghc := newGHClient(token) - ghPR, err := ghc.getPullRequest(context.Background(), owner, repoName, b.PRNumber) - if err != nil { - var apiErr *ghAPIError - if errors.As(err, &apiErr) { - switch apiErr.StatusCode { - case 404: - apiError(res, 404, "GITHUB_PR_NOT_FOUND", fmt.Sprintf("PR #%d not found in %s/%s", b.PRNumber, owner, repoName)) - return - case 401, 403: - apiError(res, 403, "GITHUB_TOKEN_INSUFFICIENT_PERMISSIONS", "Token does not have permission to read pull requests") - return - } - } - apiError(res, 502, "INTERNAL_ERROR", fmt.Sprintf("failed to fetch pull request: %s", err)) - return - } - - state := ghPR.State - if ghPR.Merged { - state = "merged" - } - - now := nowStr() - - var mergedAtStr *string - if ghPR.MergedAt != nil { - s := ghPR.MergedAt.UTC().Format("2006-01-02T15:04:05.999999999Z") - mergedAtStr = &s - } - - // Upsert the PR cache; let PostgreSQL generate id on insert, RETURNING gives us id+created_at. - upserted, err := p.db.Query(` - INSERT INTO github_pull_requests - (project_id, repo_id, pr_number, github_pr_id, title, state, html_url, head_branch, base_branch, author, merged_at, created_at, updated_at) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13) - ON CONFLICT (repo_id, pr_number) DO UPDATE SET - title=$5, state=$6, html_url=$7, head_branch=$8, base_branch=$9, - author=$10, merged_at=$11, updated_at=$13 - RETURNING id, created_at - `, projectID, b.RepoID, b.PRNumber, ghPR.ID, ghPR.Title, state, - ghPR.HTMLURL, ghPR.Head.Ref, ghPR.Base.Ref, ghPR.User.Login, mergedAtStr, now, now) - if err != nil || len(upserted.Rows) == 0 { - if err != nil { - apiError(res, 500, "INTERNAL_ERROR", err.Error()) - } else { - apiError(res, 500, "INTERNAL_ERROR", "upsert returned no rows") - } - return - } - prSc := newRowScanner(upserted.Columns, upserted.Rows[0]) - prID := prSc.str("id") - prCreatedAt := prSc.str("created_at") - - // Link the PR to the task. - rowsAffected, lErr := p.db.Exec(` - INSERT INTO github_task_pr_links (task_id, pull_request_id, created_at) - VALUES ($1,$2,$3) - ON CONFLICT (task_id, pull_request_id) DO NOTHING - `, taskID, prID, now) - if lErr != nil { - apiError(res, 500, "INTERNAL_ERROR", lErr.Error()) - return - } - if rowsAffected == 0 { - apiError(res, 409, "GITHUB_PR_ALREADY_LINKED", "Pull request is already linked to this task") - return - } - - plugin.EmitEvent("github.pr_linked", map[string]any{ - "project_id": projectID, - "task_id": taskID, - "repo_id": b.RepoID, - "pr_number": b.PRNumber, - }) - - // Return the PR details. - ok(res, pullRequestResponse{ - ID: prID, - ProjectID: projectID, - RepoID: b.RepoID, - PRNumber: b.PRNumber, - GitHubPRID: ghPR.ID, - Title: ghPR.Title, - State: state, - HTMLURL: ghPR.HTMLURL, - HeadBranch: ghPR.Head.Ref, - BaseBranch: ghPR.Base.Ref, - Author: ghPR.User.Login, - MergedAt: mergedAtStr, - CreatedAt: prCreatedAt, - UpdatedAt: now, - }) -} - -// ─── POST /tasks/:taskId/github/pull-requests ───────────────────────────────── - -func (p *githubPlugin) createPullRequest(req *plugin.Request, res *plugin.Response) { - projectID := req.Caller.ProjectID - taskID := req.PathParam("taskId") - if !p.taskBelongsToProject(taskID, projectID, res) { - return - } - - type createPullRequestBody struct { - RepoID string `json:"repo_id"` - Title string `json:"title"` - HeadBranch string `json:"head_branch"` - BaseBranch string `json:"base_branch"` - Body string `json:"body"` - } - b, err := plugin.JSONBody[createPullRequestBody](req) - if err != nil || b.RepoID == "" || b.Title == "" || b.HeadBranch == "" || b.BaseBranch == "" { - apiError(res, 400, "BAD_REQUEST", "repo_id, title, head_branch, and base_branch are required") - return - } - - token, err := p.decryptToken(projectID) - if err != nil { - writeAppError(res, err) - return - } - - repoResult, rErr := p.db.Query( - `SELECT owner, repo_name FROM github_repositories WHERE id = $1 AND project_id = $2`, - b.RepoID, projectID, - ) - if rErr != nil { - apiError(res, 500, "INTERNAL_ERROR", rErr.Error()) - return - } - if len(repoResult.Rows) == 0 { - apiError(res, 404, "GITHUB_REPOSITORY_NOT_FOUND", "Repository not found") - return - } - rSc := newRowScanner(repoResult.Columns, repoResult.Rows[0]) - owner := rSc.str("owner") - repoName := rSc.str("repo_name") - - ghc := newGHClient(token) - ghPR, err := ghc.createPullRequest(context.Background(), owner, repoName, b.Title, b.HeadBranch, b.BaseBranch, b.Body) - if err != nil { - var apiErr *ghAPIError - if errors.As(err, &apiErr) { - switch apiErr.StatusCode { - case 401, 403: - apiError(res, 403, "GITHUB_TOKEN_INSUFFICIENT_PERMISSIONS", "Token does not have permission to create pull requests") - return - case 422: - apiError(res, 422, "GITHUB_PR_VALIDATION_ERROR", fmt.Sprintf("GitHub validation error: %s", apiErr.Message)) - return - } - } - apiError(res, 502, "INTERNAL_ERROR", fmt.Sprintf("failed to create pull request: %s", err)) - return - } - - state := ghPR.State - if ghPR.Merged { - state = "merged" - } - - now := nowStr() - - var mergedAtStr *string - if ghPR.MergedAt != nil { - s := ghPR.MergedAt.UTC().Format("2006-01-02T15:04:05.999999999Z") - mergedAtStr = &s - } - - upserted, err := p.db.Query(` - INSERT INTO github_pull_requests - (project_id, repo_id, pr_number, github_pr_id, title, state, html_url, head_branch, base_branch, author, merged_at, created_at, updated_at) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$12) - ON CONFLICT (repo_id, pr_number) DO UPDATE SET - title=$5, state=$6, html_url=$7, head_branch=$8, base_branch=$9, - author=$10, merged_at=$11, updated_at=$12 - RETURNING id - `, projectID, b.RepoID, ghPR.Number, ghPR.ID, ghPR.Title, state, - ghPR.HTMLURL, ghPR.Head.Ref, ghPR.Base.Ref, ghPR.User.Login, mergedAtStr, now) - if err != nil || len(upserted.Rows) == 0 { - if err != nil { - apiError(res, 500, "INTERNAL_ERROR", err.Error()) - } else { - apiError(res, 500, "INTERNAL_ERROR", "upsert returned no rows") - } - return - } - prID := newRowScanner(upserted.Columns, upserted.Rows[0]).str("id") - - _, lErr := p.db.Exec(` - INSERT INTO github_task_pr_links (task_id, pull_request_id, created_at) - VALUES ($1,$2,$3) - ON CONFLICT (task_id, pull_request_id) DO NOTHING - `, taskID, prID, now) - if lErr != nil { - p.log.Error("failed to link PR to task: " + lErr.Error()) - } - - plugin.EmitEvent("github.pr_created", map[string]any{ - "project_id": projectID, - "task_id": taskID, - "repo_id": b.RepoID, - "pr_number": ghPR.Number, - "pr_url": ghPR.HTMLURL, - }) - - created(res, pullRequestResponse{ - ID: prID, - ProjectID: projectID, - RepoID: b.RepoID, - PRNumber: ghPR.Number, - GitHubPRID: ghPR.ID, - Title: ghPR.Title, - State: state, - HTMLURL: ghPR.HTMLURL, - HeadBranch: ghPR.Head.Ref, - BaseBranch: ghPR.Base.Ref, - Author: ghPR.User.Login, - MergedAt: mergedAtStr, - CreatedAt: now, - UpdatedAt: now, - }) -} - -// ─── DELETE /tasks/:taskId/github/pull-requests/:prId ──────────────────────── - -func (p *githubPlugin) unlinkPRFromTask(req *plugin.Request, res *plugin.Response) { - projectID := req.Caller.ProjectID - taskID := req.PathParam("taskId") - prID := req.PathParam("prId") - if !p.taskBelongsToProject(taskID, projectID, res) { - return - } - - // Re-verify the PR itself belongs to the caller's project (not just the - // task) before deleting the link — defense-in-depth against any link a - // pre-fix caller might have already created across projects. - prResult, err := p.db.Query( - `SELECT id FROM github_pull_requests WHERE id = $1 AND project_id = $2`, - prID, projectID, - ) - if err != nil { - apiError(res, 500, "INTERNAL_ERROR", err.Error()) - return - } - if len(prResult.Rows) == 0 { - apiError(res, 404, "GITHUB_PR_LINK_NOT_FOUND", "Pull request link not found") - return - } - - rowsAffected, err := p.db.Exec( - `DELETE FROM github_task_pr_links WHERE task_id = $1 AND pull_request_id = $2`, - taskID, prID, - ) - if err != nil { - apiError(res, 500, "INTERNAL_ERROR", err.Error()) - return - } - if rowsAffected == 0 { - apiError(res, 404, "GITHUB_PR_LINK_NOT_FOUND", "Pull request link not found") - return - } - noContent(res) -} - -// ─── Shared: resolve a PR's GitHub coordinates for a task ──────────────────── - -// resolvePRForTask looks up the owner/repo/PR-number GitHub needs to act on a -// PR, verifying in the same query that prID is actually linked to taskID -// within this project. Used by every handler below so a caller can't act on -// a PR outside the task (or project) it claims to be operating in. -func (p *githubPlugin) resolvePRForTask(projectID, taskID, prID string) (owner, repoName string, prNumber int, err error) { - linkResult, lErr := p.db.Query( - `SELECT pull_request_id FROM github_task_pr_links WHERE task_id = $1 AND pull_request_id = $2`, - taskID, prID, - ) - if lErr != nil { - return "", "", 0, lErr - } - if len(linkResult.Rows) == 0 { - return "", "", 0, &appError{code: "GITHUB_PR_LINK_NOT_FOUND", status: 404, msg: "Pull request link not found"} - } - - prResult, pErr := p.db.Query( - `SELECT repo_id, pr_number FROM github_pull_requests WHERE id = $1 AND project_id = $2`, - prID, projectID, - ) - if pErr != nil { - return "", "", 0, pErr - } - if len(prResult.Rows) == 0 { - return "", "", 0, &appError{code: "GITHUB_PR_NOT_FOUND", status: 404, msg: "Pull request not found"} - } - prSc := newRowScanner(prResult.Columns, prResult.Rows[0]) - repoID := prSc.str("repo_id") - prNumber = prSc.intVal("pr_number") - - repoResult, rErr := p.db.Query( - `SELECT owner, repo_name FROM github_repositories WHERE id = $1 AND project_id = $2`, - repoID, projectID, - ) - if rErr != nil { - return "", "", 0, rErr - } - if len(repoResult.Rows) == 0 { - return "", "", 0, &appError{code: "GITHUB_REPOSITORY_NOT_FOUND", status: 404, msg: "Repository not found"} - } - repoSc := newRowScanner(repoResult.Columns, repoResult.Rows[0]) - return repoSc.str("owner"), repoSc.str("repo_name"), prNumber, nil -} - -// ─── GET /tasks/:taskId/github/pull-requests/:prId ──────────────────────────── - -type pullRequestDetailsResponse struct { - Owner string `json:"owner"` - RepoName string `json:"repo_name"` - PRNumber int `json:"pr_number"` - Title string `json:"title"` - State string `json:"state"` - Body string `json:"body"` - HTMLURL string `json:"html_url"` - Diff string `json:"diff"` -} - -func (p *githubPlugin) getPullRequestDetails(req *plugin.Request, res *plugin.Response) { - projectID := req.Caller.ProjectID - taskID := req.PathParam("taskId") - prID := req.PathParam("prId") - - owner, repoName, prNumber, err := p.resolvePRForTask(projectID, taskID, prID) - if err != nil { - writeAppError(res, err) - return - } - token, err := p.decryptToken(projectID) - if err != nil { - writeAppError(res, err) - return - } - - ghc := newGHClient(token) - ctx := context.Background() - ghPR, err := ghc.getPullRequest(ctx, owner, repoName, prNumber) - if err != nil { - apiError(res, 502, "INTERNAL_ERROR", fmt.Sprintf("failed to fetch pull request: %s", err)) - return - } - diff, err := ghc.getPullRequestDiff(ctx, owner, repoName, prNumber) - if err != nil { - apiError(res, 502, "INTERNAL_ERROR", fmt.Sprintf("failed to fetch pull request diff: %s", err)) - return - } - - ok(res, pullRequestDetailsResponse{ - Owner: owner, - RepoName: repoName, - PRNumber: prNumber, - Title: ghPR.Title, - State: ghPR.State, - HTMLURL: ghPR.HTMLURL, - Diff: diff, - }) -} - -// ─── GET /tasks/:taskId/github/pull-requests/:prId/ci-status ───────────────── - -type ciCheckResponse struct { - Name string `json:"name"` - Status string `json:"status"` // "queued" | "in_progress" | "completed" | "pending" - Conclusion string `json:"conclusion"` // "success" | "failure" | ... | "" while not completed - URL string `json:"url"` -} - -type ciStatusResponse struct { - State string `json:"state"` // "success" | "failure" | "pending" | "unknown" - Checks []ciCheckResponse `json:"checks"` -} - -// overallCIState summarizes a set of checks the same way GitHub's own PR -// merge-box does: any failure wins, otherwise any still-running check makes -// the whole thing pending, otherwise (and only if there's at least one -// check) it's a success. No checks at all is reported as "unknown" rather -// than "success" so the agent doesn't mistake "no CI configured" for "CI -// passed". -func overallCIState(checks []ciCheckResponse) string { - if len(checks) == 0 { - return "unknown" - } - pending := false - for _, c := range checks { - switch c.Conclusion { - case "failure", "timed_out", "cancelled", "action_required", "error": - return "failure" - case "success", "neutral", "skipped": - continue - } - if c.Status != "completed" { - pending = true - } - } - if pending { - return "pending" - } - return "success" -} - -func (p *githubPlugin) getPullRequestCIStatus(req *plugin.Request, res *plugin.Response) { - projectID := req.Caller.ProjectID - taskID := req.PathParam("taskId") - prID := req.PathParam("prId") - - owner, repoName, prNumber, err := p.resolvePRForTask(projectID, taskID, prID) - if err != nil { - writeAppError(res, err) - return - } - token, err := p.decryptToken(projectID) - if err != nil { - writeAppError(res, err) - return - } - - ghc := newGHClient(token) - ctx := context.Background() - ghPR, err := ghc.getPullRequest(ctx, owner, repoName, prNumber) - if err != nil { - apiError(res, 502, "INTERNAL_ERROR", fmt.Sprintf("failed to fetch pull request: %s", err)) - return - } - sha := ghPR.Head.SHA - - checks := make([]ciCheckResponse, 0) - - if combined, cErr := ghc.getCombinedStatus(ctx, owner, repoName, sha); cErr == nil { - for _, s := range combined.Statuses { - checks = append(checks, ciCheckResponse{ - Name: s.Context, - Status: "completed", - Conclusion: s.State, - URL: s.TargetURL, - }) - } - } else { - p.log.Error(fmt.Sprintf("failed to fetch combined status for %s/%s@%s: %s", owner, repoName, sha, cErr)) - } - - if runs, rErr := ghc.getCheckRuns(ctx, owner, repoName, sha); rErr == nil { - for _, r := range runs.CheckRuns { - conclusion := "" - if r.Conclusion != nil { - conclusion = *r.Conclusion - } - checks = append(checks, ciCheckResponse{ - Name: r.Name, - Status: r.Status, - Conclusion: conclusion, - URL: r.HTMLURL, - }) - } - } else { - p.log.Error(fmt.Sprintf("failed to fetch check runs for %s/%s@%s: %s", owner, repoName, sha, rErr)) - } - - ok(res, ciStatusResponse{ - State: overallCIState(checks), - Checks: checks, - }) -} - -// ─── POST /tasks/:taskId/github/pull-requests/:prId/comments ───────────────── - -func (p *githubPlugin) addPullRequestComment(req *plugin.Request, res *plugin.Response) { - projectID := req.Caller.ProjectID - taskID := req.PathParam("taskId") - prID := req.PathParam("prId") - - type addPullRequestCommentBody struct { - Body string `json:"body"` - } - b, err := plugin.JSONBody[addPullRequestCommentBody](req) - if err != nil || b.Body == "" { - apiError(res, 400, "BAD_REQUEST", "body is required") - return - } - - owner, repoName, prNumber, err := p.resolvePRForTask(projectID, taskID, prID) - if err != nil { - writeAppError(res, err) - return - } - token, err := p.decryptToken(projectID) - if err != nil { - writeAppError(res, err) - return - } - - ghc := newGHClient(token) - if err := ghc.createIssueComment(context.Background(), owner, repoName, prNumber, b.Body); err != nil { - var apiErr *ghAPIError - if errors.As(err, &apiErr) && (apiErr.StatusCode == 401 || apiErr.StatusCode == 403) { - apiError(res, 403, "GITHUB_TOKEN_INSUFFICIENT_PERMISSIONS", "Token does not have permission to comment on pull requests") - return - } - apiError(res, 502, "INTERNAL_ERROR", fmt.Sprintf("failed to add comment: %s", err)) - return - } - created(res, map[string]bool{"success": true}) -} - -// ─── POST /tasks/:taskId/github/pull-requests/:prId/reviews ────────────────── - -func (p *githubPlugin) createReview(req *plugin.Request, res *plugin.Response) { - projectID := req.Caller.ProjectID - taskID := req.PathParam("taskId") - prID := req.PathParam("prId") - - type createReviewBody struct { - Event string `json:"event"` - Body string `json:"body"` - } - b, err := plugin.JSONBody[createReviewBody](req) - if err != nil { - apiError(res, 400, "BAD_REQUEST", "event is required") - return - } - switch b.Event { - case "APPROVE", "REQUEST_CHANGES", "COMMENT": - default: - apiError(res, 400, "BAD_REQUEST", "event must be one of: APPROVE, REQUEST_CHANGES, COMMENT") - return - } - - owner, repoName, prNumber, err := p.resolvePRForTask(projectID, taskID, prID) - if err != nil { - writeAppError(res, err) - return - } - token, err := p.decryptToken(projectID) - if err != nil { - writeAppError(res, err) - return - } - - ghc := newGHClient(token) - if err := ghc.createPullRequestReview(context.Background(), owner, repoName, prNumber, b.Event, b.Body); err != nil { - var apiErr *ghAPIError - if errors.As(err, &apiErr) { - switch apiErr.StatusCode { - case 401, 403: - apiError(res, 403, "GITHUB_TOKEN_INSUFFICIENT_PERMISSIONS", "Token does not have permission to review pull requests") - return - case 422: - apiError(res, 422, "GITHUB_PR_VALIDATION_ERROR", fmt.Sprintf("GitHub validation error: %s", apiErr.Message)) - return - } - } - apiError(res, 502, "INTERNAL_ERROR", fmt.Sprintf("failed to submit review: %s", err)) - return - } - created(res, map[string]bool{"success": true}) -} diff --git a/backend/scanner.go b/backend/scanner.go index 389565c..e5c841f 100644 --- a/backend/scanner.go +++ b/backend/scanner.go @@ -51,7 +51,3 @@ func (s *scanner) int64Val(col string) int64 { } return 0 } - -func (s *scanner) intVal(col string) int { - return int(s.int64Val(col)) -} diff --git a/backend/spi_access.go b/backend/spi_access.go new file mode 100644 index 0000000..840d8e6 --- /dev/null +++ b/backend/spi_access.go @@ -0,0 +1,172 @@ +package main + +import ( + "context" + "net/url" + "strconv" + "strings" + "time" + + gp "github.com/Paca-AI/plugin-sdk-go/gitprovider" +) + +// Access grants: a GitHub App can only see the repositories of accounts it is +// installed on, and each project may use a different user or organization, so +// the install happens when a project links a repository there — not when the +// App is created. Core calls BeginAccessGrant with the owner it needs, the user +// installs the App on GitHub, GitHub returns to Paca with ?installation_id, and +// CompleteAccessGrant records the installation. + +const accessScope = "access" // setupSession.Scope for an access grant + +// refreshWindow is how long a miss-driven refresh of the App's installations is +// remembered, so a repository on an account the App is not installed on does not +// ask GitHub on every call. +const refreshWindow = time.Minute + +// BeginAccessGrant implements gp.AccessGranter. +func (p *ghProvider) BeginAccessGrant(_ context.Context, req gp.BeginAccessRequest) (gp.SetupStep, error) { + if req.CallbackURL == "" { + return gp.SetupStep{}, gp.Errorf(gp.CodeValidation, "callback_url is required") + } + app, err := p.loadApp(req.Identity.ID) + if err != nil { + return gp.SetupStep{}, err + } + owner := strings.TrimSpace(req.Owner) + if owner != "" && !githubLoginRe.MatchString(owner) { + return gp.SetupStep{}, gp.Errorf(gp.CodeValidation, "owner is not a valid GitHub user or organization name") + } + + // With a known owner, GitHub's install page can open on that account + // directly; without one the user picks the account there. + install := "https://github.com/apps/" + url.PathEscape(app.Slug) + "/installations/new" + if owner != "" { + var acct struct { + ID int64 `json:"id"` + } + err := p.publicAPI(app).get("/users/"+url.PathEscape(owner), &acct) + if err != nil && gp.AsError(err).Code == gp.CodeForbidden { + // The token was refused (and could not be replaced): the endpoint is + // public, so ask without credentials. + err = (&ghAPI{}).get("/users/"+url.PathEscape(owner), &acct) + } + if err != nil { + return gp.SetupStep{}, err // not_found: no such GitHub account + } + install += "/permissions?target_id=" + strconv.FormatInt(acct.ID, 10) + } + + state, err := randomHex(24) + if err != nil { + return gp.SetupStep{}, gp.Errorf(gp.CodeInternal, "could not generate state") + } + if err := p.store.saveSetup(setupSession{ + State: state, Scope: accessScope, AppID: app.ID, OwnerOrg: owner, ExpiresAt: p.now().Add(setupSessionLife), + }); err != nil { + return gp.SetupStep{}, gp.Errorf(gp.CodeInternal, "could not start the access grant") + } + sep := "?" + if strings.Contains(install, "?") { + sep = "&" + } + return gp.SetupStep{Kind: gp.StepRedirect, URL: install + sep + "state=" + url.QueryEscape(state), State: state}, nil +} + +// CompleteAccessGrant implements gp.AccessGranter. The state must be this +// identity's own, unused and unexpired; the installation id from the URL is +// never trusted on its own — it is fetched from GitHub as the App, which only +// answers for the App's own installations. +func (p *ghProvider) CompleteAccessGrant(_ context.Context, req gp.CompleteAccessRequest) (gp.AccessGrant, error) { + if strings.TrimSpace(req.State) == "" { + return gp.AccessGrant{}, gp.Errorf(gp.CodeValidation, "state is required") + } + app, err := p.loadApp(req.Identity.ID) + if err != nil { + return gp.AccessGrant{}, err + } + s, err := p.store.loadSetup(req.State) + if err != nil || s.Scope != accessScope || s.AppID != app.ID || s.Completed || !p.now().Before(s.ExpiresAt) { + return gp.AccessGrant{}, gp.Errorf(gp.CodeForbidden, "unknown or expired access grant") + } + if req.Params["setup_action"] == "request" && req.Params["installation_id"] == "" { + // An organization owner has to approve the install first; nothing was granted yet. + return gp.AccessGrant{}, gp.Errorf(gp.CodeConflict, "the installation is waiting for approval by an organization owner") + } + instID, err := strconv.ParseInt(strings.TrimSpace(req.Params["installation_id"]), 10, 64) + if err != nil || instID <= 0 { + return gp.AccessGrant{}, gp.Errorf(gp.CodeValidation, "installation_id is required") + } + api, err := p.appAPI(app) + if err != nil { + return gp.AccessGrant{}, err + } + var gi ghInstallation + if err := api.get("/app/installations/"+strconv.FormatInt(instID, 10), &gi); err != nil { + return gp.AccessGrant{}, err + } + if s.OwnerOrg != "" && !strings.EqualFold(gi.Account.Login, s.OwnerOrg) { + return gp.AccessGrant{}, gp.Errorf(gp.CodeConflict, "the App was installed on %q, not on %q: install it on the requested account", gi.Account.Login, s.OwnerOrg) + } + if err := p.store.upsertInstallation(app.ID, installFrom(gi)); err != nil { + return gp.AccessGrant{}, gp.Errorf(gp.CodeInternal, "could not store the installation") + } + s.Completed = true + if err := p.store.updateSetup(s); err != nil { + return gp.AccessGrant{}, gp.Errorf(gp.CodeInternal, "could not record the access grant") + } + return gp.AccessGrant{Owner: gi.Account.Login}, nil +} + +func installFrom(gi ghInstallation) installRecord { + return installRecord{ + InstallationID: gi.ID, AccountLogin: gi.Account.Login, AccountType: gi.Account.Type, + Selection: gi.RepositorySelection, Suspended: gi.SuspendedAt != nil, + } +} + +// publicAPI is for GitHub's public endpoints (an account's id). An App JWT is +// refused there, so it uses an installation token when the App has one (a +// higher rate limit than anonymous calls) and no credentials otherwise. +func (p *ghProvider) publicAPI(app appRecord) *ghAPI { + if list, err := p.store.installations(app.ID); err == nil { + for _, in := range list { + if in.Suspended { + continue + } + if tok, _, err := p.mintToken(app, in, nil); err == nil { + return &ghAPI{token: tok, refresh: p.freshToken(app, in)} + } + } + } + return &ghAPI{} +} + +// refreshInstallations asks GitHub which accounts the App is installed on and +// records them. It makes an install done directly on GitHub (not through a +// Paca grant flow) take effect the first time something needs it. It reports +// whether it actually asked GitHub; a recent refresh is not repeated. +func (p *ghProvider) refreshInstallations(app appRecord) bool { + key := "inst-refresh:" + app.ID + if _, exp, ok := p.store.cacheGet(key); ok && exp.Sub(p.now()) > installationTokenReuseMargin { + return false + } + p.store.cacheSet(key, "1", p.now().Add(installationTokenReuseMargin+refreshWindow)) + api, err := p.appAPI(app) + if err != nil { + return false + } + for page := 1; page <= 5; page++ { + var list []ghInstallation + if err := api.get("/app/installations?per_page=100&page="+strconv.Itoa(page), &list); err != nil { + return true + } + for _, gi := range list { + _ = p.store.upsertInstallation(app.ID, installFrom(gi)) + } + if len(list) < 100 { + break + } + } + return true +} diff --git a/backend/spi_access_test.go b/backend/spi_access_test.go new file mode 100644 index 0000000..ec84268 --- /dev/null +++ b/backend/spi_access_test.go @@ -0,0 +1,327 @@ +package main + +import ( + "net/url" + "strings" + "testing" + "time" + + gp "github.com/Paca-AI/plugin-sdk-go/gitprovider" +) + +func grantReq(owner string) gp.BeginAccessRequest { + return gp.BeginAccessRequest{Identity: gp.IdentityRef{ID: fakeAppRowID}, Owner: owner, ReturnURL: "https://paca.example.com/projects/p", CallbackURL: "https://paca.example.com/git/setup/callback"} +} + +func TestAccessGrant_OpensTheInstallPageOnTheOwnersAccount(t *testing.T) { + p, g, st := seededProvider(t) + step, err := p.BeginAccessGrant(bg, grantReq("other-org")) + if err != nil { + t.Fatal(err) + } + u, perr := url.Parse(step.URL) + if perr != nil || step.Kind != gp.StepRedirect || u.Host != "github.com" || u.Path != "/apps/"+fakeSlug+"/installations/new/permissions" { + t.Fatalf("step = %+v", step) + } + if u.Query().Get("target_id") != "556" || u.Query().Get("state") != step.State || step.State == "" { + t.Errorf("query = %v (want the owner's account id and our state)", u.Query()) + } + if s := st.setups[step.State]; s.Scope != accessScope || s.AppID != fakeAppRowID || s.OwnerOrg != "other-org" || !s.ExpiresAt.After(time.Now()) { + t.Errorf("session = %+v", s) + } + if g.called("GET /users/other-org") != 1 { + t.Error("the owner's account id comes from GitHub") + } + + // No owner: GitHub's own account picker. + step, err = p.BeginAccessGrant(bg, grantReq("")) + if err != nil || !strings.HasPrefix(step.URL, "https://github.com/apps/"+fakeSlug+"/installations/new?state=") || strings.Contains(step.URL, "target_id") { + t.Errorf("no owner: %+v %v", step, err) + } +} + +func TestAccessGrant_BeginValidation(t *testing.T) { + p, _, _ := seededProvider(t) + r := grantReq("acme") + r.CallbackURL = "" + if _, err := p.BeginAccessGrant(bg, r); codeOf(t, err) != gp.CodeValidation { + t.Errorf("callback: %v", err) + } + for _, owner := range []string{"-bad-", "a/b", "../x", "x y"} { + if _, err := p.BeginAccessGrant(bg, grantReq(owner)); codeOf(t, err) != gp.CodeValidation { + t.Errorf("owner %q: %v", owner, err) + } + } + if _, err := p.BeginAccessGrant(bg, grantReq("no-such-account")); codeOf(t, err) != gp.CodeNotFound { + t.Errorf("unknown account: %v", err) + } + r = grantReq("acme") + r.Identity.ID = "nope" + if _, err := p.BeginAccessGrant(bg, r); codeOf(t, err) != gp.CodeNotFound { + t.Errorf("unknown identity: %v", err) + } +} + +func TestAccessGrant_CompleteRecordsTheInstallationAndMakesItUsable(t *testing.T) { + p, _, st := seededProvider(t) + st.installs[fakeAppRowID] = nil // installed nowhere yet, as right after creating the App + step, _ := p.BeginAccessGrant(bg, grantReq("acme")) + + grant, err := p.CompleteAccessGrant(bg, gp.CompleteAccessRequest{Identity: gp.IdentityRef{ID: fakeAppRowID}, State: step.State, Params: map[string]string{"installation_id": "99", "setup_action": "install"}}) + if err != nil || grant.Owner != "acme" { + t.Fatalf("%+v %v", grant, err) + } + if ins := st.installs[fakeAppRowID]; len(ins) != 1 || ins[0].AccountLogin != "acme" || ins[0].InstallationID != 99 { + t.Errorf("installations = %+v", ins) + } + // The repository is now reachable for that owner. + if _, err := p.ResolveRepository(bg, gp.ResolveRepoRequest{PathOrURL: "acme/web", Candidates: []gp.IdentityRef{{ID: fakeAppRowID}}}); err != nil { + t.Errorf("resolve after grant: %v", err) + } + // The state is single-use. + if _, err := p.CompleteAccessGrant(bg, gp.CompleteAccessRequest{Identity: gp.IdentityRef{ID: fakeAppRowID}, State: step.State, Params: map[string]string{"installation_id": "99"}}); codeOf(t, err) != gp.CodeForbidden { + t.Errorf("replay: %v", err) + } +} + +func TestAccessGrant_CompleteGuards(t *testing.T) { + p, _, st := seededProvider(t) + id := gp.IdentityRef{ID: fakeAppRowID} + step, _ := p.BeginAccessGrant(bg, grantReq("acme")) + call := func(state string, params map[string]string) error { + _, err := p.CompleteAccessGrant(bg, gp.CompleteAccessRequest{Identity: id, State: state, Params: params}) + return err + } + if codeOf(t, call("", map[string]string{"installation_id": "99"})) != gp.CodeValidation { + t.Error("state required") + } + if codeOf(t, call("never-issued", map[string]string{"installation_id": "99"})) != gp.CodeForbidden { + t.Error("unknown state") + } + if codeOf(t, call(step.State, nil)) != gp.CodeValidation { + t.Error("installation id required") + } + if codeOf(t, call(step.State, map[string]string{"installation_id": "x"})) != gp.CodeValidation { + t.Error("installation id must be numeric") + } + // An organization owner must approve first: nothing is granted, and the state stays usable. + if codeOf(t, call(step.State, map[string]string{"setup_action": "request"})) != gp.CodeConflict { + t.Error("pending approval") + } + // An installation GitHub does not return for this App stores nothing. + before := len(st.installs[fakeAppRowID]) + if codeOf(t, call(step.State, map[string]string{"installation_id": "424242"})) != gp.CodeNotFound || len(st.installs[fakeAppRowID]) != before { + t.Error("another App's installation id must be refused and not stored") + } + // Expired. + s := st.setups[step.State] + s.ExpiresAt = time.Now().Add(-time.Minute) + st.setups[step.State] = s + if codeOf(t, call(step.State, map[string]string{"installation_id": "99"})) != gp.CodeForbidden { + t.Error("expired") + } + // A state issued to one identity cannot be completed as another. + other := appRecord{ID: "app-row-2", GitHubAppID: 4242, Slug: "other", Name: "Other", WebhookSecret: "x", PrivateKeyPEM: st.apps[fakeAppRowID].PrivateKeyPEM} + st.apps[other.ID] = other + step2, _ := p.BeginAccessGrant(bg, grantReq("acme")) + _, err := p.CompleteAccessGrant(bg, gp.CompleteAccessRequest{Identity: gp.IdentityRef{ID: other.ID}, State: step2.State, Params: map[string]string{"installation_id": "99"}}) + if codeOf(t, err) != gp.CodeForbidden { + t.Errorf("cross-identity: %v", err) + } +} + +// An App installed on GitHub directly (not through a Paca grant flow) takes +// effect the first time a repository of that account is needed. +func TestInstallationsMadeOnGitHubAreDiscovered(t *testing.T) { + p, g, st := seededProvider(t) + if len(st.installs[fakeAppRowID]) != 1 { + t.Fatal("setup assumption") + } + res, err := p.ResolveRepository(bg, gp.ResolveRepoRequest{PathOrURL: "https://github.com/other-org/lib", Candidates: []gp.IdentityRef{{ID: fakeAppRowID}}}) + if err != nil || res.Repository.Ref.FullPath != "other-org/lib" { + t.Fatalf("%+v %v", res, err) + } + if len(st.installs[fakeAppRowID]) != 2 { + t.Errorf("the installation should now be recorded: %+v", st.installs[fakeAppRowID]) + } + if g.called("GET /app/installations") < 1 { + t.Error("GitHub should have been asked which accounts the App is installed on") + } +} + +func TestInstallationDiscoveryIsRateLimited(t *testing.T) { + p, g, _ := seededProvider(t) + for i := 0; i < 4; i++ { + _, err := p.ResolveRepository(bg, gp.ResolveRepoRequest{PathOrURL: "nobody-here/repo", Candidates: []gp.IdentityRef{{ID: fakeAppRowID}}}) + if codeOf(t, err) != gp.CodeNotFound { + t.Fatalf("call %d: %v", i, err) + } + } + n := 0 + for _, c := range g.calls { + if c == "GET /app/installations" { // the list, not one installation + n++ + } + } + if n != 1 { + // the list endpoint is requested once per window, however often an unknown owner is asked about + t.Errorf("GitHub was asked %d times for installations, want 1", n) + } +} + +func TestListRepositoriesDiscoversWhenNothingIsKnown(t *testing.T) { + p, _, st := seededProvider(t) + st.installs[fakeAppRowID] = nil + page, err := p.ListRepositories(bg, gp.ListReposRequest{Identity: gp.IdentityRef{ID: fakeAppRowID}}) + if err != nil || len(page.Items) == 0 { + t.Fatalf("%+v %v", page, err) + } +} + +// A public repository answers any token, so reading it proves nothing about +// whether the installation covers it. Linking must not succeed for a repository +// no credential can ever be minted for. +func TestResolveRepository_RequiresTheInstallationToCoverTheRepository(t *testing.T) { + p, g, _ := seededProvider(t) + cands := []gp.IdentityRef{{ID: fakeAppRowID}} + + // The installation on other-org covers only "lib". + covered, err := p.ResolveRepository(bg, gp.ResolveRepoRequest{PathOrURL: "other-org/lib", Candidates: cands}) + if err != nil || covered.Repository.Ref.FullPath != "other-org/lib" { + t.Fatalf("covered repository: %+v %v", covered, err) + } + _, err = p.ResolveRepository(bg, gp.ResolveRepoRequest{PathOrURL: "other-org/public-extra", Candidates: cands}) + if codeOf(t, err) != gp.CodeNotFound { + t.Fatalf("a public repository outside the selection must not resolve: %v", err) + } + // What resolves is what a credential can be minted for. + if _, err := p.MintCredential(bg, gp.MintRequest{Target: gp.Target{Identity: cands[0], Repo: covered.Repository.Ref}, Access: gp.AccessRead}); err != nil { + t.Errorf("mint for a resolved repository: %v", err) + } + + // "All repositories" installations need no per-repository check. + scoped := func() (n int) { + for _, m := range g.mints { + if !m.Unscoped { + n++ + } + } + return n + } + before := scoped() + if _, err := p.ResolveRepository(bg, gp.ResolveRepoRequest{PathOrURL: "acme/web", Candidates: cands}); err != nil { + t.Fatal(err) + } + if scoped() != before { + t.Errorf("an all-repositories installation should cost no scoped token request: %d new", scoped()-before) + } +} + +// GitHub refuses an App JWT on /users/{login}; the owner's account id must be +// looked up with an installation token, or anonymously when there is none. +func TestAccessGrant_LooksUpTheOwnerWithoutAnAppJWT(t *testing.T) { + p, g, st := seededProvider(t) + if _, err := p.BeginAccessGrant(bg, grantReq("other-org")); err != nil { + t.Fatal(err) + } + if len(g.userLookups) != 1 || !g.userLookups[0] { + t.Errorf("with an installation available the lookup should use its token: %v", g.userLookups) + } + + st.installs[fakeAppRowID] = nil // freshly created App, installed nowhere + g.userLookups = nil + if _, err := p.BeginAccessGrant(bg, grantReq("other-org")); err != nil { + t.Fatalf("an App that is installed nowhere must still be able to start a grant: %v", err) + } + if len(g.userLookups) != 1 || g.userLookups[0] { + t.Errorf("with no installation the lookup should be anonymous: %v", g.userLookups) + } +} + +// Uninstalling and reinstalling an App on an account gives a new installation +// id. The old row is dead and must not be picked ahead of the new one. +func TestAccessGrant_ReinstallReplacesTheDeadInstallation(t *testing.T) { + p, g, st := seededProvider(t) + g.installs[199] = fakeInstall{Login: "acme", Type: "Organization"} // acme reinstalled the App: new id + delete(g.installs, 99) // the old one is gone on GitHub + + step, _ := p.BeginAccessGrant(bg, grantReq("acme")) + if _, err := p.CompleteAccessGrant(bg, gp.CompleteAccessRequest{Identity: gp.IdentityRef{ID: fakeAppRowID}, State: step.State, Params: map[string]string{"installation_id": "199"}}); err != nil { + t.Fatal(err) + } + ins := st.installs[fakeAppRowID] + if len(ins) != 1 || ins[0].InstallationID != 199 { + t.Fatalf("only the live installation should remain: %+v", ins) + } + if _, err := p.ResolveRepository(bg, gp.ResolveRepoRequest{PathOrURL: "acme/web", Candidates: []gp.IdentityRef{{ID: fakeAppRowID}}}); err != nil { + t.Errorf("the new installation should be the one used: %v", err) + } +} + +// A cached installation token that GitHub revoked is replaced once, instead of +// failing every call until the cache entry would have expired. +func TestRevokedCachedTokenIsReplacedOnce(t *testing.T) { + p, g, _ := seededProvider(t) + if _, err := p.GetChangeRequest(bg, gp.CRRequest{Target: webTarget(), Number: "1"}); err != nil { + t.Fatal(err) + } + g.revokeTokens() // the cached token is now dead, the installation is fine + before := len(g.mints) + if _, err := p.GetChangeRequest(bg, gp.CRRequest{Target: webTarget(), Number: "1"}); err != nil { + t.Fatalf("a revoked cached token must be replaced transparently: %v", err) + } + if len(g.mints) != before+1 { + t.Errorf("exactly one new token should have been minted, got %d", len(g.mints)-before) + } +} + +// After GitHub uninstalls the App, the record is dropped so the account reads as +// "not installed" again (and core offers the install) instead of failing forever. +func TestUninstalledAppIsForgottenAndCanBeGrantedAgain(t *testing.T) { + p, g, st := seededProvider(t) + cands := []gp.IdentityRef{{ID: fakeAppRowID}} + if _, err := p.ResolveRepository(bg, gp.ResolveRepoRequest{PathOrURL: "acme/web", Candidates: cands}); err != nil { + t.Fatal(err) // caches a token for installation 99 + } + g.uninstall(99) + + _, err := p.ResolveRepository(bg, gp.ResolveRepoRequest{PathOrURL: "acme/web", Candidates: cands}) + if codeOf(t, err) != gp.CodeNotFound { + t.Fatalf("an uninstalled App must read as not installed: %v", err) + } + for _, in := range st.installs[fakeAppRowID] { + if in.InstallationID == 99 { + t.Errorf("the dead installation was kept: %+v", st.installs[fakeAppRowID]) + } + } + // Starting a grant must still work: the owner lookup cannot depend on the dead token. + step, err := p.BeginAccessGrant(bg, grantReq("acme")) + if err != nil || step.State == "" { + t.Fatalf("grant after uninstall: %+v %v", step, err) + } + // The user installs it again: a new installation id. + g.mu.Lock() + g.installs[205] = fakeInstall{Login: "acme", Type: "Organization", Selected: []string{"web"}} + g.mu.Unlock() + if _, err := p.CompleteAccessGrant(bg, gp.CompleteAccessRequest{Identity: cands[0], State: step.State, Params: map[string]string{"installation_id": "205"}}); err != nil { + t.Fatal(err) + } + if res, err := p.ResolveRepository(bg, gp.ResolveRepoRequest{PathOrURL: "acme/web", Candidates: cands}); err != nil || res.Repository.Ref.FullPath != "acme/web" { + t.Errorf("after reinstalling: %+v %v", res, err) + } +} + +func TestAccessGrant_CompleteRejectsAnInstallationOnAnotherAccount(t *testing.T) { + p, g, st := seededProvider(t) + st.installs[fakeAppRowID] = nil + g.installs[199] = fakeInstall{Login: "someone-else", Type: "User"} + step, _ := p.BeginAccessGrant(bg, grantReq("acme")) + + _, err := p.CompleteAccessGrant(bg, gp.CompleteAccessRequest{Identity: gp.IdentityRef{ID: fakeAppRowID}, State: step.State, Params: map[string]string{"installation_id": "199"}}) + if codeOf(t, err) != gp.CodeConflict { + t.Fatalf("err = %v, want conflict", err) + } + if ins := st.installs[fakeAppRowID]; len(ins) != 0 { + t.Errorf("installation recorded despite the mismatch: %+v", ins) + } +} diff --git a/backend/spi_api.go b/backend/spi_api.go new file mode 100644 index 0000000..6fefe8f --- /dev/null +++ b/backend/spi_api.go @@ -0,0 +1,240 @@ +package main + +import ( + "encoding/json" + "fmt" + "net/url" + "strconv" + "strings" + "time" + + gp "github.com/Paca-AI/plugin-sdk-go/gitprovider" +) + +// ghAPI is one authenticated view of the GitHub REST/GraphQL API, used by the +// Git provider. Every failure comes back as a *gp.Error so core handles one +// error type; GitHub's wording is kept short and never carries credentials. +type ghAPI struct { + token string + // refresh, when set, replaces a token GitHub answered 401 to (revoked, for + // example because the App was uninstalled and installed again) and the + // request is retried once. + refresh func() (string, error) +} + +const maxErrorMessage = 200 + +func (a *ghAPI) headers(accept string) map[string]string { + if accept == "" { + accept = "application/vnd.github+json" + } + h := map[string]string{"Accept": accept, "X-GitHub-Api-Version": ghAPIVersion} + if a.token != "" { + h["Authorization"] = "Bearer " + a.token + } + return h +} + +// do performs a request and returns the body of a 2xx answer. +func (a *ghAPI) do(method, path string, body any, accept string) (string, error) { + status, text, _, err := a.doRaw(method, path, body, accept) + if err != nil { + return "", err + } + if status >= 400 { + return "", mapGitHubError(status, text, nil) + } + return text, nil +} + +func (a *ghAPI) doRaw(method, path string, body any, accept string) (int, string, map[string]string, error) { + rawURL := path + if !strings.HasPrefix(path, "http") { + rawURL = ghBaseURL + path + } + h := a.headers(accept) + payload := "" + if body != nil { + b, err := json.Marshal(body) + if err != nil { + return 0, "", nil, gp.Errorf(gp.CodeInternal, "could not encode request") + } + payload = string(b) + h["Content-Type"] = "application/json" + } + resp, err := fetch(method, rawURL, h, payload) + if err != nil { + return 0, "", nil, gp.Errorf(gp.CodeTransient, "GitHub is unreachable") + } + if resp.Status == 401 && a.refresh != nil { + refresh := a.refresh + a.refresh = nil // one retry only + tok, rerr := refresh() + if rerr != nil { + // More informative than the 401: for an uninstalled App this is + // "no longer installed on this account". + return 0, "", nil, rerr + } + a.token = tok + h["Authorization"] = "Bearer " + tok + if resp, err = fetch(method, rawURL, h, payload); err != nil { + return 0, "", nil, gp.Errorf(gp.CodeTransient, "GitHub is unreachable") + } + } + if resp.Status >= 400 { + return resp.Status, resp.Body, resp.Headers, mapGitHubError(resp.Status, resp.Body, resp.Headers) + } + return resp.Status, resp.Body, resp.Headers, nil +} + +func (a *ghAPI) get(path string, out any) error { + text, err := a.do("GET", path, nil, "") + if err != nil { + return err + } + return decodeInto(text, out) +} + +func (a *ghAPI) send(method, path string, body, out any) error { + text, err := a.do(method, path, body, "") + if err != nil { + return err + } + if out == nil || strings.TrimSpace(text) == "" { + return nil + } + return decodeInto(text, out) +} + +func decodeInto(text string, out any) error { + if out == nil { + return nil + } + if err := json.Unmarshal([]byte(text), out); err != nil { + return gp.Errorf(gp.CodeInternal, "GitHub returned an unreadable response") + } + return nil +} + +// graphql runs one query. GraphQL answers 200 with an errors array, so those +// are classified too: NOT_FOUND → not_found, FORBIDDEN/INSUFFICIENT_SCOPES → +// forbidden, anything else → validation. +func (a *ghAPI) graphql(query string, vars map[string]any, out any) error { + text, err := a.do("POST", ghBaseURL+"/graphql", map[string]any{"query": query, "variables": vars}, "") + if err != nil { + return err + } + var env struct { + Data json.RawMessage `json:"data"` + Errors []struct { + Type string `json:"type"` + Message string `json:"message"` + } `json:"errors"` + } + if err := json.Unmarshal([]byte(text), &env); err != nil { + return gp.Errorf(gp.CodeInternal, "GitHub returned an unreadable response") + } + if len(env.Errors) > 0 { + e := env.Errors[0] + switch { + case e.Type == "NOT_FOUND" || strings.Contains(e.Message, "Could not resolve to"): + return gp.Errorf(gp.CodeNotFound, "not found on GitHub") + case e.Type == "FORBIDDEN" || e.Type == "INSUFFICIENT_SCOPES": + return gp.Errorf(gp.CodeForbidden, "the identity may not do this on GitHub") + } + return gp.Errorf(gp.CodeValidation, "%s", clip(e.Message)) + } + if out == nil { + return nil + } + return json.Unmarshal(env.Data, out) +} + +func clip(s string) string { + s = strings.TrimSpace(s) + if len(s) > maxErrorMessage { + return s[:maxErrorMessage] + "…" + } + return s +} + +// mapGitHubError classifies a failed REST call onto the SPI's error set. +func mapGitHubError(status int, body string, headers map[string]string) error { + var eb struct { + Message string `json:"message"` + Errors []any `json:"errors"` + } + _ = json.Unmarshal([]byte(body), &eb) + msg := clip(eb.Message) + if msg == "" { + msg = fmt.Sprintf("GitHub answered HTTP %d", status) + } + lower := strings.ToLower(msg) + switch { + case status == 429 || (status == 403 && (strings.Contains(lower, "rate limit") || strings.Contains(lower, "abuse") || headerOf(headers, "x-ratelimit-remaining") == "0")): + e := gp.Errorf(gp.CodeRateLimited, "GitHub is rate limiting this identity") + e.RetryAfterSeconds = retryAfter(headers) + return e + case status == 401: + return gp.Errorf(gp.CodeForbidden, "GitHub rejected the identity's credentials") + case status == 403: + return gp.Errorf(gp.CodeForbidden, "%s", msg) + case status == 404: + return gp.Errorf(gp.CodeNotFound, "%s", msg) + case status == 409 || status == 405: // 405: "not mergeable" + return gp.Errorf(gp.CodeConflict, "%s", msg) + case status == 406: // e.g. a diff beyond GitHub's size limit + return gp.Errorf(gp.CodeValidation, "%s", msg) + case status == 422: + if strings.Contains(lower, "already exists") || strings.Contains(lower, "a pull request already") { + return gp.Errorf(gp.CodeConflict, "%s", msg) + } + return gp.Errorf(gp.CodeValidation, "%s", msg) + case status >= 500: + return gp.Errorf(gp.CodeTransient, "GitHub is temporarily unavailable") + } + return gp.Errorf(gp.CodeInternal, "%s", msg) +} + +func headerOf(h map[string]string, name string) string { + for k, v := range h { + if strings.EqualFold(k, name) { + return v + } + } + return "" +} + +func retryAfter(h map[string]string) int { + if v := headerOf(h, "retry-after"); v != "" { + if n, err := strconv.Atoi(v); err == nil && n > 0 { + return n + } + } + if v := headerOf(h, "x-ratelimit-reset"); v != "" { + if n, err := strconv.ParseInt(v, 10, 64); err == nil { + if d := int(time.Until(time.Unix(n, 0)).Seconds()); d > 0 { + return d + } + } + } + return 0 +} + +// repoPath builds "/repos/{owner}/{name}" from a full path, rejecting anything +// that is not exactly owner/name. +func repoPath(fullPath string) (string, error) { + owner, name, ok := splitFullPath(fullPath) + if !ok { + return "", gp.Errorf(gp.CodeValidation, "repository must be owner/name") + } + return "/repos/" + url.PathEscape(owner) + "/" + url.PathEscape(name), nil +} + +func splitFullPath(p string) (owner, name string, ok bool) { + parts := strings.Split(strings.Trim(p, "/"), "/") + if len(parts) != 2 || parts[0] == "" || parts[1] == "" { + return "", "", false + } + return parts[0], parts[1], true +} diff --git a/backend/spi_assert.go b/backend/spi_assert.go new file mode 100644 index 0000000..bd38886 --- /dev/null +++ b/backend/spi_assert.go @@ -0,0 +1,18 @@ +package main + +import gp "github.com/Paca-AI/plugin-sdk-go/gitprovider" + +// Compile-time proof that the provider implements the SPI and exactly the +// optional interfaces Describe declares (the conformance suite checks the +// declarations match). +var ( + _ gp.Provider = (*ghProvider)(nil) + _ gp.ThreadResolver = (*ghProvider)(nil) + _ gp.FileReader = (*ghProvider)(nil) + _ gp.LabelManager = (*ghProvider)(nil) + _ gp.ReviewerRequester = (*ghProvider)(nil) + _ gp.AssigneeSetter = (*ghProvider)(nil) + _ gp.Merger = (*ghProvider)(nil) + _ gp.IdentityDeleter = (*ghProvider)(nil) + _ gp.AccessGranter = (*ghProvider)(nil) +) diff --git a/backend/spi_branches.go b/backend/spi_branches.go new file mode 100644 index 0000000..597bbe5 --- /dev/null +++ b/backend/spi_branches.go @@ -0,0 +1,94 @@ +package main + +import ( + "context" + "net/url" + "strings" + + gp "github.com/Paca-AI/plugin-sdk-go/gitprovider" +) + +// branchPath escapes a branch name segment by segment: "feat/x" keeps its +// slash, anything else that could change the path is escaped. +func branchPath(name string) string { + parts := strings.Split(name, "/") + for i, p := range parts { + parts[i] = url.PathEscape(p) + } + return strings.Join(parts, "/") +} + +// validBranchName applies git's core rules for a branch name, so a bad name is +// refused here as validation rather than as a GitHub 422. +func validBranchName(name string) bool { + if name == "" || len(name) > 250 || strings.HasPrefix(name, "/") || strings.HasSuffix(name, "/") || + strings.HasPrefix(name, "-") || strings.Contains(name, "..") || strings.Contains(name, "//") || + strings.HasSuffix(name, ".lock") || strings.HasSuffix(name, ".") || strings.Contains(name, "@{") { + return false + } + for _, r := range name { + if r < 0x20 || r == 0x7f || strings.ContainsRune(" ~^:?*[\\", r) { + return false + } + } + return true +} + +type ghBranch struct { + Name string `json:"name"` + Commit struct { + SHA string `json:"sha"` + } `json:"commit"` +} + +func branchInfo(t gp.Target, b ghBranch) gp.BranchInfo { + return gp.BranchInfo{Name: b.Name, SHA: b.Commit.SHA, + URL: "https://" + t.Repo.Host + "/" + strings.Trim(t.Repo.FullPath, "/") + "/tree/" + branchPath(b.Name)} +} + +// GetBranch implements gp.BranchManager. +func (p *ghProvider) GetBranch(_ context.Context, req gp.GetBranchRequest) (gp.BranchInfo, error) { + if !validBranchName(req.Name) { + return gp.BranchInfo{}, gp.Errorf(gp.CodeValidation, "invalid branch name") + } + api, repo, err := p.apiFor(req.Target) + if err != nil { + return gp.BranchInfo{}, err + } + var b ghBranch + if err := api.get(repo+"/branches/"+branchPath(req.Name), &b); err != nil { + return gp.BranchInfo{}, err + } + return branchInfo(req.Target, b), nil +} + +// CreateBranch implements gp.BranchManager: a new ref at the head of From (the +// default branch when empty). GitHub answers 422 "Reference already exists" +// for a taken name, which is classified as a conflict. +func (p *ghProvider) CreateBranch(_ context.Context, req gp.CreateBranchRequest) (gp.BranchInfo, error) { + if !validBranchName(req.Name) { + return gp.BranchInfo{}, gp.Errorf(gp.CodeValidation, "invalid branch name") + } + from := strings.TrimSpace(req.From) + if from == "" { + from = req.Target.Repo.DefaultBranch + } + if from == "" { + return gp.BranchInfo{}, gp.Errorf(gp.CodeValidation, "the source branch is required: the repository has no known default branch") + } + if !validBranchName(from) { + return gp.BranchInfo{}, gp.Errorf(gp.CodeValidation, "invalid source branch name") + } + api, repo, err := p.apiFor(req.Target) + if err != nil { + return gp.BranchInfo{}, err + } + var src ghBranch + if err := api.get(repo+"/branches/"+branchPath(from), &src); err != nil { + return gp.BranchInfo{}, err + } + if err := api.send("POST", repo+"/git/refs", map[string]any{"ref": "refs/heads/" + req.Name, "sha": src.Commit.SHA}, nil); err != nil { + return gp.BranchInfo{}, err + } + return branchInfo(req.Target, ghBranch{Name: req.Name, Commit: src.Commit}), nil +} diff --git a/backend/spi_conformance_test.go b/backend/spi_conformance_test.go new file mode 100644 index 0000000..acfac51 --- /dev/null +++ b/backend/spi_conformance_test.go @@ -0,0 +1,300 @@ +package main + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "strings" + "sync" + "testing" + "time" + + gp "github.com/Paca-AI/plugin-sdk-go/gitprovider" + "github.com/Paca-AI/plugin-sdk-go/gitprovider/conformance" +) + +// memStore is an in-memory spiStore. +type memStore struct { + mu sync.Mutex + apps map[string]appRecord + installs map[string][]installRecord + setups map[string]setupSession + cache map[string]memToken + seq int +} + +type memToken struct { + tok string + exp time.Time +} + +func newMemStore() *memStore { + return &memStore{apps: map[string]appRecord{}, installs: map[string][]installRecord{}, setups: map[string]setupSession{}, cache: map[string]memToken{}} +} + +func (m *memStore) loadApp(id string) (appRecord, error) { + m.mu.Lock() + defer m.mu.Unlock() + a, ok := m.apps[id] + if !ok { + return appRecord{}, errNoSuchApp + } + return a, nil +} +func (m *memStore) installations(appID string) ([]installRecord, error) { + m.mu.Lock() + defer m.mu.Unlock() + return append([]installRecord(nil), m.installs[appID]...), nil +} +func (m *memStore) createApp(a appRecord) (string, error) { + m.mu.Lock() + defer m.mu.Unlock() + m.seq++ + a.ID = "created-app-" + string(rune('0'+m.seq)) + m.apps[a.ID] = a + return a.ID, nil +} +func (m *memStore) upsertInstallation(appID string, i installRecord) error { + m.mu.Lock() + defer m.mu.Unlock() + for k, e := range m.installs[appID] { + if e.InstallationID == i.InstallationID { + i.RowID = e.RowID + m.installs[appID][k] = i + return nil + } + } + m.seq++ + i.RowID = "inst-row-" + string(rune('0'+m.seq)) + // The same account's older installation of this App is dead (see spiStore). + kept := m.installs[appID][:0:0] + for _, e := range m.installs[appID] { + if !strings.EqualFold(e.AccountLogin, i.AccountLogin) { + kept = append(kept, e) + } + } + m.installs[appID] = append(kept, i) + return nil +} +func (m *memStore) deleteApp(id string) error { + m.mu.Lock() + defer m.mu.Unlock() + delete(m.apps, id) + delete(m.installs, id) + return nil +} +func (m *memStore) deleteInstallation(appID string, installationID int64) error { + m.mu.Lock() + defer m.mu.Unlock() + kept := m.installs[appID][:0:0] + for _, e := range m.installs[appID] { + if e.InstallationID != installationID { + kept = append(kept, e) + } + } + m.installs[appID] = kept + return nil +} +func (m *memStore) cacheDelete(key string) { + m.mu.Lock() + defer m.mu.Unlock() + delete(m.cache, key) +} +func (m *memStore) saveSetup(s setupSession) error { + m.mu.Lock() + defer m.mu.Unlock() + m.setups[s.State] = s + return nil +} +func (m *memStore) loadSetup(state string) (setupSession, error) { + m.mu.Lock() + defer m.mu.Unlock() + s, ok := m.setups[state] + if !ok { + return setupSession{}, errNoSuchSetup + } + return s, nil +} +func (m *memStore) updateSetup(s setupSession) error { + m.mu.Lock() + defer m.mu.Unlock() + if _, ok := m.setups[s.State]; !ok { + return errNoSuchSetup + } + m.setups[s.State] = s + return nil +} +func (m *memStore) claimSetupCode(state string) (bool, error) { + m.mu.Lock() + defer m.mu.Unlock() + s, ok := m.setups[state] + if !ok || s.CodeUsed { + return false, nil + } + s.CodeUsed = true + m.setups[state] = s + return true, nil +} +func (m *memStore) cacheGet(key string) (string, time.Time, bool) { + m.mu.Lock() + defer m.mu.Unlock() + t, ok := m.cache[key] + return t.tok, t.exp, ok +} +func (m *memStore) cacheSet(key, token string, expires time.Time) { + m.mu.Lock() + defer m.mu.Unlock() + m.cache[key] = memToken{token, expires} +} + +// seededProvider returns a provider with the fixture App installed on "acme". +func seededProvider(t *testing.T) (*ghProvider, *fakeGitHub, *memStore) { + t.Helper() + g := newFakeGitHub(t) + st := newMemStore() + st.apps[fakeAppRowID] = appRecord{ID: fakeAppRowID, GitHubAppID: g.appID, Slug: fakeSlug, Name: "Paca", WebhookSecret: fakeWebhookSecret, PrivateKeyPEM: g.pemKey(), WebhookActive: true} + st.installs[fakeAppRowID] = []installRecord{{RowID: "inst-1", InstallationID: g.installationID, AccountLogin: "acme", AccountType: "Organization", Selection: "all"}} + return newGHProvider(st), g, st +} + +func sign(body []byte, secret string) string { + m := hmac.New(sha256.New, []byte(secret)) + m.Write(body) + return "sha256=" + hex.EncodeToString(m.Sum(nil)) +} + +// delivery builds a signed GitHub webhook delivery. +func delivery(event, id string, payload map[string]any) gp.WebhookRequest { + body, _ := json.Marshal(payload) + return gp.WebhookRequest{ + Identity: gp.IdentityRef{ID: fakeAppRowID, Handle: fakeSlug}, Method: "POST", Body: body, + Headers: map[string]string{"x-github-event": event, "x-github-delivery": id, "x-hub-signature-256": sign(body, fakeWebhookSecret), "content-type": "application/json"}, + } +} + +var ( + repoPayload = map[string]any{"id": 1, "full_name": "acme/web", "default_branch": "main"} + userSender = map[string]any{"login": "octocat", "type": "User"} +) + +func conformanceFixtures() conformance.Fixtures { + identity := gp.IdentityRef{ID: fakeAppRowID, Handle: fakeSlug} + repo := gp.RepoRef{Host: githubHost, FullPath: "acme/web", DefaultBranch: "main"} + bad := delivery("issue_comment", "d-bad", map[string]any{"action": "created", "repository": repoPayload, "sender": userSender, + "issue": map[string]any{"number": 1}, "comment": map[string]any{"id": 1, "body": "@paca-bot hi"}}) + bad.Headers["x-hub-signature-256"] = "sha256=deadbeef" + + prComment := func(id, body string, sender map[string]any) gp.WebhookRequest { + return delivery("issue_comment", id, map[string]any{"action": "created", "repository": repoPayload, "sender": sender, + "issue": map[string]any{"number": 1, "title": "Add login", "user": userSender, "pull_request": map[string]any{"url": "x"}}, + "comment": map[string]any{"id": 11, "body": body}}) + } + return conformance.Fixtures{ + Identity: identity, Repo: repo, + InvisibleRepo: gp.RepoRef{Host: githubHost, FullPath: "secret/hidden"}, + RepoURL: "https://github.com/acme/web.git", Candidates: []gp.IdentityRef{identity}, + ExpectedRepoPaths: []string{"acme/web", "acme/api"}, + + ChangeRequest: "1", MissingCR: "999", + ChangedFiles: []string{"src/main.go", "src/util.go", "docs/guide.md"}, + HeadSHA: "abc123", FailingCheckID: "run:5001", + IssueNumber: "7", ThreadID: "PRRT_1", + Actors: map[string]gp.Permission{"octocat": gp.PermissionWrite, "reader": gp.PermissionRead, "owner": gp.PermissionAdmin, "lead": gp.PermissionMaintain}, + + BadSignature: bad, + Secrets: []string{fakeWebhookSecret, "ghs_token_", "BEGIN RSA PRIVATE KEY"}, + Webhooks: []conformance.WebhookCase{ + {Name: "mention-in-pr-comment", Request: prComment("d1", "@paca-bot please fix the test", userSender), + Want: []conformance.Want{{Kind: gp.EventComment, Action: "created", RepoPath: "acme/web", SubjectKind: gp.SubjectChangeRequest, SubjectNumber: "1", ActorLogin: "octocat", ActorKind: gp.ActorUser, Addressed: []gp.AddressVia{gp.ViaMention}}}}, + {Name: "mention-with-bot-suffix", Request: prComment("d1b", "hey @Paca-Bot[bot], look", userSender), + Want: []conformance.Want{{Kind: gp.EventComment, Addressed: []gp.AddressVia{gp.ViaMention}}}}, + {Name: "comment-without-mention", + Request: delivery("issue_comment", "d2", map[string]any{"action": "created", "repository": repoPayload, "sender": map[string]any{"login": "reader", "type": "User"}, + "issue": map[string]any{"number": 7, "title": "Bug"}, "comment": map[string]any{"id": 12, "body": "thanks!"}}), + Want: []conformance.Want{{Kind: gp.EventComment, SubjectKind: gp.SubjectIssue, SubjectNumber: "7", ActorLogin: "reader", ActorKind: gp.ActorUser}}}, + {Name: "email-address-is-not-a-mention", Request: prComment("d3", "mail me at dev@paca-bot.example", userSender), + Want: []conformance.Want{{Kind: gp.EventComment, ActorLogin: "octocat"}}}, + {Name: "longer-handle-is-not-a-mention", Request: prComment("d3b", "@paca-bot-2 is another bot", userSender), + Want: []conformance.Want{{Kind: gp.EventComment}}}, + {Name: "bot-actor", Request: prComment("d4", "bump", map[string]any{"login": "dependabot[bot]", "type": "Bot"}), + Want: []conformance.Want{{Kind: gp.EventComment, ActorLogin: "dependabot[bot]", ActorKind: gp.ActorBot}}}, + {Name: "review-request-is-not-a-trigger-on-github", + Request: delivery("pull_request", "d5", map[string]any{"action": "review_requested", "repository": repoPayload, "sender": userSender, + "pull_request": map[string]any{"number": 1}}), + Want: nil}, + {Name: "ci-failed", + Request: delivery("check_run", "d7", map[string]any{"action": "completed", "repository": repoPayload, "sender": map[string]any{"login": "github-actions[bot]", "type": "Bot"}, + "check_run": map[string]any{"id": 5001, "name": "ci/test", "head_sha": "abc123", "conclusion": "failure", + "pull_requests": []map[string]any{{"number": 1, "head": map[string]string{"ref": "feat/PACA-12-login", "sha": "abc123"}, "base": map[string]string{"ref": "main"}}}}}), + Want: []conformance.Want{{Kind: gp.EventCheck, Action: "completed", ActorKind: gp.ActorBot, CheckConclusion: "failure", SubjectKind: gp.SubjectChangeRequest, SubjectNumber: "1"}}}, + {Name: "branch-created-by-push", + Request: delivery("push", "d10", map[string]any{"ref": "refs/heads/feat/PACA-12-x", "after": "def456", "created": true, "repository": repoPayload, "sender": userSender}), + Want: []conformance.Want{{Kind: gp.EventPush, Action: "created", ActorLogin: "octocat"}}}, + {Name: "tag-push-is-dropped", + Request: delivery("push", "d10b", map[string]any{"ref": "refs/tags/v1", "after": "def456", "repository": repoPayload, "sender": userSender}), + Want: nil}, + {Name: "pr-opened-with-mention-in-body", + Request: delivery("pull_request", "d11", map[string]any{"action": "opened", "repository": repoPayload, "sender": userSender, + "pull_request": map[string]any{"number": 3, "title": "Fix it", "body": "@paca-bot can you review?", "user": userSender, + "head": map[string]any{"ref": "fix", "sha": "s1", "repo": map[string]string{"full_name": "acme/web"}}, "base": map[string]any{"ref": "main"}}}), + Want: []conformance.Want{{Kind: gp.EventChangeRequest, Action: "opened", SubjectKind: gp.SubjectChangeRequest, SubjectNumber: "3", Addressed: []gp.AddressVia{gp.ViaMention}}}}, + {Name: "review-submitted", + Request: delivery("pull_request_review", "d12", map[string]any{"action": "submitted", "repository": repoPayload, "sender": userSender, + "review": map[string]any{"id": 9, "state": "changes_requested", "body": "needs work"}, "pull_request": map[string]any{"number": 1, "user": userSender}}), + Want: []conformance.Want{{Kind: gp.EventReview, Action: "submitted", SubjectNumber: "1"}}}, + {Name: "review-requested-from-the-app", + Request: delivery("pull_request", "d13", map[string]any{"action": "review_requested", "repository": repoPayload, "sender": userSender, + "requested_reviewer": map[string]any{"login": "paca-bot[bot]", "type": "Bot"}, + "pull_request": map[string]any{"number": 4, "title": "Add x", "user": userSender, + "head": map[string]any{"ref": "x", "sha": "s1", "repo": map[string]string{"full_name": "acme/web"}}, "base": map[string]any{"ref": "main"}}}), + Want: []conformance.Want{{Kind: gp.EventReviewRequest, Action: "requested", SubjectKind: gp.SubjectChangeRequest, SubjectNumber: "4", Addressed: []gp.AddressVia{gp.ViaReviewRequest}}}}, + {Name: "review-requested-from-a-person", + Request: delivery("pull_request", "d15", map[string]any{"action": "review_requested", "repository": repoPayload, "sender": userSender, + "requested_reviewer": map[string]any{"login": "bob", "type": "User"}, + "pull_request": map[string]any{"number": 4, "title": "Add x", "user": userSender, + "head": map[string]any{"ref": "x", "sha": "s1", "repo": map[string]string{"full_name": "acme/web"}}, "base": map[string]any{"ref": "main"}}}), + Want: nil}, + {Name: "issue-assigned-to-the-app", + Request: delivery("issues", "d16", map[string]any{"action": "assigned", "repository": repoPayload, "sender": userSender, + "assignee": map[string]any{"login": "paca-bot[bot]", "type": "Bot"}, + "issue": map[string]any{"number": 7, "title": "Bug", "body": "It breaks", "user": userSender}}), + Want: []conformance.Want{{Kind: gp.EventAssignment, Action: "assigned", SubjectKind: gp.SubjectIssue, SubjectNumber: "7", Addressed: []gp.AddressVia{gp.ViaAssignment}}}}, + {Name: "issue-assigned-to-a-person", + Request: delivery("issues", "d17", map[string]any{"action": "assigned", "repository": repoPayload, "sender": userSender, + "assignee": map[string]any{"login": "bob", "type": "User"}, + "issue": map[string]any{"number": 7, "title": "Bug", "user": userSender}}), + Want: nil}, + {Name: "label-added", + Request: delivery("issues", "d13l", map[string]any{"action": "labeled", "repository": repoPayload, "sender": userSender, + "issue": map[string]any{"number": 7, "title": "Bug", "user": userSender}, "label": map[string]string{"name": "paca"}}), + Want: []conformance.Want{{Kind: gp.EventLabel, Action: "labeled", SubjectKind: gp.SubjectIssue, SubjectNumber: "7"}}}, + {Name: "ping-is-dropped", Request: delivery("ping", "d14", map[string]any{"zen": "Keep it logically awesome.", "sender": userSender}), Want: nil}, + {Name: "unmapped-event-is-dropped", + Request: delivery("star", "d8", map[string]any{"action": "created", "repository": repoPayload, "sender": userSender}), Want: nil}, + }, + + SetupHost: githubHost, + SetupCompleteParams: map[string]string{"code": "c0de"}, + + MutationsAllowed: true, + NewBranchHead: "feat/new", BaseBranch: "main", + File: struct{ Ref, Path string }{Ref: "main", Path: "README.md"}, + SearchQuery: "package main", + Reviewer: "reader", + + AccessGrantParams: map[string]string{"installation_id": "99"}, AccessOwner: "acme", + } +} + +// TestGitHubProviderConformance runs the SDK's provider conformance suite +// against the GitHub adapter wired to a fake GitHub. +func TestGitHubProviderConformance(t *testing.T) { + conformance.Run(t, conformance.Harness{ + New: func(t *testing.T) gp.Provider { + p, _, _ := seededProvider(t) + return p + }, + Fixtures: conformanceFixtures(), + }) +} diff --git a/backend/spi_dbstore.go b/backend/spi_dbstore.go new file mode 100644 index 0000000..829a684 --- /dev/null +++ b/backend/spi_dbstore.go @@ -0,0 +1,212 @@ +package main + +import ( + plugin "github.com/Paca-AI/plugin-sdk-go" + "strconv" + "strings" + "time" +) + +// dbStore is the production spiStore: the plugin's own tables, secrets +// encrypted with the instance key, installation tokens cached encrypted. +type dbStore struct { + p *githubPlugin + db sqlExecutor +} + +// sqlExecutor is the part of *plugin.DB the store uses; a test can stand in for it. +type sqlExecutor interface { + Query(sql string, params ...any) (*plugin.DBQueryResult, error) + Exec(sql string, params ...any) (int64, error) +} + +func newDBStore(p *githubPlugin) dbStore { return dbStore{p: p, db: p.db} } + +func (s dbStore) loadApp(id string) (appRecord, error) { + r, err := s.db.Query(`SELECT id, github_app_id, slug, name, owner_login, html_url, webhook_secret_enc, private_key_enc, webhook_active FROM github_apps WHERE id = $1`, id) + if err != nil { + return appRecord{}, err + } + if len(r.Rows) == 0 { + return appRecord{}, errNoSuchApp + } + sc := newRowScanner(r.Columns, r.Rows[0]) + pem, err := s.p.decrypt(sc.str("private_key_enc")) + if err != nil { + return appRecord{}, err + } + secret := "" + if enc := sc.str("webhook_secret_enc"); enc != "" { + if secret, err = s.p.decrypt(enc); err != nil { + return appRecord{}, err + } + } + return appRecord{ + ID: sc.str("id"), GitHubAppID: sc.int64Val("github_app_id"), Slug: sc.str("slug"), Name: sc.str("name"), + OwnerLogin: sc.str("owner_login"), HTMLURL: sc.str("html_url"), WebhookSecret: secret, PrivateKeyPEM: pem, + WebhookActive: sc.str("webhook_active") != "false", + }, nil +} + +func (s dbStore) installations(appID string) ([]installRecord, error) { + r, err := s.db.Query(`SELECT id, installation_id, account_login, account_type, repository_selection, suspended_at FROM github_app_installations WHERE app_id = $1`, appID) + if err != nil { + return nil, err + } + out := make([]installRecord, 0, len(r.Rows)) + for _, row := range r.Rows { + sc := newRowScanner(r.Columns, row) + out = append(out, installRecord{ + RowID: sc.str("id"), InstallationID: sc.int64Val("installation_id"), AccountLogin: sc.str("account_login"), + AccountType: sc.str("account_type"), Selection: sc.str("repository_selection"), Suspended: sc.strPtr("suspended_at") != nil, + }) + } + return out, nil +} + +func (s dbStore) createApp(a appRecord) (string, error) { + pem, err := s.p.encrypt(a.PrivateKeyPEM) + if err != nil { + return "", err + } + hook, err := s.p.encrypt(a.WebhookSecret) + if err != nil { + return "", err + } + now := nowStr() + r, err := s.db.Query(` + INSERT INTO github_apps (github_app_id, slug, name, owner_login, html_url, webhook_secret_enc, private_key_enc, webhook_active, created_by, created_at, updated_at) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,'git-provider',$9,$9) RETURNING id`, + a.GitHubAppID, a.Slug, a.Name, a.OwnerLogin, a.HTMLURL, hook, pem, a.WebhookActive, now) + if err != nil { + return "", err + } + if len(r.Rows) == 0 { + return "", errNoSuchApp + } + return newRowScanner(r.Columns, r.Rows[0]).str("id"), nil +} + +func (s dbStore) upsertInstallation(appID string, i installRecord) error { + var suspended any + if i.Suspended { + suspended = nowStr() + } + now := nowStr() + _, err := s.db.Exec(` + INSERT INTO github_app_installations (app_id, installation_id, account_login, account_type, repository_selection, suspended_at, created_at, updated_at) + VALUES ($1,$2,$3,$4,$5,$6,$7,$7) + ON CONFLICT (app_id, installation_id) DO UPDATE SET + account_login = EXCLUDED.account_login, account_type = EXCLUDED.account_type, + repository_selection = EXCLUDED.repository_selection, suspended_at = EXCLUDED.suspended_at, updated_at = EXCLUDED.updated_at`, + appID, i.InstallationID, i.AccountLogin, i.AccountType, i.Selection, suspended, now) + if err != nil { + return err + } + _, err = s.db.Exec(`DELETE FROM github_app_installations WHERE app_id = $1 AND lower(account_login) = lower($2) AND installation_id <> $3`, + appID, i.AccountLogin, i.InstallationID) + return err +} + +func (s dbStore) deleteApp(id string) error { + _, err := s.db.Exec(`DELETE FROM github_apps WHERE id = $1`, id) + return err +} + +func (s dbStore) deleteInstallation(appID string, installationID int64) error { + _, err := s.db.Exec(`DELETE FROM github_app_installations WHERE app_id = $1 AND installation_id = $2`, appID, installationID) + return err +} + +func (s dbStore) saveSetup(x setupSession) error { + // app_id is set for an access grant (it belongs to an existing App) and + // stays NULL for an identity setup until the manifest code is exchanged. + var appID any + if x.AppID != "" { + appID = x.AppID + } + _, err := s.db.Exec(` + INSERT INTO github_manifest_sessions (state, created_by, app_name, owner_org, scope, agent_id, app_id, expires_at, created_at) + VALUES ($1,'git-provider',$2,$3,$4,$5,$6,$7,$8)`, + x.State, x.AppName, x.OwnerOrg, x.Scope, x.AgentID, appID, x.ExpiresAt.UTC().Format(time.RFC3339Nano), nowStr()) + return err +} + +func (s dbStore) loadSetup(state string) (setupSession, error) { + r, err := s.db.Query(`SELECT state, app_name, owner_org, scope, agent_id, app_id, code_used_at, completed_at, expires_at FROM github_manifest_sessions WHERE state = $1`, state) + if err != nil { + return setupSession{}, err + } + if len(r.Rows) == 0 { + return setupSession{}, errNoSuchSetup + } + sc := newRowScanner(r.Columns, r.Rows[0]) + exp, _ := time.Parse(time.RFC3339Nano, sc.str("expires_at")) + return setupSession{ + State: sc.str("state"), Scope: sc.str("scope"), AgentID: sc.str("agent_id"), + AppName: sc.str("app_name"), OwnerOrg: sc.str("owner_org"), AppID: sc.str("app_id"), + CodeUsed: sc.strPtr("code_used_at") != nil, Completed: sc.strPtr("completed_at") != nil, ExpiresAt: exp, + }, nil +} + +func (s dbStore) updateSetup(x setupSession) error { + var appID, codeUsed, completed any + if x.AppID != "" { + appID = x.AppID + } + if x.CodeUsed { + codeUsed = nowStr() + } + if x.Completed { + completed = nowStr() + } + _, err := s.db.Exec(`UPDATE github_manifest_sessions SET app_id = $2, code_used_at = COALESCE(code_used_at, $3), completed_at = COALESCE(completed_at, $4) WHERE state = $1`, + x.State, appID, codeUsed, completed) + return err +} + +func (s dbStore) claimSetupCode(state string) (bool, error) { + n, err := s.db.Exec(`UPDATE github_manifest_sessions SET code_used_at = $2 WHERE state = $1 AND code_used_at IS NULL`, state, nowStr()) + return n == 1, err +} + +func (s dbStore) cacheGet(key string) (string, time.Time, bool) { + if s.p.cache == nil { + return "", time.Time{}, false + } + enc, hit := s.p.cache.Get(key) + if !hit || enc == "" { + return "", time.Time{}, false + } + raw, err := s.p.decrypt(enc) + if err != nil { + return "", time.Time{}, false + } + tok, ts, ok := strings.Cut(raw, "|") + unix, perr := strconv.ParseInt(ts, 10, 64) + if !ok || perr != nil { + return "", time.Time{}, false + } + return tok, time.Unix(unix, 0), true +} + +func (s dbStore) cacheSet(key, token string, expires time.Time) { + if s.p.cache == nil { + return + } + ttl := time.Until(expires) - installationTokenReuseMargin + if ttl <= 0 { + return + } + if enc, err := s.p.encrypt(token + "|" + strconv.FormatInt(expires.Unix(), 10)); err == nil { + s.p.cache.Set(key, enc, ttl) + } +} + +func (s dbStore) cacheDelete(key string) { + if s.p.cache != nil { + s.p.cache.Delete(key) + } +} + +var _ spiStore = dbStore{} diff --git a/backend/spi_dbstore_test.go b/backend/spi_dbstore_test.go new file mode 100644 index 0000000..29a349e --- /dev/null +++ b/backend/spi_dbstore_test.go @@ -0,0 +1,121 @@ +package main + +import ( + "fmt" + "regexp" + "strconv" + "strings" + "testing" + "time" + + plugin "github.com/Paca-AI/plugin-sdk-go" +) + +// fakeSQL is just enough SQL for the setup-session statements: single-row +// INSERT with a column list, and SELECT ... WHERE col = $1. A column an INSERT +// does not name reads back as NULL, so a field the store forgets to write is +// lost exactly as it would be in the real table. (The in-memory spiStore the +// provider tests use keeps every field and cannot notice that.) +type fakeSQL struct { + tables map[string][]map[string]any +} + +var ( + insertRe = regexp.MustCompile(`(?is)INSERT INTO (\w+)\s*\(([^)]*)\)\s*VALUES\s*\((.*)\)\s*$`) + selectRe = regexp.MustCompile(`(?is)SELECT (.*?) FROM (\w+) WHERE (\w+) = \$1`) +) + +func (f *fakeSQL) Exec(sql string, params ...any) (int64, error) { + m := insertRe.FindStringSubmatch(sql) + if m == nil { + return 0, fmt.Errorf("fakeSQL: unsupported statement %q", strings.TrimSpace(sql)) + } + cols := strings.Split(m[2], ",") + vals := strings.Split(m[3], ",") + if len(cols) != len(vals) { + return 0, fmt.Errorf("fakeSQL: %d columns but %d values", len(cols), len(vals)) + } + row := map[string]any{} + for i, c := range cols { + v := strings.TrimSpace(vals[i]) + switch { + case strings.HasPrefix(v, "$"): + n, err := strconv.Atoi(v[1:]) + if err != nil || n < 1 || n > len(params) { + return 0, fmt.Errorf("fakeSQL: bad parameter %s", v) + } + row[strings.TrimSpace(c)] = params[n-1] + case strings.HasPrefix(v, "'"): + row[strings.TrimSpace(c)] = strings.Trim(v, "'") + default: + return 0, fmt.Errorf("fakeSQL: unsupported value %q", v) + } + } + if f.tables == nil { + f.tables = map[string][]map[string]any{} + } + f.tables[m[1]] = append(f.tables[m[1]], row) + return 1, nil +} + +func (f *fakeSQL) Query(sql string, params ...any) (*plugin.DBQueryResult, error) { + m := selectRe.FindStringSubmatch(sql) + if m == nil { + return nil, fmt.Errorf("fakeSQL: unsupported query %q", strings.TrimSpace(sql)) + } + var cols []string + for _, c := range strings.Split(m[1], ",") { + cols = append(cols, strings.TrimSpace(c)) + } + out := &plugin.DBQueryResult{Columns: cols} + for _, row := range f.tables[m[2]] { + if fmt.Sprint(row[m[3]]) != fmt.Sprint(params[0]) { + continue + } + r := make([]any, len(cols)) + for i, c := range cols { + r[i] = row[c] + } + out.Rows = append(out.Rows, r) + } + return out, nil +} + +func newTestDBStore() dbStore { return dbStore{p: &githubPlugin{}, db: &fakeSQL{}} } + +// An access grant is bound to the App it was started for; the callback checks +// that binding, so it has to survive a round trip through the real store. +func TestDBStore_AccessSessionKeepsItsApp(t *testing.T) { + s := newTestDBStore() + in := setupSession{ + State: "st-1", Scope: accessScope, OwnerOrg: "acme", + AppID: "8d0e1a2b-0000-4000-8000-000000000001", ExpiresAt: time.Now().Add(time.Hour).Truncate(time.Millisecond), + } + if err := s.saveSetup(in); err != nil { + t.Fatal(err) + } + got, err := s.loadSetup("st-1") + if err != nil { + t.Fatal(err) + } + if got.Scope != accessScope || got.AppID != in.AppID || got.OwnerOrg != "acme" || got.Completed || got.CodeUsed { + t.Errorf("round trip lost data: %+v", got) + } + if !got.ExpiresAt.Equal(in.ExpiresAt) { + t.Errorf("expiry %v, want %v", got.ExpiresAt, in.ExpiresAt) + } +} + +func TestDBStore_IdentitySetupSessionHasNoAppYet(t *testing.T) { + s := newTestDBStore() + if err := s.saveSetup(setupSession{State: "st-2", Scope: "shared", AppName: "Paca", ExpiresAt: time.Now().Add(time.Hour)}); err != nil { + t.Fatal(err) + } + got, err := s.loadSetup("st-2") + if err != nil || got.AppID != "" || got.Scope != "shared" || got.AppName != "Paca" { + t.Errorf("%+v %v", got, err) + } + if _, err := s.loadSetup("unknown"); err != errNoSuchSetup { + t.Errorf("unknown state: %v", err) + } +} diff --git a/backend/spi_fakegithub_test.go b/backend/spi_fakegithub_test.go new file mode 100644 index 0000000..198fb73 --- /dev/null +++ b/backend/spi_fakegithub_test.go @@ -0,0 +1,627 @@ +package main + +import ( + "crypto" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/json" + "encoding/pem" + "fmt" + "net/url" + "slices" + "strconv" + "strings" + "sync" + "testing" + "time" + + plugin "github.com/Paca-AI/plugin-sdk-go" +) + +// fakeGitHub answers the subset of the GitHub API the provider uses, from +// seeded in-memory data. It enforces what GitHub enforces and what bugs in the +// provider would violate: App JWTs must be validly signed, every other call +// needs a token minted by this fake, minted tokens record their scope, and +// state-changing calls change state the reads then show. +type fakeGitHub struct { + t *testing.T + mu sync.Mutex + key *rsa.PrivateKey + + appID int64 + installationID int64 + installs map[int64]fakeInstall // installations of the App + validTokens map[string]int64 // token → installation it was minted for + mints []mintCall + calls []string + tokenSeq int + + prs map[int]*fakePR + nextNumber int + comments []fakeComment + nextID int64 + threads map[string]*fakeThread + branches map[string]string // branch → commit sha + forceError map[string]fakeResponse // "METHOD /path" → canned answer + userLookups []bool // for each GET /users/{login}: was it authenticated? +} + +type fakeInstall struct { + Login string + Type string + // Selected lists the repositories (names) a "selected" installation covers; + // nil means all of the account's repositories. + Selected []string +} + +func (in fakeInstall) selection() string { + if in.Selected != nil { + return "selected" + } + return "all" +} + +type mintCall struct { + Repositories []string `json:"repositories"` + Permissions map[string]string `json:"permissions"` + Unscoped bool +} + +type fakePR struct { + Number int + Title string + Body string + Draft bool + Merged bool + State string + HeadRef string + HeadSHA string + Labels []string + Reviewers []string + Files []string + Diff string +} + +type fakeComment struct { + ID int64 + Number int + Body string + Login string +} + +type fakeThread struct { + ID string + Resolved bool + Replies int +} + +type fakeResponse struct { + status int + body string + headers map[string]string +} + +const ( + fakeWebhookSecret = "whsec_fake_secret_value" + fakeSlug = "paca-bot" + fakeAppRowID = "app-row-1" +) + +func newFakeGitHub(t *testing.T) *fakeGitHub { + t.Helper() + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatal(err) + } + g := &fakeGitHub{ + t: t, key: key, appID: 4242, installationID: 99, validTokens: map[string]int64{}, installs: map[int64]fakeInstall{99: {"acme", "Organization", nil}, 100: {"other-org", "Organization", []string{"lib"}}}, + prs: map[int]*fakePR{}, nextNumber: 2, nextID: 9000, forceError: map[string]fakeResponse{}, + threads: map[string]*fakeThread{"PRRT_1": {ID: "PRRT_1"}}, + branches: map[string]string{"main": "sha-main", "feat/PACA-12-login": "abc123"}, + } + g.prs[1] = &fakePR{ + Number: 1, Title: "Add login", Body: "body", State: "open", HeadRef: "feat/PACA-12-login", HeadSHA: "abc123", + Files: []string{"docs/guide.md", "src/main.go", "src/util.go"}, + Diff: "diff --git a/docs/guide.md b/docs/guide.md\n--- a/docs/guide.md\n+++ b/docs/guide.md\n@@ -1 +1,2 @@\n # Guide\n+more\n" + + "diff --git a/src/main.go b/src/main.go\n--- a/src/main.go\n+++ b/src/main.go\n@@ -1,3 +1,4 @@\n package main\n+// hi\n" + + "diff --git a/src/util.go b/src/util.go\n--- a/src/util.go\n+++ b/src/util.go\n@@ -1 +1,2 @@\n package main\n+func f() {}\n", + } + g.comments = []fakeComment{{ID: 8001, Number: 1, Body: "first", Login: "octocat"}} + oldFetch := fetch + fetch = g.fetch + t.Cleanup(func() { fetch = oldFetch }) + return g +} + +func (g *fakeGitHub) pemKey() string { + return string(pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(g.key)})) +} + +func (g *fakeGitHub) called(prefix string) int { + g.mu.Lock() + defer g.mu.Unlock() + n := 0 + for _, c := range g.calls { + if strings.HasPrefix(c, prefix) { + n++ + } + } + return n +} + +func resp(status int, v any) (*plugin.FetchResponse, error) { + b, _ := json.Marshal(v) + return &plugin.FetchResponse{Status: status, Body: string(b), Headers: map[string]string{}}, nil +} + +func text(status int, s string) (*plugin.FetchResponse, error) { + return &plugin.FetchResponse{Status: status, Body: s, Headers: map[string]string{}}, nil +} + +func notFound() (*plugin.FetchResponse, error) { + return resp(404, map[string]string{"message": "Not Found"}) +} + +// verifyJWT checks an App JWT's RS256 signature, issuer and expiry. +func (g *fakeGitHub) verifyJWT(tok string) bool { + parts := strings.Split(tok, ".") + if len(parts) != 3 { + return false + } + sig, err := base64.RawURLEncoding.DecodeString(parts[2]) + if err != nil { + return false + } + digest := sha256.Sum256([]byte(parts[0] + "." + parts[1])) + if rsa.VerifyPKCS1v15(&g.key.PublicKey, crypto.SHA256, digest[:], sig) != nil { + return false + } + raw, _ := base64.RawURLEncoding.DecodeString(parts[1]) + var c struct { + Iss string `json:"iss"` + Exp int64 `json:"exp"` + } + _ = json.Unmarshal(raw, &c) + return c.Iss == strconv.FormatInt(g.appID, 10) && c.Exp > time.Now().Unix() && c.Exp <= time.Now().Add(10*time.Minute).Unix() +} + +func (g *fakeGitHub) fetch(method, rawURL string, headers map[string]string, body string) (*plugin.FetchResponse, error) { + g.mu.Lock() + defer g.mu.Unlock() + u, err := url.Parse(rawURL) + if err != nil || u.Host != "api.github.com" { + g.t.Errorf("request to a host outside the allow-list: %s", rawURL) + return nil, fmt.Errorf("blocked") + } + path := u.Path + g.calls = append(g.calls, method+" "+path) + if f, ok := g.forceError[method+" "+path]; ok { + return &plugin.FetchResponse{Status: f.status, Body: f.body, Headers: f.headers}, nil + } + auth := strings.TrimPrefix(headers["Authorization"], "Bearer ") + var payload map[string]any + if body != "" { + _ = json.Unmarshal([]byte(body), &payload) + } + + switch { + case strings.HasPrefix(path, "/app-manifests/"): + if path != "/app-manifests/c0de/conversions" { + return resp(404, map[string]string{"message": "Not Found"}) + } + return resp(201, map[string]any{"id": g.appID, "slug": fakeSlug, "name": "Paca", "html_url": "https://github.com/apps/" + fakeSlug, + "webhook_secret": fakeWebhookSecret, "pem": g.pemKey(), "owner": map[string]string{"login": "acme"}}) + + case strings.HasPrefix(path, "/app/"): + if !g.verifyJWT(auth) { + return resp(401, map[string]string{"message": "A JSON web token could not be decoded"}) + } + rest := strings.TrimPrefix(path, "/app/installations") + switch { + case method == "GET" && path == "/app/installations": + var out []map[string]any + for _, id := range []int64{99, 100, 199, 205} { + if in, ok := g.installs[id]; ok { + out = append(out, map[string]any{"id": id, "repository_selection": in.selection(), "account": map[string]string{"login": in.Login, "type": in.Type}}) + } + } + return resp(200, out) + case method == "POST" && strings.HasSuffix(rest, "/access_tokens"): + id, _ := strconv.ParseInt(strings.Trim(strings.TrimSuffix(rest, "/access_tokens"), "/"), 10, 64) + in, ok := g.installs[id] + if !ok { + return notFound() + } + var mc mintCall + _ = json.Unmarshal([]byte(body), &mc) + mc.Unscoped = len(mc.Repositories) == 0 + for _, r := range mc.Repositories { + _, owned := fakeRepos()[in.Login+"/"+r] + if in.Selected != nil && !slices.Contains(in.Selected, r) { + owned = false + } + if !owned { + return resp(422, map[string]string{"message": "There is at least one repository that does not exist or is not accessible to the parent installation."}) + } + } + g.mints = append(g.mints, mc) + g.tokenSeq++ + tok := fmt.Sprintf("ghs_token_%d", g.tokenSeq) + g.validTokens[tok] = id + return resp(201, map[string]any{"token": tok, "expires_at": time.Now().Add(time.Hour).UTC().Format(time.RFC3339)}) + case method == "GET" && strings.HasPrefix(rest, "/"): + id, _ := strconv.ParseInt(strings.TrimPrefix(rest, "/"), 10, 64) + in, ok := g.installs[id] + if !ok { + return notFound() + } + return resp(200, map[string]any{"id": id, "repository_selection": in.selection(), "account": map[string]string{"login": in.Login, "type": in.Type}}) + } + return notFound() + + case strings.HasPrefix(path, "/users/") && method == "GET": + // A public endpoint: anonymous or an installation token work; an App JWT + // is only valid on /app/* and GitHub answers 401 to it here. + if auth != "" && g.validTokens[auth] == 0 { + return resp(401, map[string]string{"message": "Bad credentials"}) + } + g.userLookups = append(g.userLookups, auth != "") + ids := map[string]int{"acme": 555, "other-org": 556} + id, ok := ids[strings.TrimPrefix(path, "/users/")] + if !ok { + return notFound() + } + return resp(200, map[string]any{"id": id, "login": strings.TrimPrefix(path, "/users/")}) + } + + if g.validTokens[auth] == 0 { + return resp(401, map[string]string{"message": "Bad credentials"}) + } + return g.repoAPI(method, path, u.Query(), headers, payload, body) +} + +func fakeRepos() map[string]map[string]any { + return map[string]map[string]any{ + "acme/web": {"id": 1, "full_name": "acme/web", "default_branch": "main", "private": true, "description": "web app"}, + "acme/api": {"id": 2, "full_name": "acme/api", "default_branch": "main", "private": false}, + "other-org/lib": {"id": 3, "full_name": "other-org/lib", "default_branch": "main", "private": false}, + // public, so any token can read it, but the installation does not cover it + "other-org/public-extra": {"id": 4, "full_name": "other-org/public-extra", "default_branch": "main", "private": false}, + } +} + +func (g *fakeGitHub) repoAPI(method, path string, q url.Values, headers map[string]string, payload map[string]any, raw string) (*plugin.FetchResponse, error) { + // A token only sees the repositories of the account its installation is on. + account := g.installs[g.validTokens[strings.TrimPrefix(headers["Authorization"], "Bearer ")]].Login + repos := map[string]map[string]any{} + for name, r := range fakeRepos() { + if strings.HasPrefix(name, account+"/") { + repos[name] = r + } + } + if path == "/installation/repositories" { + per, _ := strconv.Atoi(q.Get("per_page")) + pg, _ := strconv.Atoi(q.Get("page")) + var all []map[string]any + for _, name := range []string{"acme/api", "acme/web", "other-org/lib"} { + if r, ok := repos[name]; ok { + all = append(all, r) + } + } + start := (pg - 1) * per + if start > len(all) { + start = len(all) + } + end := start + per + if end > len(all) { + end = len(all) + } + return resp(200, map[string]any{"total_count": len(all), "repositories": all[start:end]}) + } + if path == "/graphql" { + return g.graphql(payload) + } + rest, ok := strings.CutPrefix(path, "/repos/") + if !ok { + return notFound() + } + parts := strings.SplitN(rest, "/", 3) + if len(parts) < 2 || repos[parts[0]+"/"+parts[1]] == nil { + return notFound() + } + repo := repos[parts[0]+"/"+parts[1]] + sub := "" + if len(parts) == 3 { + sub = parts[2] + } + if sub == "" { + return resp(200, repo) + } + return g.repoSub(method, sub, q, headers, payload, raw) +} + +func (g *fakeGitHub) prJSON(pr *fakePR) map[string]any { + labels := []map[string]string{} + for _, l := range pr.Labels { + labels = append(labels, map[string]string{"name": l}) + } + revs := []map[string]string{} + for _, r := range pr.Reviewers { + revs = append(revs, map[string]string{"login": r}) + } + return map[string]any{ + "node_id": fmt.Sprintf("PR_%d", pr.Number), "number": pr.Number, "title": pr.Title, "body": pr.Body, "state": pr.State, + "draft": pr.Draft, "merged": pr.Merged, "html_url": fmt.Sprintf("https://github.com/acme/web/pull/%d", pr.Number), + "user": map[string]string{"login": "octocat", "type": "User"}, "labels": labels, "requested_reviewers": revs, + "head": map[string]any{"ref": pr.HeadRef, "sha": pr.HeadSHA, "repo": map[string]string{"full_name": "acme/web"}}, + "base": map[string]any{"ref": "main", "sha": "base000", "repo": map[string]string{"full_name": "acme/web"}}, + } +} + +func str(m map[string]any, k string) string { s, _ := m[k].(string); return s } + +func (g *fakeGitHub) repoSub(method, sub string, q url.Values, headers map[string]string, payload map[string]any, raw string) (*plugin.FetchResponse, error) { + seg := strings.Split(sub, "/") + num := func(i int) (*fakePR, bool) { + if len(seg) <= i { + return nil, false + } + n, _ := strconv.Atoi(seg[i]) + pr, ok := g.prs[n] + return pr, ok + } + switch { + // ── pull requests ── + case method == "POST" && sub == "pulls": + head := str(payload, "head") + for _, pr := range g.prs { + if pr.HeadRef == head && pr.State == "open" { + return resp(422, map[string]any{"message": "Validation Failed", "errors": []map[string]string{{"message": "A pull request already exists for acme:" + head + "."}}}) + } + } + pr := &fakePR{Number: g.nextNumber, Title: str(payload, "title"), Body: str(payload, "body"), State: "open", HeadRef: head, HeadSHA: "sha-" + head} + pr.Draft, _ = payload["draft"].(bool) + g.nextNumber++ + g.prs[pr.Number] = pr + return resp(201, g.prJSON(pr)) + case seg[0] == "pulls" && len(seg) == 2 && method == "GET": + pr, ok := num(1) + if !ok { + return notFound() + } + if strings.Contains(headers["Accept"], "diff") { + return text(200, pr.Diff) + } + return resp(200, g.prJSON(pr)) + case seg[0] == "pulls" && len(seg) == 2 && method == "PATCH": + pr, ok := num(1) + if !ok { + return notFound() + } + if v := str(payload, "title"); v != "" { + pr.Title = v + } + if v := str(payload, "state"); v != "" { + pr.State = v + } + return resp(200, g.prJSON(pr)) + case len(seg) == 3 && seg[0] == "pulls" && seg[2] == "files": + pr, ok := num(1) + if !ok { + return notFound() + } + files := []map[string]any{} + for _, f := range pr.Files { + files = append(files, map[string]any{"filename": f, "status": "modified", "additions": 1, "deletions": 0}) + } + return resp(200, files) + case len(seg) == 3 && seg[0] == "pulls" && seg[2] == "reviews" && method == "POST": + pr, ok := num(1) + if !ok { + return notFound() + } + if cs, _ := payload["comments"].([]any); len(cs) > 0 { + for _, c := range cs { + path := str(c.(map[string]any), "path") + found := false + for _, f := range pr.Files { + found = found || f == path + } + if !found { + return resp(422, map[string]string{"message": "Unprocessable Entity: Pull request review thread path could not be resolved"}) + } + } + } + g.nextID++ + return resp(200, map[string]any{"id": g.nextID, "html_url": "https://github.com/acme/web/pull/1#pullrequestreview-1"}) + case len(seg) == 3 && seg[0] == "pulls" && seg[2] == "requested_reviewers" && method == "POST": + pr, ok := num(1) + if !ok { + return notFound() + } + for _, r := range payload["reviewers"].([]any) { + pr.Reviewers = append(pr.Reviewers, r.(string)) + } + return resp(201, g.prJSON(pr)) + case len(seg) == 3 && seg[0] == "pulls" && seg[2] == "merge" && method == "PUT": + pr, ok := num(1) + if !ok { + return notFound() + } + if str(payload, "sha") != pr.HeadSHA { + return resp(409, map[string]string{"message": "Head branch was modified. Review and try the merge again."}) + } + pr.Merged, pr.State = true, "closed" + return resp(200, map[string]any{"merged": true, "sha": "merge-sha"}) + + // ── branches ── + case strings.HasPrefix(sub, "branches/") && method == "GET": + name, _ := url.PathUnescape(strings.TrimPrefix(sub, "branches/")) + sha, ok := g.branches[name] + if !ok { + return resp(404, map[string]string{"message": "Branch not found"}) + } + out := map[string]any{"name": name, "commit": map[string]string{"sha": sha}, "protected": name == "main"} + if name == "main" { + out["protection"] = map[string]any{"required_status_checks": map[string]any{"strict": true}} + } + return resp(200, out) + case sub == "git/refs" && method == "POST": + name := strings.TrimPrefix(str(payload, "ref"), "refs/heads/") + if _, taken := g.branches[name]; taken { + return resp(422, map[string]string{"message": "Reference already exists"}) + } + g.branches[name] = str(payload, "sha") + return resp(201, map[string]any{"ref": str(payload, "ref")}) + + // ── issues and comments ── + case seg[0] == "issues" && len(seg) == 2 && method == "GET": + n, _ := strconv.Atoi(seg[1]) + if n != 7 && g.prs[n] == nil { + return notFound() + } + return resp(200, map[string]any{"number": n, "title": "Bug report", "body": "it broke", "state": "open", + "html_url": fmt.Sprintf("https://github.com/acme/web/issues/%d", n), "user": map[string]string{"login": "reader"}, + "labels": []map[string]string{{"name": "bug"}}, "assignees": []map[string]string{}}) + case seg[0] == "issues" && len(seg) == 2 && method == "PATCH": + return resp(200, map[string]any{"number": 7}) + case len(seg) == 3 && seg[0] == "issues" && seg[2] == "comments": + n, _ := strconv.Atoi(seg[1]) + if n != 7 && g.prs[n] == nil { + return notFound() + } + if method == "POST" { + g.nextID++ + c := fakeComment{ID: g.nextID, Number: n, Body: str(payload, "body"), Login: "paca-bot[bot]"} + g.comments = append(g.comments, c) + return resp(201, map[string]any{"id": c.ID, "html_url": fmt.Sprintf("https://github.com/acme/web/pull/%d#issuecomment-%d", n, c.ID), "body": c.Body, "user": map[string]string{"login": c.Login, "type": "Bot"}}) + } + out := []map[string]any{} + for _, c := range g.comments { + if c.Number == n { + out = append(out, map[string]any{"id": c.ID, "body": c.Body, "html_url": "https://github.com/acme/web/pull/1#c", "created_at": "2026-01-01T00:00:00Z", "user": map[string]string{"login": c.Login, "type": "User"}}) + } + } + return resp(200, out) + case len(seg) == 3 && seg[0] == "issues" && seg[2] == "labels" && method == "POST": + n, _ := strconv.Atoi(seg[1]) + pr, ok := g.prs[n] + if !ok && n != 7 { + return notFound() + } + if ok { + for _, l := range payload["labels"].([]any) { + pr.Labels = append(pr.Labels, l.(string)) + } + } + return resp(200, []any{}) + + // ── checks ── + case len(seg) == 3 && seg[0] == "commits" && seg[2] == "check-runs": + if seg[1] != "abc123" { + return resp(200, map[string]any{"check_runs": []any{}}) + } + return resp(200, map[string]any{"check_runs": []map[string]any{ + {"id": 5001, "name": "ci/test", "status": "completed", "conclusion": "failure", "html_url": "https://github.com/acme/web/runs/5001", "output": map[string]string{"title": "1 test failed"}}, + {"id": 5002, "name": "ci/lint", "status": "completed", "conclusion": "success", "html_url": "https://github.com/acme/web/runs/5002"}, + {"id": 5003, "name": "ci/e2e", "status": "in_progress"}, + }}) + case len(seg) == 3 && seg[0] == "commits" && seg[2] == "status": + return resp(200, map[string]any{"statuses": []map[string]string{{"context": "legacy/ci", "state": "success"}}}) + case sub == "actions/jobs/5001/logs": + return text(200, "setup\nrun tests\nFAIL: TestLogin (0.01s)\nexit status 1\n") + case strings.HasPrefix(sub, "actions/jobs/"): + return notFound() + case sub == "check-runs/5003": + return resp(200, map[string]any{"output": map[string]string{"title": "Running", "summary": "still going"}}) + case sub == "check-runs" && method == "POST": + g.nextID++ + return resp(201, map[string]any{"id": g.nextID}) + case len(seg) == 2 && seg[0] == "check-runs" && method == "PATCH": + return resp(200, map[string]any{"id": 1}) + + // ── policy, files ── + case len(seg) == 3 && seg[0] == "collaborators" && seg[2] == "permission": + perm := map[string]string{"octocat": "write", "reader": "read", "owner": "admin", "lead": "maintain"}[seg[1]] + if perm == "" { + return resp(404, map[string]string{"message": "Not Found"}) + } + legacy := perm + if perm == "maintain" { + legacy = "write" + } + return resp(200, map[string]string{"permission": legacy, "role_name": perm}) + case seg[0] == "contents": + if strings.Join(seg[1:], "/") != "README.md" { + return notFound() + } + return resp(200, map[string]any{"type": "file", "sha": "f1", "encoding": "base64", + "content": base64.StdEncoding.EncodeToString([]byte("# Hello Paca\nsome text\n"))}) + } + return notFound() +} + +func gqlErr(typ, msg string) (*plugin.FetchResponse, error) { + return resp(200, map[string]any{"data": nil, "errors": []map[string]string{{"type": typ, "message": msg}}}) +} + +func (g *fakeGitHub) graphql(payload map[string]any) (*plugin.FetchResponse, error) { + q := str(payload, "query") + vars, _ := payload["variables"].(map[string]any) + switch { + case strings.Contains(q, "reviewThreads"): + if n, _ := vars["number"].(float64); int(n) != 1 { + return gqlErr("NOT_FOUND", "Could not resolve to a PullRequest with the number of 999.") + } + nodes := []map[string]any{} + for _, id := range []string{"PRRT_1"} { + th := g.threads[id] + nodes = append(nodes, map[string]any{"id": th.ID, "isResolved": th.Resolved, "isOutdated": false, "path": "src/main.go", "line": 3, + "comments": map[string]any{"nodes": []map[string]any{{"databaseId": 7001, "body": "please rename", "url": "https://github.com/acme/web/pull/1#discussion_r7001", + "createdAt": "2026-01-01T00:00:00Z", "author": map[string]string{"login": "octocat", "__typename": "User"}}}}}) + } + return resp(200, map[string]any{"data": map[string]any{"repository": map[string]any{"pullRequest": map[string]any{ + "reviewThreads": map[string]any{"pageInfo": map[string]any{"hasNextPage": false, "endCursor": ""}, "nodes": nodes}}}}}) + case strings.Contains(q, "addPullRequestReviewThreadReply"): + th := g.threads[str(vars, "id")] + if th == nil { + return gqlErr("NOT_FOUND", "Could not resolve to a node with the global id of '"+str(vars, "id")+"'") + } + th.Replies++ + g.nextID++ + return resp(200, map[string]any{"data": map[string]any{"addPullRequestReviewThreadReply": map[string]any{"comment": map[string]any{"databaseId": g.nextID, "url": "https://github.com/acme/web/pull/1#discussion_r1"}}}}) + case strings.Contains(q, "resolveReviewThread") || strings.Contains(q, "unresolveReviewThread"): + th := g.threads[str(vars, "id")] + if th == nil { + return gqlErr("NOT_FOUND", "Could not resolve to a node with the global id of '"+str(vars, "id")+"'") + } + th.Resolved = strings.Contains(q, "mutation($id:ID!){ resolveReviewThread") + return resp(200, map[string]any{"data": map[string]any{}}) + case strings.Contains(q, "markPullRequestReadyForReview"), strings.Contains(q, "convertPullRequestToDraft"): + return resp(200, map[string]any{"data": map[string]any{}}) + } + return gqlErr("UNKNOWN", "unsupported query in the fake") +} + +// revokeTokens invalidates every installation token handed out so far, as +// GitHub does when an App is uninstalled (or a token is revoked). +func (g *fakeGitHub) revokeTokens() { + g.mu.Lock() + defer g.mu.Unlock() + g.validTokens = map[string]int64{} +} + +// uninstall removes an installation and revokes the tokens issued for it. +func (g *fakeGitHub) uninstall(id int64) { + g.mu.Lock() + defer g.mu.Unlock() + delete(g.installs, id) + for tok, inst := range g.validTokens { + if inst == id { + delete(g.validTokens, tok) + } + } +} diff --git a/backend/spi_provider.go b/backend/spi_provider.go new file mode 100644 index 0000000..317cc83 --- /dev/null +++ b/backend/spi_provider.go @@ -0,0 +1,510 @@ +package main + +import ( + "context" + "encoding/json" + "net/url" + "sort" + "strconv" + "strings" + "time" + + gp "github.com/Paca-AI/plugin-sdk-go/gitprovider" +) + +const ( + githubHost = "github.com" + githubCloneBase = "https://github.com/" + botCredUsername = "x-access-token" + setupSessionLife = time.Hour +) + +// ghProvider is the GitHub implementation of the Git provider SPI +// (gitprovider.v1): identities are GitHub Apps created through the manifest +// flow, credentials are installation tokens, webhooks are the app-level +// webhook core terminates. See docs/plugins/git-provider-spi.md in the Paca +// repository for the contract this implements. +type ghProvider struct { + store spiStore + now func() time.Time +} + +func newGHProvider(store spiStore) *ghProvider { + return &ghProvider{store: store, now: time.Now} +} + +// Describe implements gp.Provider. Capabilities are declared honestly: +// - review_request / assignment are declared for requests and assignments that +// name the App's own bot; GitHub's pickers list people and teams, so for an +// ordinary App a mention or a label is the dependable way to address the bot; +// - code_search, rerun_checks, reactions and webhook_management are not +// implemented (the app-level webhook is managed by core, not per repo). +func (p *ghProvider) Describe() gp.Info { + return gp.Info{ + SPIVersion: gp.SPIVersion, + ID: githubPluginID, + DisplayName: "GitHub", + Icon: "github", + Hosts: []string{githubHost}, + SelfHostedHosts: false, + Terms: gp.Terms{ChangeRequest: "Pull request", ChangeRequestPlural: "Pull requests", Repository: "Repository"}, + Capabilities: gp.Capabilities{ + IdentityKinds: []gp.IdentityKind{gp.IdentityApp}, + CredentialModel: gp.CredentialEphemeral, + WebhookScope: gp.WebhookApp, + StatusKind: gp.StatusCheckRun, + Triggers: []gp.TriggerKind{gp.TriggerMention, gp.TriggerLabel, gp.TriggerReviewRequest, gp.TriggerAssignment, gp.TriggerCIFailure, gp.TriggerOwnCRFeedback, gp.TriggerAutoReview}, + MultipleIdentities: true, + + ResolveThreads: true, + FileRead: true, + Labels: true, + ReviewerRequests: true, + Assignees: true, + Merge: true, + IdentityDeletion: true, + Branches: true, + AccessGrants: true, + + BatchReview: true, + HiddenMarkers: true, + ForkChangeRequests: true, + DraftChangeRequests: true, + NestedPaths: false, + CIConfigGlobs: []string{".github/workflows/**", ".github/actions/**"}, + }, + } +} + +// ── installations and tokens ───────────────────────────────────────────────── + +const installationTokenReuseMargin = 5 * time.Minute + +func notFoundIdentity() error { + return gp.Errorf(gp.CodeNotFound, "the GitHub identity does not exist") +} + +func (p *ghProvider) loadApp(identityID string) (appRecord, error) { + if strings.TrimSpace(identityID) == "" { + return appRecord{}, notFoundIdentity() + } + app, err := p.store.loadApp(identityID) + if err != nil { + if err == errNoSuchApp { + return appRecord{}, notFoundIdentity() + } + return appRecord{}, gp.Errorf(gp.CodeInternal, "could not load the GitHub identity") + } + return app, nil +} + +// appAPI authenticates as the App itself (a short-lived JWT). +func (p *ghProvider) appAPI(app appRecord) (*ghAPI, error) { + jwt, err := signAppJWT(app.GitHubAppID, app.PrivateKeyPEM, p.now()) + if err != nil { + return nil, gp.Errorf(gp.CodeInternal, "could not sign the GitHub App token") + } + return &ghAPI{token: jwt}, nil +} + +// installationFor finds the live installation covering owner. +func (p *ghProvider) installationFor(app appRecord, owner string) (installRecord, error) { + return p.findInstallation(app, owner, false) +} + +func (p *ghProvider) findInstallation(app appRecord, owner string, refreshed bool) (installRecord, error) { + list, err := p.store.installations(app.ID) + if err != nil { + return installRecord{}, gp.Errorf(gp.CodeInternal, "could not load installations") + } + for _, in := range list { + if strings.EqualFold(in.AccountLogin, owner) { + if in.Suspended { + return installRecord{}, gp.Errorf(gp.CodeForbidden, "the GitHub App installation is suspended") + } + return in, nil + } + } + // Not installed as far as Paca knows. The user may have installed the App on + // GitHub directly, so ask GitHub once (rate-limited) before giving up. + if !refreshed && p.refreshInstallations(app) { + return p.findInstallation(app, owner, true) + } + return installRecord{}, gp.Errorf(gp.CodeNotFound, "the identity is not installed on this account") +} + +// mintToken returns an installation token; scope, when set, narrows it to one +// repository and the given permissions. Unscoped tokens are reused from the +// cache until shortly before expiry; scoped ones are never cached. +func (p *ghProvider) mintToken(app appRecord, in installRecord, scope map[string]any) (string, time.Time, error) { + key := "inst:" + in.RowID + if scope == nil { + if tok, exp, ok := p.store.cacheGet(key); ok && exp.Sub(p.now()) > installationTokenReuseMargin { + return tok, exp, nil + } + } + api, err := p.appAPI(app) + if err != nil { + return "", time.Time{}, err + } + body := map[string]any{} + for k, v := range scope { + body[k] = v + } + var out struct { + Token string `json:"token"` + ExpiresAt time.Time `json:"expires_at"` + } + if err := api.send("POST", "/app/installations/"+strconv.FormatInt(in.InstallationID, 10)+"/access_tokens", body, &out); err != nil { + if gp.AsError(err).Code == gp.CodeNotFound { + // GitHub has no such installation: the App was uninstalled. Forget the + // dead record so the account reads as "not installed" again. + _ = p.store.deleteInstallation(app.ID, in.InstallationID) + p.store.cacheDelete(key) + return "", time.Time{}, gp.Errorf(gp.CodeNotFound, "the identity is no longer installed on this account") + } + return "", time.Time{}, err + } + if out.Token == "" { + return "", time.Time{}, gp.Errorf(gp.CodeInternal, "GitHub returned no installation token") + } + if scope == nil { + p.store.cacheSet(key, out.Token, out.ExpiresAt) + } + return out.Token, out.ExpiresAt, nil +} + +// freshToken returns a function that discards the cached installation token and +// mints a new one, for an API view whose token GitHub rejected. +func (p *ghProvider) freshToken(app appRecord, in installRecord) func() (string, error) { + return func() (string, error) { + p.store.cacheDelete("inst:" + in.RowID) + tok, _, err := p.mintToken(app, in, nil) + return tok, err + } +} + +// apiFor returns an API view acting as identityID on the account owning repo. +func (p *ghProvider) apiFor(t gp.Target) (*ghAPI, string, error) { + owner, _, ok := splitFullPath(t.Repo.FullPath) + if !ok { + return nil, "", gp.Errorf(gp.CodeValidation, "repository must be owner/name") + } + repo, err := repoPath(t.Repo.FullPath) + if err != nil { + return nil, "", err + } + app, err := p.loadApp(t.Identity.ID) + if err != nil { + return nil, "", err + } + in, err := p.installationFor(app, owner) + if err != nil { + return nil, "", err + } + tok, _, err := p.mintToken(app, in, nil) + if err != nil { + return nil, "", err + } + return &ghAPI{token: tok, refresh: p.freshToken(app, in)}, repo, nil +} + +// ── repositories ───────────────────────────────────────────────────────────── + +type ghRepoJSON struct { + ID int64 `json:"id"` + FullName string `json:"full_name"` + DefaultBranch string `json:"default_branch"` + Private bool `json:"private"` + Description string `json:"description"` +} + +func repoRefOf(r ghRepoJSON, installationID int64) gp.RepoRef { + ref, _ := json.Marshal(map[string]int64{"installation_id": installationID}) + return gp.RepoRef{ + Host: githubHost, FullPath: r.FullName, ExternalID: strconv.FormatInt(r.ID, 10), + DefaultBranch: r.DefaultBranch, CloneURL: githubCloneBase + r.FullName + ".git", ProviderRef: ref, + } +} + +// ListRepositories implements gp.Provider. The cursor is ":" +// over the identity's installations in a stable order, so paging is exact. +func (p *ghProvider) ListRepositories(_ context.Context, req gp.ListReposRequest) (gp.RepositoryPage, error) { + app, err := p.loadApp(req.Identity.ID) + if err != nil { + return gp.RepositoryPage{}, err + } + list, err := p.store.installations(app.ID) + if err != nil { + return gp.RepositoryPage{}, gp.Errorf(gp.CodeInternal, "could not load installations") + } + if len(list) == 0 && p.refreshInstallations(app) { + list, _ = p.store.installations(app.ID) // installed on GitHub, not yet known here + } + live := list[:0:0] + for _, in := range list { + if !in.Suspended { + live = append(live, in) + } + } + sort.Slice(live, func(i, j int) bool { + return strings.ToLower(live[i].AccountLogin) < strings.ToLower(live[j].AccountLogin) + }) + + per := req.PageSize + if per <= 0 || per > 100 { + per = 30 + } + idx, page := 0, 1 + if req.Cursor != "" { + a, b, ok := strings.Cut(req.Cursor, ":") + i, e1 := strconv.Atoi(a) + n, e2 := strconv.Atoi(b) + if !ok || e1 != nil || e2 != nil || i < 0 || n < 1 { + return gp.RepositoryPage{}, gp.Errorf(gp.CodeValidation, "invalid cursor") + } + idx, page = i, n + } + if idx >= len(live) { + return gp.RepositoryPage{Items: []gp.Repository{}}, nil + } + in := live[idx] + tok, _, err := p.mintToken(app, in, nil) + if err != nil { + return gp.RepositoryPage{}, err + } + var batch struct { + Repositories []ghRepoJSON `json:"repositories"` + } + if err := (&ghAPI{token: tok, refresh: p.freshToken(app, in)}).get("/installation/repositories?per_page="+strconv.Itoa(per)+"&page="+strconv.Itoa(page), &batch); err != nil { + return gp.RepositoryPage{}, err + } + out := gp.RepositoryPage{Items: []gp.Repository{}} + q := strings.ToLower(strings.TrimSpace(req.Query)) + for _, r := range batch.Repositories { + if q != "" && !strings.Contains(strings.ToLower(r.FullName), q) { + continue + } + out.Items = append(out.Items, gp.Repository{Ref: repoRefOf(r, in.InstallationID), Private: r.Private, Description: r.Description}) + } + switch { + case len(batch.Repositories) >= per: + out.NextCursor = strconv.Itoa(idx) + ":" + strconv.Itoa(page+1) + case idx+1 < len(live): + out.NextCursor = strconv.Itoa(idx+1) + ":1" + } + return out, nil +} + +// parseRepoInput accepts owner/name, https/ssh URLs and ".git" suffixes. A URL +// for another host is rejected rather than guessed at. +func parseRepoInput(in string) (owner, name string, err error) { + s := strings.TrimSpace(in) + if s == "" { + return "", "", gp.Errorf(gp.CodeValidation, "a repository is required") + } + switch { + case strings.HasPrefix(s, "git@"): + host, path, ok := strings.Cut(strings.TrimPrefix(s, "git@"), ":") + if !ok || !strings.EqualFold(host, githubHost) { + return "", "", gp.Errorf(gp.CodeValidation, "only %s repositories are supported", githubHost) + } + s = path + case strings.Contains(s, "://"): + u, perr := url.Parse(s) + if perr != nil || !strings.EqualFold(u.Hostname(), githubHost) { + return "", "", gp.Errorf(gp.CodeValidation, "only %s repositories are supported", githubHost) + } + s = u.Path + case strings.HasPrefix(strings.ToLower(s), githubHost+"/"): + s = s[len(githubHost)+1:] + } + parts := strings.Split(strings.Trim(s, "/"), "/") + if len(parts) < 2 || parts[0] == "" || parts[1] == "" { + return "", "", gp.Errorf(gp.CodeValidation, "repository must be owner/name or a GitHub URL") + } + return parts[0], strings.TrimSuffix(parts[1], ".git"), nil +} + +// ResolveRepository implements gp.Provider: the first candidate identity that +// can read the repository wins. +func (p *ghProvider) ResolveRepository(_ context.Context, req gp.ResolveRepoRequest) (gp.ResolvedRepository, error) { + if req.Host != "" && !strings.EqualFold(req.Host, githubHost) { + return gp.ResolvedRepository{}, gp.Errorf(gp.CodeValidation, "unknown host %q", req.Host) + } + owner, name, err := parseRepoInput(req.PathOrURL) + if err != nil { + return gp.ResolvedRepository{}, err + } + if len(req.Candidates) == 0 { + return gp.ResolvedRepository{}, gp.Errorf(gp.CodeForbidden, "no GitHub identity is available to look this repository up") + } + var last error + for _, c := range req.Candidates { + app, err := p.loadApp(c.ID) + if err != nil { + last = err + continue + } + in, err := p.installationFor(app, owner) + if err != nil { + last = err + continue + } + tok, _, err := p.mintToken(app, in, nil) + if err != nil { + return gp.ResolvedRepository{}, err + } + var r ghRepoJSON + if err := (&ghAPI{token: tok, refresh: p.freshToken(app, in)}).get("/repos/"+url.PathEscape(owner)+"/"+url.PathEscape(name), &r); err != nil { + if gp.AsError(err).Code == gp.CodeNotFound { + last = err + continue + } + return gp.ResolvedRepository{}, err + } + // A public repository answers any token, so being able to read it proves + // nothing. When the installation covers only selected repositories, ask + // GitHub whether this one is among them: a token scoped to it is refused + // (422) otherwise. Without this, linking succeeds and every credential + // for the repository fails later. + if !strings.EqualFold(in.Selection, "all") { + _, _, err := p.mintToken(app, in, map[string]any{ + "repositories": []string{name}, + "permissions": map[string]string{"metadata": "read"}, + }) + if err != nil { + if gp.AsError(err).Code == gp.CodeValidation { + last = gp.Errorf(gp.CodeNotFound, "the identity is installed on %s but not on this repository", owner) + continue + } + return gp.ResolvedRepository{}, err + } + } + return gp.ResolvedRepository{ + Repository: gp.Repository{Ref: repoRefOf(r, in.InstallationID), Private: r.Private, Description: r.Description}, + Identity: gp.IdentityRef{ID: app.ID, Handle: app.Slug}, + }, nil + } + if last != nil && gp.AsError(last).Code == gp.CodeForbidden { + return gp.ResolvedRepository{}, last + } + return gp.ResolvedRepository{}, gp.Errorf(gp.CodeNotFound, "no identity can see %s/%s", owner, name) +} + +// ── credentials and policy inputs ──────────────────────────────────────────── + +// MintCredential implements gp.Provider. The token is an installation token +// narrowed to the one repository and to what git needs: contents read, or +// contents write (a push). It never carries pull_requests or workflows +// permissions, so a leaked git credential cannot rewrite CI or open PRs. +func (p *ghProvider) MintCredential(_ context.Context, req gp.MintRequest) (gp.Credential, error) { + perm := "read" + switch req.Access { + case gp.AccessRead: + case gp.AccessWrite: + perm = "write" + default: + return gp.Credential{}, gp.Errorf(gp.CodeValidation, "access must be read or write") + } + owner, name, ok := splitFullPath(req.Target.Repo.FullPath) + if !ok { + return gp.Credential{}, gp.Errorf(gp.CodeValidation, "repository must be owner/name") + } + app, err := p.loadApp(req.Target.Identity.ID) + if err != nil { + return gp.Credential{}, err + } + in, err := p.installationFor(app, owner) + if err != nil { + return gp.Credential{}, err + } + tok, exp, err := p.mintToken(app, in, map[string]any{ + "repositories": []string{name}, + "permissions": map[string]string{"contents": perm, "metadata": "read"}, + }) + if err != nil { + // GitHub answers 422 when the repository is outside a "selected" installation. + if gp.AsError(err).Code == gp.CodeValidation { + return gp.Credential{}, gp.Errorf(gp.CodeNotFound, "the repository is not accessible to this identity") + } + return gp.Credential{}, err + } + // GitHub installation tokens last an hour and cannot be shortened. When the + // caller names a lifetime, ExpiresAt reports that instead: the token is + // good until then, and a caller that stops using it at ExpiresAt is never + // holding a credential longer than it asked for. + if req.TTLSeconds > 0 { + if until := time.Now().Add(time.Duration(req.TTLSeconds) * time.Second); until.Before(exp) { + exp = until + } + } + return gp.Credential{Username: botCredUsername, Secret: tok, ExpiresAt: exp}, nil +} + +// GetActorPermission implements gp.Provider. A login GitHub does not know has +// no access; that is an answer, not an error. +func (p *ghProvider) GetActorPermission(_ context.Context, req gp.ActorPermissionRequest) (gp.Permission, error) { + login := strings.TrimSpace(req.Login) + if login == "" { + return gp.PermissionNone, gp.Errorf(gp.CodeValidation, "login is required") + } + api, repo, err := p.apiFor(req.Target) + if err != nil { + return gp.PermissionNone, err + } + var out struct { + Permission string `json:"permission"` + RoleName string `json:"role_name"` + } + if err := api.get(repo+"/collaborators/"+url.PathEscape(login)+"/permission", &out); err != nil { + if gp.AsError(err).Code == gp.CodeNotFound { + return gp.PermissionNone, nil + } + return gp.PermissionNone, err + } + // role_name distinguishes maintain/triage, which "permission" folds into write/read. + for _, v := range []string{out.RoleName, out.Permission} { + switch v { + case "admin": + return gp.PermissionAdmin, nil + case "maintain": + return gp.PermissionMaintain, nil + case "write": + return gp.PermissionWrite, nil + case "triage": + return gp.PermissionTriage, nil + case "read": + return gp.PermissionRead, nil + case "none": + return gp.PermissionNone, nil + } + } + return gp.PermissionNone, nil +} + +// GetBranchProtection implements gp.Provider using the branch endpoint, which +// needs only contents:read (the dedicated protection endpoint needs the +// administration permission the app does not request). +func (p *ghProvider) GetBranchProtection(_ context.Context, req gp.BranchProtectionRequest) (gp.BranchProtection, error) { + if strings.TrimSpace(req.Branch) == "" { + return gp.BranchProtection{}, gp.Errorf(gp.CodeValidation, "branch is required") + } + api, repo, err := p.apiFor(req.Target) + if err != nil { + return gp.BranchProtection{}, err + } + var out struct { + Protected bool `json:"protected"` + Protection struct { + RequiredStatusChecks *json.RawMessage `json:"required_status_checks"` + } `json:"protection"` + } + if err := api.get(repo+"/branches/"+url.PathEscape(req.Branch), &out); err != nil { + if gp.AsError(err).Code == gp.CodeNotFound { + return gp.BranchProtection{}, nil // a branch that does not exist is not protected + } + return gp.BranchProtection{}, err + } + return gp.BranchProtection{Protected: out.Protected, RequiresStatusChecks: out.Protection.RequiredStatusChecks != nil}, nil +} diff --git a/backend/spi_reads.go b/backend/spi_reads.go new file mode 100644 index 0000000..9d11360 --- /dev/null +++ b/backend/spi_reads.go @@ -0,0 +1,577 @@ +package main + +import ( + "context" + "encoding/base64" + "net/url" + "strconv" + "strings" + "time" + "unicode/utf8" + + gp "github.com/Paca-AI/plugin-sdk-go/gitprovider" +) + +const ( + listPerPage = 100 + maxCheckPages = 5 + defaultLogBytes = 256 * 1024 + diffAccept = "application/vnd.github.v3.diff" +) + +type ghPR struct { + NodeID string `json:"node_id"` + Number int `json:"number"` + Title string `json:"title"` + Body string `json:"body"` + State string `json:"state"` + Draft bool `json:"draft"` + Merged bool `json:"merged"` + HTMLURL string `json:"html_url"` + User ghUser `json:"user"` + UpdatedAt time.Time + Head struct { + Ref string `json:"ref"` + SHA string `json:"sha"` + Repo *struct { + FullName string `json:"full_name"` + } `json:"repo"` + } `json:"head"` + Base struct { + Ref string `json:"ref"` + SHA string `json:"sha"` + Repo struct { + FullName string `json:"full_name"` + } `json:"repo"` + } `json:"base"` + Labels []struct { + Name string `json:"name"` + } `json:"labels"` + RequestedReviewers []struct { + Login string `json:"login"` + } `json:"requested_reviewers"` +} + +func (pr ghPR) toCR() gp.ChangeRequest { + cr := gp.ChangeRequest{ + Number: strconv.Itoa(pr.Number), Title: pr.Title, Body: pr.Body, Draft: pr.Draft, + Author: pr.User.Login, URL: pr.HTMLURL, UpdatedAt: pr.UpdatedAt, + Head: gp.Branch{Ref: pr.Head.Ref, SHA: pr.Head.SHA}, + Base: gp.Branch{Ref: pr.Base.Ref, SHA: pr.Base.SHA, Repo: pr.Base.Repo.FullName}, + } + if pr.Head.Repo != nil { + cr.Head.Repo = pr.Head.Repo.FullName + cr.Head.FromFork = !strings.EqualFold(pr.Head.Repo.FullName, pr.Base.Repo.FullName) + } else { + cr.Head.FromFork = true + } + switch { + case pr.Merged: + cr.State = gp.CRMerged + case pr.State == "closed": + cr.State = gp.CRClosed + default: + cr.State = gp.CROpen + } + for _, l := range pr.Labels { + cr.Labels = append(cr.Labels, l.Name) + } + for _, r := range pr.RequestedReviewers { + cr.Reviewers = append(cr.Reviewers, r.Login) + } + return cr +} + +// number validates a change-request or issue number before it goes into a URL. +func number(n string) (string, error) { + if _, err := strconv.ParseUint(n, 10, 32); err != nil { + return "", gp.Errorf(gp.CodeValidation, "%q is not a valid number", n) + } + return n, nil +} + +func page(cursor string) (int, error) { + if cursor == "" { + return 1, nil + } + n, err := strconv.Atoi(cursor) + if err != nil || n < 1 { + return 0, gp.Errorf(gp.CodeValidation, "invalid cursor") + } + return n, nil +} + +func nextPage(got, cur int) string { + if got >= listPerPage { + return strconv.Itoa(cur + 1) + } + return "" +} + +// cutHead keeps at most max bytes from the start, never splitting a rune. +func cutHead(s string, max int) (string, bool) { + if max <= 0 || len(s) <= max { + return s, false + } + for max > 0 && !utf8.RuneStart(s[max]) { + max-- + } + return s[:max], true +} + +// cutTail keeps at most max bytes from the end, never splitting a rune. +func cutTail(s string, max int) (string, bool) { + if max <= 0 || len(s) <= max { + return s, false + } + i := len(s) - max + for i < len(s) && !utf8.RuneStart(s[i]) { + i++ + } + return s[i:], true +} + +func (p *ghProvider) getPR(api *ghAPI, repo, num string) (ghPR, error) { + var pr ghPR + err := api.get(repo+"/pulls/"+num, &pr) + return pr, err +} + +// GetChangeRequest implements gp.Provider. +func (p *ghProvider) GetChangeRequest(_ context.Context, req gp.CRRequest) (gp.ChangeRequest, error) { + num, err := number(req.Number) + if err != nil { + return gp.ChangeRequest{}, err + } + api, repo, err := p.apiFor(req.Target) + if err != nil { + return gp.ChangeRequest{}, err + } + pr, err := p.getPR(api, repo, num) + if err != nil { + return gp.ChangeRequest{}, err + } + return pr.toCR(), nil +} + +// ListChangedFiles implements gp.Provider. +func (p *ghProvider) ListChangedFiles(_ context.Context, req gp.PagedCRRequest) (gp.ChangedFilePage, error) { + num, err := number(req.Number) + if err != nil { + return gp.ChangedFilePage{}, err + } + pg, err := page(req.Cursor) + if err != nil { + return gp.ChangedFilePage{}, err + } + api, repo, err := p.apiFor(req.Target) + if err != nil { + return gp.ChangedFilePage{}, err + } + var files []struct { + Filename string `json:"filename"` + PreviousFilename string `json:"previous_filename"` + Status string `json:"status"` + Additions int `json:"additions"` + Deletions int `json:"deletions"` + } + if err := api.get(repo+"/pulls/"+num+"/files?per_page="+strconv.Itoa(listPerPage)+"&page="+strconv.Itoa(pg), &files); err != nil { + return gp.ChangedFilePage{}, err + } + out := gp.ChangedFilePage{Items: []gp.ChangedFile{}, NextCursor: nextPage(len(files), pg)} + for _, f := range files { + st := f.Status + switch st { + case "copied": + st = "added" + case "changed": + st = "modified" + } + out.Items = append(out.Items, gp.ChangedFile{Path: f.Filename, OldPath: f.PreviousFilename, Status: st, Additions: f.Additions, Deletions: f.Deletions}) + } + return out, nil +} + +// GetDiff implements gp.Provider. A per-file diff is cut out of the whole +// diff; a diff longer than MaxBytes is cut and flagged, never silently. +func (p *ghProvider) GetDiff(_ context.Context, req gp.DiffRequest) (gp.Diff, error) { + num, err := number(req.Number) + if err != nil { + return gp.Diff{}, err + } + api, repo, err := p.apiFor(req.Target) + if err != nil { + return gp.Diff{}, err + } + text, err := api.do("GET", repo+"/pulls/"+num, nil, diffAccept) + if err != nil { + return gp.Diff{}, err + } + if req.Path != "" { + sec, ok := diffSection(text, req.Path) + if !ok { + return gp.Diff{}, gp.Errorf(gp.CodeNotFound, "%s is not part of this change", req.Path) + } + text = sec + } + cut, truncated := cutHead(text, req.MaxBytes) + return gp.Diff{Text: cut, Truncated: truncated}, nil +} + +// diffSection returns the part of a unified git diff that belongs to path. +func diffSection(diff, path string) (string, bool) { + const marker = "diff --git " + var sections []string + for i := 0; i < len(diff); { + next := strings.Index(diff[i+1:], "\n"+marker) + if next < 0 { + sections = append(sections, diff[i:]) + break + } + sections = append(sections, diff[i:i+1+next+1]) + i += next + 2 + } + for _, s := range sections { + head, _, _ := strings.Cut(s, "\n") + if strings.HasSuffix(head, " b/"+path) || strings.Contains(head, " a/"+path+" ") { + return s, true + } + } + return "", false +} + +const threadsQuery = `query($owner:String!,$name:String!,$number:Int!,$after:String){ + repository(owner:$owner,name:$name){ pullRequest(number:$number){ + reviewThreads(first:50, after:$after){ pageInfo{hasNextPage endCursor} + nodes{ id isResolved isOutdated path line + comments(first:50){ nodes{ databaseId body url createdAt author{ login __typename } } } } } } } }` + +type ghThreads struct { + Repository *struct { + PullRequest *struct { + ReviewThreads struct { + PageInfo struct { + HasNextPage bool `json:"hasNextPage"` + EndCursor string `json:"endCursor"` + } `json:"pageInfo"` + Nodes []struct { + ID string `json:"id"` + IsResolved bool `json:"isResolved"` + IsOutdated bool `json:"isOutdated"` + Path string `json:"path"` + Line int `json:"line"` + Comments struct { + Nodes []struct { + DatabaseID int64 `json:"databaseId"` + Body string `json:"body"` + URL string `json:"url"` + CreatedAt time.Time `json:"createdAt"` + Author *struct { + Login string `json:"login"` + Typename string `json:"__typename"` + } `json:"author"` + } `json:"nodes"` + } `json:"comments"` + } `json:"nodes"` + } `json:"reviewThreads"` + } `json:"pullRequest"` + } `json:"repository"` +} + +// ListThreads implements gp.Provider (GraphQL: REST has no thread ids or +// resolved state). The cursor is GraphQL's own end cursor. +func (p *ghProvider) ListThreads(_ context.Context, req gp.PagedCRRequest) (gp.ThreadPage, error) { + num, err := number(req.Number) + if err != nil { + return gp.ThreadPage{}, err + } + api, _, err := p.apiFor(req.Target) + if err != nil { + return gp.ThreadPage{}, err + } + owner, name, _ := splitFullPath(req.Target.Repo.FullPath) + n, _ := strconv.Atoi(num) + vars := map[string]any{"owner": owner, "name": name, "number": n} + if req.Cursor != "" { + vars["after"] = req.Cursor + } + var data ghThreads + if err := api.graphql(threadsQuery, vars, &data); err != nil { + return gp.ThreadPage{}, err + } + if data.Repository == nil || data.Repository.PullRequest == nil { + return gp.ThreadPage{}, gp.Errorf(gp.CodeNotFound, "change request not found") + } + rt := data.Repository.PullRequest.ReviewThreads + out := gp.ThreadPage{Items: []gp.Thread{}} + for _, t := range rt.Nodes { + th := gp.Thread{ID: t.ID, Path: t.Path, Line: t.Line, Resolved: t.IsResolved, Outdated: t.IsOutdated} + for _, c := range t.Comments.Nodes { + login, kind := "ghost", gp.ActorUser + if c.Author != nil { + login = c.Author.Login + if c.Author.Typename == "Bot" { + kind = gp.ActorBot + } + } + th.Comments = append(th.Comments, gp.Comment{ID: strconv.FormatInt(c.DatabaseID, 10), Author: login, AuthorKind: kind, Body: c.Body, URL: c.URL, CreatedAt: c.CreatedAt}) + } + if len(th.Comments) > 0 { + out.Items = append(out.Items, th) + } + } + if rt.PageInfo.HasNextPage { + out.NextCursor = rt.PageInfo.EndCursor + } + return out, nil +} + +type ghComment struct { + ID int64 `json:"id"` + Body string `json:"body"` + HTMLURL string `json:"html_url"` + CreatedAt time.Time `json:"created_at"` + User ghUser `json:"user"` +} + +// ListComments implements gp.Provider: the conversation comments of an issue +// or change request (review-thread comments come from ListThreads). +func (p *ghProvider) ListComments(_ context.Context, req gp.ListCommentsRequest) (gp.CommentPage, error) { + num, err := number(req.Subject.Number) + if err != nil { + return gp.CommentPage{}, err + } + pg, err := page(req.Cursor) + if err != nil { + return gp.CommentPage{}, err + } + api, repo, err := p.apiFor(req.Target) + if err != nil { + return gp.CommentPage{}, err + } + var cs []ghComment + if err := api.get(repo+"/issues/"+num+"/comments?per_page="+strconv.Itoa(listPerPage)+"&page="+strconv.Itoa(pg), &cs); err != nil { + return gp.CommentPage{}, err + } + out := gp.CommentPage{Items: []gp.Comment{}, NextCursor: nextPage(len(cs), pg)} + for _, c := range cs { + out.Items = append(out.Items, gp.Comment{ID: strconv.FormatInt(c.ID, 10), Author: c.User.Login, AuthorKind: actorOf(c.User).Kind, Body: c.Body, URL: c.HTMLURL, CreatedAt: c.CreatedAt}) + } + return out, nil +} + +// GetIssue implements gp.Provider. +func (p *ghProvider) GetIssue(_ context.Context, req gp.IssueRequest) (gp.Issue, error) { + num, err := number(req.Number) + if err != nil { + return gp.Issue{}, err + } + api, repo, err := p.apiFor(req.Target) + if err != nil { + return gp.Issue{}, err + } + var is struct { + Number int `json:"number"` + Title string `json:"title"` + Body string `json:"body"` + State string `json:"state"` + HTMLURL string `json:"html_url"` + User ghUser `json:"user"` + Labels []struct{ Name string } `json:"labels"` + Assignees []ghUser `json:"assignees"` + } + if err := api.get(repo+"/issues/"+num, &is); err != nil { + return gp.Issue{}, err + } + out := gp.Issue{Number: strconv.Itoa(is.Number), Title: is.Title, Body: is.Body, State: is.State, Author: is.User.Login, URL: is.HTMLURL} + for _, l := range is.Labels { + out.Labels = append(out.Labels, l.Name) + } + for _, a := range is.Assignees { + out.Assignees = append(out.Assignees, a.Login) + } + return out, nil +} + +func checkStateOf(status string, conclusion *string) gp.CheckState { + if status != "completed" || conclusion == nil { + return gp.CheckPending + } + switch *conclusion { + case "success", "neutral": + return gp.CheckSuccess + case "skipped": + return gp.CheckSkipped + case "cancelled": + return gp.CheckCancelled + } + return gp.CheckFailure +} + +// GetChecks implements gp.Provider: check runs (Actions and most Apps) plus +// legacy commit statuses. Ids are "run:" and "status:". +func (p *ghProvider) GetChecks(_ context.Context, req gp.ChecksRequest) ([]gp.Check, error) { + if strings.TrimSpace(req.Ref) == "" { + return nil, gp.Errorf(gp.CodeValidation, "ref is required") + } + api, repo, err := p.apiFor(req.Target) + if err != nil { + return nil, err + } + ref := url.PathEscape(req.Ref) + out := []gp.Check{} + for pg := 1; pg <= maxCheckPages; pg++ { + var runs struct { + CheckRuns []struct { + ID int64 `json:"id"` + Name string `json:"name"` + Status string `json:"status"` + Conclusion *string `json:"conclusion"` + HTMLURL string `json:"html_url"` + Output struct { + Title string `json:"title"` + Summary string `json:"summary"` + } `json:"output"` + } `json:"check_runs"` + } + if err := api.get(repo+"/commits/"+ref+"/check-runs?per_page="+strconv.Itoa(listPerPage)+"&page="+strconv.Itoa(pg), &runs); err != nil { + return nil, err + } + for _, r := range runs.CheckRuns { + sum := r.Output.Title + if sum == "" { + sum = clip(r.Output.Summary) + } + out = append(out, gp.Check{ID: "run:" + strconv.FormatInt(r.ID, 10), Name: r.Name, State: checkStateOf(r.Status, r.Conclusion), URL: r.HTMLURL, Summary: sum}) + } + if len(runs.CheckRuns) < listPerPage { + break + } + } + var combined struct { + Statuses []struct { + Context string `json:"context"` + State string `json:"state"` + Description string `json:"description"` + TargetURL string `json:"target_url"` + } `json:"statuses"` + } + if err := api.get(repo+"/commits/"+ref+"/status", &combined); err == nil { + for _, s := range combined.Statuses { + st := gp.CheckPending + switch s.State { + case "success": + st = gp.CheckSuccess + case "failure", "error": + st = gp.CheckFailure + } + out = append(out, gp.Check{ID: "status:" + s.Context, Name: s.Context, State: st, URL: s.TargetURL, Summary: s.Description}) + } + } else if gp.AsError(err).Code != gp.CodeNotFound { + return nil, err + } + return out, nil +} + +// GetCheckLogs implements gp.Provider. For a GitHub Actions job the log is the +// job log; any other check run falls back to the output its app reported. +func (p *ghProvider) GetCheckLogs(_ context.Context, req gp.CheckLogsRequest) (gp.Logs, error) { + id, ok := strings.CutPrefix(req.CheckID, "run:") + if !ok { + return gp.Logs{}, gp.Errorf(gp.CodeNotFound, "no logs for check %q", clip(req.CheckID)) + } + if _, err := strconv.ParseUint(id, 10, 63); err != nil { + return gp.Logs{}, gp.Errorf(gp.CodeNotFound, "no such check") + } + api, repo, err := p.apiFor(req.Target) + if err != nil { + return gp.Logs{}, err + } + text, err := api.do("GET", repo+"/actions/jobs/"+id+"/logs", nil, "") + if err != nil || strings.TrimSpace(text) == "" { + var run struct { + Output struct { + Title string `json:"title"` + Summary string `json:"summary"` + Text string `json:"text"` + } `json:"output"` + } + if rerr := api.get(repo+"/check-runs/"+id, &run); rerr != nil { + return gp.Logs{}, rerr + } + text = strings.TrimSpace(strings.Join(nonEmpty(run.Output.Title, run.Output.Summary, run.Output.Text), "\n\n")) + if text == "" { + return gp.Logs{}, gp.Errorf(gp.CodeNotFound, "this check reported no log output") + } + } + limit := req.TailBytes + if limit <= 0 { + limit = defaultLogBytes + } + cut, truncated := cutTail(text, limit) + return gp.Logs{Text: cut, Truncated: truncated}, nil +} + +func nonEmpty(in ...string) []string { + var out []string + for _, s := range in { + if strings.TrimSpace(s) != "" { + out = append(out, s) + } + } + return out +} + +// GetFile implements gp.FileReader. +func (p *ghProvider) GetFile(_ context.Context, req gp.FileRequest) (gp.File, error) { + path := strings.Trim(req.Path, "/") + if path == "" || strings.Contains(path, "..") { + return gp.File{}, gp.Errorf(gp.CodeValidation, "a file path is required") + } + api, repo, err := p.apiFor(req.Target) + if err != nil { + return gp.File{}, err + } + segs := strings.Split(path, "/") + for i := range segs { + segs[i] = url.PathEscape(segs[i]) + } + u := repo + "/contents/" + strings.Join(segs, "/") + if req.Ref != "" { + u += "?ref=" + url.QueryEscape(req.Ref) + } + text, err := api.do("GET", u, nil, "") + if err != nil { + return gp.File{}, err + } + if strings.HasPrefix(strings.TrimSpace(text), "[") { + return gp.File{}, gp.Errorf(gp.CodeValidation, "%s is a directory", path) + } + var f struct { + SHA string `json:"sha"` + Encoding string `json:"encoding"` + Content string `json:"content"` + } + if err := decodeInto(text, &f); err != nil { + return gp.File{}, err + } + var content []byte + if f.Encoding == "base64" { + b, err := base64.StdEncoding.DecodeString(strings.NewReplacer("\n", "", "\r", "").Replace(f.Content)) + if err != nil { + return gp.File{}, gp.Errorf(gp.CodeInternal, "GitHub returned an unreadable file") + } + content = b + } else { // large files come back without inline content: fetch the raw bytes + raw, err := api.do("GET", u, nil, "application/vnd.github.raw+json") + if err != nil { + return gp.File{}, err + } + content = []byte(raw) + } + out := gp.File{Path: path, SHA: f.SHA, Content: content} + if req.MaxBytes > 0 && len(content) > req.MaxBytes { + out.Content, out.Truncated = content[:req.MaxBytes], true + } + return out, nil +} diff --git a/backend/spi_setup.go b/backend/spi_setup.go new file mode 100644 index 0000000..0b0cc71 --- /dev/null +++ b/backend/spi_setup.go @@ -0,0 +1,195 @@ +package main + +import ( + "context" + "encoding/json" + "net/url" + "strings" + + gp "github.com/Paca-AI/plugin-sdk-go/gitprovider" +) + +// Identity setup is the GitHub App manifest flow: Begin → a form post to GitHub +// with the manifest; GitHub redirects back to the Paca page core names in +// CallbackURL with ?code&state; Complete(code) exchanges the code for the App's +// credentials. The App is not installed here: installation is per account and +// happens when a project links a repository there (spi_access.go). +// +// The webhook of every App points at the URL core gives in WebhookURL, so +// deliveries reach core (not this plugin) and are handed back for verification. + +// BeginIdentitySetup implements gp.Provider. +func (p *ghProvider) BeginIdentitySetup(_ context.Context, req gp.BeginSetupRequest) (gp.SetupStep, error) { + if !strings.EqualFold(req.Host, githubHost) { + return gp.SetupStep{}, gp.Errorf(gp.CodeValidation, "unknown host %q: only %s is supported", req.Host, githubHost) + } + if req.Scope != gp.SetupShared && req.Scope != gp.SetupAgent { + return gp.SetupStep{}, gp.Errorf(gp.CodeValidation, "scope must be shared or agent") + } + if req.CallbackURL == "" || req.WebhookURL == "" { + return gp.SetupStep{}, gp.Errorf(gp.CodeValidation, "callback_url and webhook_url are required") + } + name := strings.TrimSpace(req.Params["name"]) + if name == "" { + name = "Paca" + if req.Scope == gp.SetupAgent && strings.TrimSpace(req.AgentName) != "" { + name = "Paca " + strings.TrimSpace(req.AgentName) + } + } + if len(name) > githubAppNameMaxLen { + return gp.SetupStep{}, gp.Errorf(gp.CodeValidation, "the app name must be at most %d characters", githubAppNameMaxLen) + } + org := strings.TrimSpace(req.Params["owner_org"]) + if org != "" && !githubLoginRe.MatchString(org) { + return gp.SetupStep{}, gp.Errorf(gp.CodeValidation, "owner_org is not a valid GitHub organization name") + } + home, err := originOf(req.CallbackURL) + if err != nil { + return gp.SetupStep{}, gp.Errorf(gp.CodeValidation, "callback_url is not a valid URL") + } + + state, err := randomHex(24) + if err != nil { + return gp.SetupStep{}, gp.Errorf(gp.CodeInternal, "could not generate state") + } + if err := p.store.saveSetup(setupSession{ + State: state, Scope: string(req.Scope), AgentID: req.AgentID, + AppName: name, OwnerOrg: org, ExpiresAt: p.now().Add(setupSessionLife), + }); err != nil { + return gp.SetupStep{}, gp.Errorf(gp.CodeInternal, "could not start setup") + } + + manifest := buildSPIManifest(name, home, req.WebhookURL, req.CallbackURL, !isLocalURL(req.WebhookURL), req.Params["public"] == "true") + mj, _ := json.Marshal(manifest) + post := "https://github.com/settings/apps/new" + if org != "" { + post = "https://github.com/organizations/" + org + "/settings/apps/new" + } + return gp.SetupStep{ + Kind: gp.StepFormPost, + URL: post + "?state=" + url.QueryEscape(state), + Fields: map[string]string{"manifest": string(mj)}, + State: state, + }, nil +} + +func originOf(raw string) (string, error) { + u, err := url.Parse(raw) + if err != nil || u.Scheme == "" || u.Host == "" { + return "", errBadURL + } + return u.Scheme + "://" + u.Host, nil +} + +type badURL struct{} + +func (badURL) Error() string { return "invalid url" } + +var errBadURL = badURL{} + +// buildSPIManifest is the App manifest for the SPI flow. The webhook goes to +// core; both redirects return to the same Paca page. +func buildSPIManifest(name, home, hookURL, callbackURL string, hookActive, public bool) map[string]any { + return map[string]any{ + "name": name, + "url": home, + "hook_attributes": map[string]any{"url": hookURL, "active": hookActive}, + "redirect_url": callbackURL, + "setup_url": callbackURL, + "setup_on_update": true, + "public": public, + "default_permissions": manifestPermissions, + "default_events": manifestEvents, + } +} + +// CompleteIdentitySetup implements gp.Provider. +func (p *ghProvider) CompleteIdentitySetup(_ context.Context, req gp.CompleteSetupRequest) (gp.Identity, error) { + code := strings.TrimSpace(req.Params["code"]) + if strings.TrimSpace(req.State) == "" || code == "" { + return gp.Identity{}, gp.Errorf(gp.CodeValidation, "state and code are required") + } + s, err := p.store.loadSetup(req.State) + if err != nil || s.Scope == accessScope || s.Completed || !p.now().Before(s.ExpiresAt) { + return gp.Identity{}, gp.Errorf(gp.CodeForbidden, "unknown or expired setup state") + } + return p.completeCreated(s, code) +} + +func identityOf(app appRecord) gp.Identity { + pr, _ := json.Marshal(map[string]any{"github_app_id": app.GitHubAppID, "slug": app.Slug}) + return gp.Identity{ + Ref: gp.IdentityRef{ID: app.ID, Handle: app.Slug, ProviderRef: pr}, + Host: githubHost, + Kind: gp.IdentityApp, + DisplayName: app.Name, + } +} + +// completeCreated is leg 1: manifest code → the App's credentials. +func (p *ghProvider) completeCreated(s setupSession, code string) (gp.Identity, error) { + // Claim the code before exchanging it: GitHub's codes are single-use and + // the response is the only time the private key is ever shown. The claim is + // atomic, so concurrent completions cannot both get past it. + claimed, err := p.store.claimSetupCode(s.State) + if err != nil { + return gp.Identity{}, gp.Errorf(gp.CodeInternal, "could not record setup progress") + } + if !claimed { + return gp.Identity{}, gp.Errorf(gp.CodeForbidden, "this setup code was already used") + } + s.CodeUsed = true + conv, err := (&ghAPI{}).convertManifest(code) + if err != nil { + return gp.Identity{}, err + } + app := appRecord{ + GitHubAppID: conv.ID, Slug: conv.Slug, Name: conv.Name, OwnerLogin: conv.Owner.Login, HTMLURL: conv.HTMLURL, + WebhookSecret: conv.WebhookSecret, PrivateKeyPEM: conv.PEM, WebhookActive: true, + } + id, err := p.store.createApp(app) + if err != nil { + return gp.Identity{}, gp.Errorf(gp.CodeInternal, "could not store the GitHub App") + } + app.ID = id + s.AppID = id + if err := p.store.updateSetup(s); err != nil { + return gp.Identity{}, gp.Errorf(gp.CodeInternal, "could not record setup progress") + } + // The App is created; it is installed later, per account, when a project + // links a repository there (see spi_access.go). + ident := identityOf(app) + s.Completed = true + if err := p.store.updateSetup(s); err != nil { + return gp.Identity{}, gp.Errorf(gp.CodeInternal, "could not record setup progress") + } + return ident, nil +} + +// convertManifest exchanges the manifest code (unauthenticated: the code is the credential). +func (a *ghAPI) convertManifest(code string) (*ghManifestConversion, error) { + var out ghManifestConversion + if err := a.send("POST", "/app-manifests/"+url.PathEscape(code)+"/conversions", map[string]any{}, &out); err != nil { + return nil, err + } + if out.ID == 0 || out.PEM == "" { + return nil, gp.Errorf(gp.CodeInternal, "GitHub returned an incomplete App") + } + return &out, nil +} + +// DeleteIdentity implements gp.IdentityDeleter: forget the App locally. The +// App itself stays on GitHub (there is no API to delete it); the operator +// removes it there. +func (p *ghProvider) DeleteIdentity(_ context.Context, req gp.DeleteIdentityRequest) (gp.Empty, error) { + if _, err := p.loadApp(req.Identity.ID); err != nil { + if gp.AsError(err).Code == gp.CodeNotFound { + return gp.Empty{}, nil // already gone + } + return gp.Empty{}, err + } + if err := p.store.deleteApp(req.Identity.ID); err != nil { + return gp.Empty{}, gp.Errorf(gp.CodeInternal, "could not delete the identity") + } + return gp.Empty{}, nil +} diff --git a/backend/spi_store.go b/backend/spi_store.go new file mode 100644 index 0000000..e070240 --- /dev/null +++ b/backend/spi_store.go @@ -0,0 +1,74 @@ +package main + +import ( + "errors" + "time" +) + +// spiStore is what the Git provider needs persisted. The production +// implementation (dbStore) is backed by the plugin's tables; tests use an +// in-memory one. Secrets are decrypted by the store, never by callers. +type spiStore interface { + loadApp(id string) (appRecord, error) + installations(appID string) ([]installRecord, error) + createApp(a appRecord) (id string, err error) + // upsertInstallation records an installation. An account has at most one + // installation of an App, and GitHub issues a new id when the App is + // uninstalled and installed again, so any other recorded installation of the + // same App on the same account is dead and is removed. + upsertInstallation(appID string, i installRecord) error + deleteApp(id string) error + // deleteInstallation forgets an installation GitHub no longer has. + deleteInstallation(appID string, installationID int64) error + + saveSetup(s setupSession) error + loadSetup(state string) (setupSession, error) + updateSetup(s setupSession) error + // claimSetupCode marks the setup's manifest code as used and reports + // whether this call was the one that did, so two concurrent completions + // cannot both exchange the same code. + claimSetupCode(state string) (bool, error) + + cacheGet(key string) (token string, expires time.Time, ok bool) + cacheSet(key, token string, expires time.Time) + cacheDelete(key string) +} + +var errNoSuchApp = errors.New("github app not found") +var errNoSuchSetup = errors.New("setup session not found") + +// appRecord is a GitHub App the instance created through the manifest flow. +type appRecord struct { + ID string // row id; the SPI IdentityRef.ID + GitHubAppID int64 + Slug string + Name string + OwnerLogin string + HTMLURL string + WebhookSecret string + PrivateKeyPEM string + WebhookActive bool +} + +// installRecord is an installation of an App on a GitHub account. +type installRecord struct { + RowID string + InstallationID int64 + AccountLogin string + AccountType string + Selection string + Suspended bool +} + +// setupSession is one manifest-flow attempt, keyed by its single-use state. +type setupSession struct { + State string + Scope string // shared | agent + AgentID string + AppName string + OwnerOrg string + AppID string // set once the manifest code has been exchanged + CodeUsed bool + Completed bool + ExpiresAt time.Time +} diff --git a/backend/spi_test.go b/backend/spi_test.go new file mode 100644 index 0000000..29e3866 --- /dev/null +++ b/backend/spi_test.go @@ -0,0 +1,786 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + plugin "github.com/Paca-AI/plugin-sdk-go" + "strings" + "testing" + "time" + "unicode/utf8" + + gp "github.com/Paca-AI/plugin-sdk-go/gitprovider" + "github.com/Paca-AI/plugin-sdk-go/plugintest" +) + +var bg = context.Background() + +func codeOf(t *testing.T, err error) gp.ErrorCode { + t.Helper() + if err == nil { + t.Fatal("expected an error") + } + var pe *gp.Error + if !errors.As(err, &pe) { + t.Fatalf("not a *gitprovider.Error: %T %v", err, err) + } + return pe.Code +} + +func webTarget() gp.Target { + return gp.Target{Identity: gp.IdentityRef{ID: fakeAppRowID}, Repo: gp.RepoRef{Host: githubHost, FullPath: "acme/web"}} +} + +func TestDescribe_DeclaresWhatGitHubCanDo(t *testing.T) { + p, _, _ := seededProvider(t) + c := p.Describe().Capabilities + for _, trig := range []gp.TriggerKind{gp.TriggerMention, gp.TriggerLabel, gp.TriggerReviewRequest, gp.TriggerAssignment} { + if !c.SupportsTrigger(trig) { + t.Errorf("%s should be declared", trig) + } + } + if c.CredentialModel != gp.CredentialEphemeral || c.WebhookScope != gp.WebhookApp || c.StatusKind != gp.StatusCheckRun { + t.Errorf("unexpected model: %+v", c) + } + if !strings.Contains(strings.Join(c.CIConfigGlobs, ","), ".github/workflows/**") { + t.Errorf("CI config globs = %v", c.CIConfigGlobs) + } + if c.CodeSearch || c.RerunChecks || c.Reactions || c.WebhookManagement { + t.Errorf("operations that are not implemented must not be declared: %+v", c) + } +} + +func TestMintCredential_IsNarrowedToOneRepoAndWhatGitNeeds(t *testing.T) { + p, g, _ := seededProvider(t) + for access, want := range map[gp.Access]string{gp.AccessRead: "read", gp.AccessWrite: "write"} { + cred, err := p.MintCredential(bg, gp.MintRequest{Target: webTarget(), Access: access}) + if err != nil { + t.Fatal(err) + } + last := g.mints[len(g.mints)-1] + if len(last.Repositories) != 1 || last.Repositories[0] != "web" { + t.Errorf("%s: token must be limited to the one repository, got %v", access, last.Repositories) + } + if last.Permissions["contents"] != want || last.Permissions["metadata"] != "read" || len(last.Permissions) != 2 { + t.Errorf("%s: permissions = %v, want exactly contents:%s + metadata:read (no pull_requests, workflows, ...)", access, last.Permissions, want) + } + if cred.Username != "x-access-token" || !strings.HasPrefix(cred.Secret, "ghs_token_") || time.Until(cred.ExpiresAt) < 30*time.Minute { + t.Errorf("credential = %+v", cred) + } + } + if len(g.mints) != 2 { + t.Fatalf("scoped tokens are never reused: %d mints for 2 requests", len(g.mints)) + } +} + +func TestMintCredential_RepositoryOutsideTheInstallation(t *testing.T) { + p, _, _ := seededProvider(t) + tg := webTarget() + tg.Repo.FullPath = "acme/not-selected" // GitHub answers 422 for it + _, err := p.MintCredential(bg, gp.MintRequest{Target: tg, Access: gp.AccessRead}) + if codeOf(t, err) != gp.CodeNotFound { + t.Fatalf("got %v, want not_found", err) + } +} + +func TestInstallationTokenIsCachedUntilNearExpiry(t *testing.T) { + p, g, st := seededProvider(t) + for i := 0; i < 3; i++ { + if _, err := p.GetChangeRequest(bg, gp.CRRequest{Target: webTarget(), Number: "1"}); err != nil { + t.Fatal(err) + } + } + if len(g.mints) != 1 { + t.Fatalf("three API calls should share one installation token, minted %d", len(g.mints)) + } + // A token about to expire is not served from the cache. + st.cacheSet("inst:inst-1", "stale", time.Now().Add(time.Minute)) + if _, err := p.GetChangeRequest(bg, gp.CRRequest{Target: webTarget(), Number: "1"}); err != nil { + t.Fatal(err) + } + if len(g.mints) != 2 { + t.Fatalf("a near-expiry token must be replaced: %d mints", len(g.mints)) + } +} + +func TestSuspendedInstallationIsRefused(t *testing.T) { + p, g, st := seededProvider(t) + st.installs[fakeAppRowID][0].Suspended = true + _, err := p.GetChangeRequest(bg, gp.CRRequest{Target: webTarget(), Number: "1"}) + if codeOf(t, err) != gp.CodeForbidden || len(g.mints) != 0 { + t.Fatalf("suspended: %v mints=%d", err, len(g.mints)) + } + page, err := p.ListRepositories(bg, gp.ListReposRequest{Identity: gp.IdentityRef{ID: fakeAppRowID}}) + if err != nil || len(page.Items) != 0 { + t.Fatalf("a suspended installation lists nothing: %+v %v", page, err) + } +} + +func TestErrorsAreClassifiedAndSafe(t *testing.T) { + p, g, _ := seededProvider(t) + pr := "GET /repos/acme/web/pulls/1" + cases := []struct { + name string + resp fakeResponse + want gp.ErrorCode + }{ + {"rate limit via 403 message", fakeResponse{403, `{"message":"API rate limit exceeded for installation"}`, map[string]string{"Retry-After": "42"}}, gp.CodeRateLimited}, + {"secondary rate limit", fakeResponse{403, `{"message":"You have exceeded a secondary rate limit"}`, nil}, gp.CodeRateLimited}, + {"429", fakeResponse{429, `{}`, nil}, gp.CodeRateLimited}, + {"forbidden", fakeResponse{403, `{"message":"Resource not accessible by integration"}`, nil}, gp.CodeForbidden}, + {"bad credentials", fakeResponse{401, `{"message":"Bad credentials"}`, nil}, gp.CodeForbidden}, + {"server error", fakeResponse{502, `oops`, nil}, gp.CodeTransient}, + {"validation", fakeResponse{422, `{"message":"Validation Failed"}`, nil}, gp.CodeValidation}, + {"conflict", fakeResponse{409, `{"message":"conflict"}`, nil}, gp.CodeConflict}, + {"unreadable success body", fakeResponse{200, `not json`, nil}, gp.CodeInternal}, + } + for _, c := range cases { + g.forceError[pr] = c.resp + _, err := p.GetChangeRequest(bg, gp.CRRequest{Target: webTarget(), Number: "1"}) + if codeOf(t, err) != c.want { + t.Errorf("%s: got %v, want %s", c.name, err, c.want) + } + for _, secret := range []string{"ghs_token_", "BEGIN RSA", fakeWebhookSecret} { + if err != nil && strings.Contains(err.Error(), secret) { + t.Errorf("%s: error leaks %q: %v", c.name, secret, err) + } + } + } + g.forceError[pr] = fakeResponse{403, `{"message":"API rate limit exceeded"}`, map[string]string{"Retry-After": "42"}} + _, err := p.GetChangeRequest(bg, gp.CRRequest{Target: webTarget(), Number: "1"}) + if gp.AsError(err).RetryAfterSeconds != 42 { + t.Errorf("retry-after = %d, want 42", gp.AsError(err).RetryAfterSeconds) + } +} + +func TestNetworkFailureIsTransient(t *testing.T) { + p, _, _ := seededProvider(t) + old := fetch + defer func() { fetch = old }() + fetch = func(string, string, map[string]string, string) (*plugin.FetchResponse, error) { + return nil, errors.New("dial tcp: boom") + } + _, err := p.GetChangeRequest(bg, gp.CRRequest{Target: webTarget(), Number: "1"}) + if codeOf(t, err) != gp.CodeTransient { + t.Fatalf("got %v", err) + } +} + +func TestNumbersAreValidatedBeforeTheyReachAURL(t *testing.T) { + p, g, _ := seededProvider(t) + for _, n := range []string{"", "1/../../x", "abc", "-1", "1?x=y"} { + if _, err := p.GetChangeRequest(bg, gp.CRRequest{Target: webTarget(), Number: n}); codeOf(t, err) != gp.CodeValidation { + t.Errorf("number %q: %v", n, err) + } + } + bad := webTarget() + bad.Repo.FullPath = "acme/web/../../admin" + if _, err := p.GetChangeRequest(bg, gp.CRRequest{Target: bad, Number: "1"}); codeOf(t, err) != gp.CodeValidation { + t.Error("a repository path with extra segments must be rejected") + } + if g.called("GET /repos") != 0 { + t.Error("nothing should have reached GitHub") + } +} + +func TestParseRepoInput(t *testing.T) { + ok := map[string][2]string{ + "acme/web": {"acme", "web"}, + "https://github.com/acme/web": {"acme", "web"}, + "https://github.com/acme/web.git": {"acme", "web"}, + "https://github.com/acme/web/": {"acme", "web"}, + "https://github.com/acme/web/pull/3": {"acme", "web"}, + "github.com/acme/web": {"acme", "web"}, + "git@github.com:acme/web.git": {"acme", "web"}, + " acme/web ": {"acme", "web"}, + } + for in, want := range ok { + o, n, err := parseRepoInput(in) + if err != nil || o != want[0] || n != want[1] { + t.Errorf("%q → %s/%s %v", in, o, n, err) + } + } + for _, in := range []string{"", "web", "https://gitlab.com/acme/web", "git@gitlab.com:acme/web.git", "/"} { + if _, _, err := parseRepoInput(in); codeOf(t, err) != gp.CodeValidation { + t.Errorf("%q should be a validation error, got %v", in, err) + } + } +} + +func TestListRepositoriesRejectsABadCursor(t *testing.T) { + p, _, _ := seededProvider(t) + for _, c := range []string{"x", "1", "a:b", "-1:1", "0:0"} { + if _, err := p.ListRepositories(bg, gp.ListReposRequest{Identity: gp.IdentityRef{ID: fakeAppRowID}, Cursor: c}); codeOf(t, err) != gp.CodeValidation { + t.Errorf("cursor %q: %v", c, err) + } + } +} + +func TestMentionsHandle(t *testing.T) { + yes := []string{"@paca-bot", "hey @paca-bot, fix", "@Paca-Bot please", "(@paca-bot)", "line\n@paca-bot", "@paca-bot[bot] hi", "thanks @paca-bot."} + no := []string{"", "paca-bot", "dev@paca-bot.example", "@paca-bot-2 hi", "@paca-botty", "x@paca-bot", "@@paca-bot", "@paca"} + for _, s := range yes { + if !mentionsHandle(s, "paca-bot") { + t.Errorf("%q should mention", s) + } + } + for _, s := range no { + if mentionsHandle(s, "paca-bot") { + t.Errorf("%q should not mention", s) + } + } + if mentionsHandle("@anything", "") { + t.Error("an empty handle mentions nobody") + } +} + +func TestCuttingKeepsUTF8Valid(t *testing.T) { + s := "héllo wörld ✓ done" + for max := 1; max < len(s); max++ { + h, ht := cutHead(s, max) + tl, tt := cutTail(s, max) + if !utf8.ValidString(h) || !utf8.ValidString(tl) || len(h) > max || len(tl) > max || !ht || !tt { + t.Fatalf("max=%d head=%q tail=%q", max, h, tl) + } + } + if h, tr := cutHead(s, len(s)); h != s || tr { + t.Error("no cut needed") + } + if h, tr := cutHead(s, 0); h != s || tr { + t.Error("0 means no limit") + } +} + +func TestDiffSection(t *testing.T) { + diff := "diff --git a/a.txt b/a.txt\n+one\ndiff --git a/dir/b.txt b/dir/b.txt\n+two\ndiff --git a/old.txt b/new.txt\nrename\n" + if s, ok := diffSection(diff, "dir/b.txt"); !ok || !strings.Contains(s, "+two") || strings.Contains(s, "+one") || strings.Contains(s, "rename") { + t.Errorf("dir/b.txt: %q %v", s, ok) + } + if s, ok := diffSection(diff, "new.txt"); !ok || !strings.Contains(s, "rename") { + t.Errorf("renamed file: %q %v", s, ok) + } + if _, ok := diffSection(diff, "b.txt"); ok { + t.Error("a suffix of another path must not match") + } +} + +// ── webhooks ───────────────────────────────────────────────────────────────── + +func parseOne(t *testing.T, p *ghProvider, req gp.WebhookRequest) gp.Event { + t.Helper() + evs, err := p.VerifyAndParseWebhook(bg, req) + if err != nil || len(evs) != 1 { + t.Fatalf("events=%d err=%v", len(evs), err) + } + return evs[0] +} + +func TestWebhook_Details(t *testing.T) { + p, _, _ := seededProvider(t) + pull := func(action string, extra map[string]any) gp.WebhookRequest { + pr := map[string]any{"number": 5, "title": "T", "body": "B", "user": userSender, "draft": true, + "head": map[string]any{"ref": "fork-branch", "sha": "s", "repo": map[string]string{"full_name": "someone/web"}}, "base": map[string]any{"ref": "main"}} + for k, v := range extra { + pr[k] = v + } + return delivery("pull_request", "dx-"+action, map[string]any{"action": action, "repository": repoPayload, "sender": userSender, "pull_request": pr, "label": map[string]string{"name": "paca"}}) + } + + ev := parseOne(t, p, pull("opened", nil)) + if ev.Text != "T" || !ev.Subject.Draft || !ev.Subject.Head.FromFork || ev.Subject.Head.Repo != "someone/web" || ev.Subject.Base != "main" || ev.Subject.Author != "octocat" { + t.Errorf("opened: %+v subject=%+v head=%+v", ev, ev.Subject, ev.Subject.Head) + } + if got := parseOne(t, p, pull("closed", map[string]any{"merged": true})); got.Action != "merged" { + t.Errorf("a merged close is a merge: %q", got.Action) + } + if got := parseOne(t, p, pull("closed", nil)); got.Action != "closed" { + t.Errorf("closed: %q", got.Action) + } + if got := parseOne(t, p, pull("synchronize", nil)); got.Action != "synchronized" { + t.Errorf("synchronize: %q", got.Action) + } + if got := parseOne(t, p, pull("ready_for_review", nil)); got.Action != "ready" { + t.Errorf("ready: %q", got.Action) + } + if got := parseOne(t, p, pull("labeled", nil)); got.Kind != gp.EventLabel || got.Label != "paca" { + t.Errorf("labeled: %+v", got) + } + // Requests and assignments that do not name this app are dropped. + for _, a := range []string{"unlabeled", "assigned", "review_requested", "locked"} { + if evs, _ := p.VerifyAndParseWebhook(bg, pull(a, nil)); len(evs) != 0 { + t.Errorf("%s should be dropped", a) + } + } + // A pull request whose head repository was deleted can only have come from a fork. + deleted := pull("opened", map[string]any{"head": map[string]any{"ref": "x", "sha": "s", "repo": nil}}) + if got := parseOne(t, p, deleted); !got.Subject.Head.FromFork { + t.Error("deleted head repo must count as a fork") + } + + // Edits do not re-address the bot, so editing a comment cannot re-trigger a run. + edited := delivery("issue_comment", "de", map[string]any{"action": "edited", "repository": repoPayload, "sender": userSender, + "issue": map[string]any{"number": 1}, "comment": map[string]any{"id": 1, "body": "@paca-bot again"}}) + if got := parseOne(t, p, edited); got.Action != "edited" || len(got.Addressed) != 0 { + t.Errorf("edited: %+v", got) + } + // An issue body is the first message: opening an issue that mentions the bot addresses it. + issue := delivery("issues", "di", map[string]any{"action": "opened", "repository": repoPayload, "sender": userSender, + "issue": map[string]any{"number": 9, "title": "Crash", "body": "@paca-bot please look", "user": userSender}}) + if got := parseOne(t, p, issue); got.Kind != gp.EventComment || got.Action != "created" || got.Subject.Kind != gp.SubjectIssue || len(got.Addressed) != 1 || !strings.Contains(got.Text, "Crash") { + t.Errorf("issue opened: %+v", got) + } + // Check conclusions are normalized. + for in, want := range map[string]string{"success": "success", "neutral": "success", "skipped": "skipped", "cancelled": "cancelled", "timed_out": "failure", "action_required": "failure"} { + chk := delivery("check_run", "dc-"+in, map[string]any{"action": "completed", "repository": repoPayload, "sender": userSender, + "check_run": map[string]any{"id": 1, "name": "n", "head_sha": "s", "conclusion": in}}) + if got := parseOne(t, p, chk); got.Check.Conclusion != want || got.Check.RunID != "1" { + t.Errorf("conclusion %s → %+v", in, got.Check) + } + } + // An incomplete check run is not a result. + running := delivery("check_run", "dr", map[string]any{"action": "completed", "repository": repoPayload, "sender": userSender, "check_run": map[string]any{"id": 1, "name": "n", "conclusion": nil}}) + if evs, _ := p.VerifyAndParseWebhook(bg, running); len(evs) != 0 { + t.Error("a check run without a conclusion must be dropped") + } +} + +func TestWebhook_VerificationFailures(t *testing.T) { + p, _, _ := seededProvider(t) + good := delivery("ping", "x", map[string]any{"zen": "z"}) + + noSig := good + noSig.Headers = map[string]string{"x-github-event": "ping"} + wrongSecret := delivery("ping", "x", map[string]any{"zen": "z"}) + wrongSecret.Headers["x-hub-signature-256"] = sign(wrongSecret.Body, "another-secret") + tampered := good + tampered.Body = append([]byte(nil), good.Body...) + tampered.Body[2] = 'X' + for name, req := range map[string]gp.WebhookRequest{"no signature": noSig, "wrong secret": wrongSecret, "tampered body": tampered} { + evs, err := p.VerifyAndParseWebhook(bg, req) + if codeOf(t, err) != gp.CodeInvalidSignature || len(evs) != 0 { + t.Errorf("%s: %v %v", name, evs, err) + } + if err != nil && strings.Contains(err.Error(), fakeWebhookSecret) { + t.Errorf("%s: the secret leaked into the error", name) + } + } + unknown := delivery("ping", "x", map[string]any{}) + unknown.Identity.ID = "nope" + if _, err := p.VerifyAndParseWebhook(bg, unknown); codeOf(t, err) != gp.CodeNotFound { + t.Errorf("unknown identity: %v", err) + } + // A signed body that is not JSON is a validation error, not a panic. + junk := gp.WebhookRequest{Identity: good.Identity, Body: []byte("not json"), Headers: map[string]string{"x-hub-signature-256": sign([]byte("not json"), fakeWebhookSecret), "x-github-event": "push"}} + if _, err := p.VerifyAndParseWebhook(bg, junk); codeOf(t, err) != gp.CodeValidation { + t.Errorf("junk: %v", err) + } + // Without a delivery id the event id is still deterministic. + req := delivery("push", "", map[string]any{"ref": "refs/heads/x", "repository": repoPayload, "sender": userSender}) + delete(req.Headers, "x-github-delivery") + a, b := parseOne(t, p, req), parseOne(t, p, req) + if a.ID == "" || a.ID != b.ID { + t.Errorf("ids %q %q", a.ID, b.ID) + } +} + +// ── identity setup ─────────────────────────────────────────────────────────── + +func beginReq() gp.BeginSetupRequest { + return gp.BeginSetupRequest{Scope: gp.SetupShared, Host: githubHost, CallbackURL: "https://paca.example.com/plugin-pages/x/cb", WebhookURL: "https://paca.example.com/api/v1/git/webhooks/key1", ReturnURL: "https://paca.example.com/settings"} +} + +func TestSetup_BeginBuildsTheManifest(t *testing.T) { + p, _, st := seededProvider(t) + req := beginReq() + req.Params = map[string]string{"name": "Acme Paca", "owner_org": "acme", "public": "true"} + step, err := p.BeginIdentitySetup(bg, req) + if err != nil { + t.Fatal(err) + } + if step.Kind != gp.StepFormPost || !strings.HasPrefix(step.URL, "https://github.com/organizations/acme/settings/apps/new?state=") || step.State == "" { + t.Fatalf("step = %+v", step) + } + var m map[string]any + if err := json.Unmarshal([]byte(step.Fields["manifest"]), &m); err != nil { + t.Fatal(err) + } + hook := m["hook_attributes"].(map[string]any) + if hook["url"] != req.WebhookURL || hook["active"] != true { + t.Errorf("the webhook must go to core's endpoint: %v", hook) + } + if m["redirect_url"] != req.CallbackURL || m["setup_url"] != req.CallbackURL || m["name"] != "Acme Paca" || m["public"] != true || m["url"] != "https://paca.example.com" { + t.Errorf("manifest = %v", m) + } + perms := m["default_permissions"].(map[string]any) + if perms["checks"] != "write" || perms["contents"] != "write" { + t.Errorf("permissions = %v", perms) + } + if s, err := st.loadSetup(step.State); err != nil || s.Scope != "shared" || !s.ExpiresAt.After(time.Now()) { + t.Errorf("session = %+v %v", s, err) + } + // A local webhook URL is switched off rather than left failing. + local := beginReq() + local.WebhookURL = "http://localhost:8080/api/v1/git/webhooks/k" + step, _ = p.BeginIdentitySetup(bg, local) + _ = json.Unmarshal([]byte(step.Fields["manifest"]), &m) + if m["hook_attributes"].(map[string]any)["active"] != false { + t.Error("an unreachable webhook URL must be inactive") + } +} + +func TestSetup_BeginValidation(t *testing.T) { + p, _, _ := seededProvider(t) + mutate := map[string]func(*gp.BeginSetupRequest){ + "host": func(r *gp.BeginSetupRequest) { r.Host = "gitlab.com" }, + "scope": func(r *gp.BeginSetupRequest) { r.Scope = "everyone" }, + "callback": func(r *gp.BeginSetupRequest) { r.CallbackURL = "" }, + "webhook": func(r *gp.BeginSetupRequest) { r.WebhookURL = "" }, + "relative cb": func(r *gp.BeginSetupRequest) { r.CallbackURL = "/relative" }, + "bad org": func(r *gp.BeginSetupRequest) { r.Params = map[string]string{"owner_org": "-bad-"} }, + "name too long": func(r *gp.BeginSetupRequest) { r.Params = map[string]string{"name": strings.Repeat("x", 40)} }, + } + for name, f := range mutate { + r := beginReq() + f(&r) + if _, err := p.BeginIdentitySetup(bg, r); codeOf(t, err) != gp.CodeValidation { + t.Errorf("%s: %v", name, err) + } + } + a := beginReq() + a.Scope, a.AgentName = gp.SetupAgent, "Dev" + step, err := p.BeginIdentitySetup(bg, a) + if err != nil { + t.Fatal(err) + } + var m map[string]any + _ = json.Unmarshal([]byte(step.Fields["manifest"]), &m) + if m["name"] != "Paca Dev" { + t.Errorf("agent apps are named after the agent: %v", m["name"]) + } +} + +func TestSetup_CreatesTheAppAndStopsThere(t *testing.T) { + p, g, st := seededProvider(t) + step, _ := p.BeginIdentitySetup(bg, beginReq()) + + // The manifest code is the only input; an installation id alone is not a setup. + if _, err := p.CompleteIdentitySetup(bg, gp.CompleteSetupRequest{State: step.State, Params: map[string]string{"installation_id": "99"}}); codeOf(t, err) != gp.CodeValidation { + t.Errorf("no code: %v", err) + } + id, err := p.CompleteIdentitySetup(bg, gp.CompleteSetupRequest{State: step.State, Params: map[string]string{"code": "c0de"}}) + if err != nil { + t.Fatal(err) + } + if id.Ref.ID == "" || id.Ref.Handle != fakeSlug || id.Kind != gp.IdentityApp || id.Host != githubHost || id.DisplayName != "Paca" { + t.Fatalf("identity = %+v", id) + } + if id.Next != nil { + t.Fatalf("the App is installed later, per account, when a project links a repository there; got a next step %+v", id.Next) + } + if len(st.installs[id.Ref.ID]) != 0 { + t.Error("setup must not install the App anywhere") + } + stored := st.apps[id.Ref.ID] + if stored.WebhookSecret != fakeWebhookSecret || !strings.Contains(stored.PrivateKeyPEM, "RSA PRIVATE KEY") { + t.Errorf("credentials must be kept: %+v", stored.Slug) + } + if b, _ := json.Marshal(id); strings.Contains(string(b), "RSA PRIVATE KEY") || strings.Contains(string(b), fakeWebhookSecret) { + t.Errorf("secrets must not appear in the identity returned to core: %s", b) + } + // The one-time code cannot be exchanged again, and the finished state is dead. + if _, err := p.CompleteIdentitySetup(bg, gp.CompleteSetupRequest{State: step.State, Params: map[string]string{"code": "c0de"}}); codeOf(t, err) != gp.CodeForbidden { + t.Errorf("replayed code: %v", err) + } + if g.called("POST /app-manifests/") != 1 { + t.Errorf("the code was exchanged %d times", g.called("POST /app-manifests/")) + } +} + +func TestSetup_StateIsRequiredAndExpires(t *testing.T) { + p, _, st := seededProvider(t) + if _, err := p.CompleteIdentitySetup(bg, gp.CompleteSetupRequest{State: "unknown", Params: map[string]string{"code": "c0de"}}); codeOf(t, err) != gp.CodeForbidden { + t.Errorf("unknown state: %v", err) + } + step, _ := p.BeginIdentitySetup(bg, beginReq()) + s := st.setups[step.State] + s.ExpiresAt = time.Now().Add(-time.Minute) + st.setups[step.State] = s + if _, err := p.CompleteIdentitySetup(bg, gp.CompleteSetupRequest{State: step.State, Params: map[string]string{"code": "c0de"}}); codeOf(t, err) != gp.CodeForbidden { + t.Errorf("expired state: %v", err) + } +} + +func TestSetup_AnAccessGrantStateCannotCreateAnApp(t *testing.T) { + p, g, _ := seededProvider(t) + grant, err := p.BeginAccessGrant(bg, gp.BeginAccessRequest{Identity: gp.IdentityRef{ID: fakeAppRowID}, CallbackURL: "https://paca.example.com/cb"}) + if err != nil { + t.Fatal(err) + } + if _, err := p.CompleteIdentitySetup(bg, gp.CompleteSetupRequest{State: grant.State, Params: map[string]string{"code": "c0de"}}); codeOf(t, err) != gp.CodeForbidden { + t.Errorf("a state issued for an access grant must not complete an identity setup: %v", err) + } + if g.called("POST /app-manifests/") != 0 { + t.Error("the manifest code must not be exchanged") + } +} + +func TestDeleteIdentity(t *testing.T) { + p, _, st := seededProvider(t) + if _, err := p.DeleteIdentity(bg, gp.DeleteIdentityRequest{Identity: gp.IdentityRef{ID: fakeAppRowID}}); err != nil { + t.Fatal(err) + } + if len(st.apps) != 0 { + t.Error("app not removed") + } + if _, err := p.DeleteIdentity(bg, gp.DeleteIdentityRequest{Identity: gp.IdentityRef{ID: fakeAppRowID}}); err != nil { + t.Errorf("deleting twice must be fine: %v", err) + } +} + +// ── writes ─────────────────────────────────────────────────────────────────── + +func TestSetStatus_UsesTheCRHeadAndUpdatesByReference(t *testing.T) { + p, g, _ := seededProvider(t) + subj := gp.SubjectRef{Kind: gp.SubjectChangeRequest, Number: "1"} + res, err := p.SetStatus(bg, gp.SetStatusRequest{Target: webTarget(), Subject: subj, State: gp.RunInProgress, Title: "Paca / dev"}) + if err != nil || res.StatusRef == "" { + t.Fatalf("%+v %v", res, err) + } + if g.called("GET /repos/acme/web/pulls/1") != 1 { + t.Error("the head sha should come from the pull request") + } + if _, err := p.SetStatus(bg, gp.SetStatusRequest{Target: webTarget(), Subject: subj, State: gp.RunSuccess, Title: "Paca / dev", StatusRef: res.StatusRef}); err != nil { + t.Fatal(err) + } + if g.called("PATCH /repos/acme/web/check-runs/"+res.StatusRef) != 1 { + t.Errorf("calls: %v", g.calls) + } + for name, req := range map[string]gp.SetStatusRequest{ + "no title": {Target: webTarget(), Subject: subj, State: gp.RunQueued}, + "bad state": {Target: webTarget(), Subject: subj, State: "weird", Title: "t"}, + "no sha": {Target: webTarget(), State: gp.RunQueued, Title: "t"}, + "bad ref": {Target: webTarget(), Subject: subj, State: gp.RunQueued, Title: "t", StatusRef: "../x"}, + } { + if _, err := p.SetStatus(bg, req); codeOf(t, err) != gp.CodeValidation { + t.Errorf("%s: %v", name, err) + } + } +} + +func TestOpenChangeRequest_ReportsWhatWasApplied(t *testing.T) { + p, _, _ := seededProvider(t) + cr, err := p.OpenChangeRequest(bg, gp.OpenCRRequest{Target: webTarget(), Title: "T", Head: "feat/z", Base: "main", Labels: []string{"paca"}, Reviewers: []string{"reader"}}) + if err != nil { + t.Fatal(err) + } + if cr.State != gp.CROpen || len(cr.Labels) != 1 || len(cr.Reviewers) != 1 || cr.Head.Ref != "feat/z" { + t.Errorf("cr = %+v", cr) + } +} + +func TestSubmitReview_MapsVerdictAndSides(t *testing.T) { + p, g, _ := seededProvider(t) + _ = g + if _, err := p.SubmitReview(bg, gp.SubmitReviewRequest{Target: webTarget(), Number: "1", Verdict: gp.VerdictRequestChanges, Summary: "no", + Comments: []gp.InlineComment{{Path: "src/main.go", Line: 2, Side: "old", Body: "x"}}}); err != nil { + t.Fatal(err) + } + for name, c := range map[string]gp.InlineComment{"no line": {Path: "a", Body: "x"}, "no body": {Path: "a", Line: 1}, "no path": {Line: 1, Body: "x"}} { + _, err := p.SubmitReview(bg, gp.SubmitReviewRequest{Target: webTarget(), Number: "1", Verdict: gp.VerdictComment, Comments: []gp.InlineComment{c}}) + if codeOf(t, err) != gp.CodeValidation { + t.Errorf("%s: %v", name, err) + } + } +} + +func TestGetChecks_StatesAndIds(t *testing.T) { + p, _, _ := seededProvider(t) + checks, err := p.GetChecks(bg, gp.ChecksRequest{Target: webTarget(), Ref: "abc123"}) + if err != nil { + t.Fatal(err) + } + got := map[string]gp.CheckState{} + for _, c := range checks { + got[c.ID] = c.State + } + want := map[string]gp.CheckState{"run:5001": gp.CheckFailure, "run:5002": gp.CheckSuccess, "run:5003": gp.CheckPending, "status:legacy/ci": gp.CheckSuccess} + for id, st := range want { + if got[id] != st { + t.Errorf("%s = %q, want %q (all: %v)", id, got[id], st, got) + } + } + // A running check has no job log; its reported output is the fallback. + logs, err := p.GetCheckLogs(bg, gp.CheckLogsRequest{Target: webTarget(), CheckID: "run:5003"}) + if err != nil || !strings.Contains(logs.Text, "still going") { + t.Errorf("fallback logs: %+v %v", logs, err) + } + for _, id := range []string{"status:legacy/ci", "run:abc", "garbage"} { + if _, err := p.GetCheckLogs(bg, gp.CheckLogsRequest{Target: webTarget(), CheckID: id}); codeOf(t, err) != gp.CodeNotFound { + t.Errorf("%s: %v", id, err) + } + } +} + +func TestGetBranchProtection(t *testing.T) { + p, _, _ := seededProvider(t) + bp, err := p.GetBranchProtection(bg, gp.BranchProtectionRequest{Target: webTarget(), Branch: "main"}) + if err != nil || !bp.Protected || !bp.RequiresStatusChecks { + t.Errorf("main: %+v %v", bp, err) + } + bp, err = p.GetBranchProtection(bg, gp.BranchProtectionRequest{Target: webTarget(), Branch: "scratch"}) + if err != nil || bp.Protected { + t.Errorf("an unknown branch is not protected: %+v %v", bp, err) + } + if _, err := p.GetBranchProtection(bg, gp.BranchProtectionRequest{Target: webTarget()}); codeOf(t, err) != gp.CodeValidation { + t.Error("branch required") + } +} + +func TestJWTIsShortLivedAndSignedByTheAppKey(t *testing.T) { + p, g, _ := seededProvider(t) + // The fake rejects an App call whose JWT is badly signed, for the wrong + // issuer, or lives longer than GitHub's 10 minute cap, so a successful mint + // proves all three. + if _, err := p.MintCredential(bg, gp.MintRequest{Target: webTarget(), Access: gp.AccessRead}); err != nil { + t.Fatal(err) + } + p.store.(*memStore).apps[fakeAppRowID] = func() appRecord { a := p.store.(*memStore).apps[fakeAppRowID]; a.GitHubAppID = 1; return a }() + _ = g + if _, err := p.MintCredential(bg, gp.MintRequest{Target: webTarget(), Access: gp.AccessRead}); codeOf(t, err) != gp.CodeForbidden { + t.Errorf("wrong issuer must be rejected by GitHub: %v", err) + } +} + +// ── wire ───────────────────────────────────────────────────────────────────── + +func TestSPIRoutesAreMountedOnTheInternalPrefix(t *testing.T) { + g := newFakeGitHub(t) + _ = g + tc := plugintest.NewContext(t) + var pl githubPlugin + if err := pl.Init(tc.PluginContext()); err != nil { + t.Fatal(err) + } + res := tc.Call("POST", "/_git/:op", plugintest.Request{PathParams: map[string]string{"op": "describe"}}) + if res.StatusCode != 200 { + t.Fatalf("describe: %d %s", res.StatusCode, res.BodyString()) + } + var env gp.Envelope + if err := json.Unmarshal(res.Body, &env); err != nil || !env.OK { + t.Fatalf("envelope: %v %s", err, res.BodyString()) + } + var info gp.Info + if err := json.Unmarshal(env.Result, &info); err != nil || info.ID != githubPluginID || info.SPIVersion != gp.SPIVersion { + t.Fatalf("info: %+v %v", info, err) + } + res = tc.Call("POST", "/_git/:op", plugintest.Request{PathParams: map[string]string{"op": "mint_credential"}, Body: []byte(`{"target":{"identity":{"id":"nope"},"repo":{"full_path":"a/b"}},"access":"read"}`)}) + if res.StatusCode != gp.CodeNotFound.HTTPStatus() || !strings.Contains(res.BodyString(), `"not_found"`) { + t.Fatalf("unknown identity over the wire: %d %s", res.StatusCode, res.BodyString()) + } +} + +func TestBranches(t *testing.T) { + p, g, _ := seededProvider(t) + bm := gp.BranchManager(p) + tg := webTarget() + tg.Repo.DefaultBranch = "main" + + got, err := bm.GetBranch(bg, gp.GetBranchRequest{Target: tg, Name: "feat/PACA-12-login"}) + if err != nil || got.SHA != "abc123" || got.URL != "https://github.com/acme/web/tree/feat/PACA-12-login" { + t.Fatalf("GetBranch: %+v %v", got, err) + } + if _, err := bm.GetBranch(bg, gp.GetBranchRequest{Target: tg, Name: "nope"}); codeOf(t, err) != gp.CodeNotFound { + t.Fatalf("missing branch: %v", err) + } + if g.called("GET /repos/acme/web/branches/feat/PACA-12-login") != 1 { + t.Errorf("a slash in a branch name must stay a path separator: %v", g.calls) + } + + made, err := bm.CreateBranch(bg, gp.CreateBranchRequest{Target: tg, Name: "paca/PACA-1"}) + if err != nil || made.SHA != "sha-main" || made.Name != "paca/PACA-1" { + t.Fatalf("CreateBranch from the default: %+v %v", made, err) + } + if g.branches["paca/PACA-1"] != "sha-main" { + t.Errorf("the ref was not created at the source head: %v", g.branches) + } + fromOther, err := bm.CreateBranch(bg, gp.CreateBranchRequest{Target: tg, Name: "paca/PACA-2", From: "feat/PACA-12-login"}) + if err != nil || fromOther.SHA != "abc123" { + t.Fatalf("CreateBranch from another branch: %+v %v", fromOther, err) + } + if _, err := bm.CreateBranch(bg, gp.CreateBranchRequest{Target: tg, Name: "paca/PACA-1"}); codeOf(t, err) != gp.CodeConflict { + t.Fatalf("a taken name must be a conflict: %v", err) + } + if _, err := bm.CreateBranch(bg, gp.CreateBranchRequest{Target: tg, Name: "paca/x", From: "gone"}); codeOf(t, err) != gp.CodeNotFound { + t.Fatalf("a missing source must be not_found: %v", err) + } + noDefault := webTarget() + if _, err := bm.CreateBranch(bg, gp.CreateBranchRequest{Target: noDefault, Name: "paca/x"}); codeOf(t, err) != gp.CodeValidation { + t.Fatalf("no source and no default: %v", err) + } + for _, bad := range []string{"", "has space", "a..b", "-flag", "x.lock", "a//b", "tilde~", "/lead", "trail/", "a@{b"} { + if _, err := bm.CreateBranch(bg, gp.CreateBranchRequest{Target: tg, Name: bad}); codeOf(t, err) != gp.CodeValidation { + t.Errorf("%q must be refused as validation: %v", bad, err) + } + if _, err := bm.GetBranch(bg, gp.GetBranchRequest{Target: tg, Name: bad}); codeOf(t, err) != gp.CodeValidation { + t.Errorf("get %q must be refused as validation: %v", bad, err) + } + } +} + +func TestMintCredential_ReportsTheRequestedLifetimeWhenShorter(t *testing.T) { + p, _, _ := seededProvider(t) + until := func(c gp.Credential) time.Duration { return time.Until(c.ExpiresAt) } + + short, err := p.MintCredential(bg, gp.MintRequest{Target: webTarget(), Access: gp.AccessRead, TTLSeconds: 300}) + if err != nil || until(short) > 5*time.Minute || until(short) < 4*time.Minute { + t.Fatalf("a requested lifetime caps ExpiresAt: %v %v", until(short), err) + } + unspecified, err := p.MintCredential(bg, gp.MintRequest{Target: webTarget(), Access: gp.AccessRead}) + if err != nil || until(unspecified) < 30*time.Minute { + t.Fatalf("without a request the token's own expiry is reported: %v %v", until(unspecified), err) + } + long, err := p.MintCredential(bg, gp.MintRequest{Target: webTarget(), Access: gp.AccessRead, TTLSeconds: 7 * 24 * 3600}) + if err != nil || until(long) > 61*time.Minute { + t.Fatalf("a longer request cannot extend a token: %v %v", until(long), err) + } +} + +// An App only receives the events its manifest subscribes to, so every +// delivery the adapter understands must be listed there. +func TestManifest_SubscribesToEveryEventTheAdapterHandles(t *testing.T) { + have := map[string]bool{} + for _, e := range manifestEvents { + have[e] = true + } + for _, e := range []string{"issues", "issue_comment", "pull_request", "pull_request_review", "pull_request_review_comment", "check_run", "push"} { + if !have[e] { + t.Errorf("the manifest does not subscribe to %q", e) + } + } +} + +func TestIsLocalURL(t *testing.T) { + for url, want := range map[string]bool{ + "http://localhost:8080/x": true, "http://127.0.0.1/x": true, "http://[::1]/x": true, + "http://10.1.2.3/x": true, "http://192.168.0.5/x": true, "http://172.16.0.1/x": true, + "http://169.254.169.254/x": true, "http://100.64.1.1/x": true, "http://[fd00::1]/x": true, + "http://paca.lan/x": true, "https://paca.example.com/x": false, "http://172.32.0.1/x": false, + "http://8.8.8.8/x": false, "http://100.128.0.1/x": false, + } { + if got := isLocalURL(url); got != want { + t.Errorf("isLocalURL(%q) = %v, want %v", url, got, want) + } + } +} + +func TestClaimSetupCodeIsSingleUse(t *testing.T) { + st := newMemStore() + _ = st.saveSetup(setupSession{State: "s"}) + if ok, err := st.claimSetupCode("s"); !ok || err != nil { + t.Fatalf("first claim = %v %v", ok, err) + } + if ok, _ := st.claimSetupCode("s"); ok { + t.Error("second claim succeeded") + } +} diff --git a/backend/spi_webhook.go b/backend/spi_webhook.go new file mode 100644 index 0000000..b34c443 --- /dev/null +++ b/backend/spi_webhook.go @@ -0,0 +1,353 @@ +package main + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "regexp" + "strconv" + "strings" + "sync" + + gp "github.com/Paca-AI/plugin-sdk-go/gitprovider" +) + +// VerifyAndParseWebhook implements gp.Provider. It is the only place GitHub's +// webhook format lives: it checks X-Hub-Signature-256 against the App's +// webhook secret and turns the delivery into zero or more normalized events. +// Event types Paca has no use for are dropped, not guessed at. +func (p *ghProvider) VerifyAndParseWebhook(_ context.Context, req gp.WebhookRequest) ([]gp.Event, error) { + app, err := p.loadApp(req.Identity.ID) + if err != nil { + return nil, err + } + sig := req.Headers["x-hub-signature-256"] + if app.WebhookSecret == "" || !verifyHMAC(req.Body, app.WebhookSecret, sig) { + return nil, gp.Errorf(gp.CodeInvalidSignature, "webhook signature does not match") + } + name := req.Headers["x-github-event"] + var d ghDelivery + if err := json.Unmarshal(req.Body, &d); err != nil { + return nil, gp.Errorf(gp.CodeValidation, "webhook payload is not valid JSON") + } + id := req.Headers["x-github-delivery"] + if id == "" { + sum := sha256.Sum256(req.Body) + id = hex.EncodeToString(sum[:8]) + } + ev, ok := d.normalize(name, app) + if !ok { + return nil, nil + } + ev.ID = id + ":" + name + ev.RawRef = id + return []gp.Event{ev}, nil +} + +type ghUser struct { + Login string `json:"login"` + Type string `json:"type"` +} + +type ghDelivery struct { + Action string `json:"action"` + Sender ghUser `json:"sender"` + Repository struct { + ID int64 `json:"id"` + FullName string `json:"full_name"` + DefaultBranch string `json:"default_branch"` + } `json:"repository"` + Label *struct { + Name string `json:"name"` + } `json:"label"` + + // RequestedReviewer is who a review was requested from (review_requested) + // and Assignee who an issue or pull request was assigned to (assigned). + RequestedReviewer *ghUser `json:"requested_reviewer"` + Assignee *ghUser `json:"assignee"` + + Issue *struct { + Number int `json:"number"` + Title string `json:"title"` + Body string `json:"body"` + User ghUser `json:"user"` + PullRequest json.RawMessage `json:"pull_request"` + } `json:"issue"` + Comment *struct { + ID int64 `json:"id"` + Body string `json:"body"` + Path string `json:"path"` + Line int `json:"line"` + } `json:"comment"` + Review *struct { + ID int64 `json:"id"` + Body string `json:"body"` + State string `json:"state"` + } `json:"review"` + PullRequest *ghPRPayload `json:"pull_request"` + CheckRun *struct { + ID int64 `json:"id"` + Name string `json:"name"` + HeadSHA string `json:"head_sha"` + Conclusion *string `json:"conclusion"` + PullRequest []struct { + Number int `json:"number"` + Head struct { + Ref string `json:"ref"` + SHA string `json:"sha"` + } `json:"head"` + Base struct { + Ref string `json:"ref"` + } `json:"base"` + } `json:"pull_requests"` + } `json:"check_run"` + + // push + Ref string `json:"ref"` + After string `json:"after"` + Created bool `json:"created"` + Deleted bool `json:"deleted"` +} + +type ghPRPayload struct { + Number int `json:"number"` + Title string `json:"title"` + Body string `json:"body"` + Draft bool `json:"draft"` + Merged bool `json:"merged"` + // HTMLURL is the pull request's page. + HTMLURL string `json:"html_url"` + User ghUser `json:"user"` + Head struct { + Ref string `json:"ref"` + SHA string `json:"sha"` + Repo *struct { + FullName string `json:"full_name"` + } `json:"repo"` + } `json:"head"` + Base struct { + Ref string `json:"ref"` + } `json:"base"` +} + +func actorOf(u ghUser) gp.Actor { + k := gp.ActorUser + if strings.EqualFold(u.Type, "Bot") { + k = gp.ActorBot + } + return gp.Actor{Login: u.Login, Kind: k} +} + +func (d *ghDelivery) repoRef() gp.RepoRef { + return gp.RepoRef{ + Host: githubHost, FullPath: d.Repository.FullName, ExternalID: strconv.FormatInt(d.Repository.ID, 10), + DefaultBranch: d.Repository.DefaultBranch, CloneURL: githubCloneBase + d.Repository.FullName + ".git", + } +} + +func prSubject(pr *ghPRPayload, baseRepo string) *gp.EventSubject { + head := &gp.Branch{Ref: pr.Head.Ref, SHA: pr.Head.SHA} + if pr.Head.Repo != nil { + head.Repo = pr.Head.Repo.FullName + head.FromFork = !strings.EqualFold(pr.Head.Repo.FullName, baseRepo) + } else { + head.FromFork = true // the head repository was deleted: it can only have been a fork + } + return &gp.EventSubject{Kind: gp.SubjectChangeRequest, Number: strconv.Itoa(pr.Number), Head: head, Base: pr.Base.Ref, Draft: pr.Draft, Author: pr.User.Login, URL: pr.HTMLURL} +} + +func conclusionOf(c string) string { + switch c { + case "success", "neutral": + return "success" + case "skipped": + return "skipped" + case "cancelled": + return "cancelled" + } + return "failure" // failure, timed_out, action_required, startup_failure, stale +} + +// normalize maps one GitHub event onto the SPI. ok is false for events Paca +// does not use. +func (d *ghDelivery) normalize(name string, app appRecord) (gp.Event, bool) { + if d.Repository.FullName == "" { + return gp.Event{}, false // ping and other non-repository deliveries + } + ev := gp.Event{Repo: d.repoRef(), Actor: actorOf(d.Sender)} + if ev.Actor.Login == "" { + return gp.Event{}, false + } + address := func(text string) { + if mentionsHandle(text, app.Slug) { + ev.Addressed = append(ev.Addressed, gp.Addressed{IdentityID: app.ID, Via: gp.ViaMention}) + } + } + + switch name { + case "issue_comment": + if d.Issue == nil || d.Comment == nil || (d.Action != "created" && d.Action != "edited") { + return gp.Event{}, false + } + kind := gp.SubjectIssue + if len(d.Issue.PullRequest) > 0 && string(d.Issue.PullRequest) != "null" { + kind = gp.SubjectChangeRequest + } + ev.Kind, ev.Action, ev.Text = gp.EventComment, d.Action, d.Comment.Body + ev.Subject = &gp.EventSubject{Kind: kind, Number: strconv.Itoa(d.Issue.Number), Author: d.Issue.User.Login} + ev.Location = &gp.Location{CommentID: strconv.FormatInt(d.Comment.ID, 10)} + if d.Action == "created" { + address(d.Comment.Body) + } + + case "pull_request_review_comment": + if d.PullRequest == nil || d.Comment == nil || (d.Action != "created" && d.Action != "edited") { + return gp.Event{}, false + } + ev.Kind, ev.Action, ev.Text = gp.EventComment, d.Action, d.Comment.Body + ev.Subject = prSubject(d.PullRequest, d.Repository.FullName) + ev.Location = &gp.Location{Path: d.Comment.Path, Line: d.Comment.Line, CommentID: strconv.FormatInt(d.Comment.ID, 10)} + if d.Action == "created" { + address(d.Comment.Body) + } + + case "pull_request_review": + if d.PullRequest == nil || d.Review == nil || d.Action != "submitted" { + return gp.Event{}, false + } + ev.Kind, ev.Action, ev.Text = gp.EventReview, "submitted", d.Review.Body + ev.Subject = prSubject(d.PullRequest, d.Repository.FullName) + ev.Location = &gp.Location{CommentID: strconv.FormatInt(d.Review.ID, 10)} + address(d.Review.Body) + + case "pull_request": + if d.PullRequest == nil { + return gp.Event{}, false + } + ev.Subject = prSubject(d.PullRequest, d.Repository.FullName) + switch d.Action { + case "opened", "edited", "reopened", "synchronize", "ready_for_review", "converted_to_draft": + ev.Kind, ev.Text = gp.EventChangeRequest, d.PullRequest.Title + ev.Action = map[string]string{"synchronize": "synchronized", "ready_for_review": "ready", "converted_to_draft": "draft"}[d.Action] + if ev.Action == "" { + ev.Action = d.Action + } + if d.Action == "opened" { + address(d.PullRequest.Title + "\n" + d.PullRequest.Body) + } + case "closed": + ev.Kind, ev.Text, ev.Action = gp.EventChangeRequest, d.PullRequest.Title, "closed" + if d.PullRequest.Merged { + ev.Action = "merged" + } + case "labeled": + if d.Label == nil { + return gp.Event{}, false + } + ev.Kind, ev.Action, ev.Label = gp.EventLabel, "labeled", d.Label.Name + case "review_requested": + // Only a request naming this app is for Paca; requests for people or + // teams are the repository's own business. + if !isApp(d.RequestedReviewer, app) { + return gp.Event{}, false + } + ev.Kind, ev.Action, ev.Text = gp.EventReviewRequest, "requested", d.PullRequest.Title + ev.Addressed = append(ev.Addressed, gp.Addressed{IdentityID: app.ID, Via: gp.ViaReviewRequest}) + case "assigned": + if !isApp(d.Assignee, app) { + return gp.Event{}, false + } + ev.Kind, ev.Action, ev.Text = gp.EventAssignment, "assigned", d.PullRequest.Title + ev.Addressed = append(ev.Addressed, gp.Addressed{IdentityID: app.ID, Via: gp.ViaAssignment}) + default: + return gp.Event{}, false + } + + case "issues": + if d.Issue == nil { + return gp.Event{}, false + } + ev.Subject = &gp.EventSubject{Kind: gp.SubjectIssue, Number: strconv.Itoa(d.Issue.Number), Author: d.Issue.User.Login} + switch d.Action { + case "opened": + // The issue body is the first message of the thread; treat it as a comment. + ev.Kind, ev.Action = gp.EventComment, "created" + ev.Text = strings.TrimSpace(d.Issue.Title + "\n\n" + d.Issue.Body) + address(d.Issue.Title + "\n" + d.Issue.Body) + case "labeled": + if d.Label == nil { + return gp.Event{}, false + } + ev.Kind, ev.Action, ev.Label = gp.EventLabel, "labeled", d.Label.Name + case "assigned": + if !isApp(d.Assignee, app) { + return gp.Event{}, false + } + ev.Kind, ev.Action = gp.EventAssignment, "assigned" + ev.Text = strings.TrimSpace(d.Issue.Title + "\n\n" + d.Issue.Body) + ev.Addressed = append(ev.Addressed, gp.Addressed{IdentityID: app.ID, Via: gp.ViaAssignment}) + default: + return gp.Event{}, false + } + + case "check_run": + if d.CheckRun == nil || d.Action != "completed" || d.CheckRun.Conclusion == nil { + return gp.Event{}, false + } + ev.Kind, ev.Action = gp.EventCheck, "completed" + ev.Check = &gp.CheckInfo{ + Name: d.CheckRun.Name, Conclusion: conclusionOf(*d.CheckRun.Conclusion), + RunID: strconv.FormatInt(d.CheckRun.ID, 10), SHA: d.CheckRun.HeadSHA, + } + if len(d.CheckRun.PullRequest) > 0 { + pr := d.CheckRun.PullRequest[0] + ev.Subject = &gp.EventSubject{Kind: gp.SubjectChangeRequest, Number: strconv.Itoa(pr.Number), Base: pr.Base.Ref, + Head: &gp.Branch{Ref: pr.Head.Ref, SHA: pr.Head.SHA}} + } + + case "push": + ref, isBranch := strings.CutPrefix(d.Ref, "refs/heads/") + if !isBranch || ref == "" { + return gp.Event{}, false // tags and other refs are not branch activity + } + ev.Kind, ev.Action = gp.EventPush, "pushed" + if d.Created { + ev.Action = "created" + } else if d.Deleted { + ev.Action = "deleted" + } + ev.Push = &gp.PushInfo{Ref: ref, SHA: d.After, Created: d.Created, Deleted: d.Deleted} + + default: + return gp.Event{}, false + } + return ev, true +} + +// isApp reports whether u is the app's own bot account ("[bot]"). +func isApp(u *ghUser, app appRecord) bool { + return u != nil && app.Slug != "" && strings.EqualFold(u.Login, app.Slug+"[bot]") +} + +// mentionsHandle reports whether text @-mentions handle (also as handle[bot]). +// An e-mail address or a longer handle sharing the prefix is not a mention. +func mentionsHandle(text, handle string) bool { + if handle == "" { + return false + } + return mentionRegexp(handle).MatchString(text) +} + +// mentionRegexps caches the compiled pattern per handle: every event of a +// delivery is checked against the same few handles. +var mentionRegexps sync.Map // handle → *regexp.Regexp + +func mentionRegexp(handle string) *regexp.Regexp { + if re, ok := mentionRegexps.Load(handle); ok { + return re.(*regexp.Regexp) + } + re := regexp.MustCompile(`(?i)(?:^|[^\w@])@` + regexp.QuoteMeta(handle) + `(?:\[bot\])?(?:$|[^\w-])`) + mentionRegexps.Store(handle, re) + return re +} diff --git a/backend/spi_writes.go b/backend/spi_writes.go new file mode 100644 index 0000000..57e523b --- /dev/null +++ b/backend/spi_writes.go @@ -0,0 +1,365 @@ +package main + +import ( + "context" + "strconv" + "strings" + + gp "github.com/Paca-AI/plugin-sdk-go/gitprovider" +) + +func blank(s string) bool { return strings.TrimSpace(s) == "" } + +// OpenChangeRequest implements gp.Provider. Labels and reviewers are applied +// after the pull request exists; if either fails the request is still returned +// (retrying the open would only collide with the one just created) and the +// result lists what was actually applied. GitHub has no idempotency keys, so +// IdempotentWrites is not declared and core de-duplicates itself. +func (p *ghProvider) OpenChangeRequest(_ context.Context, req gp.OpenCRRequest) (gp.ChangeRequest, error) { + if blank(req.Title) || blank(req.Head) || blank(req.Base) { + return gp.ChangeRequest{}, gp.Errorf(gp.CodeValidation, "title, head and base are required") + } + api, repo, err := p.apiFor(req.Target) + if err != nil { + return gp.ChangeRequest{}, err + } + var pr ghPR + if err := api.send("POST", repo+"/pulls", map[string]any{ + "title": req.Title, "body": req.Body, "head": req.Head, "base": req.Base, "draft": req.Draft, + }, &pr); err != nil { + return gp.ChangeRequest{}, err + } + num := strconv.Itoa(pr.Number) + if len(req.Labels) > 0 { + if err := api.send("POST", repo+"/issues/"+num+"/labels", map[string]any{"labels": req.Labels}, nil); err == nil { + pr.Labels = nil + for _, l := range req.Labels { + pr.Labels = append(pr.Labels, struct { + Name string `json:"name"` + }{l}) + } + } + } + if len(req.Reviewers) > 0 { + if err := api.send("POST", repo+"/pulls/"+num+"/requested_reviewers", map[string]any{"reviewers": req.Reviewers}, nil); err == nil { + for _, r := range req.Reviewers { + pr.RequestedReviewers = append(pr.RequestedReviewers, struct { + Login string `json:"login"` + }{r}) + } + } + } + return pr.toCR(), nil +} + +// UpdateChangeRequest implements gp.Provider. Only open and closed can be set; +// merging is its own operation. Toggling draft needs GraphQL. +func (p *ghProvider) UpdateChangeRequest(_ context.Context, req gp.UpdateCRRequest) (gp.ChangeRequest, error) { + num, err := number(req.Number) + if err != nil { + return gp.ChangeRequest{}, err + } + patch := map[string]any{} + if req.Title != nil { + if blank(*req.Title) { + return gp.ChangeRequest{}, gp.Errorf(gp.CodeValidation, "title must not be empty") + } + patch["title"] = *req.Title + } + if req.Body != nil { + patch["body"] = *req.Body + } + if req.State != nil { + if *req.State != gp.CROpen && *req.State != gp.CRClosed { + return gp.ChangeRequest{}, gp.Errorf(gp.CodeValidation, "state can only be set to open or closed; merging is a separate operation") + } + patch["state"] = string(*req.State) + } + api, repo, err := p.apiFor(req.Target) + if err != nil { + return gp.ChangeRequest{}, err + } + var pr ghPR + if len(patch) > 0 { + err = api.send("PATCH", repo+"/pulls/"+num, patch, &pr) + } else { + pr, err = p.getPR(api, repo, num) + } + if err != nil { + return gp.ChangeRequest{}, err + } + if req.Draft != nil && *req.Draft != pr.Draft { + mutation := `mutation($id:ID!){ markPullRequestReadyForReview(input:{pullRequestId:$id}){ clientMutationId } }` + if *req.Draft { + mutation = `mutation($id:ID!){ convertPullRequestToDraft(input:{pullRequestId:$id}){ clientMutationId } }` + } + if err := api.graphql(mutation, map[string]any{"id": pr.NodeID}, nil); err != nil { + return gp.ChangeRequest{}, err + } + pr.Draft = *req.Draft + } + return pr.toCR(), nil +} + +// Comment implements gp.Provider: a conversation comment on an issue or change request. +func (p *ghProvider) Comment(_ context.Context, req gp.CommentRequest) (gp.CommentResult, error) { + if blank(req.Body) { + return gp.CommentResult{}, gp.Errorf(gp.CodeValidation, "the comment is empty") + } + num, err := number(req.Subject.Number) + if err != nil { + return gp.CommentResult{}, err + } + api, repo, err := p.apiFor(req.Target) + if err != nil { + return gp.CommentResult{}, err + } + var c ghComment + if err := api.send("POST", repo+"/issues/"+num+"/comments", map[string]any{"body": req.Body}, &c); err != nil { + return gp.CommentResult{}, err + } + return gp.CommentResult{ID: strconv.FormatInt(c.ID, 10), URL: c.HTMLURL}, nil +} + +// ReplyThread implements gp.Provider (GraphQL; the thread id is the node id +// ListThreads returned). +func (p *ghProvider) ReplyThread(_ context.Context, req gp.ReplyThreadRequest) (gp.CommentResult, error) { + if blank(req.Body) { + return gp.CommentResult{}, gp.Errorf(gp.CodeValidation, "the reply is empty") + } + if blank(req.ThreadID) { + return gp.CommentResult{}, gp.Errorf(gp.CodeValidation, "thread_id is required") + } + api, _, err := p.apiFor(req.Target) + if err != nil { + return gp.CommentResult{}, err + } + var out struct { + AddPullRequestReviewThreadReply struct { + Comment struct { + DatabaseID int64 `json:"databaseId"` + URL string `json:"url"` + } `json:"comment"` + } `json:"addPullRequestReviewThreadReply"` + } + if err := api.graphql(`mutation($id:ID!,$body:String!){ addPullRequestReviewThreadReply(input:{pullRequestReviewThreadId:$id, body:$body}){ comment{ databaseId url } } }`, + map[string]any{"id": req.ThreadID, "body": req.Body}, &out); err != nil { + return gp.CommentResult{}, err + } + c := out.AddPullRequestReviewThreadReply.Comment + return gp.CommentResult{ID: strconv.FormatInt(c.DatabaseID, 10), URL: c.URL}, nil +} + +// SubmitReview implements gp.Provider. GitHub reviews are batched natively, so +// BatchReview is declared and nothing is emulated. +func (p *ghProvider) SubmitReview(_ context.Context, req gp.SubmitReviewRequest) (gp.ReviewResult, error) { + event := map[gp.Verdict]string{gp.VerdictComment: "COMMENT", gp.VerdictApprove: "APPROVE", gp.VerdictRequestChanges: "REQUEST_CHANGES"}[req.Verdict] + if event == "" { + return gp.ReviewResult{}, gp.Errorf(gp.CodeValidation, "verdict must be comment, approve or request_changes") + } + num, err := number(req.Number) + if err != nil { + return gp.ReviewResult{}, err + } + comments := make([]map[string]any, 0, len(req.Comments)) + for _, c := range req.Comments { + if blank(c.Path) || c.Line < 1 || blank(c.Body) { + return gp.ReviewResult{}, gp.Errorf(gp.CodeValidation, "inline comments need a path, a line and a body") + } + side := "RIGHT" + if c.Side == "old" { + side = "LEFT" + } + comments = append(comments, map[string]any{"path": c.Path, "line": c.Line, "side": side, "body": c.Body}) + } + api, repo, err := p.apiFor(req.Target) + if err != nil { + return gp.ReviewResult{}, err + } + body := map[string]any{"event": event, "body": req.Summary} + if len(comments) > 0 { + body["comments"] = comments + } + if req.HeadSHA != "" { + body["commit_id"] = req.HeadSHA + } + var out struct { + ID int64 `json:"id"` + HTMLURL string `json:"html_url"` + } + if err := api.send("POST", repo+"/pulls/"+num+"/reviews", body, &out); err != nil { + return gp.ReviewResult{}, err + } + return gp.ReviewResult{ID: strconv.FormatInt(out.ID, 10), URL: out.HTMLURL, Posted: len(comments)}, nil +} + +// SetStatus implements gp.Provider as a check run (the App's own status line +// on the commit); StatusRef is the check run id, so a later call updates it. +func (p *ghProvider) SetStatus(_ context.Context, req gp.SetStatusRequest) (gp.StatusResult, error) { + if blank(req.Title) { + return gp.StatusResult{}, gp.Errorf(gp.CodeValidation, "title is required") + } + status, conclusion := "", "" + switch req.State { + case gp.RunQueued: + status = "queued" + case gp.RunInProgress: + status = "in_progress" + case gp.RunSuccess, gp.RunFailure, gp.RunCancelled: + status, conclusion = "completed", string(req.State) + default: + return gp.StatusResult{}, gp.Errorf(gp.CodeValidation, "unknown state %q", req.State) + } + api, repo, err := p.apiFor(req.Target) + if err != nil { + return gp.StatusResult{}, err + } + summary := req.Summary + if blank(summary) { + summary = req.Title + } + body := map[string]any{"name": req.Title, "status": status, "output": map[string]any{"title": req.Title, "summary": summary}} + if conclusion != "" { + body["conclusion"] = conclusion + } + if req.URL != "" { + body["details_url"] = req.URL + } + var out struct { + ID int64 `json:"id"` + } + if req.StatusRef != "" { + if _, err := strconv.ParseUint(req.StatusRef, 10, 63); err != nil { + return gp.StatusResult{}, gp.Errorf(gp.CodeValidation, "status_ref is not a check run id") + } + if err := api.send("PATCH", repo+"/check-runs/"+req.StatusRef, body, &out); err != nil { + return gp.StatusResult{}, err + } + return gp.StatusResult{StatusRef: req.StatusRef}, nil + } + sha := req.SHA + if sha == "" && req.Subject.Kind == gp.SubjectChangeRequest && req.Subject.Number != "" { + num, err := number(req.Subject.Number) + if err != nil { + return gp.StatusResult{}, err + } + pr, err := p.getPR(api, repo, num) + if err != nil { + return gp.StatusResult{}, err + } + sha = pr.Head.SHA + } + if sha == "" { + return gp.StatusResult{}, gp.Errorf(gp.CodeValidation, "sha or a change request is required") + } + body["head_sha"] = sha + if err := api.send("POST", repo+"/check-runs", body, &out); err != nil { + return gp.StatusResult{}, err + } + return gp.StatusResult{StatusRef: strconv.FormatInt(out.ID, 10)}, nil +} + +// ── optional operations ────────────────────────────────────────────────────── + +// ResolveThread implements gp.ThreadResolver. +func (p *ghProvider) ResolveThread(_ context.Context, req gp.ResolveThreadRequest) (gp.Empty, error) { + if blank(req.ThreadID) { + return gp.Empty{}, gp.Errorf(gp.CodeValidation, "thread_id is required") + } + api, _, err := p.apiFor(req.Target) + if err != nil { + return gp.Empty{}, err + } + name := "resolveReviewThread" + if !req.Resolved { + name = "unresolveReviewThread" + } + return gp.Empty{}, api.graphql(`mutation($id:ID!){ `+name+`(input:{threadId:$id}){ thread{ id } } }`, map[string]any{"id": req.ThreadID}, nil) +} + +// AddLabels implements gp.LabelManager. +func (p *ghProvider) AddLabels(_ context.Context, req gp.LabelsRequest) (gp.Empty, error) { + num, err := number(req.Subject.Number) + if err != nil { + return gp.Empty{}, err + } + if len(req.Labels) == 0 { + return gp.Empty{}, gp.Errorf(gp.CodeValidation, "at least one label is required") + } + api, repo, err := p.apiFor(req.Target) + if err != nil { + return gp.Empty{}, err + } + return gp.Empty{}, api.send("POST", repo+"/issues/"+num+"/labels", map[string]any{"labels": req.Labels}, nil) +} + +// RequestReviewers implements gp.ReviewerRequester (people, not the bot: an +// ordinary GitHub App cannot be a requested reviewer). +func (p *ghProvider) RequestReviewers(_ context.Context, req gp.PeopleRequest) (gp.Empty, error) { + if req.Subject.Kind != gp.SubjectChangeRequest { + return gp.Empty{}, gp.Errorf(gp.CodeValidation, "reviewers can only be requested on a pull request") + } + num, err := number(req.Subject.Number) + if err != nil { + return gp.Empty{}, err + } + if len(req.Logins) == 0 { + return gp.Empty{}, gp.Errorf(gp.CodeValidation, "at least one login is required") + } + api, repo, err := p.apiFor(req.Target) + if err != nil { + return gp.Empty{}, err + } + return gp.Empty{}, api.send("POST", repo+"/pulls/"+num+"/requested_reviewers", map[string]any{"reviewers": req.Logins}, nil) +} + +// SetAssignees implements gp.AssigneeSetter: the assignees become exactly logins. +func (p *ghProvider) SetAssignees(_ context.Context, req gp.PeopleRequest) (gp.Empty, error) { + num, err := number(req.Subject.Number) + if err != nil { + return gp.Empty{}, err + } + api, repo, err := p.apiFor(req.Target) + if err != nil { + return gp.Empty{}, err + } + logins := req.Logins + if logins == nil { + logins = []string{} + } + return gp.Empty{}, api.send("PATCH", repo+"/issues/"+num, map[string]any{"assignees": logins}, nil) +} + +// MergeChangeRequest implements gp.Merger. GitHub itself refuses (409) when the +// head moved since ExpectedHeadSHA, which is the guarantee core relies on. +func (p *ghProvider) MergeChangeRequest(_ context.Context, req gp.MergeRequest) (gp.MergeResult, error) { + num, err := number(req.Number) + if err != nil { + return gp.MergeResult{}, err + } + if blank(req.ExpectedHeadSHA) { + return gp.MergeResult{}, gp.Errorf(gp.CodeValidation, "expected_head_sha is required") + } + method := req.Method + if method == "" { + method = "merge" + } + if method != "merge" && method != "squash" && method != "rebase" { + return gp.MergeResult{}, gp.Errorf(gp.CodeValidation, "method must be merge, squash or rebase") + } + api, repo, err := p.apiFor(req.Target) + if err != nil { + return gp.MergeResult{}, err + } + var out struct { + SHA string `json:"sha"` + Merged bool `json:"merged"` + } + if err := api.send("PUT", repo+"/pulls/"+num+"/merge", map[string]any{"sha": req.ExpectedHeadSHA, "merge_method": method}, &out); err != nil { + return gp.MergeResult{}, err + } + if !out.Merged { + return gp.MergeResult{}, gp.Errorf(gp.CodeConflict, "GitHub did not merge the pull request") + } + return gp.MergeResult{SHA: out.SHA}, nil +} diff --git a/backend/webhook.go b/backend/webhook.go deleted file mode 100644 index a2cb136..0000000 --- a/backend/webhook.go +++ /dev/null @@ -1,333 +0,0 @@ -package main - -import ( - "crypto/hmac" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "regexp" - "strconv" - "strings" - "time" - - plugin "github.com/Paca-AI/plugin-sdk-go" -) - -// handlesWebhookEvent reports whether this plugin processes event at all. -// The set is deliberately closed: anything else is dropped before the -// repository lookup, so a subscription that grows on GitHub's side cannot -// make every delivery pay for a database query. -func handlesWebhookEvent(event string) bool { - switch event { - case "pull_request", "push": - return true - default: - return false - } -} - -// branchTaskRefRe matches a task-ID prefix in a branch name (e.g. "PROJ-42"). -var branchTaskRefRe = regexp.MustCompile(`(?i)\b([A-Z][A-Z0-9]{1,19})-(\d{1,6})\b`) - -// ─── POST /webhook ──────────────────────────────────────────────────────────── - -func (p *githubPlugin) receiveWebhook(req *plugin.Request, res *plugin.Response) { - // projectId comes from the URL the integration registered with GitHub - // (.../projects/:projectId/webhook) — this route has no requireActions - // middleware (GitHub itself calls it, with no Paca auth), so - // req.Caller.ProjectID is never populated here; the path segment is the - // only source of truth for which project this delivery is for. - projectID := req.PathParam("projectId") - event := req.Headers["X-Github-Event"] - signature := req.Headers["X-Hub-Signature-256"] - - body := req.Body - if len(body) == 0 { - res.NoContent() - return - } - - // Only the two events this plugin acts on get any further work. A hook - // created before this gate existed is still subscribed to check_run, and - // GitHub sends one such delivery per CI job per state — each of which - // would otherwise cost a repository lookup and an HMAC verification - // before landing in handleWebhookEvent's default branch. - if !handlesWebhookEvent(event) { - p.log.Info("github: ignoring event this plugin does not handle, event=" + event) - res.NoContent() - return - } - - repoFullName := extractRepoFullName(body) - if repoFullName == "" { - res.NoContent() - return - } - - // Always 204 so GitHub does not retry on application errors. - if err := p.handleWebhookEvent(projectID, repoFullName, event, signature, body); err != nil { - p.log.Error("github: webhook handler error: " + err.Error()) - } - res.NoContent() -} - -func (p *githubPlugin) handleWebhookEvent(projectID, repoFullName, event, signature string, payload []byte) error { - p.log.Info("github: webhook received, repo=" + repoFullName + ", event=" + event) - - // Look up the repository by (project_id, full_name) — full_name alone is - // only unique per-project (two projects can legitimately link the same - // repo), so scoping by the URL's own project_id avoids picking an - // arbitrary row when that happens; the previous full_name-only lookup - // could resolve to a different project's row, using its secret to - // verify a delivery meant for this project (which then just fails - // closed on the signature check) or its repo/default_branch for event - // processing. - result, err := p.db.Query( - `SELECT id, project_id, integration_id, owner, repo_name, full_name, default_branch, webhook_secret_enc FROM github_repositories WHERE full_name = $1 AND project_id = $2`, - repoFullName, projectID, - ) - if err != nil { - p.log.Error("github: failed to query repository: " + err.Error() + ", repo=" + repoFullName) - return err - } - if len(result.Rows) == 0 { - p.log.Info("github: repository not found, repo=" + repoFullName) - return nil - } - sc := newRowScanner(result.Columns, result.Rows[0]) - repoID := sc.str("id") - projectID = sc.str("project_id") - webhookSecretEnc := sc.str("webhook_secret_enc") - - // Verify HMAC signature. A missing secret fails closed rather than - // skipping verification — an unsigned/unverifiable delivery must never - // be trusted, even though every repo linked through the normal API - // always has a secret generated for it today. - if webhookSecretEnc == "" { - p.log.Error("github: repository has no webhook secret configured, refusing to process delivery, repo=" + repoFullName) - return nil - } - secret, dErr := p.decrypt(webhookSecretEnc) - if dErr != nil { - p.log.Error("github: failed to decrypt webhook secret: " + dErr.Error() + ", repo=" + repoFullName) - return dErr - } - if !verifyHMAC(payload, secret, signature) { - p.log.Info("github: invalid webhook signature, repo=" + repoFullName) - return nil // silently drop invalid signatures - } - - switch event { - case "pull_request": - p.log.Info("github: handling pull_request event, repo=" + repoFullName) - return p.handlePREvent(repoID, projectID, payload) - case "push": - return p.handlePushEvent(repoID, projectID, payload) - default: - p.log.Info("github: unhandled event type, event=" + event) - } - return nil -} - -func (p *githubPlugin) handlePREvent(repoID, projectID string, payload []byte) error { - var event struct { - Action string `json:"action"` - PullRequest ghPullRequest `json:"pull_request"` - } - if err := json.Unmarshal(payload, &event); err != nil { - p.log.Error("github: failed to parse pull_request event: " + err.Error()) - return err - } - gh := &event.PullRequest - - p.log.Info("github: processing pull_request, action=" + event.Action + ", pr_number=" + strconv.Itoa(gh.Number) + ", title=" + gh.Title + ", repo_id=" + repoID) - - state := gh.State - if gh.Merged { - state = "merged" - } - - // Read the PR's previously cached state before the upsert overwrites - // it, so we can tell a genuine open/closed/merged transition (the - // github.pr_state_changed automation trigger's source) apart from a - // re-delivered webhook or a non-state-changing action like - // "synchronize"/"labeled", both of which still update the row above but - // shouldn't re-fire an automation. previousState == "" (no existing - // row) means this is the PR's first webhook delivery — not a - // transition, so it never fires pr_state_changed either. - var previousState string - if existing, exErr := p.db.Query(`SELECT state FROM github_pull_requests WHERE repo_id = $1 AND pr_number = $2`, repoID, gh.Number); exErr == nil && len(existing.Rows) > 0 { - previousState = newRowScanner(existing.Columns, existing.Rows[0]).str("state") - } - - now := time.Now().UTC().Format(time.RFC3339Nano) - - var mergedAtStr *string - if gh.MergedAt != nil { - s := gh.MergedAt.UTC().Format(time.RFC3339Nano) - mergedAtStr = &s - } - - // Upsert the PR cache; let PostgreSQL generate id on insert, RETURNING gives us the id. - upserted, err := p.db.Query(` - INSERT INTO github_pull_requests - (project_id, repo_id, pr_number, github_pr_id, title, state, html_url, head_branch, base_branch, author, merged_at, created_at, updated_at) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13) - ON CONFLICT (repo_id, pr_number) DO UPDATE SET - title=$5, state=$6, html_url=$7, head_branch=$8, base_branch=$9, - author=$10, merged_at=$11, updated_at=$13 - RETURNING id - `, projectID, repoID, gh.Number, gh.ID, gh.Title, state, - gh.HTMLURL, gh.Head.Ref, gh.Base.Ref, gh.User.Login, mergedAtStr, now, now) - if err != nil { - p.log.Error("github: failed to upsert PR: " + err.Error() + ", repo_id=" + repoID + ", pr_number=" + strconv.Itoa(gh.Number)) - return err - } - var prID string - if len(upserted.Rows) > 0 { - prID = newRowScanner(upserted.Columns, upserted.Rows[0]).str("id") - } - - p.log.Info("github: PR saved successfully, pr_id=" + prID + ", pr_number=" + strconv.Itoa(gh.Number) + ", action=" + event.Action) - - // On "opened"/"reopened": auto-link to task if head branch is already linked. - if event.Action == "opened" || event.Action == "reopened" { - brResult, _ := p.db.Query( - `SELECT task_id FROM github_task_branches WHERE repo_id = $1 AND branch_name = $2`, - repoID, gh.Head.Ref, - ) - if brResult != nil && len(brResult.Rows) > 0 { - taskID := newRowScanner(brResult.Columns, brResult.Rows[0]).str("task_id") - _, _ = p.db.Exec(` - INSERT INTO github_task_pr_links (task_id, pull_request_id, created_at) - VALUES ($1,$2,$3) - ON CONFLICT (task_id, pull_request_id) DO NOTHING - `, taskID, prID, now) - - plugin.EmitEvent("github.pr_linked", map[string]any{ - "project_id": projectID, - "task_id": taskID, - "repo_id": repoID, - "pr_number": gh.Number, - }) - p.log.Info("github: PR auto-linked to task, task_id=" + taskID + ", pr_number=" + strconv.Itoa(gh.Number)) - } - } - - // Emit PR updated for all linked tasks — and, when the derived - // open/closed/merged state actually transitioned since the last - // webhook delivery, the more specific pr_state_changed event too (the - // github.pr_state_changed automation trigger's source). - stateChanged := previousState != "" && previousState != state - linkedResult, _ := p.db.Query(`SELECT task_id FROM github_task_pr_links WHERE pull_request_id = $1`, prID) - if linkedResult != nil { - for _, row := range linkedResult.Rows { - taskID := newRowScanner(linkedResult.Columns, row).str("task_id") - plugin.EmitEvent("github.pr_updated", map[string]any{ - "project_id": projectID, - "task_id": taskID, - "repo_id": repoID, - "pr_number": gh.Number, - "action": event.Action, - }) - if stateChanged { - plugin.EmitEvent("github.pr_state_changed", map[string]any{ - "project_id": projectID, - "task_id": taskID, - "repo_id": repoID, - "pr_number": gh.Number, - "from_state": previousState, - "to_state": state, - }) - } - } - } - return nil -} - -func (p *githubPlugin) handlePushEvent(repoID, projectID string, payload []byte) error { - var event struct { - Ref string `json:"ref"` - Created bool `json:"created"` - Deleted bool `json:"deleted"` - } - if err := json.Unmarshal(payload, &event); err != nil || !event.Created || event.Deleted { - return nil - } - - // Extract "refs/heads/branch-name" → "branch-name". - branchName := strings.TrimPrefix(event.Ref, "refs/heads/") - if branchName == event.Ref { - return nil - } - - prefix, taskNumber, ok := extractBranchTaskRef(branchName) - if !ok { - return nil - } - - // Look up the task by project prefix and task number. - taskResult, tErr := p.db.Query(` - SELECT t.id FROM tasks t - JOIN projects pr ON pr.id = t.project_id - WHERE UPPER(pr.task_id_prefix) = UPPER($1) AND t.task_number = $2 - LIMIT 1 - `, prefix, taskNumber) - if tErr != nil || len(taskResult.Rows) == 0 { - return nil - } - taskID := newRowScanner(taskResult.Columns, taskResult.Rows[0]).str("id") - - now := time.Now().UTC().Format(time.RFC3339Nano) - _, _ = p.db.Exec(` - INSERT INTO github_task_branches (task_id, repo_id, branch_name, created_at) - VALUES ($1,$2,$3,$4) - ON CONFLICT (task_id, repo_id, branch_name) DO NOTHING - `, taskID, repoID, branchName, now) - - plugin.EmitEvent("github.branch_linked", map[string]any{ - "project_id": projectID, - "task_id": taskID, - "repo_id": repoID, - "branch_name": branchName, - }) - return nil -} - -// ─── Helpers ────────────────────────────────────────────────────────────────── - -func verifyHMAC(payload []byte, secret, signatureHeader string) bool { - const prefix = "sha256=" - if !strings.HasPrefix(signatureHeader, prefix) { - return false - } - expected := signatureHeader[len(prefix):] - mac := hmac.New(sha256.New, []byte(secret)) - mac.Write(payload) - got := hex.EncodeToString(mac.Sum(nil)) - return hmac.Equal([]byte(got), []byte(expected)) -} - -func extractRepoFullName(payload []byte) string { - var v struct { - Repository struct { - FullName string `json:"full_name"` - } `json:"repository"` - } - if err := json.Unmarshal(payload, &v); err != nil { - return "" - } - return v.Repository.FullName -} - -func extractBranchTaskRef(branchName string) (prefix string, taskNumber int64, ok bool) { - m := branchTaskRefRe.FindStringSubmatch(branchName) - if m == nil { - return "", 0, false - } - n, err := strconv.ParseInt(m[2], 10, 64) - if err != nil || n <= 0 { - return "", 0, false - } - return strings.ToUpper(m[1]), n, true -} diff --git a/backend/webhook_test.go b/backend/webhook_test.go deleted file mode 100644 index 6a1422c..0000000 --- a/backend/webhook_test.go +++ /dev/null @@ -1,45 +0,0 @@ -package main - -import "testing" - -// The gate is the reason a repository hook subscribed to check_run — which -// GitHub delivers once per CI job per state — costs this plugin nothing. -func TestHandlesWebhookEvent(t *testing.T) { - cases := map[string]bool{ - "pull_request": true, - "push": true, - "check_run": false, - "check_suite": false, - "workflow_run": false, - "issues": false, - "": false, - } - for event, want := range cases { - if got := handlesWebhookEvent(event); got != want { - t.Errorf("handlesWebhookEvent(%q) = %v, want %v", event, got, want) - } - } -} - -// A check_run delivery — one per CI job per state — must cost this plugin -// nothing: no repository lookup, no HMAC work, no dispatch. Asserted on the -// log lines, since receiveWebhook answers 204 either way. -func TestReceiveWebhook_IgnoresUnhandledEventType(t *testing.T) { - tc := setupPlugin(t) - seedEncryptedRepo(t, tc, "repo-1", testProjectID, "octocat/hello-world", "my-projects-secret") - - payload := []byte(`{"repository":{"full_name":"octocat/hello-world"},"action":"completed"}`) - req := signedWebhookRequest(testProjectID, payload, "my-projects-secret") - req.Headers["X-Github-Event"] = "check_run" - - res := tc.Call("POST", "/webhook", req) - if res.StatusCode != 204 { - t.Fatalf("expected 204, got %d: %s", res.StatusCode, res.BodyString()) - } - if !tc.Log.HasMessage("ignoring event this plugin does not handle") { - t.Fatalf("expected the unhandled event to be dropped before dispatch; log entries: %+v", tc.Log.Entries()) - } - if tc.Log.HasMessage("webhook received") { - t.Fatalf("unhandled event reached handleWebhookEvent (repository lookup + HMAC); log entries: %+v", tc.Log.Entries()) - } -} diff --git a/frontend/bun.lock b/frontend/bun.lock deleted file mode 100644 index 22b2484..0000000 --- a/frontend/bun.lock +++ /dev/null @@ -1,275 +0,0 @@ -{ - "lockfileVersion": 1, - "configVersion": 1, - "workspaces": { - "": { - "name": "@paca-ai/github-plugin", - "dependencies": { - "@tanstack/react-query": "^5.99.2", - "lucide-react": "^0.511.0", - "react": "^19.0.0", - "react-dom": "^19.0.0", - }, - "devDependencies": { - "@originjs/vite-plugin-federation": "^1.3.6", - "@paca-ai/plugin-sdk-react": "^0.2.0", - "@types/react": "^19.2.0", - "@types/react-dom": "^19.2.0", - "@vitejs/plugin-react": "^4.3.0", - "typescript": "^5.8.3", - "vite": "^6.0.0", - }, - }, - }, - "packages": { - "@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], - - "@babel/compat-data": ["@babel/compat-data@7.29.3", "", {}, "sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg=="], - - "@babel/core": ["@babel/core@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/traverse": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA=="], - - "@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="], - - "@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.28.6", "", { "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA=="], - - "@babel/helper-globals": ["@babel/helper-globals@7.28.0", "", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="], - - "@babel/helper-module-imports": ["@babel/helper-module-imports@7.28.6", "", { "dependencies": { "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw=="], - - "@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.6", "", { "dependencies": { "@babel/helper-module-imports": "^7.28.6", "@babel/helper-validator-identifier": "^7.28.5", "@babel/traverse": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA=="], - - "@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.28.6", "", {}, "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug=="], - - "@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="], - - "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], - - "@babel/helper-validator-option": ["@babel/helper-validator-option@7.27.1", "", {}, "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg=="], - - "@babel/helpers": ["@babel/helpers@7.29.2", "", { "dependencies": { "@babel/template": "^7.28.6", "@babel/types": "^7.29.0" } }, "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw=="], - - "@babel/parser": ["@babel/parser@7.29.3", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA=="], - - "@babel/plugin-transform-react-jsx-self": ["@babel/plugin-transform-react-jsx-self@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw=="], - - "@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw=="], - - "@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="], - - "@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="], - - "@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], - - "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="], - - "@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="], - - "@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="], - - "@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="], - - "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="], - - "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="], - - "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="], - - "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="], - - "@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="], - - "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="], - - "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="], - - "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="], - - "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="], - - "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="], - - "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="], - - "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="], - - "@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="], - - "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="], - - "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="], - - "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="], - - "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="], - - "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="], - - "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="], - - "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="], - - "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="], - - "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="], - - "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], - - "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], - - "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], - - "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], - - "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], - - "@originjs/vite-plugin-federation": ["@originjs/vite-plugin-federation@1.4.1", "", { "dependencies": { "estree-walker": "^3.0.2", "magic-string": "^0.27.0" } }, "sha512-Uo08jW5pj1t58OUKuZNkmzcfTN2pqeVuAWCCiKf/75/oll4Efq4cHOqSE1FXMlvwZNGDziNdDyBbQ5IANem3CQ=="], - - "@paca-ai/plugin-sdk-react": ["@paca-ai/plugin-sdk-react@0.2.0", "", { "peerDependencies": { "@tanstack/react-query": ">=5.0.0", "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-tiSM9DHx9zw+lqtsLyyMd9gww5nvpljTKm08me/Kg8NLjJSH7AH57zYfgL4QoQdpNxfPtZWylqeUh7ioZbbaVQ=="], - - "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="], - - "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.60.3", "", { "os": "android", "cpu": "arm" }, "sha512-x35CNW/ANXG3hE/EZpRU8MXX1JDN86hBb2wMGAtltkz7pc6cxgjpy1OMMfDosOQ+2hWqIkag/fGok1Yady9nGw=="], - - "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.60.3", "", { "os": "android", "cpu": "arm64" }, "sha512-xw3xtkDApIOGayehp2+Rz4zimfkaX65r4t47iy+ymQB2G4iJCBBfj0ogVg5jpvjpn8UWn/+q9tprxleYeNp3Hw=="], - - "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.60.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-vo6Y5Qfpx7/5EaamIwi0WqW2+zfiusVihKatLvtN1VFVy3D13uERk/6gZLU1UiHRL6fDXqj/ELIeVRGnvcTE1g=="], - - "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.60.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-D+0QGcZhBzTN82weOnsSlY7V7+RMmPuF1CkbxyMAGE8+ZHeUjyb76ZiWmBlCu//AQQONvxcqRbwZTajZKqjuOw=="], - - "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.60.3", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-6HnvHCT7fDyj6R0Ph7A6x8dQS/S38MClRWeDLqc0MdfWkxjiu1HSDYrdPhqSILzjTIC/pnXbbJbo+ft+gy/9hQ=="], - - "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.60.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-KHLgC3WKlUYW3ShFKnnosZDOJ0xjg9zp7au3sIm2bs/tGBeC2ipmvRh/N7JKi0t9Ue20C0dpEshi8WUubg+cnA=="], - - "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.60.3", "", { "os": "linux", "cpu": "arm" }, "sha512-DV6fJoxEYWJOvaZIsok7KrYl0tPvga5OZ2yvKHNNYyk/2roMLqQAbGhr78EQ5YhHpnhLKJD3S1WFusAkmUuV5g=="], - - "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.60.3", "", { "os": "linux", "cpu": "arm" }, "sha512-mQKoJAzvuOs6F+TZybQO4GOTSMUu7v0WdxEk24krQ/uUxXoPTtHjuaUuPmFhtBcM4K0ons8nrE3JyhTuCFtT/w=="], - - "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.60.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-Whjj2qoiJ6+OOJMGptTYazaJvjOJm+iKHpXQM1P3LzGjt7Ff++Tp7nH4N8J/BUA7R9IHfDyx4DJIflifwnbmIA=="], - - "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.60.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-4YTNHKqGng5+yiZt3mg77nmyuCfmNfX4fPmyUapBcIk+BdwSwmCWGXOUxhXbBEkFHtoN5boLj/5NON+u5QC9tg=="], - - "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.60.3", "", { "os": "linux", "cpu": "none" }, "sha512-SU3kNlhkpI4UqlUc2VXPGK9o886ZsSeGfMAX2ba2b8DKmMXq4AL7KUrkSWVbb7koVqx41Yczx6dx5PNargIrEA=="], - - "@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.60.3", "", { "os": "linux", "cpu": "none" }, "sha512-6lDLl5h4TXpB1mTf2rQWnAk/LcXrx9vBfu/DT5TIPhvMhRWaZ5MxkIc8u4lJAmBo6klTe1ywXIUHFjylW505sg=="], - - "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.60.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-BMo8bOw8evlup/8G+cj5xWtPyp93xPdyoSN16Zy90Q2QZ0ZYRhCt6ZJSwbrRzG9HApFabjwj2p25TUPDWrhzqQ=="], - - "@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.60.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-E0L8X1dZN1/Rph+5VPF6Xj2G7JJvMACVXtamTJIDrVI44Y3K+G8gQaMEAavbqCGTa16InptiVrX6eM6pmJ+7qA=="], - - "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.60.3", "", { "os": "linux", "cpu": "none" }, "sha512-oZJ/WHaVfHUiRAtmTAeo3DcevNsVvH8mbvodjZy7D5QKvCefO371SiKRpxoDcCxB3PTRTLayWBkvmDQKTcX/sw=="], - - "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.60.3", "", { "os": "linux", "cpu": "none" }, "sha512-Dhbyh7j9FybM3YaTgaHmVALwA8AkUwTPccyCQ79TG9AJUsMQqgN1DDEZNr4+QUfwiWvLDumW5vdwzoeUF+TNxQ=="], - - "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.60.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-cJd1X5XhHHlltkaypz1UcWLA8AcoIi1aWhsvaWDskD1oz2eKCypnqvTQ8ykMNI0RSmm7NkTdSqSSD7zM0xa6Ig=="], - - "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.60.3", "", { "os": "linux", "cpu": "x64" }, "sha512-DAZDBHQfG2oQuhY7mc6I3/qB4LU2fQCjRvxbDwd/Jdvb9fypP4IJ4qmtu6lNjes6B531AI8cg1aKC2di97bUxA=="], - - "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.60.3", "", { "os": "linux", "cpu": "x64" }, "sha512-cRxsE8c13mZOh3vP+wLDxpQBRrOHDIGOWyDL93Sy0Ga8y515fBcC2pjUfFwUe5T7tqvTvWbCpg1URM/AXdWIXA=="], - - "@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.60.3", "", { "os": "openbsd", "cpu": "x64" }, "sha512-QaWcIgRxqEdQdhJqW4DJctsH6HCmo5vHxY0krHSX4jMtOqfzC+dqDGuHM87bu4H8JBeibWx7jFz+h6/4C8wA5Q=="], - - "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.60.3", "", { "os": "none", "cpu": "arm64" }, "sha512-AaXwSvUi3QIPtroAUw1t5yHGIyqKEXwH54WUocFolZhpGDruJcs8c+xPNDRn4XiQsS7MEwnYsHW2l0MBLDMkWg=="], - - "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.60.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-65LAKM/bAWDqKNEelHlcHvm2V+Vfb8C6INFxQXRHCvaVN1rJfwr4NvdP4FyzUaLqWfaCGaadf6UbTm8xJeYfEg=="], - - "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.60.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-EEM2gyhBF5MFnI6vMKdX1LAosE627RGBzIoGMdLloPZkXrUN0Ckqgr2Qi8+J3zip/8NVVro3/FjB+tjhZUgUHA=="], - - "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.60.3", "", { "os": "win32", "cpu": "x64" }, "sha512-E5Eb5H/DpxaoXH++Qkv28RcUJboMopmdDUALBczvHMf7hNIxaDZqwY5lK12UK1BHacSmvupoEWGu+n993Z0y1A=="], - - "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.60.3", "", { "os": "win32", "cpu": "x64" }, "sha512-hPt/bgL5cE+Qp+/TPHBqptcAgPzgj46mPcg/16zNUmbQk0j+mOEQV/+Lqu8QRtDV3Ek95Q6FeFITpuhl6OTsAA=="], - - "@tanstack/query-core": ["@tanstack/query-core@5.100.10", "", {}, "sha512-8UR0yJR+GiQ40m3lPhUr0xbfAupe6GSQiksSBSa9SM2NjezFyxXCIA69/lz8cSoNKZLrw1/PktIyQBJcVeMi3w=="], - - "@tanstack/react-query": ["@tanstack/react-query@5.100.10", "", { "dependencies": { "@tanstack/query-core": "5.100.10" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-FLaZf2RCrA/Zgp4aiu5tG3TyasTRO7aZ99skxQpr3Hg/zXOhu6yq5FZCYQ/tRaJtM9ylnoK8tFK7PolXQadv6Q=="], - - "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], - - "@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="], - - "@types/babel__template": ["@types/babel__template@7.4.4", "", { "dependencies": { "@babel/parser": "^7.1.0", "@babel/types": "^7.0.0" } }, "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A=="], - - "@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="], - - "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], - - "@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="], - - "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], - - "@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="], - - "baseline-browser-mapping": ["baseline-browser-mapping@2.10.29", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-Asa2krT+XTPZINCS+2QcyS8WTkObE77RwkydwF7h6DmnKqbvlalz93m/dnphUyCa6SWSP51VgtEUf2FN+gelFQ=="], - - "browserslist": ["browserslist@4.28.2", "", { "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", "electron-to-chromium": "^1.5.328", "node-releases": "^2.0.36", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg=="], - - "caniuse-lite": ["caniuse-lite@1.0.30001792", "", {}, "sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw=="], - - "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], - - "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], - - "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], - - "electron-to-chromium": ["electron-to-chromium@1.5.353", "", {}, "sha512-kOrWphBi8TOZyiJZqsgqIle0lw+tzmnQK83pV9dZUd01Nm2POECSyFQMAuarzZdYqQW7FH9RaYOuaRo3h+bQ3w=="], - - "esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], - - "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], - - "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], - - "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], - - "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], - - "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], - - "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], - - "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], - - "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], - - "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], - - "lucide-react": ["lucide-react@0.511.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-VK5a2ydJ7xm8GvBeKLS9mu1pVK6ucef9780JVUjw6bAjJL/QXnd4Y0p7SPeOUMC27YhzNCZvm5d/QX0Tp3rc0w=="], - - "magic-string": ["magic-string@0.27.0", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.13" } }, "sha512-8UnnX2PeRAPZuN12svgR9j7M1uWMovg/CEnIwIG0LFkXSJJe4PdfUGiTGl8V9bsBHFUtfVINcSyYxd7q+kx9fA=="], - - "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], - - "nanoid": ["nanoid@3.3.12", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="], - - "node-releases": ["node-releases@2.0.44", "", {}, "sha512-5WUyunoPMsvvEhS8AxHtRzP+oA8UCkJ7YRxatWKjngndhDGLiqEVAQKWjFAiAiuL8zMRGzGSJxFnLetoa43qGQ=="], - - "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], - - "picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], - - "postcss": ["postcss@8.5.14", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg=="], - - "react": ["react@19.2.6", "", {}, "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q=="], - - "react-dom": ["react-dom@19.2.6", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.6" } }, "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g=="], - - "react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="], - - "rollup": ["rollup@4.60.3", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.60.3", "@rollup/rollup-android-arm64": "4.60.3", "@rollup/rollup-darwin-arm64": "4.60.3", "@rollup/rollup-darwin-x64": "4.60.3", "@rollup/rollup-freebsd-arm64": "4.60.3", "@rollup/rollup-freebsd-x64": "4.60.3", "@rollup/rollup-linux-arm-gnueabihf": "4.60.3", "@rollup/rollup-linux-arm-musleabihf": "4.60.3", "@rollup/rollup-linux-arm64-gnu": "4.60.3", "@rollup/rollup-linux-arm64-musl": "4.60.3", "@rollup/rollup-linux-loong64-gnu": "4.60.3", "@rollup/rollup-linux-loong64-musl": "4.60.3", "@rollup/rollup-linux-ppc64-gnu": "4.60.3", "@rollup/rollup-linux-ppc64-musl": "4.60.3", "@rollup/rollup-linux-riscv64-gnu": "4.60.3", "@rollup/rollup-linux-riscv64-musl": "4.60.3", "@rollup/rollup-linux-s390x-gnu": "4.60.3", "@rollup/rollup-linux-x64-gnu": "4.60.3", "@rollup/rollup-linux-x64-musl": "4.60.3", "@rollup/rollup-openbsd-x64": "4.60.3", "@rollup/rollup-openharmony-arm64": "4.60.3", "@rollup/rollup-win32-arm64-msvc": "4.60.3", "@rollup/rollup-win32-ia32-msvc": "4.60.3", "@rollup/rollup-win32-x64-gnu": "4.60.3", "@rollup/rollup-win32-x64-msvc": "4.60.3", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-pAQK9HalE84QSm4Po3EmWIZPd3FnjkShVkiMlz1iligWYkWQ7wHYd1PF/T7QZ5TVSD6uSTon5gBVMSM4JfBV+A=="], - - "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], - - "semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], - - "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], - - "tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="], - - "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], - - "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], - - "vite": ["vite@6.4.2", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ=="], - - "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], - } -} diff --git a/frontend/index.html b/frontend/index.html deleted file mode 100644 index cb0ff5c..0000000 --- a/frontend/index.html +++ /dev/null @@ -1 +0,0 @@ -export {}; diff --git a/frontend/package.json b/frontend/package.json deleted file mode 100644 index 34db25c..0000000 --- a/frontend/package.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "name": "@paca-ai/github-plugin", - "version": "0.1.0", - "private": true, - "type": "module", - "scripts": { - "build": "vite build", - "dev": "vite build --watch", - "typecheck": "tsc --noEmit", - "preview": "vite preview" - }, - "dependencies": { - "@tanstack/react-query": "^5.99.2", - "lucide-react": "^0.511.0", - "react": "^19.0.0", - "react-dom": "^19.0.0" - }, - "devDependencies": { - "@originjs/vite-plugin-federation": "^1.3.6", - "@paca-ai/plugin-sdk-react": "^0.2.0", - "@types/react": "^19.2.0", - "@types/react-dom": "^19.2.0", - "typescript": "^5.8.3", - "vite": "^6.0.0", - "@vitejs/plugin-react": "^4.3.0" - } -} diff --git a/frontend/src/GitHubSettingsTab.tsx b/frontend/src/GitHubSettingsTab.tsx deleted file mode 100644 index c2b52b1..0000000 --- a/frontend/src/GitHubSettingsTab.tsx +++ /dev/null @@ -1,930 +0,0 @@ -import { PluginApiClient, PluginQueryClientProvider } from "@paca-ai/plugin-sdk-react"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { - AlertCircle, - BookOpen, - Check, - GitBranch, - GitPullRequest, - KeyRound, - Loader2, - Plus, - RefreshCw, - Search, - Trash2, - Unlink, - X, -} from "lucide-react"; -import { useMemo, useState } from "react"; -import { - type AccessibleRepo, - accessibleReposKey, - deleteGitHubToken, - ErrorCode, - getPluginErrorCode, - type GitHubIntegration, - getGitHubIntegration, - integrationKey, - type LinkedRepository, - linkedReposKey, - listAccessibleRepos, - listLinkedRepositories, - linkRepository, - setGitHubToken, - unlinkRepository, -} from "./github-api"; - -// ── Utilities ────────────────────────────────────────────────────────────────── - -function cn(...classes: (string | undefined | null | false)[]): string { - return classes.filter(Boolean).join(" "); -} - -// ── GitHub Icon ──────────────────────────────────────────────────────────────── - -function GitHubIcon(props: React.SVGProps) { - return ( - - GitHub - - - ); -} - -// ── Simple UI Primitives ─────────────────────────────────────────────────────── - -function Btn({ - children, - variant = "default", - size = "default", - className, - ...props -}: React.ButtonHTMLAttributes & { - variant?: "default" | "outline" | "destructive" | "ghost"; - size?: "default" | "sm"; -}) { - const base = - "inline-flex items-center justify-center gap-1.5 rounded-md font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50"; - const variants = { - default: "bg-primary text-primary-foreground hover:bg-primary/90", - outline: - "border border-input bg-background hover:bg-accent hover:text-accent-foreground", - destructive: - "bg-destructive text-destructive-foreground hover:bg-destructive/90", - ghost: "hover:bg-accent hover:text-accent-foreground", - }; - const sizes = { - default: "h-10 px-4 py-2 text-sm", - sm: "h-8 px-3 text-xs", - }; - return ( - - ); -} - -function Inp({ - className, - ...props -}: React.InputHTMLAttributes) { - return ( - - ); -} - -function Skeleton({ className }: { className?: string }) { - return
; -} - -// ── Modal ───────────────────────────────────────────────────────────────────── - -function Modal({ - open, - onClose, - children, -}: { - open: boolean; - onClose: () => void; - children: React.ReactNode; -}) { - if (!open) return null; - return ( -
- {children} -
- ); -} - -function ModalContent({ - className, - children, -}: { - className?: string; - children: React.ReactNode; -}) { - return ( -
e.stopPropagation()} - className={cn( - "relative z-10 mx-auto flex flex-col max-h-[85vh] lg:h-[85vh] overflow-hidden rounded-lg border border-border bg-background shadow-xl w-full", - className, - )} - > - {children} -
- ); -} - -// ── Token card ──────────────────────────────────────────────────────────────── - -function TokenCard({ - api, - projectId, - hasIntegration, - onTokenSet, - canEdit, -}: { - api: PluginApiClient; - projectId: string; - hasIntegration: boolean; - onTokenSet: () => void; - canEdit: boolean; -}) { - const queryClient = useQueryClient(); - const [token, setToken] = useState(""); - const [showToken, setShowToken] = useState(false); - const [error, setError] = useState(null); - const [confirmOpen, setConfirmOpen] = useState(false); - - const saveMutation = useMutation({ - mutationFn: () => setGitHubToken(api, token.trim()), - onSuccess: async () => { - await queryClient.invalidateQueries({ - queryKey: integrationKey(projectId), - }); - setToken(""); - setError(null); - onTokenSet(); - }, - onError: (err: unknown) => { - const code = getPluginErrorCode(err); - if (code === ErrorCode.GitHubInvalidToken) { - setError( - "GitHub rejected the token. Check it has the required scopes (repo, pull request, admin:repo_hook).", - ); - return; - } - setError("Failed to save token. Please try again."); - }, - }); - - const deleteMutation = useMutation({ - mutationFn: () => deleteGitHubToken(api), - onSuccess: async () => { - await queryClient.invalidateQueries({ - queryKey: integrationKey(projectId), - }); - await queryClient.removeQueries({ - queryKey: linkedReposKey(projectId), - }); - await queryClient.removeQueries({ - queryKey: accessibleReposKey(projectId), - }); - setConfirmOpen(false); - }, - onError: () => { - setError("Failed to remove token. Please try again."); - }, - }); - - if (hasIntegration) { - return ( - <> -
-
-
- -
-
-

Personal access token saved

-

- Token is stored encrypted. It is never returned by the API. -

-
-
- {canEdit && ( - setConfirmOpen(true)} - disabled={deleteMutation.isPending} - > - {deleteMutation.isPending ? ( - - ) : ( - - )} - Remove token - - )} -
- - setConfirmOpen(false)}> - -
- -
-

- Remove GitHub token -

-

- Removing the token will also unlink all repositories and disable - webhook events. This action cannot be undone. -

- {deleteMutation.isError && ( -

- Failed to remove token. Please try again. -

- )} -
- setConfirmOpen(false)} - > - Cancel - - deleteMutation.mutate()} - > - {deleteMutation.isPending ? ( - <> - - Removing… - - ) : ( - "Remove token" - )} - -
-
-
- - ); - } - - return ( -
-

- Add a GitHub personal access token with{" "} - - repo - {" "} - (includes{" "} - - contents - {" "} - and{" "} - - pull requests - {" "} - permissions) and the{" "} - - admin:repo_hook - {" "} - scope to link repositories, create branches, and track pull requests. -

-
-
- { - setToken(e.target.value); - setError(null); - }} - placeholder="ghp_xxxxxxxxxxxxxxxxxxxx" - disabled={!canEdit || saveMutation.isPending} - className={cn( - "pr-8 font-mono text-sm", - error ? "border-destructive focus-visible:ring-destructive/30" : "", - )} - autoComplete="off" - onKeyDown={(e) => { - if (e.key === "Enter" && token.trim()) saveMutation.mutate(); - }} - /> - -
- saveMutation.mutate()} - className="shrink-0" - > - {saveMutation.isPending ? ( - - ) : null} - Save token - -
- {error ?

{error}

: null} -
- ); -} - -// ── Add repository dialog ───────────────────────────────────────────────────── - -function AddRepoDialog({ - api, - projectId, - open, - onOpenChange, -}: { - api: PluginApiClient; - projectId: string; - open: boolean; - onOpenChange: (open: boolean) => void; -}) { - const queryClient = useQueryClient(); - const { - data: repos = [], - isLoading, - isFetching, - } = useQuery({ - queryKey: accessibleReposKey(projectId), - queryFn: () => listAccessibleRepos(api), - enabled: open, - staleTime: 0, - }); - const [search, setSearch] = useState(""); - const [error, setError] = useState(null); - - const linkMutation = useMutation({ - mutationFn: (repo: AccessibleRepo) => - linkRepository(api, repo.owner, repo.repo_name), - onSuccess: async () => { - await queryClient.invalidateQueries({ - queryKey: linkedReposKey(projectId), - }); - setError(null); - onOpenChange(false); - }, - onError: (err: unknown) => { - const code = getPluginErrorCode(err); - if (code === ErrorCode.GitHubWebhookURLNotPublic) { - setError( - "Cannot register webhook because this API URL is not publicly reachable (for example localhost). Configure PUBLIC_URL to a public HTTPS URL and try again.", - ); - return; - } - if (code === ErrorCode.GitHubWebhookCreationFailed) { - setError( - "Could not create the webhook. Make sure your token has the admin:repo_hook scope and you have admin access to the repository.", - ); - return; - } - if (code === ErrorCode.GitHubRepoAlreadyLinked) { - setError("This repository is already linked to the project."); - return; - } - if (code === ErrorCode.GitHubRepoNotAccessible) { - setError( - "Repository not found or not accessible. Check that your token has the repo scope.", - ); - return; - } - setError("Failed to link repository. Please try again."); - }, - }); - - const filtered = repos.filter((r) => - r.full_name.toLowerCase().includes(search.toLowerCase()), - ); - - function handleOpenChange(next: boolean) { - if (!next) { - setSearch(""); - setError(null); - } - onOpenChange(next); - } - - return ( - handleOpenChange(false)}> - -
-
- -
-

Add repository

-
-

- Select a repository from your GitHub account to link to this project. - A webhook will be registered automatically. -

- - {/* Search + reload */} -
-
- - setSearch(e.target.value)} - className="pl-9 text-sm" - // biome-ignore lint/a11y/noAutofocus: intentional dialog autofocus - autoFocus - /> -
- -
- - {error ? ( -

- {error} -

- ) : null} - - {/* Repo list */} -
-
- {isLoading ? ( - [0, 1, 2, 3].map((i) => ( - - )) - ) : repos.length === 0 ? ( -
- -

No accessible repositories found.

-
- ) : filtered.length === 0 ? ( -

- No repositories match “{search}&rdquo -

- ) : ( - filtered.map((repo) => ( - - )) - )} -
-
- -
- handleOpenChange(false)} - > - Close - -
-
-
- ); -} - -// ── Linked repo item ────────────────────────────────────────────────────────── - -function LinkedRepoItem({ - api, - projectId, - repo, - canEdit, -}: { - api: PluginApiClient; - projectId: string; - repo: LinkedRepository; - canEdit: boolean; -}) { - const queryClient = useQueryClient(); - const [confirmOpen, setConfirmOpen] = useState(false); - - const unlinkMutation = useMutation({ - mutationFn: () => unlinkRepository(api, repo.id), - onSuccess: async () => { - await queryClient.invalidateQueries({ - queryKey: linkedReposKey(projectId), - }); - setConfirmOpen(false); - }, - }); - - return ( - <> -
-
-
- -
-
- - {repo.full_name} - -

- Default branch:{" "} - {repo.default_branch} -

-
-
- {canEdit && ( - setConfirmOpen(true)} - > - - Unlink - - )} -
- - setConfirmOpen(false)}> - -
- -
-

- Unlink repository -

-

- This will remove the link to{" "} - - {repo.full_name} - {" "} - and attempt to delete the webhook from GitHub. -

- {unlinkMutation.isError && ( -

- Failed to unlink. Please try again. -

- )} -
- setConfirmOpen(false)} - > - Cancel - - unlinkMutation.mutate()} - > - {unlinkMutation.isPending ? ( - <> - - Unlinking… - - ) : ( - "Unlink repository" - )} - -
-
-
- - ); -} - -// ── Main Settings ───────────────────────────────────────────────────────────── - -function GitHubSettingsInner({ - api, - projectId, - canEdit, -}: { - api: PluginApiClient; - projectId: string; - canEdit: boolean; -}) { - const { data: integration, isLoading: integrationLoading } = useQuery< - GitHubIntegration | undefined - >({ - queryKey: integrationKey(projectId), - queryFn: () => getGitHubIntegration(api), - retry: false, - throwOnError: false, - }); - - const { data: linkedRepos = [], isLoading: reposLoading } = useQuery< - LinkedRepository[] - >({ - queryKey: linkedReposKey(projectId), - queryFn: () => listLinkedRepositories(api), - retry: false, - throwOnError: false, - enabled: !!integration, - }); - - const queryClient = useQueryClient(); - const hasIntegration = integration?.connected === true; - const hasRepos = linkedRepos.length > 0; - - const [addRepoOpen, setAddRepoOpen] = useState(false); - - const steps = [ - { num: 1, label: "Connect a GitHub token", done: hasIntegration }, - { num: 2, label: "Link a repository", done: hasRepos }, - ]; - - return ( -
- {/* Header card */} -
-
- -

- GitHub Integration -

-
-

- Link GitHub repositories to track pull requests, create branches from - tasks, and receive webhook events automatically. You can link multiple - repositories to a single project. -

- - {/* Progress steps */} -
- {steps.map((step, idx) => ( -
-
- {step.done ? : step.num} -
- - {step.label} - - {idx < steps.length - 1 && ( -
- )} -
- ))} -
- - {/* Token section */} -
-
- - -
- {integrationLoading ? ( - - ) : ( - setAddRepoOpen(true)} - canEdit={canEdit} - /> - )} -
-
- - {/* Repository section — only after token */} - {hasIntegration && ( -
-
-
- -

- Linked Repositories -

- -
- {canEdit && ( - setAddRepoOpen(true)} - > - - Add repository - - )} -
-

- {hasRepos - ? "Webhooks are registered automatically for each linked repository." - : "No repositories linked yet. Link a repository to track pull requests and branches."} -

- - {reposLoading ? ( -
- - -
- ) : ( -
- {linkedRepos.map((repo) => ( - - ))} - - {!hasRepos && ( -
- -

No repositories linked yet.

- {canEdit && ( - setAddRepoOpen(true)} - > - - Add your first repository - - )} -
- )} -
- )} - - {canEdit && ( - - )} -
- )} - - {/* Webhook hint */} - {hasRepos && ( -
- -

- Webhooks are registered on all linked repositories. GitHub will push{" "} - pull_request events to keep PR - status in sync automatically. -

-
- )} - - {/* No integration hint */} - {!hasIntegration && !integrationLoading && ( -
- -

- No GitHub integration configured. Add a personal access token to get - started. -

-
- )} -
- ); -} - -// ── Public export ───────────────────────────────────────────────────────────── - -interface GitHubSettingsTabProps { - projectId: string; - canEdit?: boolean; -} - -export default function GitHubSettingsTab({ - projectId, - canEdit = true, -}: GitHubSettingsTabProps) { - const api = useMemo( - () => - new PluginApiClient({ - baseUrl: `${window.location.origin}/api/v1`, - projectId, - fetch: (url, init) => - window.fetch(url, { ...init, credentials: "include" }), - }), - [projectId], - ); - - return ( - - - - ); -} diff --git a/frontend/src/GitHubTaskSection.tsx b/frontend/src/GitHubTaskSection.tsx deleted file mode 100644 index 3e716f0..0000000 --- a/frontend/src/GitHubTaskSection.tsx +++ /dev/null @@ -1,1185 +0,0 @@ -import { PluginApiClient, PluginQueryClientProvider } from "@paca-ai/plugin-sdk-react"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { - Check, - ChevronDown, - ChevronRight, - ClipboardCopy, - ExternalLink, - GitBranch, - GitMerge, - GitPullRequest, - GitPullRequestClosed, - Link2, - Loader2, - Plus, - Terminal, - Trash2, -} from "lucide-react"; -import { useMemo, useState } from "react"; -import { - type CreateBranchResult, - createBranch, - ErrorCode, - getPluginErrorCode, - type LinkedRepository, - linkBranchToTask, - linkedReposKey, - listLinkedRepositories, - listTaskBranches, - listTaskPRs, - linkPRToTask, - type PullRequest, - type TaskBranch, - taskBranchesKey, - taskPRsKey, - unlinkPRFromTask, -} from "./github-api"; - -// ── Utilities ────────────────────────────────────────────────────────────────── - -function cn(...classes: (string | undefined | null | false)[]): string { - return classes.filter(Boolean).join(" "); -} - -// ─────────────────────── Pull Requests Section ──────────────────────────────── - -// ── PR state badge ──────────────────────────────────────────────────────────── - -function PRStateBadge({ state }: { state: PullRequest["state"] }) { - if (state === "merged") { - return ( - - - Merged - - ); - } - if (state === "closed") { - return ( - - - Closed - - ); - } - return ( - - - Open - - ); -} - -// ── Single PR row ───────────────────────────────────────────────────────────── - -function PRRow({ - api, - pr, - projectId, - taskId, - canEdit, -}: { - api: PluginApiClient; - pr: PullRequest; - projectId: string; - taskId: string; - canEdit: boolean; -}) { - const queryClient = useQueryClient(); - - const unlinkMutation = useMutation({ - mutationFn: () => unlinkPRFromTask(api, taskId, pr.id), - onSuccess: () => { - queryClient.invalidateQueries({ - queryKey: taskPRsKey(projectId, taskId), - }); - }, - }); - - return ( -
-
- -
-
- - {pr.title} - - -
- - #{pr.pr_number} - - · - - {pr.head_branch} - - {pr.author && ( - <> - · - - by {pr.author} - - - )} -
-
- {canEdit && ( - - )} -
- ); -} - -// ── Helpers ─────────────────────────────────────────────────────────────────── - -function parseGitHubPRUrl( - raw: string, -): { fullName: string; prNumber: number } | null { - try { - const url = new URL(raw.trim()); - if (url.hostname !== "github.com") return null; - const parts = url.pathname.replace(/^\//, "").split("/"); - if (parts.length < 4 || parts[2] !== "pull") return null; - const prNumber = Number(parts[3]); - if (!Number.isInteger(prNumber) || prNumber <= 0) return null; - return { fullName: `${parts[0]}/${parts[1]}`, prNumber }; - } catch { - return null; - } -} - -// ── Link PR form ────────────────────────────────────────────────────────────── - -function LinkPRForm({ - api, - projectId, - taskId, - repos, - onDone, -}: { - api: PluginApiClient; - projectId: string; - taskId: string; - repos: LinkedRepository[]; - onDone: () => void; -}) { - const queryClient = useQueryClient(); - const [selectedRepoId, setSelectedRepoId] = useState( - repos.length === 1 ? repos[0].id : "", - ); - const [value, setValue] = useState(""); - const [error, setError] = useState(null); - - const parsed = parseGitHubPRUrl(value); - const urlMatchedRepo = parsed - ? (repos.find((r) => r.full_name === parsed.fullName) ?? null) - : null; - const effectiveRepoId = parsed ? (urlMatchedRepo?.id ?? "") : selectedRepoId; - - const mutation = useMutation({ - mutationFn: () => { - const prNum = parsed ? parsed.prNumber : Number(value); - return linkPRToTask(api, taskId, effectiveRepoId, prNum); - }, - onSuccess: async () => { - await queryClient.invalidateQueries({ - queryKey: taskPRsKey(projectId, taskId), - }); - setValue(""); - setError(null); - onDone(); - }, - onError: (err: unknown) => { - const code = getPluginErrorCode(err); - if (code === ErrorCode.GitHubIntegrationNotFound) { - setError("No GitHub token configured for this project."); - return; - } - if (code === ErrorCode.GitHubRepositoryNotFound) { - setError("Repository not found. It may have been unlinked."); - return; - } - if (code === ErrorCode.GitHubPRNotFound) { - const displayNum = parsed ? parsed.prNumber : value; - setError( - `PR #${displayNum} was not found in the selected repository.`, - ); - return; - } - if (code === ErrorCode.GitHubPRAlreadyLinked) { - const displayNum = parsed ? parsed.prNumber : value; - setError(`PR #${displayNum} is already linked to this task.`); - return; - } - if (code === ErrorCode.GitHubTokenInsufficientPermissions) { - setError( - "Your GitHub token does not have permission to read pull requests. Update it in Project Settings > GitHub.", - ); - return; - } - setError("Failed to link pull request. Please try again."); - }, - }); - - function submit() { - if (parsed) { - if (!urlMatchedRepo) { - setError( - `Repository "${parsed.fullName}" is not linked to this project.`, - ); - return; - } - } else { - if (!effectiveRepoId) { - setError("Select a repository."); - return; - } - const num = Number(value); - if (!value.trim() || !Number.isInteger(num) || num <= 0) { - setError("Enter a valid PR number or paste a GitHub PR URL."); - return; - } - } - mutation.mutate(); - } - - return ( -
- {/* Repository selector */} -
-

Repository

- -
- - {/* PR number or URL */} -
-

- PR number or GitHub URL -

- { - setValue(e.target.value); - setError(null); - }} - onKeyDown={(e) => { - if (e.key === "Enter") submit(); - if (e.key === "Escape") onDone(); - }} - placeholder="42 or https://github.com/owner/repo/pull/42" - className={cn( - "w-full rounded-md border bg-background px-2.5 py-1.5 text-xs focus:outline-none focus:ring-1 focus:ring-ring", - error ? "border-destructive" : "border-border/60", - )} - // biome-ignore lint/a11y/noAutofocus: intentional for inline form - autoFocus - disabled={mutation.isPending} - /> - {parsed && urlMatchedRepo && ( -

- Will link PR #{parsed.prNumber} from{" "} - {urlMatchedRepo.full_name} -

- )} -
- - {error && ( -

- {error} -

- )} - - {/* Action buttons */} -
- - -
-
- ); -} - -// ── Pull Requests section ───────────────────────────────────────────────────── - -function PullRequestsSection({ - api, - projectId, - taskId, - canEdit, -}: { - api: PluginApiClient; - projectId: string; - taskId: string; - canEdit: boolean; -}) { - const { data: prs = [], isLoading } = useQuery({ - queryKey: taskPRsKey(projectId, taskId), - queryFn: () => listTaskPRs(api, taskId), - retry: false, - throwOnError: false, - }); - const { data: linkedRepos = [] } = useQuery({ - queryKey: linkedReposKey(projectId), - queryFn: () => listLinkedRepositories(api), - retry: false, - throwOnError: false, - }); - const [expanded, setExpanded] = useState(true); - const [linking, setLinking] = useState(false); - - const count = prs.length; - const canLinkPR = canEdit && linkedRepos.length > 0; - - return ( -
- - - {expanded && ( -
- {isLoading ? ( -
- - Loading… -
- ) : ( - <> - {prs.map((pr) => ( - - ))} - - {count === 0 && !linking && ( -

- No pull requests linked yet. -

- )} - - {linking ? ( - setLinking(false)} - /> - ) : ( - canLinkPR && ( - - ) - )} - - )} -
- )} -
- ); -} - -// ─────────────────────── Branches Section ───────────────────────────────────── - -// ── Copy button ─────────────────────────────────────────────────────────────── - -function CopyButton({ text }: { text: string }) { - const [copied, setCopied] = useState(false); - - const handleCopy = () => { - navigator.clipboard?.writeText(text)?.catch(() => {}); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - }; - - return ( - - ); -} - -// ── Command block ───────────────────────────────────────────────────────────── - -function CommandBlock({ command }: { command: string }) { - return ( -
- - - {command} - - -
- ); -} - -// ── Existing branch row ─────────────────────────────────────────────────────── - -function BranchRow({ branch }: { branch: TaskBranch }) { - const cloneCmd = `git fetch origin && git checkout ${branch.branch_name}`; - return ( -
-
- - - {branch.branch_name} - -
- -
- ); -} - -// ── Branch creation form ────────────────────────────────────────────────────── - -const BRANCH_TYPES = [ - "feat", - "fix", - "chore", - "docs", - "test", - "refactor", -] as const; - -function CreateBranchForm({ - api, - projectId, - taskId, - taskIdPrefix, - taskNumber, - taskTitle, - repos, - onDone, -}: { - api: PluginApiClient; - projectId: string; - taskId: string; - taskIdPrefix: string; - taskNumber: number; - taskTitle?: string; - repos: { id: string; full_name: string }[]; - onDone: () => void; -}) { - const queryClient = useQueryClient(); - - const taskRef = taskIdPrefix - ? `${taskIdPrefix.toUpperCase()}-${taskNumber}` - : `${taskNumber}`; - - const defaultSlug = taskTitle - ? `-${taskTitle - .toLowerCase() - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-|-$/g, "") - .slice(0, 30)}` - : ""; - - const [type, setType] = useState<(typeof BRANCH_TYPES)[number]>("feat"); - const [branchName, setBranchName] = useState( - `${type}/${taskRef}${defaultSlug}`, - ); - const [selectedRepoId, setSelectedRepoId] = useState( - repos.length === 1 ? repos[0].id : "", - ); - const [sourceBranch, setSourceBranch] = useState(""); - const [error, setError] = useState(null); - - function handleTypeChange(newType: (typeof BRANCH_TYPES)[number]) { - setType(newType); - setBranchName((prev) => { - const slash = prev.indexOf("/"); - const rest = slash >= 0 ? prev.slice(slash) : `/${taskRef}`; - return `${newType}${rest}`; - }); - } - - const createMutation = useMutation({ - mutationFn: (): Promise => - createBranch( - api, - taskId, - selectedRepoId, - branchName, - sourceBranch || undefined, - ), - onSuccess: () => { - queryClient.invalidateQueries({ - queryKey: taskBranchesKey(projectId, taskId), - }); - onDone(); - }, - onError: (err: unknown) => { - const code = getPluginErrorCode(err); - if (code === ErrorCode.GitHubIntegrationNotFound) { - setError("No GitHub token configured for this project."); - return; - } - if (code === ErrorCode.GitHubRepositoryNotFound) { - setError("Repository not found. It may have been unlinked."); - return; - } - if (code === ErrorCode.GitHubBranchAlreadyLinked) { - setError("This branch is already linked to the task."); - return; - } - if (code === ErrorCode.GitHubTokenInsufficientPermissions) { - setError( - "Your GitHub token does not have permission to create branches. Please update it in Project Settings > GitHub with a token that has the repo (contents) scope.", - ); - return; - } - setError("Failed to create branch. Please try again."); - }, - }); - - const localCmd = `git checkout -b ${branchName} && git push -u origin ${branchName}`; - - function validateForm(): boolean { - if (!branchName.trim()) { - setError("Branch name is required."); - return false; - } - if (repos.length > 1 && !selectedRepoId) { - setError("Select a repository."); - return false; - } - return true; - } - - function handleCreate() { - setError(null); - if (!validateForm()) return; - createMutation.mutate(); - } - - return ( -
- {/* Branch type pills */} -
-

Type

-
- {BRANCH_TYPES.map((t) => ( - - ))} -
-
- - {/* Branch name */} -
-

Branch name

- { - setBranchName(e.target.value); - setError(null); - }} - placeholder={`feat/${taskRef}`} - className="w-full rounded-md border border-border/60 bg-background px-2.5 py-1.5 text-xs font-mono focus:outline-none focus:ring-1 focus:ring-ring" - spellCheck={false} - /> -
- - {/* Repo selector — only when multiple repos */} - {repos.length > 1 && ( -
-

Repository

- -
- )} - - {/* Source branch (optional) */} -
-

- Source branch{" "} - (optional, defaults to repo default) -

- setSourceBranch(e.target.value)} - placeholder="main" - className="w-full rounded-md border border-border/60 bg-background px-2.5 py-1.5 text-xs font-mono focus:outline-none focus:ring-1 focus:ring-ring" - spellCheck={false} - /> -
- - {error && ( -

- {error} -

- )} - - {/* Action buttons */} -
- - -
-

- Or create locally: -

- -
-
- - -
- ); -} - -// ── Helpers ─────────────────────────────────────────────────────────────────── - -function parseGitHubBranchUrl( - raw: string, -): { fullName: string; branchName: string } | null { - try { - const url = new URL(raw.trim()); - if (url.hostname !== "github.com") return null; - const parts = url.pathname.replace(/^\//, "").split("/"); - if (parts.length < 4 || parts[2] !== "tree") return null; - const branchName = parts.slice(3).join("/"); - if (!branchName) return null; - return { fullName: `${parts[0]}/${parts[1]}`, branchName }; - } catch { - return null; - } -} - -// ── Link branch form ────────────────────────────────────────────────────────── - -function LinkBranchForm({ - api, - projectId, - taskId, - repos, - onDone, -}: { - api: PluginApiClient; - projectId: string; - taskId: string; - repos: LinkedRepository[]; - onDone: () => void; -}) { - const queryClient = useQueryClient(); - const [selectedRepoId, setSelectedRepoId] = useState( - repos.length === 1 ? repos[0].id : "", - ); - const [value, setValue] = useState(""); - const [error, setError] = useState(null); - - const parsed = parseGitHubBranchUrl(value); - const urlMatchedRepo = parsed - ? (repos.find((r) => r.full_name === parsed.fullName) ?? null) - : null; - const effectiveRepoId = parsed ? (urlMatchedRepo?.id ?? "") : selectedRepoId; - const branchName = parsed ? parsed.branchName : value.trim(); - - const mutation = useMutation({ - mutationFn: () => linkBranchToTask(api, taskId, effectiveRepoId, branchName), - onSuccess: async () => { - await queryClient.invalidateQueries({ - queryKey: taskBranchesKey(projectId, taskId), - }); - setValue(""); - setError(null); - onDone(); - }, - onError: (err: unknown) => { - const code = getPluginErrorCode(err); - if (code === ErrorCode.GitHubIntegrationNotFound) { - setError("No GitHub token configured for this project."); - return; - } - if (code === ErrorCode.GitHubRepositoryNotFound) { - setError("Repository not found. It may have been unlinked."); - return; - } - if (code === ErrorCode.GitHubBranchNotFound) { - setError( - `Branch "${branchName}" was not found in the selected repository.`, - ); - return; - } - if (code === ErrorCode.GitHubBranchAlreadyLinked) { - setError(`Branch "${branchName}" is already linked to this task.`); - return; - } - if (code === ErrorCode.GitHubTokenInsufficientPermissions) { - setError( - "Your GitHub token does not have permission to read branches. Update it in Project Settings > GitHub.", - ); - return; - } - setError("Failed to link branch. Please try again."); - }, - }); - - function submit() { - if (parsed) { - if (!urlMatchedRepo) { - setError( - `Repository "${parsed.fullName}" is not linked to this project.`, - ); - return; - } - } else { - if (!effectiveRepoId) { - setError("Select a repository."); - return; - } - if (!branchName) { - setError("Enter a branch name or paste a GitHub branch URL."); - return; - } - } - mutation.mutate(); - } - - return ( -
- {/* Repository selector */} -
-

Repository

- -
- - {/* Branch name or URL */} -
-

- Branch name or GitHub URL -

- { - setValue(e.target.value); - setError(null); - }} - onKeyDown={(e) => { - if (e.key === "Enter") submit(); - if (e.key === "Escape") onDone(); - }} - placeholder="feature/foo or https://github.com/owner/repo/tree/feature/foo" - className={cn( - "w-full rounded-md border bg-background px-2.5 py-1.5 text-xs font-mono focus:outline-none focus:ring-1 focus:ring-ring", - error ? "border-destructive" : "border-border/60", - )} - spellCheck={false} - // biome-ignore lint/a11y/noAutofocus: intentional for inline form - autoFocus - disabled={mutation.isPending} - /> - {parsed && urlMatchedRepo && ( -

- Will link branch{" "} - - {parsed.branchName} - {" "} - from {urlMatchedRepo.full_name} -

- )} -
- - {error && ( -

- {error} -

- )} - - {/* Action buttons */} -
- - -
-
- ); -} - -// ── Branches section ────────────────────────────────────────────────────────── - -function BranchesSection({ - api, - projectId, - taskId, - taskIdPrefix, - taskNumber, - taskTitle, - canEdit, -}: { - api: PluginApiClient; - projectId: string; - taskId: string; - taskIdPrefix: string; - taskNumber: number; - taskTitle?: string; - canEdit: boolean; -}) { - const [expanded, setExpanded] = useState(true); - const [mode, setMode] = useState<"create" | "link" | null>(null); - - const { data: branches = [], isLoading } = useQuery({ - queryKey: taskBranchesKey(projectId, taskId), - queryFn: () => listTaskBranches(api, taskId), - retry: false, - throwOnError: false, - }); - - const { data: linkedRepos = [] } = useQuery({ - queryKey: linkedReposKey(projectId), - queryFn: () => listLinkedRepositories(api), - retry: false, - throwOnError: false, - }); - - const count = branches.length; - const canCreate = canEdit && linkedRepos.length > 0; - - return ( -
- - - {expanded && ( -
- {isLoading ? ( -
- - Loading… -
- ) : ( - <> - {branches.map((branch) => ( - - ))} - - {count === 0 && mode === null && ( -

- No branches linked yet. -

- )} - - {mode === "create" && ( - setMode(null)} - /> - )} - - {mode === "link" && ( - setMode(null)} - /> - )} - - {mode === null && canCreate && ( -
- - -
- )} - - )} -
- )} -
- ); -} - -// ─────────────────────── Main Task Section ──────────────────────────────────── - -interface GitHubTaskSectionProps { - projectId: string; - taskId: string; - canEdit?: boolean; -} - -function GitHubTaskSectionInner({ - api, - projectId, - taskId, - canEdit, -}: { - api: PluginApiClient; - projectId: string; - taskId: string; - canEdit: boolean; -}) { - // Fetch task and project metadata needed for branch creation - const { data: task } = useQuery({ - queryKey: ["plugin", "task", taskId], - queryFn: () => api.getTask(taskId), - staleTime: 60_000, - }); - - const { data: project } = useQuery({ - queryKey: ["plugin", "project", projectId], - queryFn: () => api.getProject(), - staleTime: 60_000, - }); - - const { data: linkedRepos, isLoading: reposLoading } = useQuery({ - queryKey: linkedReposKey(projectId), - queryFn: () => listLinkedRepositories(api), - retry: false, - throwOnError: false, - }); - - const taskIdPrefix = project?.task_id_prefix ?? ""; - const taskNumber = task?.task_number ?? 0; - const taskTitle = task?.title; - - // Don't render anything until we know whether repos are linked - if (reposLoading) return null; - if (!linkedRepos || linkedRepos.length === 0) return null; - - return ( -
- - -
- ); -} - -export default function GitHubTaskSection({ - projectId, - taskId, - canEdit = true, -}: GitHubTaskSectionProps) { - const api = useMemo( - () => - new PluginApiClient({ - baseUrl: `${window.location.origin}/api/v1`, - projectId, - fetch: (url, init) => - window.fetch(url, { ...init, credentials: "include" }), - }), - [projectId], - ); - - return ( - - - - ); -} diff --git a/frontend/src/github-api.ts b/frontend/src/github-api.ts deleted file mode 100644 index 94ee217..0000000 --- a/frontend/src/github-api.ts +++ /dev/null @@ -1,254 +0,0 @@ -import { type PluginApiClient } from "@paca-ai/plugin-sdk-react"; - -const PLUGIN_ID = "com.paca.github"; - -// ── Error codes ──────────────────────────────────────────────────────────────── - -export const ErrorCode = { - GitHubIntegrationNotFound: "GITHUB_INTEGRATION_NOT_FOUND", - GitHubRepositoryNotFound: "GITHUB_REPOSITORY_NOT_FOUND", - GitHubPRNotFound: "GITHUB_PR_NOT_FOUND", - GitHubPRAlreadyLinked: "GITHUB_PR_ALREADY_LINKED", - GitHubInvalidToken: "GITHUB_INVALID_TOKEN", - GitHubRepoNotAccessible: "GITHUB_REPO_NOT_ACCESSIBLE", - GitHubRepoAlreadyLinked: "GITHUB_REPO_ALREADY_LINKED", - GitHubWebhookCreationFailed: "GITHUB_WEBHOOK_CREATION_FAILED", - GitHubWebhookURLNotPublic: "GITHUB_WEBHOOK_URL_NOT_PUBLIC", - GitHubBranchAlreadyLinked: "GITHUB_BRANCH_ALREADY_LINKED", - GitHubBranchNotFound: "GITHUB_BRANCH_NOT_FOUND", - GitHubTokenInsufficientPermissions: "GITHUB_TOKEN_INSUFFICIENT_PERMISSIONS", - BadRequest: "BAD_REQUEST", -} as const; - -export type ErrorCodeValue = (typeof ErrorCode)[keyof typeof ErrorCode]; - -/** - * Extracts the error_code field from a PluginApiClient error. - * The React SDK throws: `[PluginApiClient] METHOD URL → STATUS: BODY` - * where BODY is the JSON from the plugin backend. - */ -export function getPluginErrorCode(err: unknown): ErrorCodeValue | null { - if (!(err instanceof Error)) return null; - const arrowIdx = err.message.lastIndexOf("→ "); - if (arrowIdx === -1) return null; - const rest = err.message.slice(arrowIdx + 2); - const colonIdx = rest.indexOf(": "); - if (colonIdx === -1) return null; - const maybeJson = rest.slice(colonIdx + 2); - try { - const body = JSON.parse(maybeJson) as { error_code?: string }; - const code = body.error_code; - if (!code) return null; - const known = Object.values(ErrorCode) as string[]; - return known.includes(code) ? (code as ErrorCodeValue) : null; - } catch { - return null; - } -} - -// ── Domain types ─────────────────────────────────────────────────────────────── - -export interface GitHubIntegration { - id?: string; - project_id: string; - connected: boolean; - created_at?: string; - updated_at?: string; -} - -export interface AccessibleRepo { - full_name: string; - owner: string; - repo_name: string; - default_branch: string; - private: boolean; - description: string; -} - -export interface LinkedRepository { - id: string; - project_id: string; - owner: string; - repo_name: string; - full_name: string; - default_branch: string; - clone_url: string; - webhook_active: boolean; - created_at: string; - updated_at: string; -} - -export interface PullRequest { - id: string; - project_id: string; - repo_id: string; - pr_number: number; - github_pr_id: number; - title: string; - state: "open" | "closed" | "merged"; - html_url: string; - head_branch: string; - base_branch: string; - author: string; - merged_at: string | null; - created_at: string; - updated_at: string; -} - -export interface TaskBranch { - id: string; - task_id: string; - repo_id: string; - branch_name: string; - created_at: string; -} - -export interface CreateBranchResult { - branch_name: string; -} - -// ── Query key factories ──────────────────────────────────────────────────────── - -export const integrationKey = (projectId: string) => - [PLUGIN_ID, "integration", projectId] as const; - -export const linkedReposKey = (projectId: string) => - [PLUGIN_ID, "linked-repos", projectId] as const; - -export const accessibleReposKey = (projectId: string) => - [PLUGIN_ID, "accessible-repos", projectId] as const; - -export const taskPRsKey = (projectId: string, taskId: string) => - [PLUGIN_ID, "prs", projectId, taskId] as const; - -export const taskBranchesKey = (projectId: string, taskId: string) => - [PLUGIN_ID, "branches", projectId, taskId] as const; - -// ── API functions ────────────────────────────────────────────────────────────── - -export async function getGitHubIntegration( - api: PluginApiClient, -): Promise { - return api.pluginGet(PLUGIN_ID, `/projects/${api.projectId}/integration`); -} - -export async function setGitHubToken( - api: PluginApiClient, - token: string, -): Promise { - return api.pluginPost(PLUGIN_ID, `/projects/${api.projectId}/integration/token`, { - token, - }); -} - -export async function deleteGitHubToken(api: PluginApiClient): Promise { - return api.pluginDelete(PLUGIN_ID, `/projects/${api.projectId}/integration/token`); -} - -export async function listAccessibleRepos( - api: PluginApiClient, -): Promise { - return api.pluginGet(PLUGIN_ID, `/projects/${api.projectId}/integration/accessible-repos`); -} - -export async function linkRepository( - api: PluginApiClient, - owner: string, - repoName: string, -): Promise { - return api.pluginPost( - PLUGIN_ID, - `/projects/${api.projectId}/repositories`, - { owner, repo_name: repoName }, - ); -} - -export async function listLinkedRepositories( - api: PluginApiClient, -): Promise { - return api.pluginGet( - PLUGIN_ID, - `/projects/${api.projectId}/repositories`, - ); -} - -export async function unlinkRepository( - api: PluginApiClient, - repoId: string, -): Promise { - return api.pluginDelete( - PLUGIN_ID, - `/projects/${api.projectId}/repositories/${repoId}`, - ); -} - -export async function listTaskPRs( - api: PluginApiClient, - taskId: string, -): Promise { - return api.pluginGet( - PLUGIN_ID, - `/projects/${api.projectId}/tasks/${taskId}/pull-requests`, - ); -} - -export async function linkPRToTask( - api: PluginApiClient, - taskId: string, - repoId: string, - prNumber: number, -): Promise { - return api.pluginPost( - PLUGIN_ID, - `/projects/${api.projectId}/tasks/${taskId}/pull-requests/link`, - { repo_id: repoId, pr_number: prNumber }, - ); -} - -export async function unlinkPRFromTask( - api: PluginApiClient, - taskId: string, - prId: string, -): Promise { - return api.pluginDelete( - PLUGIN_ID, - `/projects/${api.projectId}/tasks/${taskId}/pull-requests/${prId}`, - ); -} - -export async function listTaskBranches( - api: PluginApiClient, - taskId: string, -): Promise { - return api.pluginGet( - PLUGIN_ID, - `/projects/${api.projectId}/tasks/${taskId}/branches`, - ); -} - -export async function createBranch( - api: PluginApiClient, - taskId: string, - repoId: string, - branchName: string, - sourceBranch?: string, -): Promise { - return api.pluginPost( - PLUGIN_ID, - `/projects/${api.projectId}/tasks/${taskId}/branches`, - { repo_id: repoId, branch_name: branchName, source_branch: sourceBranch }, - ); -} - -export async function linkBranchToTask( - api: PluginApiClient, - taskId: string, - repoId: string, - branchName: string, -): Promise { - return api.pluginPost( - PLUGIN_ID, - `/projects/${api.projectId}/tasks/${taskId}/branches/link`, - { repo_id: repoId, branch_name: branchName }, - ); -} diff --git a/frontend/src/index.ts b/frontend/src/index.ts deleted file mode 100644 index ce601ee..0000000 --- a/frontend/src/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { default as GitHubSettingsTab } from "./GitHubSettingsTab"; -export { default as GitHubTaskSection } from "./GitHubTaskSection"; diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json deleted file mode 100644 index 1d14c6f..0000000 --- a/frontend/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2020", - "lib": ["ES2020", "DOM", "DOM.Iterable"], - "module": "ESNext", - "moduleResolution": "bundler", - "jsx": "react-jsx", - "strict": true, - "skipLibCheck": true, - "noEmit": true, - "paths": { - "@paca-ai/plugin-sdk-react": ["../../plugin-sdk-react/src/index.ts"] - } - }, - "include": ["src"] -} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts deleted file mode 100644 index 242d6bf..0000000 --- a/frontend/vite.config.ts +++ /dev/null @@ -1,27 +0,0 @@ -import react from "@vitejs/plugin-react"; -import federation from "@originjs/vite-plugin-federation"; -import { defineConfig } from "vite"; - -export default defineConfig({ - plugins: [ - react(), - federation({ - name: "com_paca_github", - filename: "remoteEntry.js", - exposes: { - "./GitHubSettingsTab": "./src/GitHubSettingsTab.tsx", - "./GitHubTaskSection": "./src/GitHubTaskSection.tsx", - }, - shared: { - react: { requiredVersion: "^19.0.0" }, - "react-dom": { requiredVersion: "^19.0.0" }, - "@tanstack/react-query": { requiredVersion: "^5.0.0" }, - }, - }), - ], - build: { - target: "esnext", - minify: false, - cssCodeSplit: false, - }, -}); diff --git a/mcp/bun.lock b/mcp/bun.lock deleted file mode 100644 index 59a72f0..0000000 --- a/mcp/bun.lock +++ /dev/null @@ -1,329 +0,0 @@ -{ - "lockfileVersion": 1, - "configVersion": 1, - "workspaces": { - "": { - "name": "@paca-ai/github-plugin-mcp", - "dependencies": { - "@paca-ai/plugin-sdk-mcp": "^0.2.0", - }, - "devDependencies": { - "typescript": "^5.8.3", - "vite": "^6.0.0", - }, - }, - }, - "packages": { - "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="], - - "@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="], - - "@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="], - - "@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="], - - "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="], - - "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="], - - "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="], - - "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="], - - "@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="], - - "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="], - - "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="], - - "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="], - - "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="], - - "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="], - - "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="], - - "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="], - - "@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="], - - "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="], - - "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="], - - "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="], - - "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="], - - "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="], - - "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="], - - "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="], - - "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="], - - "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="], - - "@hono/node-server": ["@hono/node-server@1.19.14", "", { "peerDependencies": { "hono": "^4" } }, "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw=="], - - "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="], - - "@paca-ai/plugin-sdk-mcp": ["@paca-ai/plugin-sdk-mcp@0.2.0", "", { "peerDependencies": { "@modelcontextprotocol/sdk": ">=0.6.0" } }, "sha512-wpZqKLmfoO98OO2xRfCtrXzfRzJX06yr48LCfu+dE2p1kpNUyWHvp1XkTPOLHTBBOuebMwSDcuZT94u+egv4uA=="], - - "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.60.3", "", { "os": "android", "cpu": "arm" }, "sha512-x35CNW/ANXG3hE/EZpRU8MXX1JDN86hBb2wMGAtltkz7pc6cxgjpy1OMMfDosOQ+2hWqIkag/fGok1Yady9nGw=="], - - "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.60.3", "", { "os": "android", "cpu": "arm64" }, "sha512-xw3xtkDApIOGayehp2+Rz4zimfkaX65r4t47iy+ymQB2G4iJCBBfj0ogVg5jpvjpn8UWn/+q9tprxleYeNp3Hw=="], - - "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.60.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-vo6Y5Qfpx7/5EaamIwi0WqW2+zfiusVihKatLvtN1VFVy3D13uERk/6gZLU1UiHRL6fDXqj/ELIeVRGnvcTE1g=="], - - "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.60.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-D+0QGcZhBzTN82weOnsSlY7V7+RMmPuF1CkbxyMAGE8+ZHeUjyb76ZiWmBlCu//AQQONvxcqRbwZTajZKqjuOw=="], - - "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.60.3", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-6HnvHCT7fDyj6R0Ph7A6x8dQS/S38MClRWeDLqc0MdfWkxjiu1HSDYrdPhqSILzjTIC/pnXbbJbo+ft+gy/9hQ=="], - - "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.60.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-KHLgC3WKlUYW3ShFKnnosZDOJ0xjg9zp7au3sIm2bs/tGBeC2ipmvRh/N7JKi0t9Ue20C0dpEshi8WUubg+cnA=="], - - "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.60.3", "", { "os": "linux", "cpu": "arm" }, "sha512-DV6fJoxEYWJOvaZIsok7KrYl0tPvga5OZ2yvKHNNYyk/2roMLqQAbGhr78EQ5YhHpnhLKJD3S1WFusAkmUuV5g=="], - - "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.60.3", "", { "os": "linux", "cpu": "arm" }, "sha512-mQKoJAzvuOs6F+TZybQO4GOTSMUu7v0WdxEk24krQ/uUxXoPTtHjuaUuPmFhtBcM4K0ons8nrE3JyhTuCFtT/w=="], - - "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.60.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-Whjj2qoiJ6+OOJMGptTYazaJvjOJm+iKHpXQM1P3LzGjt7Ff++Tp7nH4N8J/BUA7R9IHfDyx4DJIflifwnbmIA=="], - - "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.60.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-4YTNHKqGng5+yiZt3mg77nmyuCfmNfX4fPmyUapBcIk+BdwSwmCWGXOUxhXbBEkFHtoN5boLj/5NON+u5QC9tg=="], - - "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.60.3", "", { "os": "linux", "cpu": "none" }, "sha512-SU3kNlhkpI4UqlUc2VXPGK9o886ZsSeGfMAX2ba2b8DKmMXq4AL7KUrkSWVbb7koVqx41Yczx6dx5PNargIrEA=="], - - "@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.60.3", "", { "os": "linux", "cpu": "none" }, "sha512-6lDLl5h4TXpB1mTf2rQWnAk/LcXrx9vBfu/DT5TIPhvMhRWaZ5MxkIc8u4lJAmBo6klTe1ywXIUHFjylW505sg=="], - - "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.60.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-BMo8bOw8evlup/8G+cj5xWtPyp93xPdyoSN16Zy90Q2QZ0ZYRhCt6ZJSwbrRzG9HApFabjwj2p25TUPDWrhzqQ=="], - - "@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.60.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-E0L8X1dZN1/Rph+5VPF6Xj2G7JJvMACVXtamTJIDrVI44Y3K+G8gQaMEAavbqCGTa16InptiVrX6eM6pmJ+7qA=="], - - "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.60.3", "", { "os": "linux", "cpu": "none" }, "sha512-oZJ/WHaVfHUiRAtmTAeo3DcevNsVvH8mbvodjZy7D5QKvCefO371SiKRpxoDcCxB3PTRTLayWBkvmDQKTcX/sw=="], - - "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.60.3", "", { "os": "linux", "cpu": "none" }, "sha512-Dhbyh7j9FybM3YaTgaHmVALwA8AkUwTPccyCQ79TG9AJUsMQqgN1DDEZNr4+QUfwiWvLDumW5vdwzoeUF+TNxQ=="], - - "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.60.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-cJd1X5XhHHlltkaypz1UcWLA8AcoIi1aWhsvaWDskD1oz2eKCypnqvTQ8ykMNI0RSmm7NkTdSqSSD7zM0xa6Ig=="], - - "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.60.3", "", { "os": "linux", "cpu": "x64" }, "sha512-DAZDBHQfG2oQuhY7mc6I3/qB4LU2fQCjRvxbDwd/Jdvb9fypP4IJ4qmtu6lNjes6B531AI8cg1aKC2di97bUxA=="], - - "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.60.3", "", { "os": "linux", "cpu": "x64" }, "sha512-cRxsE8c13mZOh3vP+wLDxpQBRrOHDIGOWyDL93Sy0Ga8y515fBcC2pjUfFwUe5T7tqvTvWbCpg1URM/AXdWIXA=="], - - "@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.60.3", "", { "os": "openbsd", "cpu": "x64" }, "sha512-QaWcIgRxqEdQdhJqW4DJctsH6HCmo5vHxY0krHSX4jMtOqfzC+dqDGuHM87bu4H8JBeibWx7jFz+h6/4C8wA5Q=="], - - "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.60.3", "", { "os": "none", "cpu": "arm64" }, "sha512-AaXwSvUi3QIPtroAUw1t5yHGIyqKEXwH54WUocFolZhpGDruJcs8c+xPNDRn4XiQsS7MEwnYsHW2l0MBLDMkWg=="], - - "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.60.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-65LAKM/bAWDqKNEelHlcHvm2V+Vfb8C6INFxQXRHCvaVN1rJfwr4NvdP4FyzUaLqWfaCGaadf6UbTm8xJeYfEg=="], - - "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.60.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-EEM2gyhBF5MFnI6vMKdX1LAosE627RGBzIoGMdLloPZkXrUN0Ckqgr2Qi8+J3zip/8NVVro3/FjB+tjhZUgUHA=="], - - "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.60.3", "", { "os": "win32", "cpu": "x64" }, "sha512-E5Eb5H/DpxaoXH++Qkv28RcUJboMopmdDUALBczvHMf7hNIxaDZqwY5lK12UK1BHacSmvupoEWGu+n993Z0y1A=="], - - "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.60.3", "", { "os": "win32", "cpu": "x64" }, "sha512-hPt/bgL5cE+Qp+/TPHBqptcAgPzgj46mPcg/16zNUmbQk0j+mOEQV/+Lqu8QRtDV3Ek95Q6FeFITpuhl6OTsAA=="], - - "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], - - "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], - - "ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], - - "ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], - - "body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="], - - "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], - - "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], - - "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], - - "content-disposition": ["content-disposition@1.1.0", "", {}, "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g=="], - - "content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="], - - "cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], - - "cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="], - - "cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="], - - "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], - - "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], - - "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], - - "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], - - "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], - - "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], - - "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], - - "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], - - "es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="], - - "esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], - - "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], - - "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="], - - "eventsource": ["eventsource@3.0.7", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="], - - "eventsource-parser": ["eventsource-parser@3.0.8", "", {}, "sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ=="], - - "express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="], - - "express-rate-limit": ["express-rate-limit@8.5.1", "", { "dependencies": { "ip-address": "^10.2.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-5O6KYmyJEpuPJV5hNTXKbAHWRqrzyu+OI3vUnSd2kXFubIVpG7ezpgxQy76Zo5GQZtrQBg86hF+CM/NX+cioiQ=="], - - "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], - - "fast-uri": ["fast-uri@3.1.2", "", {}, "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ=="], - - "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], - - "finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="], - - "forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="], - - "fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="], - - "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], - - "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], - - "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], - - "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], - - "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], - - "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], - - "hasown": ["hasown@2.0.3", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg=="], - - "hono": ["hono@4.12.18", "", {}, "sha512-RWzP96k/yv0PQfyXnWjs6zot20TqfpfsNXhOnev8d1InAxubW93L11/oNUc3tQqn2G0bSdAOBpX+2uDFHV7kdQ=="], - - "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], - - "iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], - - "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], - - "ip-address": ["ip-address@10.2.0", "", {}, "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA=="], - - "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], - - "is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="], - - "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], - - "jose": ["jose@6.2.3", "", {}, "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw=="], - - "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], - - "json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="], - - "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], - - "media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="], - - "merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="], - - "mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], - - "mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], - - "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], - - "nanoid": ["nanoid@3.3.12", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="], - - "negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], - - "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], - - "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], - - "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="], - - "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], - - "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], - - "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], - - "path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="], - - "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], - - "picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], - - "pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="], - - "postcss": ["postcss@8.5.14", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg=="], - - "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], - - "qs": ["qs@6.15.1", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg=="], - - "range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="], - - "raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="], - - "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], - - "rollup": ["rollup@4.60.3", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.60.3", "@rollup/rollup-android-arm64": "4.60.3", "@rollup/rollup-darwin-arm64": "4.60.3", "@rollup/rollup-darwin-x64": "4.60.3", "@rollup/rollup-freebsd-arm64": "4.60.3", "@rollup/rollup-freebsd-x64": "4.60.3", "@rollup/rollup-linux-arm-gnueabihf": "4.60.3", "@rollup/rollup-linux-arm-musleabihf": "4.60.3", "@rollup/rollup-linux-arm64-gnu": "4.60.3", "@rollup/rollup-linux-arm64-musl": "4.60.3", "@rollup/rollup-linux-loong64-gnu": "4.60.3", "@rollup/rollup-linux-loong64-musl": "4.60.3", "@rollup/rollup-linux-ppc64-gnu": "4.60.3", "@rollup/rollup-linux-ppc64-musl": "4.60.3", "@rollup/rollup-linux-riscv64-gnu": "4.60.3", "@rollup/rollup-linux-riscv64-musl": "4.60.3", "@rollup/rollup-linux-s390x-gnu": "4.60.3", "@rollup/rollup-linux-x64-gnu": "4.60.3", "@rollup/rollup-linux-x64-musl": "4.60.3", "@rollup/rollup-openbsd-x64": "4.60.3", "@rollup/rollup-openharmony-arm64": "4.60.3", "@rollup/rollup-win32-arm64-msvc": "4.60.3", "@rollup/rollup-win32-ia32-msvc": "4.60.3", "@rollup/rollup-win32-x64-gnu": "4.60.3", "@rollup/rollup-win32-x64-msvc": "4.60.3", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-pAQK9HalE84QSm4Po3EmWIZPd3FnjkShVkiMlz1iligWYkWQ7wHYd1PF/T7QZ5TVSD6uSTon5gBVMSM4JfBV+A=="], - - "router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="], - - "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], - - "send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="], - - "serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="], - - "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], - - "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], - - "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], - - "side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="], - - "side-channel-list": ["side-channel-list@1.0.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4" } }, "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w=="], - - "side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="], - - "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], - - "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], - - "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], - - "tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="], - - "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], - - "type-is": ["type-is@2.0.1", "", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="], - - "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], - - "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], - - "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], - - "vite": ["vite@6.4.2", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ=="], - - "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], - - "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], - - "zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], - - "zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="], - } -} diff --git a/mcp/package.json b/mcp/package.json deleted file mode 100644 index 91e5ce3..0000000 --- a/mcp/package.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "name": "@paca-ai/github-plugin-mcp", - "version": "0.1.0", - "private": true, - "type": "module", - "scripts": { - "build": "vite build", - "dev": "vite build --watch", - "typecheck": "tsc --noEmit" - }, - "dependencies": { - "@paca-ai/plugin-sdk-mcp": "^0.2.0" - }, - "devDependencies": { - "typescript": "^5.8.3", - "vite": "^6.0.0" - } -} diff --git a/mcp/src/index.ts b/mcp/src/index.ts deleted file mode 100644 index 8f081ec..0000000 --- a/mcp/src/index.ts +++ /dev/null @@ -1,820 +0,0 @@ -import { - PluginAPIClient, - type PluginMCPContext, - type PluginMCPEntry, - type Tool, - errorResult, - textResult, -} from "@paca-ai/plugin-sdk-mcp"; - -// ── Domain types ────────────────────────────────────────────────────────────── - -interface GitHubIntegration { - id: string; - project_id: string; - created_at: string; - updated_at: string; -} - -interface LinkedRepository { - id: string; - project_id: string; - owner: string; - repo_name: string; - full_name: string; - default_branch: string; - clone_url: string; - webhook_active: boolean; - created_at: string; - updated_at: string; -} - - -interface PullRequest { - id: string; - task_id: string; - repo_id: string; - pr_number: number; - title: string; - state: "open" | "closed" | "merged"; - html_url: string; - head_branch: string; - author: string | null; - merged_at: string | null; - created_at: string; -} - -interface PullRequestDetails { - owner: string; - repo_name: string; - pr_number: number; - title: string; - state: string; - body: string; - html_url: string; - diff: string; -} - -interface CICheck { - name: string; - status: string; - conclusion: string; - url: string; -} - -interface PullRequestCIStatus { - state: string; - checks: CICheck[]; -} - -interface TaskBranch { - id: string; - task_id: string; - repo_id: string; - branch_name: string; - created_at: string; -} - -interface CreateBranchResult { - branch_name: string; - repo_full_name: string; - html_url: string; -} - -interface CreatePullRequestResult { - id: string; - project_id: string; - repo_id: string; - pr_number: number; - github_pr_id: number; - title: string; - state: string; - html_url: string; - head_branch: string; - base_branch: string; - author: string; - merged_at: string | null; - created_at: string; - updated_at: string; -} - -// ── Formatting helpers ──────────────────────────────────────────────────────── - -function formatIntegration(integration: GitHubIntegration): string { - return `GitHub Integration: -ID: ${integration.id} -Project ID: ${integration.project_id} -Created: ${integration.created_at} -Updated: ${integration.updated_at}`; -} - -function formatLinkedRepo(repo: LinkedRepository): string { - return `Repository: ${repo.full_name} -ID: ${repo.id} -Owner: ${repo.owner} -Repo Name: ${repo.repo_name} -Default Branch: ${repo.default_branch} -Clone URL: ${repo.clone_url} -Webhook Active: ${repo.webhook_active ? "Yes" : "No"} -Created: ${repo.created_at}`; -} - -function formatPullRequest(pr: PullRequest): string { - return `Pull Request: #${pr.pr_number} - ${pr.title} -ID: ${pr.id} -State: ${pr.state} -Author: ${pr.author ?? "Unknown"} -URL: ${pr.html_url} -Head Branch: ${pr.head_branch} -Created: ${pr.created_at} -Merged: ${pr.merged_at ? `Yes (${pr.merged_at})` : "No"}`; -} - -function formatPullRequestDetails(pr: PullRequestDetails): string { - return `Pull Request: #${pr.pr_number} - ${pr.title} (${pr.owner}/${pr.repo_name}) -State: ${pr.state} -URL: ${pr.html_url} -Description: ${pr.body || "(none)"} - -Diff: -${pr.diff || "(no changes)"}`; -} - -function formatCIStatus(ci: PullRequestCIStatus): string { - if (ci.checks.length === 0) { - return "CI status: unknown (no checks found for this commit)."; - } - const checkLines = ci.checks - .map((c) => `- ${c.name}: ${c.status}${c.conclusion ? ` (${c.conclusion})` : ""}`) - .join("\n"); - return `CI status: ${ci.state}\n\n${checkLines}`; -} - -function formatBranch(branch: TaskBranch): string { - return `Branch: ${branch.branch_name} -ID: ${branch.id} -Task ID: ${branch.task_id} -Repo ID: ${branch.repo_id} -Created: ${branch.created_at}`; -} - -function formatList(items: T[], formatter: (item: T) => string): string { - if (items.length === 0) return "(none)"; - return items.map(formatter).join("\n\n---\n\n"); -} - -// ── Tool definitions ────────────────────────────────────────────────────────── - -const UUID_DESC = - "The technical UUID of the %s (e.g., '550e8400-e29b-41d4-a716-446655440000')."; - -const projectIdProp = { - type: "string", - description: - UUID_DESC.replace("%s", "project") + - " Use list_projects to get the project ID. Do NOT use the project name.", -}; - -const taskIdProp = { - type: "string", - description: - UUID_DESC.replace("%s", "task") + " Use list_tasks to get the task ID.", -}; - -const tools: Tool[] = [ - // ── Integration ────────────────────────────────────────────────────────── - { - name: "github_get_integration", - description: "Get GitHub integration status for a project.", - inputSchema: { - type: "object", - properties: { - projectId: projectIdProp, - }, - required: ["projectId"], - }, - }, - { - name: "github_set_token", - description: - "Set (or replace) the GitHub personal access token for a project. The token must have at least the 'repo' scope.", - inputSchema: { - type: "object", - properties: { - projectId: projectIdProp, - token: { - type: "string", - description: "The GitHub personal access token (e.g., 'ghp_xxxx').", - }, - }, - required: ["projectId", "token"], - }, - }, - { - name: "github_delete_token", - description: "Delete the GitHub token for a project, removing GitHub integration.", - inputSchema: { - type: "object", - properties: { - projectId: projectIdProp, - }, - required: ["projectId"], - }, - }, - // ── Repositories ───────────────────────────────────────────────────────── - { - name: "github_list_linked_repos", - description: "List GitHub repositories linked to a project.", - inputSchema: { - type: "object", - properties: { - projectId: projectIdProp, - }, - required: ["projectId"], - }, - }, - { - name: "github_link_repository", - description: - "Link a GitHub repository to a project. The repository must be accessible with the project's GitHub token.", - inputSchema: { - type: "object", - properties: { - projectId: projectIdProp, - owner: { - type: "string", - description: "The repository owner (GitHub username or org).", - }, - repo_name: { - type: "string", - description: "The repository name (not including the owner).", - }, - }, - required: ["projectId", "owner", "repo_name"], - }, - }, - { - name: "github_unlink_repository", - description: "Unlink a GitHub repository from a project.", - inputSchema: { - type: "object", - properties: { - projectId: projectIdProp, - repoId: { - type: "string", - description: - UUID_DESC.replace("%s", "linked repository") + - " Use github_list_linked_repos to get the repo ID.", - }, - }, - required: ["projectId", "repoId"], - }, - }, - // ── Pull Requests ───────────────────────────────────────────────────────── - { - name: "github_list_task_prs", - description: "List pull requests linked to a task.", - inputSchema: { - type: "object", - properties: { - projectId: projectIdProp, - taskId: taskIdProp, - }, - required: ["projectId", "taskId"], - }, - }, - { - name: "github_link_pr_to_task", - description: "Link a GitHub pull request to a task.", - inputSchema: { - type: "object", - properties: { - projectId: projectIdProp, - taskId: taskIdProp, - repoId: { - type: "string", - description: - UUID_DESC.replace("%s", "linked repository") + - " Use github_list_linked_repos to get the repo ID.", - }, - pr_number: { - type: "number", - description: "The GitHub pull request number (e.g., 42).", - }, - }, - required: ["projectId", "taskId", "repoId", "pr_number"], - }, - }, - { - name: "github_unlink_pr_from_task", - description: "Unlink a pull request from a task.", - inputSchema: { - type: "object", - properties: { - projectId: projectIdProp, - taskId: taskIdProp, - prId: { - type: "string", - description: - UUID_DESC.replace("%s", "linked pull request") + - " Use github_list_task_prs to get the PR ID.", - }, - }, - required: ["projectId", "taskId", "prId"], - }, - }, - { - name: "github_create_pull_request", - description: - "Create a new pull request on GitHub for a task. The pull request will be created on the linked repository and automatically linked to the task. Returns the PR URL.", - inputSchema: { - type: "object", - properties: { - projectId: projectIdProp, - taskId: taskIdProp, - repoId: { - type: "string", - description: - UUID_DESC.replace("%s", "linked repository") + - " Use github_list_linked_repos to get the repo ID.", - }, - title: { - type: "string", - description: - "The title for the pull request (e.g., 'feat: add user authentication').", - }, - head_branch: { - type: "string", - description: - "The name of the branch that contains the changes (the source branch, e.g., 'feat/PROJ-42-add-feature').", - }, - base_branch: { - type: "string", - description: - "The name of the branch to merge into (the target branch, e.g., 'main' or 'develop').", - }, - body: { - type: "string", - description: - "The description/body for the pull request in Markdown format (optional).", - }, - }, - required: ["projectId", "taskId", "repoId", "title", "head_branch", "base_branch"], - }, - }, - { - name: "github_get_pull_request", - description: - "Fetch a pull request's title, description, state, and diff so it can be reviewed.", - inputSchema: { - type: "object", - properties: { - projectId: projectIdProp, - taskId: taskIdProp, - prId: { - type: "string", - description: - UUID_DESC.replace("%s", "linked pull request") + - " Use github_list_task_prs to get the PR ID.", - }, - }, - required: ["projectId", "taskId", "prId"], - }, - }, - { - name: "github_get_pull_request_ci_status", - description: - "Get the CI/check status for a pull request's latest commit (e.g. GitHub Actions runs, other check runs, and legacy commit statuses). Returns an overall state (success, failure, pending, or unknown) plus the individual checks.", - inputSchema: { - type: "object", - properties: { - projectId: projectIdProp, - taskId: taskIdProp, - prId: { - type: "string", - description: - UUID_DESC.replace("%s", "linked pull request") + - " Use github_list_task_prs to get the PR ID.", - }, - }, - required: ["projectId", "taskId", "prId"], - }, - }, - { - name: "github_comment_pull_request", - description: "Add a general (non-review) comment to a pull request.", - inputSchema: { - type: "object", - properties: { - projectId: projectIdProp, - taskId: taskIdProp, - prId: { - type: "string", - description: - UUID_DESC.replace("%s", "linked pull request") + - " Use github_list_task_prs to get the PR ID.", - }, - body: { - type: "string", - description: "The comment text.", - }, - }, - required: ["projectId", "taskId", "prId", "body"], - }, - }, - { - name: "github_review_pull_request", - description: - "Submit a formal review on a pull request (approve, request changes, or comment). Call github_get_pull_request first to see the diff.", - inputSchema: { - type: "object", - properties: { - projectId: projectIdProp, - taskId: taskIdProp, - prId: { - type: "string", - description: - UUID_DESC.replace("%s", "linked pull request") + - " Use github_list_task_prs to get the PR ID.", - }, - event: { - type: "string", - enum: ["APPROVE", "REQUEST_CHANGES", "COMMENT"], - description: "The review verdict.", - }, - body: { - type: "string", - description: - "Review summary (required for REQUEST_CHANGES and COMMENT).", - }, - }, - required: ["projectId", "taskId", "prId", "event"], - }, - }, - // ── Branches ───────────────────────────────────────────────────────────── - { - name: "github_create_branch", - description: - "Create a new branch on GitHub for a task and link it to the task.", - inputSchema: { - type: "object", - properties: { - projectId: projectIdProp, - taskId: taskIdProp, - repoId: { - type: "string", - description: - UUID_DESC.replace("%s", "linked repository") + - " Use github_list_linked_repos to get the repo ID.", - }, - branch_name: { - type: "string", - description: - "The name for the new branch (e.g., 'feat/PROJ-42-add-feature').", - }, - source_branch: { - type: "string", - description: - "The source branch to branch from. Defaults to the repository's default branch (optional).", - }, - }, - required: ["projectId", "taskId", "repoId", "branch_name"], - }, - }, - { - name: "github_link_branch_to_task", - description: - "Link an existing GitHub branch to a task (does not create the branch on GitHub).", - inputSchema: { - type: "object", - properties: { - projectId: projectIdProp, - taskId: taskIdProp, - repoId: { - type: "string", - description: - UUID_DESC.replace("%s", "linked repository") + - " Use github_list_linked_repos to get the repo ID.", - }, - branch_name: { - type: "string", - description: - "The existing branch name (e.g., 'feature/my-branch').", - }, - }, - required: ["projectId", "taskId", "repoId", "branch_name"], - }, - }, - { - name: "github_list_task_branches", - description: "List branches linked to a task.", - inputSchema: { - type: "object", - properties: { - projectId: projectIdProp, - taskId: taskIdProp, - }, - required: ["projectId", "taskId"], - }, - }, -]; - -// ── Entry ───────────────────────────────────────────────────────────────────── - -const entry: PluginMCPEntry = { - tools, - - async handleToolCall( - name: string, - args: Record, - context: PluginMCPContext, - ) { - const api = new PluginAPIClient(context); - - try { - switch (name) { - // ── Integration ──────────────────────────────────────────────── - case "github_get_integration": { - const { projectId } = args as { projectId: string }; - const integration = await api.pluginGet( - `projects/${projectId}/integration`, - ); - return textResult(formatIntegration(integration)); - } - - case "github_set_token": { - const { projectId, token } = args as { - projectId: string; - token: string; - }; - const integration = await api.pluginPost( - `projects/${projectId}/integration/token`, - { token }, - ); - return textResult( - `GitHub token set successfully:\n\n${formatIntegration(integration)}`, - ); - } - - case "github_delete_token": { - const { projectId } = args as { projectId: string }; - await api.pluginDelete(`projects/${projectId}/integration/token`); - return textResult("GitHub token deleted successfully."); - } - - // ── Repositories ─────────────────────────────────────────────── - case "github_list_linked_repos": { - const { projectId } = args as { projectId: string }; - const repos = await api.pluginGet( - `projects/${projectId}/repositories`, - ); - return textResult( - `Linked GitHub Repositories:\n\n${formatList(repos, formatLinkedRepo)}`, - ); - } - - case "github_link_repository": { - const { projectId, owner, repo_name } = args as { - projectId: string; - owner: string; - repo_name: string; - }; - const repo = await api.pluginPost( - `projects/${projectId}/repositories`, - { owner, repo_name }, - ); - return textResult( - `Repository linked successfully:\n\n${formatLinkedRepo(repo)}`, - ); - } - - case "github_unlink_repository": { - const { projectId, repoId } = args as { - projectId: string; - repoId: string; - }; - await api.pluginDelete( - `projects/${projectId}/repositories/${repoId}`, - ); - return textResult(`Repository ${repoId} unlinked successfully.`); - } - - // ── Pull Requests ────────────────────────────────────────────── - case "github_list_task_prs": { - const { projectId, taskId } = args as { - projectId: string; - taskId: string; - }; - const prs = await api.pluginGet( - `projects/${projectId}/tasks/${taskId}/pull-requests`, - ); - return textResult( - `Pull Requests:\n\n${formatList(prs, formatPullRequest)}`, - ); - } - - case "github_link_pr_to_task": { - const { projectId, taskId, repoId, pr_number } = args as { - projectId: string; - taskId: string; - repoId: string; - pr_number: number; - }; - const pr = await api.pluginPost( - `projects/${projectId}/tasks/${taskId}/pull-requests/link`, - { repo_id: repoId, pr_number }, - ); - return textResult( - `Pull request linked successfully:\n\n${formatPullRequest(pr)}`, - ); - } - - case "github_unlink_pr_from_task": { - const { projectId, taskId, prId } = args as { - projectId: string; - taskId: string; - prId: string; - }; - await api.pluginDelete( - `projects/${projectId}/tasks/${taskId}/pull-requests/${prId}`, - ); - return textResult(`Pull request ${prId} unlinked successfully.`); - } - - case "github_create_pull_request": { - const { projectId, taskId, repoId, title, head_branch, base_branch, body } = - args as { - projectId: string; - taskId: string; - repoId: string; - title: string; - head_branch: string; - base_branch: string; - body?: string; - }; - const pr = await api.pluginPost( - `projects/${projectId}/tasks/${taskId}/pull-requests`, - { repo_id: repoId, title, head_branch, base_branch, body: body ?? "" }, - ); - return textResult( - `Pull request created successfully:\n\n#${pr.pr_number} ${pr.title}\nState: ${pr.state}\nAuthor: ${pr.author}\nHead: ${pr.head_branch} → Base: ${pr.base_branch}\nURL: ${pr.html_url}`, - ); - } - - case "github_get_pull_request": { - const { projectId, taskId, prId } = args as { - projectId: string; - taskId: string; - prId: string; - }; - const pr = await api.pluginGet( - `projects/${projectId}/tasks/${taskId}/pull-requests/${prId}`, - ); - return textResult(formatPullRequestDetails(pr)); - } - - case "github_get_pull_request_ci_status": { - const { projectId, taskId, prId } = args as { - projectId: string; - taskId: string; - prId: string; - }; - const ci = await api.pluginGet( - `projects/${projectId}/tasks/${taskId}/pull-requests/${prId}/ci-status`, - ); - return textResult(formatCIStatus(ci)); - } - - case "github_comment_pull_request": { - const { projectId, taskId, prId, body } = args as { - projectId: string; - taskId: string; - prId: string; - body: string; - }; - await api.pluginPost( - `projects/${projectId}/tasks/${taskId}/pull-requests/${prId}/comments`, - { body }, - ); - return textResult("Comment posted successfully."); - } - - case "github_review_pull_request": { - const { projectId, taskId, prId, event, body } = args as { - projectId: string; - taskId: string; - prId: string; - event: string; - body?: string; - }; - await api.pluginPost( - `projects/${projectId}/tasks/${taskId}/pull-requests/${prId}/reviews`, - { event, body: body ?? "" }, - ); - return textResult("Review submitted successfully."); - } - - // ── Branches ─────────────────────────────────────────────────── - case "github_create_branch": { - const { projectId, taskId, repoId, branch_name, source_branch } = - args as { - projectId: string; - taskId: string; - repoId: string; - branch_name: string; - source_branch?: string; - }; - const result = await api.pluginPost( - `projects/${projectId}/tasks/${taskId}/branches`, - { repo_id: repoId, branch_name, source_branch }, - ); - return textResult( - `Branch created successfully:\n\nBranch: ${result.branch_name}\nRepository: ${result.repo_full_name}\nURL: ${result.html_url}`, - ); - } - - case "github_link_branch_to_task": { - const { projectId, taskId, repoId, branch_name } = args as { - projectId: string; - taskId: string; - repoId: string; - branch_name: string; - }; - const branch = await api.pluginPost( - `projects/${projectId}/tasks/${taskId}/branches/link`, - { repo_id: repoId, branch_name }, - ); - return textResult( - `Branch linked successfully:\n\n${formatBranch(branch)}`, - ); - } - - case "github_list_task_branches": { - const { projectId, taskId } = args as { - projectId: string; - taskId: string; - }; - const branches = await api.pluginGet( - `projects/${projectId}/tasks/${taskId}/branches`, - ); - return textResult( - `Branches:\n\n${formatList(branches, formatBranch)}`, - ); - } - - default: - return errorResult(`Unknown tool: ${name}`); - } - } catch (err: unknown) { - const msg = err instanceof Error ? err.message : String(err); - return errorResult(`GitHub plugin error: ${msg}`); - } - }, - - async getToolContext( - toolId: string, - args: Record, - context: PluginMCPContext, - ) { - if (toolId !== "get_task") return null; - const { projectId, taskId } = args as { projectId: string; taskId: string }; - - const api = new PluginAPIClient(context); - try { - const [branches, prs] = await Promise.all([ - api.pluginGet( - `projects/${projectId}/tasks/${taskId}/branches`, - ), - api.pluginGet( - `projects/${projectId}/tasks/${taskId}/pull-requests`, - ), - ]); - if (branches.length === 0 && prs.length === 0) return null; - - const lines = ["## GitHub"]; - if (branches.length > 0) { - lines.push( - "", - "**Branches:**", - ...branches.map((b) => `- ${b.branch_name}`), - ); - } - if (prs.length > 0) { - lines.push( - "", - "**Pull Requests:**", - ...prs.map( - (pr) => `- #${pr.pr_number} [${pr.state}] ${pr.title} — ${pr.html_url}`, - ), - ); - } - return lines.join("\n"); - } catch { - // Best-effort enrichment — a transient failure here should not - // break the get_task response for the AI client. - return null; - } - }, -}; - -export default entry; diff --git a/mcp/tsconfig.json b/mcp/tsconfig.json deleted file mode 100644 index fb6f94d..0000000 --- a/mcp/tsconfig.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "ESNext", - "moduleResolution": "bundler", - "strict": true, - "skipLibCheck": true - }, - "include": ["src"] -} diff --git a/mcp/vite.config.ts b/mcp/vite.config.ts deleted file mode 100644 index 9f94da5..0000000 --- a/mcp/vite.config.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { defineConfig } from "vite"; - -export default defineConfig({ - build: { - lib: { - entry: "./src/index.ts", - formats: ["es"], - fileName: () => "mcp.js", - }, - target: "es2022", - minify: false, - rollupOptions: { - // No externals — the output is a self-contained ESM bundle loaded - // dynamically by the Paca MCP server at runtime. - external: [], - }, - }, -}); diff --git a/plugin.json b/plugin.json index 31a14b0..ea2e064 100644 --- a/plugin.json +++ b/plugin.json @@ -1,368 +1,16 @@ { "id": "com.paca.github", - "displayName": "GitHub Integration", - "description": "Integrates GitHub repositories, pull requests, and branches with Paca projects and tasks.", - "version": "0.4.1", - "minCoreVersion": "v0.19.0", - "capabilities": ["repository"], + "displayName": "GitHub", + "description": "GitHub provider for Paca's Git integration: GitHub App identities, repository access, credentials and webhooks.", + "version": "0.5.0", + "minCoreVersion": "v0.20.0", + "capabilities": ["git-provider"], "permissions": ["db.read", "db.write"], - "customPermissions": [ - { - "key": "github:manage", - "label": "Manage GitHub integration", - "description": "View and manage this project's GitHub integration: connect or disconnect the access token, and link or unlink repositories. Does not affect PR/branch info on individual tasks, which any project member with task access can already see.", - "scope": "project" - } - ], "backend": { - "allowedConfigKeys": ["ENCRYPTION_KEY", "PUBLIC_URL"], + "allowedConfigKeys": ["ENCRYPTION_KEY"], "allowedOutboundDomains": ["api.github.com"], "sensitiveFields": { - "github_integrations": ["access_token_enc"], - "github_repositories": ["webhook_secret_enc"] - }, - "routes": [ - { - "method": "GET", - "path": "/projects/:projectId/integration", - "middlewares": [ - { "name": "optionalAuthn" }, - { "name": "requireFreshPassword" }, - { - "name": "requireActions", - "scope": "project", - "actions": ["github:manage"] - } - ] - }, - { - "method": "POST", - "path": "/projects/:projectId/integration/token", - "middlewares": [ - { "name": "optionalAuthn" }, - { "name": "requireFreshPassword" }, - { - "name": "requireActions", - "scope": "project", - "actions": ["github:manage"] - } - ] - }, - { - "method": "DELETE", - "path": "/projects/:projectId/integration/token", - "middlewares": [ - { "name": "optionalAuthn" }, - { "name": "requireFreshPassword" }, - { - "name": "requireActions", - "scope": "project", - "actions": ["github:manage"] - } - ] - }, - { - "method": "GET", - "path": "/projects/:projectId/integration/accessible-repos", - "middlewares": [ - { "name": "optionalAuthn" }, - { "name": "requireFreshPassword" }, - { - "name": "requireActions", - "scope": "project", - "actions": ["github:manage"] - } - ] - }, - { - "method": "GET", - "path": "/projects/:projectId/repositories", - "middlewares": [ - { "name": "optionalAuthn" }, - { "name": "requireFreshPassword" }, - { - "name": "requireActions", - "scope": "project", - "actions": ["github:manage"] - } - ] - }, - { - "method": "POST", - "path": "/projects/:projectId/repositories", - "middlewares": [ - { "name": "optionalAuthn" }, - { "name": "requireFreshPassword" }, - { - "name": "requireActions", - "scope": "project", - "actions": ["github:manage"] - } - ] - }, - { - "method": "DELETE", - "path": "/projects/:projectId/repositories/:repoId", - "middlewares": [ - { "name": "optionalAuthn" }, - { "name": "requireFreshPassword" }, - { - "name": "requireActions", - "scope": "project", - "actions": ["github:manage"] - } - ] - }, - { - "method": "GET", - "path": "/projects/:projectId/repositories/:repoId/clone-info", - "middlewares": [ - { "name": "optionalAuthn" }, - { "name": "requireFreshPassword" }, - { - "name": "requireActions", - "scope": "project", - "actions": ["github:manage"] - } - ] - }, - { - "method": "GET", - "path": "/projects/:projectId/tasks/:taskId/pull-requests", - "middlewares": [ - { "name": "optionalAuthn" }, - { "name": "requireFreshPassword" }, - { - "name": "requireActions", - "scope": "project", - "actions": ["tasks:read"] - } - ] - }, - { - "method": "POST", - "path": "/projects/:projectId/tasks/:taskId/pull-requests", - "middlewares": [ - { "name": "optionalAuthn" }, - { "name": "requireFreshPassword" }, - { - "name": "requireActions", - "scope": "project", - "actions": ["tasks:write"] - } - ] - }, - { - "method": "POST", - "path": "/projects/:projectId/tasks/:taskId/pull-requests/link", - "middlewares": [ - { "name": "optionalAuthn" }, - { "name": "requireFreshPassword" }, - { - "name": "requireActions", - "scope": "project", - "actions": ["tasks:write"] - } - ] - }, - { - "method": "DELETE", - "path": "/projects/:projectId/tasks/:taskId/pull-requests/:prId", - "middlewares": [ - { "name": "optionalAuthn" }, - { "name": "requireFreshPassword" }, - { - "name": "requireActions", - "scope": "project", - "actions": ["tasks:write"] - } - ] - }, - { - "method": "GET", - "path": "/projects/:projectId/tasks/:taskId/pull-requests/:prId", - "middlewares": [ - { "name": "optionalAuthn" }, - { "name": "requireFreshPassword" }, - { - "name": "requireActions", - "scope": "project", - "actions": ["tasks:read"] - } - ] - }, - { - "method": "GET", - "path": "/projects/:projectId/tasks/:taskId/pull-requests/:prId/ci-status", - "middlewares": [ - { "name": "optionalAuthn" }, - { "name": "requireFreshPassword" }, - { - "name": "requireActions", - "scope": "project", - "actions": ["tasks:read"] - } - ] - }, - { - "method": "POST", - "path": "/projects/:projectId/tasks/:taskId/pull-requests/:prId/comments", - "middlewares": [ - { "name": "optionalAuthn" }, - { "name": "requireFreshPassword" }, - { - "name": "requireActions", - "scope": "project", - "actions": ["tasks:write"] - } - ] - }, - { - "method": "POST", - "path": "/projects/:projectId/tasks/:taskId/pull-requests/:prId/reviews", - "middlewares": [ - { "name": "optionalAuthn" }, - { "name": "requireFreshPassword" }, - { - "name": "requireActions", - "scope": "project", - "actions": ["tasks:write"] - } - ] - }, - { - "method": "GET", - "path": "/projects/:projectId/tasks/:taskId/branches", - "middlewares": [ - { "name": "optionalAuthn" }, - { "name": "requireFreshPassword" }, - { - "name": "requireActions", - "scope": "project", - "actions": ["tasks:read"] - } - ] - }, - { - "method": "POST", - "path": "/projects/:projectId/tasks/:taskId/branches", - "middlewares": [ - { "name": "optionalAuthn" }, - { "name": "requireFreshPassword" }, - { - "name": "requireActions", - "scope": "project", - "actions": ["tasks:write"] - } - ] - }, - { - "method": "POST", - "path": "/projects/:projectId/tasks/:taskId/branches/link", - "middlewares": [ - { "name": "optionalAuthn" }, - { "name": "requireFreshPassword" }, - { - "name": "requireActions", - "scope": "project", - "actions": ["tasks:write"] - } - ] - }, - { - "method": "POST", - "path": "/projects/:projectId/webhook", - "middlewares": [{ "name": "optionalAuthn" }] - } - ] - }, - "frontend": { - "remoteEntryUrl": "/plugins/com.paca.github/assets/remoteEntry.js", - "extensionPoints": [ - { - "point": "project.settings.tab", - "component": "GitHubSettingsTab", - "label": "GitHub", - "order": 100, - "requiredPermission": "github:manage" - }, - { - "point": "task.detail.section", - "component": "GitHubTaskSection", - "order": 5 - } - ] - }, - "automation": { - "triggers": [ - { - "type": "github.pr_linked", - "label": "GitHub: Pull Request Created", - "eventTopic": "github.pr_linked" - }, - { - "type": "github.pr_state_changed", - "label": "GitHub: Pull Request State Changed", - "eventTopic": "github.pr_state_changed" - }, - { - "type": "github.branch_linked", - "label": "GitHub: Branch Created", - "eventTopic": "github.branch_linked" - } - ], - "conditions": [ - { - "type": "github.pr_state", - "label": "GitHub: Pull Request State", - "configSchema": { - "type": "object", - "required": ["expected_state"], - "properties": { - "expected_state": { - "type": "string", - "title": "Expected State", - "enum": ["open", "closed", "merged"] - } - } - } - } - ], - "actions": [ - { - "type": "github.merge_pr", - "label": "GitHub: Merge Pull Request", - "configSchema": { - "type": "object", - "properties": { - "merge_method": { - "type": "string", - "title": "Merge Method", - "enum": ["merge", "squash", "rebase"], - "default": "merge" - } - } - } - }, - { - "type": "github.comment_pr", - "label": "GitHub: Comment on Pull Request", - "configSchema": { - "type": "object", - "required": ["body"], - "properties": { - "body": { "type": "string", "title": "Comment Body", "format": "textarea" } - } - } - } - ] - }, - "mcp": { - "remoteEntryUrl": "/plugins-mcp/com.paca.github/mcp.js", - "toolContextHooks": ["get_task"] - }, - "skills": { - "baseUrl": "/plugins-skills/com.paca.github", - "names": ["paca-github-workflow"] + "github_apps": ["webhook_secret_enc", "private_key_enc"] + } } } diff --git a/skills/paca-github-workflow/SKILL.md b/skills/paca-github-workflow/SKILL.md deleted file mode 100644 index 8cfa141..0000000 --- a/skills/paca-github-workflow/SKILL.md +++ /dev/null @@ -1,71 +0,0 @@ ---- -name: paca-github-workflow -description: Link a GitHub repository to a Paca project, create branches, open and manage pull requests, review PRs, and check CI status — using the com.paca.github plugin's tools. Use when asked to open a PR, create a branch, link a pull request to a task, review or comment on a PR, or check CI/build status for a task. -triggers: - - /paca-github-workflow - - create a pull request - - open a pr - - create branch - - link pull request - - review this pr - - check ci status ---- - -# GitHub Workflow Skill - -This plugin (`com.paca.github`) connects a Paca project to GitHub via a Personal Access Token (PAT) — not a GitHub App — and links repositories, branches, and pull requests to individual tasks. All GitHub API calls happen server-side; you only ever call this plugin's tools. - -## Prerequisites — check before doing anything else - -1. `github_get_integration({projectId})`. If `connected` is false, a GitHub PAT must be connected first (scopes `repo` + `admin:repo_hook`) — you cannot mint one yourself. Tell the user to add it under Project Settings → GitHub, or, if they give you a token directly in the conversation, call `github_set_token({projectId, token})` yourself. -2. `github_list_linked_repos({projectId})`. If empty and you already know the exact `owner`/`repo_name`, call `github_link_repository({projectId, owner, repo_name})` — this also creates the required webhook automatically. If you don't know the exact owner/repo name, ask the user; there's no tool to browse "which repos can this token see" (that picker is UI-only). - -Every branch/PR tool below 404s (`GITHUB_INTEGRATION_NOT_FOUND` / `GITHUB_REPOSITORY_NOT_FOUND`) until both of these are satisfied. - -## Tools - -**Integration** -- `github_get_integration(projectId)` — connection status. -- `github_set_token(projectId, token)` — only when the user hands you a token directly. -- `github_delete_token(projectId)` — disconnects and removes webhooks. - -**Repositories** -- `github_list_linked_repos(projectId)` -- `github_link_repository(projectId, owner, repo_name)` -- `github_unlink_repository(projectId, repoId)` - -**Branches** -- `github_create_branch(projectId, taskId, repoId, branch_name, source_branch?)` — `source_branch` defaults to the repo's default branch if omitted. -- `github_list_task_branches(projectId, taskId)` - -**Pull requests** -- `github_list_task_prs(projectId, taskId)` -- `github_create_pull_request(projectId, taskId, repoId, title, head_branch, base_branch, body?)` — creates the PR on GitHub **and** links it to the task in the same call. -- `github_link_pr_to_task(projectId, taskId, repoId, pr_number)` — only for a PR that already existed before you touched it (opened by a human, or via `gh pr create` directly). -- `github_unlink_pr_from_task(projectId, taskId, prId)` -- `github_get_pull_request(projectId, taskId, prId)` — title, state, body, and the full diff. -- `github_get_pull_request_ci_status(projectId, taskId, prId)` — combined GitHub Actions / check-run / legacy status state. -- `github_comment_pull_request(projectId, taskId, prId, body)` — a plain issue-style comment. -- `github_review_pull_request(projectId, taskId, prId, event, body?)` — `event` is `APPROVE`, `REQUEST_CHANGES`, or `COMMENT`; `body` is required by GitHub for the latter two. - -`repoId` and `prId` are Paca's own internal ids — get `repoId` from `github_list_linked_repos` and `prId` from `github_list_task_prs`. Neither is a GitHub PR number or `owner/repo` string; don't substitute one for the other. - -## Workflow — finishing a task with a PR - -1. Confirm the prerequisites above. -2. Name the branch `/-[-slug]` — e.g. `feat/PROJ-42-add-auth` — matching the task's own reference. This is load-bearing, not cosmetic: both the manual UI and the webhook auto-linker match on exactly this pattern to associate branches and PRs with a task without an explicit link call. -3. `github_create_branch`, or push a branch with a matching name yourself. -4. Commit and push your changes (plain git — outside this plugin's scope). -5. `github_create_pull_request`. This both opens the PR and links it to the task — don't also call `github_link_pr_to_task` afterward; that's only for PRs that already existed independently of you. -6. Before merging or requesting review, call `github_get_pull_request_ci_status`. Treat `pending` **and** `unknown` as not yet safe to merge — `unknown` means no checks have reported at all, not that everything passed. - -## Reviewing a PR - -1. `github_get_pull_request` first, to read the diff — never review blind. -2. `github_review_pull_request` with `event: APPROVE | REQUEST_CHANGES | COMMENT`. -3. For a lighter note that isn't a formal review verdict, use `github_comment_pull_request` instead. - -## Constraints - -- `github_comment_pull_request` and `github_review_pull_request` post directly to GitHub — they do **not** add anything to the Paca task's own activity or comments. If the user also wants a note left on the Paca task, add that separately with the core `add_task_comment` tool. -- You cannot mint a GitHub PAT and cannot enumerate which repos a token can see — if you don't already know the exact `owner`/`repo_name`, ask rather than guess.