From 984005b0098af5ed9ab13ba1280f3773d7433318 Mon Sep 17 00:00:00 2001 From: pikann22 Date: Fri, 23 Oct 2026 10:29:30 +0000 Subject: [PATCH 1/8] feat: Add GitHub App integration and UI components - Introduced new UI components in `ui.tsx` for buttons, forms, modals, and other elements to enhance user interaction. - Updated `vite.config.ts` to expose the `GitHubAppCallbackPage` component. - Modified `index.ts` to include new properties in the `GitHubIntegration` interface for app connection details. - Bumped plugin version to 0.6.0 and added new permissions for managing GitHub Apps in `plugin.json`. - Implemented new API routes for GitHub App management in `plugin.json`. - Updated documentation in `SKILL.md` to reflect changes in GitHub integration options, including support for GitHub Apps. --- README.md | 20 +- backend/appauth.go | 222 ++++ backend/apps.go | 697 +++++++++++ backend/apps_test.go | 400 ++++++ backend/branches.go | 4 +- backend/client.go | 91 +- backend/identities.go | 149 +++ backend/identities_test.go | 111 ++ backend/integration.go | 174 ++- backend/migrations/0002_github_apps.sql | 73 ++ backend/migrations/0003_agent_identities.sql | 17 + backend/plugin.go | 23 +- backend/project_app.go | 314 +++++ backend/pull_requests.go | 12 +- frontend/src/AgentIdentities.tsx | 180 +++ frontend/src/AppsPanel.tsx | 160 +++ frontend/src/Branches.tsx | 358 ++++++ frontend/src/ConnectionBar.tsx | 132 ++ frontend/src/GitHubAppCallbackPage.tsx | 136 ++ frontend/src/GitHubSettingsTab.tsx | 960 ++------------ frontend/src/GitHubTaskSection.tsx | 1171 +----------------- frontend/src/PullRequests.tsx | 234 ++++ frontend/src/RepositoriesPanel.tsx | 274 ++++ frontend/src/SetupPanel.tsx | 340 +++++ frontend/src/app-flow.tsx | 204 +++ frontend/src/github-api.ts | 230 ++++ frontend/src/styles.css | 277 +++++ frontend/src/ui.tsx | 410 ++++++ frontend/vite.config.ts | 1 + mcp/src/index.ts | 6 + plugin.json | 186 ++- skills/paca-github-workflow/SKILL.md | 4 +- 32 files changed, 5477 insertions(+), 2093 deletions(-) create mode 100644 backend/appauth.go create mode 100644 backend/apps.go create mode 100644 backend/apps_test.go create mode 100644 backend/identities.go create mode 100644 backend/identities_test.go create mode 100644 backend/migrations/0002_github_apps.sql create mode 100644 backend/migrations/0003_agent_identities.sql create mode 100644 backend/project_app.go create mode 100644 frontend/src/AgentIdentities.tsx create mode 100644 frontend/src/AppsPanel.tsx create mode 100644 frontend/src/Branches.tsx create mode 100644 frontend/src/ConnectionBar.tsx create mode 100644 frontend/src/GitHubAppCallbackPage.tsx create mode 100644 frontend/src/PullRequests.tsx create mode 100644 frontend/src/RepositoriesPanel.tsx create mode 100644 frontend/src/SetupPanel.tsx create mode 100644 frontend/src/app-flow.tsx create mode 100644 frontend/src/styles.css create mode 100644 frontend/src/ui.tsx diff --git a/README.md b/README.md index 2b925c6..c98b8ec 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,25 @@ 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. +Without PUBLIC_URL, PAT-connected projects cannot link repositories (webhook creation needs a reachable callback URL), and GitHub App creation is refused. If PUBLIC_URL is a private/loopback address, a GitHub App is still created but with its webhook disabled. + +## GitHub App connection (self-hosted, Manifest Flow) + +Besides a personal access token, a project can connect through a **GitHub App** created from inside Paca: + +1. A user with the instance-wide `github:manage_apps` action opens Project Settings → GitHub → *Create GitHub App*, picks a name and where to create it (personal account or organization). +2. Paca sends the pre-filled manifest to GitHub; the user confirms once. +3. GitHub redirects the browser back to a **page of the Paca web app** (`/plugin-pages/com.paca.github/github-app-callback`, a plugin `app.page`; needs a Paca release with `app.page` support). That page lives under the authenticated layout, so an expired access token is refreshed (or the user signs in and is returned to it) before it calls the plugin's authenticated `POST /apps/callback/created`, which exchanges the one-time code for the app's credentials and stores them encrypted (`github_apps`). The manifest `state` is single-use, expires after an hour and is bound to the user who started the flow. +4. The user installs the app on the account/organization; GitHub returns to the same page, which calls `POST /apps/callback/installed`. +5. Project managers (`github:manage`) pick an installation that was made available to their project. Installation tokens (1 hour) are minted from the app's private key and cached encrypted; clone-info returns the token with its expiry. + +An app delivers a single webhook for every installed repository (`POST /webhooks/apps/:appKey`, verified with that app's secret and fanned out to every project that linked the repo), so linking a repo through an app needs no `admin:repo_hook` permission and creates no per-repo webhook. + +### Per-agent identities + +In an App-connected project, a project manager can map a member (typically an AI agent) to a different installation under Settings → GitHub → *Agent identities*. Calls made by that account — branches, pull requests, comments, reviews, clone tokens — then use that installation's token, so GitHub attributes them to that app's bot. This is what allows a reviewer agent to formally approve a developer agent's PR. Accounts without a mapping use the project's default installation. Mappings are keyed by account, so they apply to API-key callers (agents) as they do to people. If a mapped installation is suspended or withdrawn from the project, calls by that account fail with `GITHUB_AGENT_IDENTITY_UNAVAILABLE` rather than silently acting as the default identity. Automation actions (merge/comment nodes) have no calling account and use the project default. + +Not yet implemented: review-thread/merge tools, new PR/review event triggers, GitHub Enterprise Server (`host`), importing an existing app. --- diff --git a/backend/appauth.go b/backend/appauth.go new file mode 100644 index 0000000..acfc263 --- /dev/null +++ b/backend/appauth.go @@ -0,0 +1,222 @@ +package main + +import ( + "context" + "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 +} + +// ─── Installation tokens ───────────────────────────────────────────────────── + +const ( + authTypePAT = "pat" + authTypeApp = "app" + + // installationTokenSkew is how long before GitHub's expiry a cached + // installation token stops being served, so a token handed to a caller + // (or to a git clone) never dies mid-use. + installationTokenSkew = 5 * time.Minute +) + +func installationTokenCacheKey(installationRowID string) string { + return "installation-token:" + installationRowID +} + +type cachedInstallationToken struct { + Token string `json:"token"` + ExpiresAt int64 `json:"expires_at"` +} + +// installationToken returns a valid installation access token for the +// installation row, minting one with the App's private key on a cache miss. +// The cache is only an optimisation: entries are encrypted at rest and a miss +// (or an unavailable cache backend) just mints again. +func (p *githubPlugin) installationToken(installationRowID string) (string, time.Time, error) { + cacheKey := installationTokenCacheKey(installationRowID) + if p.cache != nil { + if enc, hit := p.cache.Get(cacheKey); hit && enc != "" { + if raw, err := p.decrypt(enc); err == nil { + var c cachedInstallationToken + if json.Unmarshal([]byte(raw), &c) == nil && c.Token != "" { + if exp := time.Unix(c.ExpiresAt, 0); time.Until(exp) > installationTokenSkew { + return c.Token, exp, nil + } + } + } + } + } + + inst, err := p.db.Query( + `SELECT app_id, installation_id, suspended_at FROM github_app_installations WHERE id = $1`, + installationRowID, + ) + if err != nil { + return "", time.Time{}, err + } + if len(inst.Rows) == 0 { + return "", time.Time{}, &appError{code: "GITHUB_INSTALLATION_NOT_FOUND", status: 404, msg: "GitHub App installation not found"} + } + isc := newRowScanner(inst.Columns, inst.Rows[0]) + if isc.strPtr("suspended_at") != nil { + return "", time.Time{}, &appError{code: "GITHUB_INSTALLATION_SUSPENDED", status: 422, msg: "GitHub App installation is suspended"} + } + ghInstallationID := isc.int64Val("installation_id") + + jwt, err := p.appJWT(isc.str("app_id")) + if err != nil { + return "", time.Time{}, err + } + + var minted struct { + Token string `json:"token"` + ExpiresAt time.Time `json:"expires_at"` + } + url := fmt.Sprintf("%s/app/installations/%d/access_tokens", ghBaseURL, ghInstallationID) + if err := newGHClient(jwt).post(context.Background(), url, map[string]any{}, &minted); err != nil { + return "", time.Time{}, err + } + if minted.Token == "" { + return "", time.Time{}, errors.New("github app: installation token response had no token") + } + + if p.cache != nil { + payload, _ := json.Marshal(cachedInstallationToken{Token: minted.Token, ExpiresAt: minted.ExpiresAt.Unix()}) + if enc, eErr := p.encrypt(string(payload)); eErr == nil { + if ttl := time.Until(minted.ExpiresAt) - installationTokenSkew; ttl > 0 { + p.cache.Set(cacheKey, enc, ttl) + } + } + } + return minted.Token, minted.ExpiresAt, nil +} + +// appJWT signs an App JWT for the app row with the given id. +func (p *githubPlugin) appJWT(appRowID string) (string, error) { + app, err := p.db.Query(`SELECT github_app_id, private_key_enc FROM github_apps WHERE id = $1`, appRowID) + if err != nil { + return "", err + } + if len(app.Rows) == 0 { + return "", &appError{code: "GITHUB_APP_NOT_FOUND", status: 404, msg: "GitHub App not found"} + } + asc := newRowScanner(app.Columns, app.Rows[0]) + pemStr, err := p.decrypt(asc.str("private_key_enc")) + if err != nil { + return "", err + } + return signAppJWT(asc.int64Val("github_app_id"), pemStr, time.Now()) +} + +// resolveCredential returns the project's default credential. +func (p *githubPlugin) resolveCredential(projectID string) (string, time.Time, error) { + return p.resolveCredentialFor(projectID, "") +} + +// resolveCredentialFor returns the token a call made by accountID should use: +// the stored PAT, or an installation token — the account's own installation +// when the project maps one for it, otherwise the project's default. The +// second value is the token's expiry (zero for a PAT). +// +// A mapping that is no longer usable (installation suspended, or withdrawn +// from the project) fails closed instead of falling back to the default: a +// silent fallback would act on GitHub as a different identity than the one +// the project configured, which is exactly what the mapping exists to prevent. +func (p *githubPlugin) resolveCredentialFor(projectID, accountID string) (string, time.Time, error) { + result, err := p.db.Query( + `SELECT auth_type, access_token_enc, installation_id FROM github_integrations WHERE project_id = $1`, + projectID, + ) + if err != nil { + return "", time.Time{}, err + } + if len(result.Rows) == 0 { + return "", time.Time{}, &appError{code: "GITHUB_INTEGRATION_NOT_FOUND", status: 404, msg: "GitHub integration not found"} + } + sc := newRowScanner(result.Columns, result.Rows[0]) + if sc.str("auth_type") != authTypeApp { + token, err := p.decrypt(sc.str("access_token_enc")) + return token, time.Time{}, err + } + + installationRowID := sc.str("installation_id") + if accountID != "" { + mapped, err := p.db.Query( + `SELECT installation_id FROM github_agent_identities WHERE project_id = $1 AND account_id = $2`, + projectID, accountID) + if err != nil { + return "", time.Time{}, err + } + if len(mapped.Rows) > 0 { + mappedID := newRowScanner(mapped.Columns, mapped.Rows[0]).str("installation_id") + allowed, err := p.projectMayUseInstallation(projectID, mappedID) + if err != nil { + return "", time.Time{}, err + } + if !allowed { + return "", time.Time{}, &appError{code: "GITHUB_AGENT_IDENTITY_UNAVAILABLE", status: 422, + msg: "The GitHub identity mapped to this account is suspended or no longer available to the project"} + } + installationRowID = mappedID + } + } + if installationRowID == "" { + return "", time.Time{}, &appError{code: "GITHUB_INTEGRATION_NOT_FOUND", status: 404, msg: "GitHub integration not found"} + } + return p.installationToken(installationRowID) +} diff --git a/backend/apps.go b/backend/apps.go new file mode 100644 index 0000000..af18273 --- /dev/null +++ b/backend/apps.go @@ -0,0 +1,697 @@ +package main + +import ( + "context" + "crypto/rand" + "encoding/hex" + "errors" + "fmt" + "net/url" + "regexp" + "strconv" + "strings" + "time" + + plugin "github.com/Paca-AI/plugin-sdk-go" +) + +// GitHub App management: the Manifest Flow that lets an administrator create +// a Paca GitHub App on their own account/org with a couple of clicks, the +// installation callback, and the (instance-level) catalogue of apps and +// installations that projects bind to. +// +// POST /apps/manifest → manifest + GitHub URL for the browser to POST +// GET /apps/callback/created (GitHub redirect: manifest code → credentials) +// GET /apps/callback/installed (GitHub redirect: installation_id) + +const ( + // callbackPageSlug is the slug of the plugin's app.page that GitHub + // redirects back to (plugin.json navItems). + callbackPageSlug = "github-app-callback" + + manifestSessionTTL = time.Hour + + // githubAppNameMaxLen is GitHub's limit on an App's name. + githubAppNameMaxLen = 34 +) + +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 plugin +// uses today and what the planned PR-lifecycle tools need: 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": "read", + "statuses": "read", + "actions": "read", +} + +var manifestEvents = []string{ + "push", + "pull_request", + "pull_request_review", + "pull_request_review_comment", + "pull_request_review_thread", + "issue_comment", + "check_run", + "check_suite", +} + +// ─── DTOs ──────────────────────────────────────────────────────────────────── + +type manifestResponse struct { + PostURL string `json:"post_url"` + State string `json:"state"` + Manifest map[string]any `json:"manifest"` + WebhookActive bool `json:"webhook_active"` +} + +type installationResponse struct { + ID string `json:"id"` + InstallationID int64 `json:"installation_id"` + AccountLogin string `json:"account_login"` + AccountType string `json:"account_type"` + RepositorySelection string `json:"repository_selection"` + AllowAllProjects bool `json:"allow_all_projects"` + ProjectIDs []string `json:"project_ids"` + Suspended bool `json:"suspended"` + ManageURL string `json:"manage_url"` +} + +type appResponse struct { + ID string `json:"id"` + GitHubAppID int64 `json:"github_app_id"` + Slug string `json:"slug"` + Name string `json:"name"` + OwnerLogin string `json:"owner_login"` + HTMLURL string `json:"html_url"` + WebhookActive bool `json:"webhook_active"` + CreatedAt string `json:"created_at"` + Installations []installationResponse `json:"installations"` +} + +// projectInstallationResponse is what a project manager sees when choosing an +// installation: no secrets and no information about other projects. +type projectInstallationResponse struct { + ID string `json:"id"` + AppName string `json:"app_name"` + AccountLogin string `json:"account_login"` + AccountType string `json:"account_type"` + RepositorySelection string `json:"repository_selection"` +} + +// ─── helpers ───────────────────────────────────────────────────────────────── + +func randomHex(nBytes int) (string, error) { + b := make([]byte, nBytes) + if _, err := rand.Read(b); err != nil { + return "", err + } + return hex.EncodeToString(b), nil +} + +// publicBaseURL returns PUBLIC_URL without a trailing slash. +func (p *githubPlugin) publicBaseURL() (string, error) { + publicURL, ok := p.cfg.Get("PUBLIC_URL") + if !ok || strings.TrimSpace(publicURL) == "" { + return "", errors.New("PUBLIC_URL is not configured") + } + return strings.TrimRight(strings.TrimSpace(publicURL), "/"), nil +} + +func (p *githubPlugin) pluginBaseURL() (string, error) { + base, err := p.publicBaseURL() + if err != nil { + return "", err + } + return base + "/api/v1/plugins/" + githubPluginID, nil +} + +// isLocalURL reports whether rawURL points at a host GitHub cannot reach from +// the internet (loopback, private ranges, .local/.internal names), in which +// case webhook delivery is switched off rather than left failing. +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", host == "::1", host == "0.0.0.0": + return true + case strings.HasSuffix(host, ".local"), strings.HasSuffix(host, ".internal"), + strings.HasSuffix(host, ".localhost"), strings.HasSuffix(host, ".lan"): + return true + case strings.HasPrefix(host, "127."), strings.HasPrefix(host, "10."), strings.HasPrefix(host, "192.168."): + return true + case strings.HasPrefix(host, "172."): + parts := strings.Split(host, ".") + if len(parts) > 1 { + if n, err := strconv.Atoi(parts[1]); err == nil && n >= 16 && n <= 31 { + return true + } + } + } + return false +} + +// sameOrigin reports whether candidate shares scheme and host with base. +func sameOrigin(base, candidate string) bool { + b, err1 := url.Parse(base) + c, err2 := url.Parse(candidate) + if err1 != nil || err2 != nil || c.Scheme == "" || c.Host == "" { + return false + } + return strings.EqualFold(b.Scheme, c.Scheme) && strings.EqualFold(b.Host, c.Host) +} + +// header reads a request header regardless of the key casing the host uses. +func header(req *plugin.Request, name string) string { + for k, v := range req.Headers { + if strings.EqualFold(k, name) { + return v + } + } + return "" +} + +// ─── POST /apps/manifest ───────────────────────────────────────────────────── + +type createManifestBody struct { + Name string `json:"name"` + OwnerOrg string `json:"owner_org"` + AllowAllProjects bool `json:"allow_all_projects"` + Public bool `json:"public"` + ReturnURL string `json:"return_url"` +} + +func (p *githubPlugin) createManifest(req *plugin.Request, res *plugin.Response) { + b, err := plugin.JSONBody[createManifestBody](req) + if err != nil { + apiError(res, 400, "BAD_REQUEST", "invalid request body") + return + } + name := strings.TrimSpace(b.Name) + if name == "" || len(name) > githubAppNameMaxLen { + apiError(res, 400, "BAD_REQUEST", fmt.Sprintf("name is required and must be at most %d characters", githubAppNameMaxLen)) + return + } + org := strings.TrimSpace(b.OwnerOrg) + if org != "" && !githubLoginRe.MatchString(org) { + apiError(res, 400, "BAD_REQUEST", "owner_org is not a valid GitHub organization name") + return + } + + pluginBase, err := p.pluginBaseURL() + if err != nil { + apiError(res, 422, "GITHUB_WEBHOOK_URL_REQUIRED", "PUBLIC_URL is not configured") + return + } + publicBase, _ := p.publicBaseURL() + returnURL := strings.TrimSpace(b.ReturnURL) + if returnURL != "" && !sameOrigin(publicBase, returnURL) { + apiError(res, 400, "BAD_REQUEST", "return_url must be on this Paca instance") + return + } + + state, err := randomHex(24) + if err != nil { + apiError(res, 500, "INTERNAL_ERROR", "failed to generate state") + return + } + appKey, err := randomHex(16) + if err != nil { + apiError(res, 500, "INTERNAL_ERROR", "failed to generate app key") + return + } + + now := time.Now().UTC() + if _, err := p.db.Exec(` + INSERT INTO github_manifest_sessions + (state, app_key, created_by, app_name, owner_org, allow_all_projects, return_url, expires_at, created_at) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) + `, state, appKey, req.Caller.UserID, name, org, b.AllowAllProjects, returnURL, + now.Add(manifestSessionTTL).Format(time.RFC3339Nano), now.Format(time.RFC3339Nano)); err != nil { + apiError(res, 500, "INTERNAL_ERROR", err.Error()) + return + } + + webhookActive := !isLocalURL(publicBase) + manifest := buildManifest(name, publicBase, pluginBase, appKey, webhookActive, b.Public) + + postURL := "https://github.com/settings/apps/new" + if org != "" { + postURL = "https://github.com/organizations/" + org + "/settings/apps/new" + } + postURL += "?state=" + url.QueryEscape(state) + + ok(res, manifestResponse{PostURL: postURL, State: state, Manifest: manifest, WebhookActive: webhookActive}) +} + +// buildManifest assembles the GitHub App manifest. The webhook URL carries the +// app_key so deliveries can be verified before the numeric app id is known. +func buildManifest(name, publicBase, pluginBase, appKey string, webhookActive, public bool) map[string]any { + return map[string]any{ + "name": name, + "url": publicBase, + "hook_attributes": map[string]any{ + "url": pluginBase + "/webhooks/apps/" + appKey, + "active": webhookActive, + }, + // Both legs return to the same Paca web-app page (the plugin's app.page); + // it tells them apart by the parameters GitHub appends (code vs + // installation_id). + "redirect_url": publicBase + "/plugin-pages/" + githubPluginID + "/" + callbackPageSlug, + "setup_url": publicBase + "/plugin-pages/" + githubPluginID + "/" + callbackPageSlug, + "setup_on_update": true, + "public": public, + "default_permissions": manifestPermissions, + "default_events": manifestEvents, + } +} + +// ─── POST /apps/callback/created ───────────────────────────────────────────── + +// The two callbacks are not hit by GitHub directly. GitHub redirects the +// browser to a page of the Paca web app (the plugin's app.page, see +// frontend/src/GitHubAppCallbackPage.tsx), which restores the user's session +// if its short-lived cookie lapsed during the detour and then calls these +// routes with the parameters GitHub appended. So they are ordinary +// authenticated, permission-gated JSON routes, and the manifest session is +// additionally bound to the user who started it. + +// Stable codes the callback page turns into messages. +const ( + setupInvalidState = "GITHUB_SETUP_INVALID_STATE" + setupMissingParam = "GITHUB_SETUP_MISSING_PARAMETER" + setupConversionFailed = "GITHUB_SETUP_CONVERSION_FAILED" + setupStoreFailed = "GITHUB_SETUP_STORE_FAILED" + setupInstallationFailed = "GITHUB_SETUP_INSTALLATION_FAILED" +) + +// callbackResult tells the page where to send the browser next. +type callbackResult struct { + // NextURL, when set, is the GitHub URL for the next leg of the flow. + NextURL string `json:"next_url,omitempty"` + // ReturnURL is the Paca page that started the flow. + ReturnURL string `json:"return_url"` +} + +// loadManifestSession returns the live (unexpired) session for state, bound to +// the user who started it: a state that leaks (a URL in a log, a screenshot) +// is useless to anyone else. +func (p *githubPlugin) loadManifestSession(state, userID string) (*scanner, error) { + if state == "" { + return nil, errors.New("missing state") + } + r, err := p.db.Query( + `SELECT state, app_key, created_by, app_name, owner_org, allow_all_projects, return_url, app_id, code_used_at, completed_at, expires_at FROM github_manifest_sessions WHERE state = $1`, + state) + if err != nil { + return nil, err + } + if len(r.Rows) == 0 { + return nil, errors.New("unknown state") + } + sc := newRowScanner(r.Columns, r.Rows[0]) + if userID == "" || sc.str("created_by") != userID { + return nil, errors.New("state belongs to another user") + } + exp, perr := time.Parse(time.RFC3339Nano, sc.str("expires_at")) + if perr != nil || time.Now().After(exp) { + return nil, errors.New("state expired") + } + return sc, nil +} + +// returnTarget is where the browser goes when the flow ends. +func (p *githubPlugin) returnTarget(sess *scanner) string { + if u := sess.str("return_url"); u != "" { + return u + } + base, _ := p.publicBaseURL() + return base + "/home" +} + +type createdCallbackBody struct { + Code string `json:"code"` + State string `json:"state"` +} + +func (p *githubPlugin) callbackCreated(req *plugin.Request, res *plugin.Response) { + b, err := plugin.JSONBody[createdCallbackBody](req) + if err != nil || b.State == "" || b.Code == "" { + apiError(res, 400, setupMissingParam, "code and state are required") + return + } + sess, err := p.loadManifestSession(b.State, req.Caller.UserID) + if err != nil { + apiError(res, 400, setupInvalidState, "The setup link is invalid, expired or belongs to another user") + return + } + + // Claim the code atomically so a replayed callback cannot create a second app. + claimed, err := p.db.Exec( + `UPDATE github_manifest_sessions SET code_used_at = $1 WHERE state = $2 AND code_used_at IS NULL`, + nowStr(), b.State) + if err != nil || claimed == 0 { + apiError(res, 409, setupInvalidState, "This setup link was already used") + return + } + + conv, err := newGHClient("").convertManifestCode(context.Background(), b.Code) + if err != nil { + p.log.Error("github: manifest conversion failed: " + err.Error()) + apiError(res, 502, setupConversionFailed, "GitHub rejected the app setup") + return + } + + appRowID, err := p.storeApp(sess, conv, req.Caller.UserID) + if err != nil { + p.log.Error("github: storing app failed: " + err.Error()) + apiError(res, 500, setupStoreFailed, "The app was created on GitHub but could not be saved") + return + } + if _, err := p.db.Exec(`UPDATE github_manifest_sessions SET app_id = $1 WHERE state = $2`, appRowID, b.State); err != nil { + apiError(res, 500, setupStoreFailed, "The app was created on GitHub but could not be saved") + return + } + + // Leg two: send the user on to install the freshly created app. + ok(res, callbackResult{NextURL: installURL(conv.Slug, b.State), ReturnURL: p.returnTarget(sess)}) +} + +func installURL(slug, state string) string { + return "https://github.com/apps/" + slug + "/installations/new?state=" + url.QueryEscape(state) +} + +func (p *githubPlugin) storeApp(sess *scanner, conv *ghManifestConversion, userID string) (string, error) { + if conv.ID == 0 || conv.PEM == "" { + return "", errors.New("conversion response is missing the app id or private key") + } + // Refuse to store a key we could not later sign with. + if _, err := parseRSAPrivateKey(conv.PEM); err != nil { + return "", err + } + pemEnc, err := p.encrypt(conv.PEM) + if err != nil { + return "", err + } + secretEnc, err := p.encrypt(conv.ClientSecret) + if err != nil { + return "", err + } + hookEnc, err := p.encrypt(conv.WebhookSecret) + if err != nil { + return "", err + } + publicBase, _ := p.publicBaseURL() + now := nowStr() + ins, err := p.db.Query(` + INSERT INTO github_apps + (app_key, github_app_id, slug, name, owner_login, html_url, client_id, client_secret_enc, + webhook_secret_enc, private_key_enc, webhook_active, created_by, created_at, updated_at) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$13) + RETURNING id`, + sess.str("app_key"), conv.ID, conv.Slug, conv.Name, conv.Owner.Login, conv.HTMLURL, conv.ClientID, + secretEnc, hookEnc, pemEnc, !isLocalURL(publicBase), userID, now) + if err != nil { + return "", err + } + if len(ins.Rows) == 0 { + return "", errors.New("insert returned no rows") + } + return newRowScanner(ins.Columns, ins.Rows[0]).str("id"), nil +} + +// ─── POST /apps/callback/installed ─────────────────────────────────────────── + +type installedCallbackBody struct { + InstallationID string `json:"installation_id"` + State string `json:"state"` +} + +func (p *githubPlugin) callbackInstalled(req *plugin.Request, res *plugin.Response) { + b, err := plugin.JSONBody[installedCallbackBody](req) + installationID, perr := strconv.ParseInt(strings.TrimSpace(b.InstallationID), 10, 64) + if err != nil || b.State == "" || perr != nil || installationID <= 0 { + apiError(res, 400, setupMissingParam, "installation_id and state are required") + return + } + sess, err := p.loadManifestSession(b.State, req.Caller.UserID) + if err != nil || sess.str("app_id") == "" { + apiError(res, 400, setupInvalidState, "The setup link is invalid, expired or belongs to another user") + return + } + claimed, err := p.db.Exec( + `UPDATE github_manifest_sessions SET completed_at = $1 WHERE state = $2 AND completed_at IS NULL`, + nowStr(), b.State) + if err != nil || claimed == 0 { + apiError(res, 409, setupInvalidState, "This setup link was already used") + return + } + + appRowID := sess.str("app_id") + jwt, err := p.appJWT(appRowID) + if err != nil { + apiError(res, 500, setupInstallationFailed, "The installation could not be verified") + return + } + inst, err := newGHClient(jwt).getInstallation(context.Background(), installationID) + if err != nil { + p.log.Error("github: fetching installation failed: " + err.Error()) + apiError(res, 502, setupInstallationFailed, "GitHub did not confirm the installation") + return + } + if _, err := p.upsertInstallation(appRowID, inst, sess.str("allow_all_projects") == "true"); err != nil { + p.log.Error("github: storing installation failed: " + err.Error()) + apiError(res, 500, setupStoreFailed, "The installation could not be saved") + return + } + ok(res, callbackResult{ReturnURL: p.returnTarget(sess)}) +} + +// upsertInstallation records an installation. allowAll only applies when the +// installation is first created; later syncs never widen access. +func (p *githubPlugin) upsertInstallation(appRowID string, inst *ghInstallation, allowAll bool) (string, error) { + var suspended any + if inst.SuspendedAt != nil { + suspended = inst.SuspendedAt.UTC().Format(time.RFC3339Nano) + } + now := nowStr() + r, err := p.db.Query(` + INSERT INTO github_app_installations + (app_id, installation_id, account_login, account_type, repository_selection, + allow_all_projects, suspended_at, created_at, updated_at) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$8) + 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 + RETURNING id`, + appRowID, inst.ID, inst.Account.Login, inst.Account.Type, inst.RepositorySelection, allowAll, suspended, now) + if err != nil { + return "", err + } + if len(r.Rows) == 0 { + return "", errors.New("upsert returned no rows") + } + return newRowScanner(r.Columns, r.Rows[0]).str("id"), nil +} + +// ─── GET /apps ─────────────────────────────────────────────────────────────── + +func (p *githubPlugin) listApps(_ *plugin.Request, res *plugin.Response) { + apps, err := p.db.Query(` + SELECT id, github_app_id, slug, name, owner_login, html_url, webhook_active, created_at + FROM github_apps ORDER BY created_at ASC`) + if err != nil { + apiError(res, 500, "INTERNAL_ERROR", err.Error()) + return + } + insts, err := p.db.Query(` + SELECT id, app_id, installation_id, account_login, account_type, repository_selection, + allow_all_projects, suspended_at + FROM github_app_installations ORDER BY created_at ASC`) + if err != nil { + apiError(res, 500, "INTERNAL_ERROR", err.Error()) + return + } + links, err := p.db.Query(`SELECT installation_id, project_id FROM github_installation_projects`) + if err != nil { + apiError(res, 500, "INTERNAL_ERROR", err.Error()) + return + } + projectsByInst := map[string][]string{} + for _, row := range links.Rows { + sc := newRowScanner(links.Columns, row) + k := sc.str("installation_id") + projectsByInst[k] = append(projectsByInst[k], sc.str("project_id")) + } + + items := make([]appResponse, 0, len(apps.Rows)) + index := map[string]int{} + for _, row := range apps.Rows { + sc := newRowScanner(apps.Columns, row) + a := appResponse{ + 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"), + WebhookActive: sc.str("webhook_active") == "true", + CreatedAt: sc.str("created_at"), + Installations: []installationResponse{}, + } + index[a.ID] = len(items) + items = append(items, a) + } + for _, row := range insts.Rows { + sc := newRowScanner(insts.Columns, row) + i, found := index[sc.str("app_id")] + if !found { + continue + } + id := sc.str("id") + pids := projectsByInst[id] + if pids == nil { + pids = []string{} + } + items[i].Installations = append(items[i].Installations, installationResponse{ + ID: id, + InstallationID: sc.int64Val("installation_id"), + AccountLogin: sc.str("account_login"), + AccountType: sc.str("account_type"), + RepositorySelection: sc.str("repository_selection"), + AllowAllProjects: sc.str("allow_all_projects") == "true", + ProjectIDs: pids, + Suspended: sc.strPtr("suspended_at") != nil, + ManageURL: installationManageURL(sc.str("account_type"), sc.str("account_login"), sc.int64Val("installation_id")), + }) + } + ok(res, items) +} + +func installationManageURL(accountType, login string, installationID int64) string { + if strings.EqualFold(accountType, "Organization") { + return fmt.Sprintf("https://github.com/organizations/%s/settings/installations/%d", login, installationID) + } + return fmt.Sprintf("https://github.com/settings/installations/%d", installationID) +} + +// ─── DELETE /apps/:appId ───────────────────────────────────────────────────── + +// deleteApp forgets an app locally. Projects bound to its installations fall +// back to "not connected"; the App itself must be deleted on GitHub (there is +// no API for it), which the UI links to. +func (p *githubPlugin) deleteApp(req *plugin.Request, res *plugin.Response) { + appID := req.PathParam("appId") + n, err := p.db.Exec(`DELETE FROM github_apps WHERE id = $1`, appID) + if err != nil { + apiError(res, 500, "INTERNAL_ERROR", err.Error()) + return + } + if n == 0 { + apiError(res, 404, "GITHUB_APP_NOT_FOUND", "GitHub App not found") + return + } + noContent(res) +} + +// ─── POST /apps/:appId/install ─────────────────────────────────────────────── + +// startInstall returns the GitHub URL for installing an existing app on +// another account/org, with a fresh state so the callback is attributable. +func (p *githubPlugin) startInstall(req *plugin.Request, res *plugin.Response) { + appID := req.PathParam("appId") + b, _ := plugin.JSONBody[createManifestBody](req) + + publicBase, err := p.publicBaseURL() + if err != nil { + apiError(res, 422, "GITHUB_WEBHOOK_URL_REQUIRED", "PUBLIC_URL is not configured") + return + } + returnURL := strings.TrimSpace(b.ReturnURL) + if returnURL != "" && !sameOrigin(publicBase, returnURL) { + apiError(res, 400, "BAD_REQUEST", "return_url must be on this Paca instance") + return + } + app, err := p.db.Query(`SELECT slug, app_key, name FROM github_apps WHERE id = $1`, appID) + if err != nil { + apiError(res, 500, "INTERNAL_ERROR", err.Error()) + return + } + if len(app.Rows) == 0 { + apiError(res, 404, "GITHUB_APP_NOT_FOUND", "GitHub App not found") + return + } + asc := newRowScanner(app.Columns, app.Rows[0]) + state, err := randomHex(24) + if err != nil { + apiError(res, 500, "INTERNAL_ERROR", "failed to generate state") + return + } + now := time.Now().UTC() + if _, err := p.db.Exec(` + INSERT INTO github_manifest_sessions + (state, app_key, created_by, app_name, allow_all_projects, return_url, app_id, code_used_at, expires_at, created_at) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$8) + `, state, asc.str("app_key"), req.Caller.UserID, asc.str("name"), b.AllowAllProjects, returnURL, appID, + now.Format(time.RFC3339Nano), now.Add(manifestSessionTTL).Format(time.RFC3339Nano)); err != nil { + apiError(res, 500, "INTERNAL_ERROR", err.Error()) + return + } + ok(res, map[string]string{"url": installURL(asc.str("slug"), state)}) +} + +// ─── POST /apps/:appId/installations/:installationId/access ────────────────── + +type installationAccessBody struct { + AllowAllProjects bool `json:"allow_all_projects"` + ProjectIDs []string `json:"project_ids"` +} + +func (p *githubPlugin) setInstallationAccess(req *plugin.Request, res *plugin.Response) { + appID := req.PathParam("appId") + instID := req.PathParam("installationId") + b, err := plugin.JSONBody[installationAccessBody](req) + if err != nil { + apiError(res, 400, "BAD_REQUEST", "invalid request body") + return + } + exists, err := p.db.Query(`SELECT id FROM github_app_installations WHERE id = $1 AND app_id = $2`, instID, appID) + if err != nil { + apiError(res, 500, "INTERNAL_ERROR", err.Error()) + return + } + if len(exists.Rows) == 0 { + apiError(res, 404, "GITHUB_INSTALLATION_NOT_FOUND", "GitHub App installation not found") + return + } + if _, err := p.db.Exec( + `UPDATE github_app_installations SET allow_all_projects = $1, updated_at = $2 WHERE id = $3`, + b.AllowAllProjects, nowStr(), instID); err != nil { + apiError(res, 500, "INTERNAL_ERROR", err.Error()) + return + } + if _, err := p.db.Exec(`DELETE FROM github_installation_projects WHERE installation_id = $1`, instID); err != nil { + apiError(res, 500, "INTERNAL_ERROR", err.Error()) + return + } + for _, pid := range b.ProjectIDs { + if _, err := p.db.Exec( + `INSERT INTO github_installation_projects (installation_id, project_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`, + instID, pid); err != nil { + apiError(res, 400, "BAD_REQUEST", "unknown project_id: "+pid) + return + } + } + noContent(res) +} diff --git a/backend/apps_test.go b/backend/apps_test.go new file mode 100644 index 0000000..95be9ba --- /dev/null +++ b/backend/apps_test.go @@ -0,0 +1,400 @@ +package main + +import ( + "crypto" + "crypto/hmac" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/hex" + "encoding/json" + "encoding/pem" + "strings" + "testing" + "time" + + plugin "github.com/Paca-AI/plugin-sdk-go" + "github.com/Paca-AI/plugin-sdk-go/plugintest" +) + +func newTestKey(t *testing.T) *rsa.PrivateKey { + t.Helper() + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatal(err) + } + return key +} + +func pkcs1PEM(key *rsa.PrivateKey) string { + return string(pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)})) +} + +func TestSignAppJWT_VerifiesAndCarriesClaims(t *testing.T) { + key := newTestKey(t) + now := time.Unix(1_700_000_000, 0) + jwt, err := signAppJWT(12345, pkcs1PEM(key), now) + if err != nil { + t.Fatal(err) + } + parts := strings.Split(jwt, ".") + if len(parts) != 3 { + t.Fatalf("want 3 segments, got %d", len(parts)) + } + sig, err := base64.RawURLEncoding.DecodeString(parts[2]) + if err != nil { + t.Fatal(err) + } + digest := sha256.Sum256([]byte(parts[0] + "." + parts[1])) + if err := rsa.VerifyPKCS1v15(&key.PublicKey, crypto.SHA256, digest[:], sig); err != nil { + t.Fatalf("signature does not verify: %v", err) + } + raw, _ := base64.RawURLEncoding.DecodeString(parts[1]) + var claims struct { + Iss string `json:"iss"` + Iat int64 `json:"iat"` + Exp int64 `json:"exp"` + } + if err := json.Unmarshal(raw, &claims); err != nil { + t.Fatal(err) + } + if claims.Iss != "12345" || claims.Iat != now.Unix()-60 || claims.Exp != now.Unix()+540 { + t.Fatalf("unexpected claims: %+v", claims) + } +} + +func TestParseRSAPrivateKey_AcceptsPKCS8AndRejectsGarbage(t *testing.T) { + key := newTestKey(t) + der, err := x509.MarshalPKCS8PrivateKey(key) + if err != nil { + t.Fatal(err) + } + p8 := string(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der})) + if _, err := parseRSAPrivateKey(p8); err != nil { + t.Fatalf("pkcs8: %v", err) + } + if _, err := parseRSAPrivateKey("not pem"); err == nil { + t.Fatal("expected error for non-PEM input") + } +} + +func TestIsLocalURL(t *testing.T) { + cases := map[string]bool{ + "http://localhost:8080": true, + "http://127.0.0.1": true, + "http://192.168.1.10": true, + "http://10.0.0.5": true, + "http://172.20.0.2": true, + "http://172.32.0.2": false, + "https://paca.example.com": false, + "https://paca.internal": true, + "https://1.2.3.4": false, + } + for u, want := range cases { + if got := isLocalURL(u); got != want { + t.Errorf("isLocalURL(%q) = %v, want %v", u, got, want) + } + } +} + +func TestSameOrigin(t *testing.T) { + base := "https://paca.example.com" + if !sameOrigin(base, "https://paca.example.com/projects/1/settings") { + t.Error("same host should match") + } + for _, bad := range []string{"https://evil.example.com/", "http://paca.example.com/", "/relative", "javascript:alert(1)", ""} { + if sameOrigin(base, bad) { + t.Errorf("sameOrigin accepted %q", bad) + } + } +} + +func TestBuildManifest(t *testing.T) { + m := buildManifest("Paca", "https://paca.example.com", "https://paca.example.com/api/v1/plugins/com.paca.github", "abc", true, false) + hook := m["hook_attributes"].(map[string]any) + if hook["url"] != "https://paca.example.com/api/v1/plugins/com.paca.github/webhooks/apps/abc" || hook["active"] != true { + t.Fatalf("hook_attributes: %v", hook) + } + // Both legs return to a page of the Paca web app, not to a plugin API route. + const page = "https://paca.example.com/plugin-pages/com.paca.github/github-app-callback" + if m["redirect_url"] != page || m["setup_url"] != page { + t.Fatalf("redirect_url=%v setup_url=%v", m["redirect_url"], m["setup_url"]) + } + if m["public"] != false { + t.Fatal("app must be private by default") + } +} + +// ── handlers ────────────────────────────────────────────────────────────────── + +func setupAppsPlugin(t *testing.T) *plugintest.Context { + t.Helper() + tc := setupPlugin(t) + tc.Config.Set("ENCRYPTION_KEY", testEncryptionKey) + tc.Config.Set("PUBLIC_URL", "https://paca.example.com") + tc.DB.SeedRows("github_manifest_sessions", + []string{"state", "app_key", "created_by", "app_name", "owner_org", "allow_all_projects", + "return_url", "app_id", "code_used_at", "completed_at", "expires_at", "created_at"}, nil) + tc.DB.SeedRows("github_apps", + []string{"id", "app_key", "github_app_id", "slug", "name", "private_key_enc", "webhook_secret_enc"}, nil) + return tc +} + +func adminReq() plugintest.Request { + return plugintest.Request{Caller: plugin.CallerIdentity{UserID: "admin-1"}, PathParams: map[string]string{}} +} + +func TestCreateManifest_StoresSessionAndReturnsGitHubURL(t *testing.T) { + tc := setupAppsPlugin(t) + res := tc.Call("POST", "/apps/manifest", adminReq().WithJSONBody(map[string]any{ + "name": "Paca Acme", "owner_org": "acme-inc", "return_url": "https://paca.example.com/projects/1", + })) + if res.StatusCode != 200 { + t.Fatalf("status %d: %s", res.StatusCode, res.BodyString()) + } + var out struct { + Data manifestResponse `json:"data"` + } + if err := json.Unmarshal(res.Body, &out); err != nil { + t.Fatal(err) + } + if !strings.HasPrefix(out.Data.PostURL, "https://github.com/organizations/acme-inc/settings/apps/new?state=") { + t.Fatalf("post_url: %s", out.Data.PostURL) + } + if !out.Data.WebhookActive { + t.Error("public https URL should enable the webhook") + } + if rows := tc.DB.AllRows("github_manifest_sessions"); len(rows) != 1 { + t.Fatalf("want 1 session row, got %d", len(rows)) + } +} + +func TestCreateManifest_Validation(t *testing.T) { + tc := setupAppsPlugin(t) + bad := []map[string]any{ + {"name": ""}, + {"name": strings.Repeat("x", githubAppNameMaxLen+1)}, + {"name": "ok", "owner_org": "bad/org"}, + {"name": "ok", "return_url": "https://evil.example.com/"}, + } + for _, body := range bad { + res := tc.Call("POST", "/apps/manifest", adminReq().WithJSONBody(body)) + if res.StatusCode != 400 { + t.Errorf("body %v: want 400, got %d", body, res.StatusCode) + } + } + if rows := tc.DB.AllRows("github_manifest_sessions"); len(rows) != 0 { + t.Fatalf("rejected requests must not create sessions, got %d", len(rows)) + } +} + +const sessionCols = "state,app_key,created_by,app_name,owner_org,allow_all_projects,return_url,app_id,code_used_at,completed_at,expires_at,created_at" + +func seedSession(tc *plugintest.Context, state, createdBy, appID string, expires time.Time) { + var app any + if appID != "" { + app = appID + } + tc.DB.SeedRows("github_manifest_sessions", strings.Split(sessionCols, ","), + [][]any{{state, "k", createdBy, "Paca", "", false, "https://paca.example.com/projects/1", + app, nil, nil, expires.UTC().Format(time.RFC3339Nano), "now"}}) +} + +func decodeError(t *testing.T, res *plugin.Response) string { + t.Helper() + var out struct { + ErrorCode string `json:"error_code"` + } + if err := json.Unmarshal(res.Body, &out); err != nil { + t.Fatalf("body %q: %v", res.BodyString(), err) + } + return out.ErrorCode +} + +func TestCallbackCreated_RejectsUnknownState(t *testing.T) { + tc := setupAppsPlugin(t) + res := tc.Call("POST", "/apps/callback/created", adminReq().WithJSONBody(map[string]string{"state": "nope", "code": "c"})) + if res.StatusCode != 400 || decodeError(t, res) != setupInvalidState { + t.Fatalf("status=%d body=%s", res.StatusCode, res.BodyString()) + } +} + +func TestCallbackCreated_RequiresCodeAndState(t *testing.T) { + tc := setupAppsPlugin(t) + res := tc.Call("POST", "/apps/callback/created", adminReq().WithJSONBody(map[string]string{"state": "s"})) + if res.StatusCode != 400 || decodeError(t, res) != setupMissingParam { + t.Fatalf("status=%d body=%s", res.StatusCode, res.BodyString()) + } +} + +// A state that leaks must be useless to anyone but the user who started the flow. +func TestCallbacks_StateIsBoundToItsUser(t *testing.T) { + tc := setupAppsPlugin(t) + seedSession(tc, "live", "admin-1", "app-row-1", time.Now().Add(time.Hour)) + other := plugintest.Request{Caller: plugin.CallerIdentity{UserID: "someone-else"}} + + created := tc.Call("POST", "/apps/callback/created", other.WithJSONBody(map[string]string{"state": "live", "code": "c"})) + installed := tc.Call("POST", "/apps/callback/installed", other.WithJSONBody(map[string]string{"state": "live", "installation_id": "1"})) + for name, res := range map[string]*plugin.Response{"created": created, "installed": installed} { + if res.StatusCode != 400 || decodeError(t, res) != setupInvalidState { + t.Errorf("%s: status=%d body=%s", name, res.StatusCode, res.BodyString()) + } + } +} + +func TestCallbacks_NoCallerIsRejected(t *testing.T) { + tc := setupAppsPlugin(t) + seedSession(tc, "live", "admin-1", "app-row-1", time.Now().Add(time.Hour)) + res := tc.Call("POST", "/apps/callback/installed", plugintest.Request{}.WithJSONBody(map[string]string{"state": "live", "installation_id": "1"})) + if res.StatusCode != 400 || decodeError(t, res) != setupInvalidState { + t.Fatalf("status=%d body=%s", res.StatusCode, res.BodyString()) + } +} + +func TestCallbackInstalled_ExpiredStateIsRejected(t *testing.T) { + tc := setupAppsPlugin(t) + seedSession(tc, "old", "admin-1", "app-row-1", time.Now().Add(-time.Minute)) + res := tc.Call("POST", "/apps/callback/installed", adminReq().WithJSONBody(map[string]string{"state": "old", "installation_id": "1"})) + if res.StatusCode != 400 || decodeError(t, res) != setupInvalidState { + t.Fatalf("status=%d body=%s", res.StatusCode, res.BodyString()) + } +} + +func TestCallbackInstalled_RejectsBadInstallationID(t *testing.T) { + tc := setupAppsPlugin(t) + seedSession(tc, "live", "admin-1", "app-row-1", time.Now().Add(time.Hour)) + for _, id := range []string{"", "abc", "0", "-3"} { + res := tc.Call("POST", "/apps/callback/installed", adminReq().WithJSONBody(map[string]string{"state": "live", "installation_id": id})) + if res.StatusCode != 400 || decodeError(t, res) != setupMissingParam { + t.Errorf("id %q: status=%d body=%s", id, res.StatusCode, res.BodyString()) + } + } +} + +// Without an app yet (the created leg never ran) the installed leg has nothing to attach to. +func TestCallbackInstalled_RequiresCreatedLegFirst(t *testing.T) { + tc := setupAppsPlugin(t) + seedSession(tc, "live", "admin-1", "", time.Now().Add(time.Hour)) + res := tc.Call("POST", "/apps/callback/installed", adminReq().WithJSONBody(map[string]string{"state": "live", "installation_id": "7"})) + if res.StatusCode != 400 || decodeError(t, res) != setupInvalidState { + t.Fatalf("status=%d body=%s", res.StatusCode, res.BodyString()) + } +} + +func appWebhookSetup(t *testing.T) (*plugintest.Context, string) { + tc := setupAppsPlugin(t) + p := &githubPlugin{cfg: tc.PluginContext().Config()} + enc, err := p.encrypt("hook-secret") + if err != nil { + t.Fatal(err) + } + tc.DB.SeedRows("github_apps", + []string{"id", "app_key", "github_app_id", "slug", "name", "private_key_enc", "webhook_secret_enc"}, + [][]any{{"app-row-1", "key1", float64(1), "paca", "Paca", "x", enc}}) + return tc, "key1" +} + +func signBody(body []byte, secret string) string { + return "sha256=" + hmacHex(body, secret) +} + +func TestAppWebhook_RejectsBadSignature(t *testing.T) { + tc, key := appWebhookSetup(t) + req := adminReq() + req.PathParams = map[string]string{"appKey": key} + req.Body = []byte(`{"zen":"x"}`) + req.Headers = map[string]string{"x-github-event": "ping", "x-hub-signature-256": "sha256=deadbeef"} + if res := tc.Call("POST", "/webhooks/apps/:appKey", req); res.StatusCode != 401 { + t.Fatalf("want 401, got %d", res.StatusCode) + } +} + +func TestAppWebhook_AcceptsValidPing(t *testing.T) { + tc, key := appWebhookSetup(t) + body := []byte(`{"zen":"x"}`) + req := adminReq() + req.PathParams = map[string]string{"appKey": key} + req.Body = body + req.Headers = map[string]string{"x-github-event": "ping", "x-hub-signature-256": signBody(body, "hook-secret")} + if res := tc.Call("POST", "/webhooks/apps/:appKey", req); res.StatusCode != 204 { + t.Fatalf("want 204, got %d: %s", res.StatusCode, res.BodyString()) + } +} + +func TestAppWebhook_UnknownAppIs404(t *testing.T) { + tc, _ := appWebhookSetup(t) + req := adminReq() + req.PathParams = map[string]string{"appKey": "other"} + req.Body = []byte(`{}`) + if res := tc.Call("POST", "/webhooks/apps/:appKey", req); res.StatusCode != 404 { + t.Fatalf("want 404, got %d", res.StatusCode) + } +} + +// ── installation tokens ─────────────────────────────────────────────────────── + +func TestInstallationToken_MintsCachesAndReuses(t *testing.T) { + tc := setupAppsPlugin(t) + p := &githubPlugin{db: tc.PluginContext().DB(), cfg: tc.PluginContext().Config(), cache: tc.PluginContext().Cache(), log: tc.PluginContext().Log()} + key := newTestKey(t) + pemEnc, err := p.encrypt(pkcs1PEM(key)) + if err != nil { + t.Fatal(err) + } + tc.DB.SeedRows("github_apps", + []string{"id", "app_key", "github_app_id", "slug", "name", "private_key_enc", "webhook_secret_enc"}, + [][]any{{"app-row-1", "k", float64(42), "paca", "Paca", pemEnc, ""}}) + tc.DB.SeedRows("github_app_installations", + []string{"id", "app_id", "installation_id", "suspended_at"}, + [][]any{{"inst-row-1", "app-row-1", float64(777), nil}}) + + calls := 0 + orig := fetch + defer func() { fetch = orig }() + fetch = func(method, rawURL string, headers map[string]string, body string) (*plugin.FetchResponse, error) { + calls++ + if method != "POST" || rawURL != "https://api.github.com/app/installations/777/access_tokens" { + t.Fatalf("unexpected call %s %s", method, rawURL) + } + if !strings.HasPrefix(headers["Authorization"], "Bearer ") || strings.Count(headers["Authorization"], ".") != 2 { + t.Fatalf("expected a JWT bearer, got %q", headers["Authorization"]) + } + exp := time.Now().Add(time.Hour).UTC().Format(time.RFC3339) + return &plugin.FetchResponse{Status: 201, Body: `{"token":"ghs_abc","expires_at":"` + exp + `"}`}, nil + } + + for i := 0; i < 3; i++ { + tok, exp, err := p.installationToken("inst-row-1") + if err != nil { + t.Fatal(err) + } + if tok != "ghs_abc" || time.Until(exp) < 50*time.Minute { + t.Fatalf("token=%q exp=%v", tok, exp) + } + } + if calls != 1 { + t.Fatalf("token should be minted once and then served from cache, minted %d times", calls) + } + if v, ok := tc.Cache.Get(installationTokenCacheKey("inst-row-1")); !ok || strings.Contains(v, "ghs_abc") { + t.Fatal("cached token must be present and encrypted") + } +} + +func TestInstallationToken_SuspendedIsRefused(t *testing.T) { + tc := setupAppsPlugin(t) + p := &githubPlugin{db: tc.PluginContext().DB(), cfg: tc.PluginContext().Config(), cache: tc.PluginContext().Cache(), log: tc.PluginContext().Log()} + tc.DB.SeedRows("github_app_installations", + []string{"id", "app_id", "installation_id", "suspended_at"}, + [][]any{{"inst-row-1", "app-row-1", float64(777), "2026-01-01T00:00:00Z"}}) + if _, _, err := p.installationToken("inst-row-1"); err == nil { + t.Fatal("expected suspended installation to be refused") + } +} + +func hmacHex(body []byte, secret string) string { + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write(body) + return hex.EncodeToString(mac.Sum(nil)) +} diff --git a/backend/branches.go b/backend/branches.go index 4f591de..e250f11 100644 --- a/backend/branches.go +++ b/backend/branches.go @@ -42,7 +42,7 @@ func (p *githubPlugin) createBranch(req *plugin.Request, res *plugin.Response) { return } - token, err := p.decryptToken(projectID) + token, err := p.decryptTokenFor(projectID, req.Caller.UserID) if err != nil { writeAppError(res, err) return @@ -129,7 +129,7 @@ func (p *githubPlugin) linkBranchToTask(req *plugin.Request, res *plugin.Respons return } - token, err := p.decryptToken(projectID) + token, err := p.decryptTokenFor(projectID, req.Caller.UserID) if err != nil { writeAppError(res, err) return diff --git a/backend/client.go b/backend/client.go index 0427fb5..395cd6a 100644 --- a/backend/client.go +++ b/backend/client.go @@ -16,6 +16,9 @@ const ( ghAPIVersion = "2022-11-28" ) +// fetch is the outbound HTTP call; a variable so tests can stub GitHub. +var fetch = plugin.Fetch + // ghRepository is a minimal GitHub repository representation. type ghRepository struct { ID int64 `json:"id"` @@ -99,15 +102,20 @@ func newGHClient(token string) *ghClient { } func (c *ghClient) headers() map[string]string { - return map[string]string{ - "Authorization": "Bearer " + c.token, + h := map[string]string{ "Accept": "application/vnd.github+json", "X-GitHub-Api-Version": ghAPIVersion, } + // An empty token is for the few unauthenticated endpoints (the manifest + // code conversion); sending "Bearer " there would be rejected. + if c.token != "" { + h["Authorization"] = "Bearer " + c.token + } + return h } func (c *ghClient) get(_ context.Context, rawURL string, out any) error { - resp, err := plugin.Fetch("GET", rawURL, c.headers(), "") + resp, err := fetch("GET", rawURL, c.headers(), "") if err != nil { return fmt.Errorf("githubclient: execute request: %w", err) } @@ -129,7 +137,7 @@ func (c *ghClient) post(_ context.Context, rawURL string, body, out any) error { } hdrs := c.headers() hdrs["Content-Type"] = "application/json" - resp, err := plugin.Fetch("POST", rawURL, hdrs, string(bodyJSON)) + resp, err := fetch("POST", rawURL, hdrs, string(bodyJSON)) if err != nil { return fmt.Errorf("githubclient: execute request: %w", err) } @@ -145,7 +153,7 @@ func (c *ghClient) post(_ context.Context, rawURL string, body, out any) error { } func (c *ghClient) doDelete(_ context.Context, rawURL string) error { - resp, err := plugin.Fetch("DELETE", rawURL, c.headers(), "") + resp, err := fetch("DELETE", rawURL, c.headers(), "") if err != nil { return fmt.Errorf("githubclient: execute request: %w", err) } @@ -339,7 +347,7 @@ func (c *ghClient) mergePullRequest(ctx context.Context, owner, repo string, prN } hdrs := c.headers() hdrs["Content-Type"] = "application/json" - resp, err := plugin.Fetch("PUT", url, hdrs, string(bodyJSON)) + resp, err := fetch("PUT", url, hdrs, string(bodyJSON)) if err != nil { return fmt.Errorf("githubclient: execute request: %w", err) } @@ -356,7 +364,7 @@ func (c *ghClient) getPullRequestDiff(_ context.Context, owner, repo string, prN 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, "") + resp, err := fetch("GET", url, hdrs, "") if err != nil { return "", fmt.Errorf("githubclient: execute request: %w", err) } @@ -441,3 +449,72 @@ func (c *ghClient) createBranch(ctx context.Context, owner, repo, newBranch, sou "sha": refResp.Object.SHA, }, nil) } + +// ─── GitHub App endpoints ──────────────────────────────────────────────────── + +// ghManifestConversion is the response of POST /app-manifests/{code}/conversions. +// It is the only time GitHub ever returns the App's private key, client secret +// 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"` +} + +// convertManifestCode exchanges the one-time manifest code for the App's +// credentials. The endpoint is unauthenticated; the code is the credential. +func (c *ghClient) convertManifestCode(ctx context.Context, code string) (*ghManifestConversion, error) { + var out ghManifestConversion + if err := c.post(ctx, ghBaseURL+"/app-manifests/"+url.PathEscape(code)+"/conversions", map[string]any{}, &out); err != nil { + return nil, err + } + return &out, nil +} + +// 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"` +} + +// getInstallation fetches an installation; the client must hold an App JWT. +func (c *ghClient) getInstallation(ctx context.Context, installationID int64) (*ghInstallation, error) { + var out ghInstallation + if err := c.get(ctx, fmt.Sprintf("%s/app/installations/%d", ghBaseURL, installationID), &out); err != nil { + return nil, err + } + return &out, nil +} + +// listInstallationRepositories lists every repository the installation can +// see; the client must hold an installation token. +func (c *ghClient) listInstallationRepositories(ctx context.Context) ([]ghRepository, error) { + var all []ghRepository + for page := 1; ; page++ { + var batch struct { + Repositories []ghRepository `json:"repositories"` + } + u := fmt.Sprintf("%s/installation/repositories?per_page=100&page=%d", ghBaseURL, page) + if err := c.get(ctx, u, &batch); err != nil { + return nil, err + } + all = append(all, batch.Repositories...) + if len(batch.Repositories) < 100 { + break + } + } + return all, nil +} diff --git a/backend/identities.go b/backend/identities.go new file mode 100644 index 0000000..a6b9eb5 --- /dev/null +++ b/backend/identities.go @@ -0,0 +1,149 @@ +package main + +import ( + "strings" + + plugin "github.com/Paca-AI/plugin-sdk-go" +) + +// Per-agent GitHub identity: inside an App-connected project, an account (in +// practice an AI agent) can be mapped to its own installation so everything it +// does on GitHub — branches, PRs, reviews, comments — is attributed to that +// app's bot instead of the project's default identity. + +type identityResponse struct { + MemberID string `json:"member_id"` + InstallationID string `json:"installation_id"` + AppName string `json:"app_name"` + AccountLogin string `json:"account_login"` +} + +// ─── GET /integration/agent-identities ─────────────────────────────────────── + +func (p *githubPlugin) listAgentIdentities(req *plugin.Request, res *plugin.Response) { + projectID := req.Caller.ProjectID + r, err := p.db.Query(` + SELECT m.id AS member_id, ai.installation_id, i.account_login, a.name AS app_name + FROM github_agent_identities ai + JOIN project_members m ON m.account_id = ai.account_id AND m.project_id = ai.project_id AND m.deleted_at IS NULL + JOIN github_app_installations i ON i.id = ai.installation_id + JOIN github_apps a ON a.id = i.app_id + WHERE ai.project_id = $1 + ORDER BY a.name ASC, i.account_login ASC`, projectID) + if err != nil { + apiError(res, 500, "INTERNAL_ERROR", err.Error()) + return + } + items := make([]identityResponse, 0, len(r.Rows)) + for _, row := range r.Rows { + sc := newRowScanner(r.Columns, row) + items = append(items, identityResponse{ + MemberID: sc.str("member_id"), + InstallationID: sc.str("installation_id"), + AppName: sc.str("app_name"), + AccountLogin: sc.str("account_login"), + }) + } + ok(res, items) +} + +// ─── POST /integration/agent-identities ────────────────────────────────────── + +type setIdentityBody struct { + MemberID string `json:"member_id"` + InstallationID string `json:"installation_id"` +} + +func (p *githubPlugin) setAgentIdentity(req *plugin.Request, res *plugin.Response) { + projectID := req.Caller.ProjectID + b, err := plugin.JSONBody[setIdentityBody](req) + if err != nil || strings.TrimSpace(b.MemberID) == "" || strings.TrimSpace(b.InstallationID) == "" { + apiError(res, 400, "BAD_REQUEST", "member_id and installation_id are required") + return + } + + pa, err := p.loadProjectAuth(projectID) + if err != nil { + writeAppError(res, err) + return + } + if pa.authType != authTypeApp { + apiError(res, 409, "GITHUB_APP_REQUIRED", "Per-agent identities need the project to be connected through a GitHub App") + return + } + + accountID, found, err := p.memberAccountID(projectID, b.MemberID) + if err != nil { + apiError(res, 500, "INTERNAL_ERROR", err.Error()) + return + } + if !found { + apiError(res, 404, "GITHUB_MEMBER_NOT_FOUND", "Project member not found") + return + } + + allowed, err := p.projectMayUseInstallation(projectID, b.InstallationID) + if err != nil { + apiError(res, 500, "INTERNAL_ERROR", err.Error()) + return + } + if !allowed { + apiError(res, 403, "GITHUB_INSTALLATION_NOT_ALLOWED", "This installation is not available to the project") + return + } + // Prove the identity works before an agent depends on it. + if _, _, err := p.installationToken(b.InstallationID); err != nil { + writeAppError(res, err) + return + } + + now := nowStr() + if _, err := p.db.Exec(` + INSERT INTO github_agent_identities (project_id, account_id, installation_id, created_at, updated_at) + VALUES ($1, $2, $3, $4, $4) + ON CONFLICT (project_id, account_id) DO UPDATE SET + installation_id = EXCLUDED.installation_id, updated_at = EXCLUDED.updated_at + `, projectID, accountID, b.InstallationID, now); err != nil { + apiError(res, 500, "INTERNAL_ERROR", err.Error()) + return + } + p.listAgentIdentities(req, res) +} + +// ─── DELETE /integration/agent-identities/:memberId ────────────────────────── + +func (p *githubPlugin) deleteAgentIdentity(req *plugin.Request, res *plugin.Response) { + projectID := req.Caller.ProjectID + accountID, found, err := p.memberAccountID(projectID, req.PathParam("memberId")) + if err != nil { + apiError(res, 500, "INTERNAL_ERROR", err.Error()) + return + } + if !found { + apiError(res, 404, "GITHUB_MEMBER_NOT_FOUND", "Project member not found") + return + } + if _, err := p.db.Exec( + `DELETE FROM github_agent_identities WHERE project_id = $1 AND account_id = $2`, + projectID, accountID); err != nil { + apiError(res, 500, "INTERNAL_ERROR", err.Error()) + return + } + noContent(res) +} + +// memberAccountID resolves a project membership id to its account id, +// scoped to the project so a member of another project can never be named. +func (p *githubPlugin) memberAccountID(projectID, memberID string) (string, bool, error) { + r, err := p.db.Query( + `SELECT account_id FROM project_members WHERE id = $1 AND project_id = $2 AND deleted_at IS NULL`, + memberID, projectID) + if err != nil { + return "", false, err + } + if len(r.Rows) == 0 { + return "", false, nil + } + id := newRowScanner(r.Columns, r.Rows[0]).str("account_id") + return id, id != "", nil +} diff --git a/backend/identities_test.go b/backend/identities_test.go new file mode 100644 index 0000000..3eee1f3 --- /dev/null +++ b/backend/identities_test.go @@ -0,0 +1,111 @@ +package main + +import ( + "strings" + "testing" + "time" + + plugin "github.com/Paca-AI/plugin-sdk-go" + "github.com/Paca-AI/plugin-sdk-go/plugintest" +) + +// seedTwoInstallations connects testProjectID to inst-default and makes +// inst-reviewer available to it, with a stubbed GitHub that hands out a token +// named after the installation it was minted for. +func seedTwoInstallations(t *testing.T, mapped bool, reviewerAllowed bool) (*plugintest.Context, *githubPlugin) { + t.Helper() + tc := setupAppsPlugin(t) + p := &githubPlugin{db: tc.PluginContext().DB(), cfg: tc.PluginContext().Config(), cache: tc.PluginContext().Cache(), log: tc.PluginContext().Log()} + pemEnc, err := p.encrypt(pkcs1PEM(newTestKey(t))) + if err != nil { + t.Fatal(err) + } + tc.DB.SeedRows("github_apps", + []string{"id", "app_key", "github_app_id", "slug", "name", "private_key_enc", "webhook_secret_enc"}, + [][]any{{"app-1", "k", float64(1), "paca", "Paca", pemEnc, ""}}) + tc.DB.SeedRows("github_app_installations", + []string{"id", "app_id", "installation_id", "allow_all_projects", "suspended_at"}, + [][]any{ + {"inst-default", "app-1", float64(100), true, nil}, + {"inst-reviewer", "app-1", float64(200), reviewerAllowed, nil}, + }) + tc.DB.SeedRows("github_installation_projects", []string{"installation_id", "project_id"}, nil) + tc.DB.SeedRows("github_integrations", + []string{"id", "project_id", "auth_type", "installation_id", "access_token_enc"}, + [][]any{{"integ-1", testProjectID, "app", "inst-default", nil}}) + var rows [][]any + if mapped { + rows = [][]any{{testProjectID, "agent-account", "inst-reviewer"}} + } + tc.DB.SeedRows("github_agent_identities", []string{"project_id", "account_id", "installation_id"}, rows) + + orig := fetch + t.Cleanup(func() { fetch = orig }) + fetch = func(method, rawURL string, headers map[string]string, body string) (*plugin.FetchResponse, error) { + name := "default" + if strings.Contains(rawURL, "/200/") { + name = "reviewer" + } + exp := time.Now().Add(time.Hour).UTC().Format(time.RFC3339) + return &plugin.FetchResponse{Status: 201, Body: `{"token":"ghs_` + name + `","expires_at":"` + exp + `"}`}, nil + } + return tc, p +} + +func TestResolveCredentialFor_MappedAccountUsesItsOwnInstallation(t *testing.T) { + _, p := seedTwoInstallations(t, true, true) + tok, _, err := p.resolveCredentialFor(testProjectID, "agent-account") + if err != nil || tok != "ghs_reviewer" { + t.Fatalf("mapped account: token=%q err=%v", tok, err) + } +} + +func TestResolveCredentialFor_UnmappedAccountUsesProjectDefault(t *testing.T) { + _, p := seedTwoInstallations(t, true, true) + for _, account := range []string{"someone-else", ""} { + tok, _, err := p.resolveCredentialFor(testProjectID, account) + if err != nil || tok != "ghs_default" { + t.Fatalf("account %q: token=%q err=%v", account, tok, err) + } + } +} + +func TestResolveCredentialFor_WithdrawnMappingFailsClosed(t *testing.T) { + _, p := seedTwoInstallations(t, true, false) // reviewer installation no longer open to the project + _, _, err := p.resolveCredentialFor(testProjectID, "agent-account") + ae, ok := err.(*appError) + if !ok || ae.code != "GITHUB_AGENT_IDENTITY_UNAVAILABLE" { + t.Fatalf("want GITHUB_AGENT_IDENTITY_UNAVAILABLE, got %v", err) + } +} + +func TestSetAgentIdentity_RequiresAppConnection(t *testing.T) { + tc := setupAppsPlugin(t) + tc.DB.SeedRows("github_integrations", + []string{"id", "project_id", "auth_type", "installation_id", "access_token_enc"}, + [][]any{{"integ-1", testProjectID, "pat", nil, "enc"}}) + req := callerReq().WithJSONBody(map[string]any{"member_id": "m1", "installation_id": "inst-1"}) + if res := tc.Call("POST", "/integration/agent-identities", req); res.StatusCode != 409 { + t.Fatalf("want 409, got %d: %s", res.StatusCode, res.BodyString()) + } +} + +func TestSetAgentIdentity_UnknownMemberIs404(t *testing.T) { + tc, _ := seedTwoInstallations(t, false, true) + tc.DB.SeedRows("project_members", []string{"id", "project_id", "account_id", "deleted_at"}, + [][]any{{"member-other-project", "project-2", "acc-x", nil}}) + req := callerReq().WithJSONBody(map[string]any{"member_id": "member-other-project", "installation_id": "inst-reviewer"}) + if res := tc.Call("POST", "/integration/agent-identities", req); res.StatusCode != 404 { + t.Fatalf("a member of another project must not be addressable, got %d: %s", res.StatusCode, res.BodyString()) + } +} + +func TestSetAgentIdentity_RejectsInstallationNotOpenToProject(t *testing.T) { + tc, _ := seedTwoInstallations(t, false, false) + tc.DB.SeedRows("project_members", []string{"id", "project_id", "account_id", "deleted_at"}, + [][]any{{"member-1", testProjectID, "agent-account", nil}}) + req := callerReq().WithJSONBody(map[string]any{"member_id": "member-1", "installation_id": "inst-reviewer"}) + if res := tc.Call("POST", "/integration/agent-identities", req); res.StatusCode != 403 { + t.Fatalf("want 403, got %d: %s", res.StatusCode, res.BodyString()) + } +} diff --git a/backend/integration.go b/backend/integration.go index 1507d36..34ee46b 100644 --- a/backend/integration.go +++ b/backend/integration.go @@ -18,6 +18,12 @@ type integrationResponse struct { Connected bool `json:"connected"` CreatedAt *string `json:"created_at,omitempty"` UpdatedAt *string `json:"updated_at,omitempty"` + // AuthType is "pat" or "app". For "app", the fields below describe the + // bound installation. + AuthType string `json:"auth_type,omitempty"` + InstallationID string `json:"installation_id,omitempty"` + AppName string `json:"app_name,omitempty"` + AccountLogin string `json:"account_login,omitempty"` } // accessibleRepoResponse is returned by GET /integration/accessible-repos. @@ -68,21 +74,18 @@ func webhookURLFromPublicURL(cfg *plugin.Config, projectID string) (string, erro return fmt.Sprintf("%s/api/v1/plugins/%s/projects/%s/webhook", base, githubPluginID, projectID), nil } -// ─── Helper: decrypt PAT for a project ─────────────────────────────────────── +// ─── Helper: credential for a project (PAT or App installation token) ──────── 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) + token, _, err := p.resolveCredential(projectID) + return token, err +} + +// decryptTokenFor is decryptToken for a call made by a specific account, so +// an agent with a mapped GitHub identity acts as that identity. +func (p *githubPlugin) decryptTokenFor(projectID, accountID string) (string, error) { + token, _, err := p.resolveCredentialFor(projectID, accountID) + return token, err } // ─── GET /integration ──────────────────────────────────────────────────────── @@ -90,7 +93,7 @@ func (p *githubPlugin) decryptToken(projectID string) (string, error) { 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`, + `SELECT project_id, auth_type, installation_id, created_at, updated_at FROM github_integrations WHERE project_id = $1`, projectID, ) if err != nil { @@ -104,12 +107,30 @@ func (p *githubPlugin) getIntegration(req *plugin.Request, res *plugin.Response) sc := newRowScanner(result.Columns, result.Rows[0]) ca := sc.str("created_at") ua := sc.str("updated_at") - ok(res, integrationResponse{ + out := integrationResponse{ ProjectID: sc.str("project_id"), Connected: true, CreatedAt: &ca, UpdatedAt: &ua, - }) + AuthType: sc.str("auth_type"), + } + if out.AuthType == "" { + out.AuthType = authTypePAT + } + if out.AuthType == authTypeApp { + out.InstallationID = sc.str("installation_id") + if out.InstallationID == "" { + // The installation was removed (app deleted/uninstalled). + out.Connected = false + } else if r, qErr := p.db.Query(` + SELECT i.account_login, a.name AS app_name FROM github_app_installations i + JOIN github_apps a ON a.id = i.app_id WHERE i.id = $1`, out.InstallationID); qErr == nil && len(r.Rows) > 0 { + isc := newRowScanner(r.Columns, r.Rows[0]) + out.AppName = isc.str("app_name") + out.AccountLogin = isc.str("account_login") + } + } + ok(res, out) } // ─── POST /integration/token ───────────────────────────────────────────────── @@ -144,11 +165,23 @@ func (p *githubPlugin) setToken(req *plugin.Request, res *plugin.Response) { return } + // Repos linked through an App have no per-repo webhooks, so they cannot + // simply be re-pointed at a PAT. + if pa, aErr := p.loadProjectAuth(projectID); aErr == nil && pa.authType == authTypeApp { + if linked, qErr := p.db.Query(`SELECT id FROM github_repositories WHERE project_id = $1`, projectID); qErr == nil && len(linked.Rows) > 0 { + apiError(res, 409, "GITHUB_UNLINK_REPOS_FIRST", + "Unlink this project's repositories before changing how it connects to GitHub") + 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 + INSERT INTO github_integrations (project_id, access_token_enc, auth_type, installation_id, created_at, updated_at) + VALUES ($1, $2, 'pat', NULL, $3, $4) + ON CONFLICT (project_id) DO UPDATE SET + access_token_enc = EXCLUDED.access_token_enc, auth_type = 'pat', installation_id = NULL, + updated_at = EXCLUDED.updated_at `, projectID, enc, now, now) if err != nil { apiError(res, 500, "INTERNAL_ERROR", err.Error()) @@ -199,6 +232,7 @@ func (p *githubPlugin) deleteToken(req *plugin.Request, res *plugin.Response) { } } + _, _ = p.db.Exec(`DELETE FROM github_agent_identities WHERE project_id = $1`, projectID) _, execErr := p.db.Exec(`DELETE FROM github_integrations WHERE project_id = $1`, projectID) if execErr != nil { apiError(res, 500, "INTERNAL_ERROR", execErr.Error()) @@ -219,7 +253,12 @@ func (p *githubPlugin) listAccessibleRepos(req *plugin.Request, res *plugin.Resp } ghc := newGHClient(token) - repos, err := ghc.listRepositories(context.Background()) + var repos []ghRepository + if pa, aErr := p.loadProjectAuth(projectID); aErr == nil && pa.authType == authTypeApp { + repos, err = ghc.listInstallationRepositories(context.Background()) + } else { + repos, err = ghc.listRepositories(context.Background()) + } if err != nil { apiError(res, 502, "INTERNAL_ERROR", fmt.Sprintf("failed to list repositories: %s", err)) return @@ -252,6 +291,13 @@ func (p *githubPlugin) listRepositories(req *plugin.Request, res *plugin.Respons return } + // Repos linked through an App are covered by the App's single webhook, so + // they have no webhook_id of their own. + appHook := false + if pa, aErr := p.loadProjectAuth(projectID); aErr == nil && pa.authType == authTypeApp { + appHook = p.appWebhookActive(pa.installationID) + } + items := make([]repositoryResponse, 0, len(result.Rows)) for _, row := range result.Rows { sc := newRowScanner(result.Columns, row) @@ -264,7 +310,7 @@ func (p *githubPlugin) listRepositories(req *plugin.Request, res *plugin.Respons FullName: fullName, DefaultBranch: sc.str("default_branch"), CloneURL: "https://github.com/" + fullName + ".git", - WebhookActive: sc.int64Val("webhook_id") > 0, + WebhookActive: appHook || sc.int64Val("webhook_id") > 0, CreatedAt: sc.str("created_at"), UpdatedAt: sc.str("updated_at"), }) @@ -287,11 +333,23 @@ func (p *githubPlugin) linkRepository(req *plugin.Request, res *plugin.Response) return } - webhookURL, err := webhookURLFromPublicURL(p.cfg, projectID) + pa, err := p.loadProjectAuth(projectID) if err != nil { - apiError(res, 422, "GITHUB_WEBHOOK_URL_REQUIRED", "PUBLIC_URL is not configured") + writeAppError(res, err) return } + useApp := pa.authType == authTypeApp + + // A PAT connection registers a webhook per repo, which needs a public URL. + // An App connection is covered by the App's own webhook. + webhookURL := "" + if !useApp { + 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 { @@ -321,39 +379,37 @@ func (p *githubPlugin) linkRepository(req *plugin.Request, res *plugin.Response) 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") + integrationID := pa.integrationID - // 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", "check_run"}) - 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") + var webhookID int64 + encSecret := "" + if !useApp { + // 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", "check_run"}) + 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 } - 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 + 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() @@ -364,7 +420,9 @@ func (p *githubPlugin) linkRepository(req *plugin.Request, res *plugin.Response) 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 webhookID > 0 { + _ = ghc.deleteWebhook(context.Background(), ghRepo.Owner.Login, ghRepo.Name, webhookID) + } if dbErr != nil { apiError(res, 500, "INTERNAL_ERROR", dbErr.Error()) } else { @@ -382,7 +440,7 @@ func (p *githubPlugin) linkRepository(req *plugin.Request, res *plugin.Response) FullName: ghRepo.FullName, DefaultBranch: ghRepo.DefaultBranch, CloneURL: "https://github.com/" + ghRepo.FullName + ".git", - WebhookActive: webhookID > 0, + WebhookActive: webhookID > 0 || (useApp && p.appWebhookActive(pa.installationID)), CreatedAt: now, UpdatedAt: now, }) @@ -449,11 +507,15 @@ func (p *githubPlugin) getRepoCloneInfo(req *plugin.Request, res *plugin.Respons apiError(res, 404, "GITHUB_REPOSITORY_NOT_FOUND", "repository not found") return } - token, err := p.decryptToken(projectID) + token, expiresAt, err := p.resolveCredentialFor(projectID, req.Caller.UserID) if err != nil { writeAppError(res, err) return } + var expiresUnix float64 + if !expiresAt.IsZero() { + expiresUnix = float64(expiresAt.Unix()) + } sc := newRowScanner(result.Columns, result.Rows[0]) fullName := sc.str("full_name") ok(res, repoCloneInfo{ @@ -463,7 +525,7 @@ func (p *githubPlugin) getRepoCloneInfo(req *plugin.Request, res *plugin.Respons RepoName: sc.str("repo_name"), CloneURL: "https://github.com/" + fullName + ".git", Token: token, - ExpiresAt: 0, + ExpiresAt: expiresUnix, }) } diff --git a/backend/migrations/0002_github_apps.sql b/backend/migrations/0002_github_apps.sql new file mode 100644 index 0000000..765064f --- /dev/null +++ b/backend/migrations/0002_github_apps.sql @@ -0,0 +1,73 @@ +-- 0002_github_apps.sql +-- Adds instance-level GitHub Apps (created through the GitHub App Manifest +-- flow), their installations, and the per-project binding to an installation. +-- 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(), + -- Random token embedded in the app's webhook URL. It is generated before + -- GitHub assigns the numeric app id, so it is what routes a delivery to + -- the right webhook secret. + app_key TEXT NOT NULL UNIQUE, + 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 '', + client_id TEXT NOT NULL DEFAULT '', + client_secret_enc 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', + -- When false, only the projects listed in github_installation_projects may + -- bind this installation. + allow_all_projects BOOLEAN NOT NULL DEFAULT FALSE, + suspended_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (app_id, installation_id) +); + +CREATE TABLE IF NOT EXISTS github_installation_projects ( + installation_id UUID NOT NULL REFERENCES github_app_installations(id) ON DELETE CASCADE, + project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + PRIMARY KEY (installation_id, project_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, + app_key TEXT NOT NULL, + created_by TEXT NOT NULL, + app_name TEXT NOT NULL DEFAULT '', + owner_org TEXT NOT NULL DEFAULT '', + allow_all_projects BOOLEAN NOT NULL DEFAULT FALSE, + return_url 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() +); + +-- A project is connected either by PAT (access_token_enc) or by binding an +-- app installation. +ALTER TABLE github_integrations ALTER COLUMN access_token_enc DROP NOT NULL; +ALTER TABLE github_integrations ADD COLUMN IF NOT EXISTS auth_type TEXT NOT NULL DEFAULT 'pat'; +ALTER TABLE github_integrations + ADD COLUMN IF NOT EXISTS installation_id UUID REFERENCES github_app_installations(id) ON DELETE SET NULL; diff --git a/backend/migrations/0003_agent_identities.sql b/backend/migrations/0003_agent_identities.sql new file mode 100644 index 0000000..8c4d2d0 --- /dev/null +++ b/backend/migrations/0003_agent_identities.sql @@ -0,0 +1,17 @@ +-- 0003_agent_identities.sql +-- Maps an account (typically an AI agent) to the GitHub App installation it +-- acts as inside one project. Accounts without a mapping use the project's +-- default installation. +-- Run with search_path = plugin_data_com_paca_github, public. + +CREATE TABLE IF NOT EXISTS github_agent_identities ( + project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + account_id UUID NOT NULL, + installation_id UUID NOT NULL REFERENCES github_app_installations(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (project_id, account_id) +); + +CREATE INDEX IF NOT EXISTS idx_github_agent_identities_installation_id + ON github_agent_identities (installation_id); diff --git a/backend/plugin.go b/backend/plugin.go index 0010b64..5c9e0ff 100644 --- a/backend/plugin.go +++ b/backend/plugin.go @@ -19,9 +19,10 @@ func nowStr() string { // 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. @@ -29,6 +30,7 @@ func (p *githubPlugin) Init(ctx *plugin.Context) error { p.db = ctx.DB() p.log = ctx.Log() p.cfg = ctx.Config() + p.cache = ctx.Cache() // Event handlers ctx.On("task.deleted", p.handleTaskDeleted) @@ -59,6 +61,21 @@ func (p *githubPlugin) Init(ctx *plugin.Context) error { ctx.Route("POST", "/tasks/:taskId/branches", p.createBranch) ctx.Route("POST", "/tasks/:taskId/branches/link", p.linkBranchToTask) + // ── GitHub Apps (instance-level, Manifest Flow) ─────────────────────────── + ctx.Route("POST", "/apps/manifest", p.createManifest) + ctx.Route("POST", "/apps/callback/created", p.callbackCreated) + ctx.Route("POST", "/apps/callback/installed", p.callbackInstalled) + ctx.Route("GET", "/apps", p.listApps) + ctx.Route("DELETE", "/apps/:appId", p.deleteApp) + ctx.Route("POST", "/apps/:appId/install", p.startInstall) + ctx.Route("POST", "/apps/:appId/installations/:installationId/access", p.setInstallationAccess) + ctx.Route("GET", "/integration/installations", p.listProjectInstallations) + ctx.Route("POST", "/integration/app", p.bindInstallation) + ctx.Route("GET", "/integration/agent-identities", p.listAgentIdentities) + ctx.Route("POST", "/integration/agent-identities", p.setAgentIdentity) + ctx.Route("DELETE", "/integration/agent-identities/:memberId", p.deleteAgentIdentity) + ctx.Route("POST", "/webhooks/apps/:appKey", p.receiveAppWebhook) + // ── Webhook ─────────────────────────────────────────────────────────────── ctx.Route("POST", "/webhook", p.receiveWebhook) diff --git a/backend/project_app.go b/backend/project_app.go new file mode 100644 index 0000000..f956db5 --- /dev/null +++ b/backend/project_app.go @@ -0,0 +1,314 @@ +package main + +import ( + "encoding/json" + "strings" + "time" + + plugin "github.com/Paca-AI/plugin-sdk-go" +) + +// ─── GET /projects/:projectId/integration/installations ────────────────────── + +// listProjectInstallations returns the app installations this project may +// bind: those open to every project plus those that name it on their +// allow-list. Suspended installations are left out. +func (p *githubPlugin) listProjectInstallations(req *plugin.Request, res *plugin.Response) { + projectID := req.Caller.ProjectID + r, err := p.db.Query(` + SELECT i.id, i.account_login, i.account_type, i.repository_selection, a.name AS app_name + FROM github_app_installations i + JOIN github_apps a ON a.id = i.app_id + WHERE i.suspended_at IS NULL + AND (i.allow_all_projects = TRUE + OR EXISTS (SELECT 1 FROM github_installation_projects ip + WHERE ip.installation_id = i.id AND ip.project_id = $1)) + ORDER BY a.name ASC, i.account_login ASC`, projectID) + if err != nil { + apiError(res, 500, "INTERNAL_ERROR", err.Error()) + return + } + items := make([]projectInstallationResponse, 0, len(r.Rows)) + for _, row := range r.Rows { + sc := newRowScanner(r.Columns, row) + items = append(items, projectInstallationResponse{ + ID: sc.str("id"), + AppName: sc.str("app_name"), + AccountLogin: sc.str("account_login"), + AccountType: sc.str("account_type"), + RepositorySelection: sc.str("repository_selection"), + }) + } + ok(res, items) +} + +// ─── POST /projects/:projectId/integration/app ─────────────────────────────── + +type bindAppBody struct { + InstallationID string `json:"installation_id"` +} + +func (p *githubPlugin) bindInstallation(req *plugin.Request, res *plugin.Response) { + projectID := req.Caller.ProjectID + b, err := plugin.JSONBody[bindAppBody](req) + if err != nil || b.InstallationID == "" { + apiError(res, 400, "BAD_REQUEST", "installation_id is required") + return + } + + allowed, err := p.projectMayUseInstallation(projectID, b.InstallationID) + if err != nil { + apiError(res, 500, "INTERNAL_ERROR", err.Error()) + return + } + if !allowed { + apiError(res, 403, "GITHUB_INSTALLATION_NOT_ALLOWED", "This installation is not available to the project") + return + } + + // Changing what a connection authenticates as would orphan repositories + // linked through the old one (their per-repo webhooks belong to the PAT). + existing, err := p.db.Query( + `SELECT id, auth_type, installation_id FROM github_integrations WHERE project_id = $1`, projectID) + if err != nil { + apiError(res, 500, "INTERNAL_ERROR", err.Error()) + return + } + if len(existing.Rows) > 0 { + sc := newRowScanner(existing.Columns, existing.Rows[0]) + unchanged := sc.str("auth_type") == authTypeApp && sc.str("installation_id") == b.InstallationID + if !unchanged { + linked, qErr := p.db.Query(`SELECT id FROM github_repositories WHERE project_id = $1`, projectID) + if qErr != nil { + apiError(res, 500, "INTERNAL_ERROR", qErr.Error()) + return + } + if len(linked.Rows) > 0 { + apiError(res, 409, "GITHUB_UNLINK_REPOS_FIRST", + "Unlink this project's repositories before changing how it connects to GitHub") + return + } + } + } + + // Prove the installation works (key decrypts, GitHub accepts the JWT) + // before reporting the project as connected. + if _, _, err := p.installationToken(b.InstallationID); err != nil { + writeAppError(res, err) + return + } + + now := nowStr() + if _, err := p.db.Exec(` + INSERT INTO github_integrations (project_id, auth_type, installation_id, access_token_enc, created_at, updated_at) + VALUES ($1, 'app', $2, NULL, $3, $3) + ON CONFLICT (project_id) DO UPDATE SET + auth_type = 'app', installation_id = EXCLUDED.installation_id, + access_token_enc = NULL, updated_at = EXCLUDED.updated_at + `, projectID, b.InstallationID, now); err != nil { + apiError(res, 500, "INTERNAL_ERROR", err.Error()) + return + } + p.getIntegration(req, res) +} + +func (p *githubPlugin) projectMayUseInstallation(projectID, installationRowID string) (bool, error) { + r, err := p.db.Query( + `SELECT allow_all_projects, suspended_at FROM github_app_installations WHERE id = $1`, + installationRowID) + if err != nil { + return false, err + } + if len(r.Rows) == 0 { + return false, nil + } + sc := newRowScanner(r.Columns, r.Rows[0]) + if sc.strPtr("suspended_at") != nil { + return false, nil + } + if sc.str("allow_all_projects") == "true" { + return true, nil + } + l, err := p.db.Query( + `SELECT project_id FROM github_installation_projects WHERE installation_id = $1 AND project_id = $2`, + installationRowID, projectID) + if err != nil { + return false, err + } + return len(l.Rows) > 0, nil +} + +// projectAuth describes how a project is connected, for handlers whose +// behaviour differs between PAT and App connections. +type projectAuth struct { + integrationID string + authType string + installationID string +} + +func (p *githubPlugin) loadProjectAuth(projectID string) (*projectAuth, error) { + r, err := p.db.Query( + `SELECT id, auth_type, installation_id FROM github_integrations WHERE project_id = $1`, projectID) + if err != nil { + return nil, err + } + if len(r.Rows) == 0 { + return nil, &appError{code: "GITHUB_INTEGRATION_NOT_FOUND", status: 404, msg: "GitHub integration not found"} + } + sc := newRowScanner(r.Columns, r.Rows[0]) + a := &projectAuth{integrationID: sc.str("id"), authType: sc.str("auth_type"), installationID: sc.str("installation_id")} + if a.authType == "" { + a.authType = authTypePAT + } + return a, nil +} + +// appWebhookActive reports whether the app behind installationRowID delivers +// webhooks (false when PUBLIC_URL was not reachable from GitHub at creation). +func (p *githubPlugin) appWebhookActive(installationRowID string) bool { + r, err := p.db.Query(` + SELECT a.webhook_active FROM github_app_installations i + JOIN github_apps a ON a.id = i.app_id WHERE i.id = $1`, installationRowID) + if err != nil || len(r.Rows) == 0 { + return false + } + return newRowScanner(r.Columns, r.Rows[0]).str("webhook_active") == "true" +} + +// ─── POST /webhooks/apps/:appKey ───────────────────────────────────────────── + +// receiveAppWebhook handles every delivery for one GitHub App. Unlike the +// per-project PAT webhook, one App delivers for all installed repos, so the +// payload is verified once against the App's secret and then fanned out to +// every project that linked the repository through that installation. +func (p *githubPlugin) receiveAppWebhook(req *plugin.Request, res *plugin.Response) { + appKey := req.PathParam("appKey") + app, err := p.db.Query(`SELECT id, webhook_secret_enc FROM github_apps WHERE app_key = $1`, appKey) + if err != nil { + p.log.Error("github: app webhook lookup failed: " + err.Error()) + res.NoContent() + return + } + if len(app.Rows) == 0 { + apiError(res, 404, "GITHUB_APP_NOT_FOUND", "unknown app") + return + } + asc := newRowScanner(app.Columns, app.Rows[0]) + secret, err := p.decrypt(asc.str("webhook_secret_enc")) + if err != nil || secret == "" { + p.log.Error("github: app has no usable webhook secret, refusing delivery") + res.NoContent() + return + } + if !verifyHMAC(req.Body, secret, header(req, "X-Hub-Signature-256")) { + apiError(res, 401, "INVALID_SIGNATURE", "invalid webhook signature") + return + } + + event := header(req, "X-GitHub-Event") + if err := p.handleAppEvent(asc.str("id"), event, req.Body); err != nil { + p.log.Error("github: app webhook handler error: " + err.Error() + ", event=" + event) + } + // Always 2xx after authentication so GitHub does not retry on app errors. + res.NoContent() +} + +type appEventPayload struct { + Action string `json:"action"` + Installation 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"` + } `json:"installation"` + Repository struct { + FullName string `json:"full_name"` + } `json:"repository"` +} + +func (p *githubPlugin) handleAppEvent(appRowID, event string, payload []byte) error { + if event == "ping" { + return nil + } + var ev appEventPayload + if err := json.Unmarshal(payload, &ev); err != nil { + return err + } + if ev.Installation.ID == 0 { + return nil + } + + switch event { + case "installation": + return p.syncInstallationEvent(appRowID, &ev) + case "installation_repositories": + return nil + case "pull_request", "push": + return p.fanOutRepoEvent(appRowID, event, &ev, payload) + default: + p.log.Info("github: unhandled app event type, event=" + event) + return nil + } +} + +func (p *githubPlugin) syncInstallationEvent(appRowID string, ev *appEventPayload) error { + switch ev.Action { + case "created", "unsuspend", "suspend", "new_permissions_accepted": + inst := &ghInstallation{ + ID: ev.Installation.ID, + RepositorySelection: ev.Installation.RepositorySelection, + SuspendedAt: ev.Installation.SuspendedAt, + } + inst.Account.Login = ev.Installation.Account.Login + inst.Account.Type = ev.Installation.Account.Type + if ev.Action == "suspend" && inst.SuspendedAt == nil { + now := time.Now().UTC() + inst.SuspendedAt = &now + } + // A "created" event for an installation made outside the Paca flow + // starts closed: an admin must opt it in before projects can use it. + _, err := p.upsertInstallation(appRowID, inst, false) + return err + case "deleted": + _, err := p.db.Exec( + `DELETE FROM github_app_installations WHERE app_id = $1 AND installation_id = $2`, + appRowID, ev.Installation.ID) + return err + } + return nil +} + +func (p *githubPlugin) fanOutRepoEvent(appRowID, event string, ev *appEventPayload, payload []byte) error { + fullName := ev.Repository.FullName + if fullName == "" { + return nil + } + targets, err := p.db.Query(` + SELECT r.id AS repo_id, r.project_id + FROM github_repositories r + JOIN github_integrations gi ON gi.id = r.integration_id + JOIN github_app_installations i ON i.id = gi.installation_id + WHERE i.app_id = $1 AND i.installation_id = $2 AND LOWER(r.full_name) = LOWER($3)`, + appRowID, ev.Installation.ID, strings.TrimSpace(fullName)) + if err != nil { + return err + } + var firstErr error + for _, row := range targets.Rows { + sc := newRowScanner(targets.Columns, row) + var herr error + switch event { + case "pull_request": + herr = p.handlePREvent(sc.str("repo_id"), sc.str("project_id"), payload) + case "push": + herr = p.handlePushEvent(sc.str("repo_id"), sc.str("project_id"), payload) + } + if herr != nil && firstErr == nil { + firstErr = herr + } + } + return firstErr +} diff --git a/backend/pull_requests.go b/backend/pull_requests.go index 00b4652..4f63f1e 100644 --- a/backend/pull_requests.go +++ b/backend/pull_requests.go @@ -105,7 +105,7 @@ func (p *githubPlugin) linkPRToTask(req *plugin.Request, res *plugin.Response) { return } - token, err := p.decryptToken(projectID) + token, err := p.decryptTokenFor(projectID, req.Caller.UserID) if err != nil { writeAppError(res, err) return @@ -245,7 +245,7 @@ func (p *githubPlugin) createPullRequest(req *plugin.Request, res *plugin.Respon return } - token, err := p.decryptToken(projectID) + token, err := p.decryptTokenFor(projectID, req.Caller.UserID) if err != nil { writeAppError(res, err) return @@ -463,7 +463,7 @@ func (p *githubPlugin) getPullRequestDetails(req *plugin.Request, res *plugin.Re writeAppError(res, err) return } - token, err := p.decryptToken(projectID) + token, err := p.decryptTokenFor(projectID, req.Caller.UserID) if err != nil { writeAppError(res, err) return @@ -545,7 +545,7 @@ func (p *githubPlugin) getPullRequestCIStatus(req *plugin.Request, res *plugin.R writeAppError(res, err) return } - token, err := p.decryptToken(projectID) + token, err := p.decryptTokenFor(projectID, req.Caller.UserID) if err != nil { writeAppError(res, err) return @@ -619,7 +619,7 @@ func (p *githubPlugin) addPullRequestComment(req *plugin.Request, res *plugin.Re writeAppError(res, err) return } - token, err := p.decryptToken(projectID) + token, err := p.decryptTokenFor(projectID, req.Caller.UserID) if err != nil { writeAppError(res, err) return @@ -666,7 +666,7 @@ func (p *githubPlugin) createReview(req *plugin.Request, res *plugin.Response) { writeAppError(res, err) return } - token, err := p.decryptToken(projectID) + token, err := p.decryptTokenFor(projectID, req.Caller.UserID) if err != nil { writeAppError(res, err) return diff --git a/frontend/src/AgentIdentities.tsx b/frontend/src/AgentIdentities.tsx new file mode 100644 index 0000000..fcedf14 --- /dev/null +++ b/frontend/src/AgentIdentities.tsx @@ -0,0 +1,180 @@ +import { type PluginApiClient } from "@paca-ai/plugin-sdk-react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { ArrowRight, Bot, Trash2, UserRound } from "lucide-react"; +import { useState } from "react"; +import { + agentIdentitiesKey, + deleteAgentIdentity, + ErrorCode, + fetchMembers, + getPluginErrorCode, + listAgentIdentities, + listProjectInstallations, + projectInstallationsKey, + setAgentIdentity, +} from "./github-api"; +import { Avatar, Banner, Button, EmptyState, Field, Select, Skeleton } from "./ui"; + +/** + * Maps project members (typically AI agents) to their own GitHub App + * installation, so each acts on GitHub as its own bot. Members without a + * mapping use the project's default connection. + */ +export function AgentIdentitiesPanel({ api, projectId, canEdit }: { api: PluginApiClient; projectId: string; canEdit: boolean }) { + const queryClient = useQueryClient(); + const [memberId, setMemberId] = useState(""); + const [installationId, setInstallationId] = useState(""); + const [error, setError] = useState(null); + + const { data: identities = [], isLoading } = useQuery({ + queryKey: agentIdentitiesKey(projectId), + queryFn: () => listAgentIdentities(api), + retry: false, + }); + const { data: installations = [] } = useQuery({ + queryKey: projectInstallationsKey(projectId), + queryFn: () => listProjectInstallations(api), + retry: false, + }); + const { data: members } = useQuery({ + queryKey: [projectId, "github-agent-members"], + queryFn: () => fetchMembers(projectId, { memberType: "agent" }), + retry: false, + }); + + // Names for mapped members are looked up by id so they resolve even when the + // member is not in the picker's current search page. + const mappedIds = identities.map((i) => i.member_id); + const { data: mapped, isLoading: mappedLoading } = useQuery({ + queryKey: [projectId, "github-mapped-members", mappedIds.join(",")], + queryFn: () => fetchMembers(projectId, { ids: mappedIds }), + enabled: mappedIds.length > 0, + retry: false, + }); + const nameOf = (id: string) => { + const m = mapped?.find((x) => x.id === id) ?? members?.find((x) => x.id === id); + if (m) return m.name; + return mappedLoading ? "…" : "Former member"; + }; + + const refresh = () => queryClient.invalidateQueries({ queryKey: agentIdentitiesKey(projectId) }); + const save = useMutation({ + mutationFn: (v: { member: string; installation: string }) => setAgentIdentity(api, v.member, v.installation), + onSuccess: async () => { + setMemberId(""); + setError(null); + await refresh(); + }, + onError: (err: unknown) => { + setError( + getPluginErrorCode(err) === ErrorCode.GitHubInstallationNotAllowed + ? "That identity isn’t available to this project." + : "GitHub rejected this identity. Check the app is still installed.", + ); + }, + }); + const remove = useMutation({ mutationFn: (id: string) => deleteAgentIdentity(api, id), onSuccess: refresh }); + + if (isLoading) return ; + + return ( +
+ +

+ Give an agent its own GitHub App so its commits, pull requests and reviews are attributed to that app’s bot — which is what lets one agent formally approve another’s work. Agents without an identity use the project’s connection. +

+ + {identities.length === 0 ? ( + } title="No agent identities yet"> + Assign an identity below. You can create more GitHub Apps from the GitHub Apps tab. + + ) : ( +
+ {identities.map((i) => ( +
+ +
+

{nameOf(i.member_id)}

+

+ acts as {i.app_name} on {i.account_login} +

+
+ {canEdit && ( + + )} +
+ ))} +
+ )} + + {canEdit && + (installations.length === 0 ? ( + No installations are available to this project yet. + ) : ( +
{ + e.preventDefault(); + if (memberId && installationId) save.mutate({ member: memberId, installation: installationId }); + }} + > +

Assign an identity

+
+ + {(id) => ( + + )} + + + {(id) => ( + + )} + + +
+ {error && {error}} +
+ ))} +
+ ); +} diff --git a/frontend/src/AppsPanel.tsx b/frontend/src/AppsPanel.tsx new file mode 100644 index 0000000..8e4ddf9 --- /dev/null +++ b/frontend/src/AppsPanel.tsx @@ -0,0 +1,160 @@ +import { type PluginApiClient } from "@paca-ai/plugin-sdk-react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Bot, ExternalLink, Plus, Trash2, TriangleAlert } from "lucide-react"; +import { useState } from "react"; +import { CreateAppDialog, rememberReturnUrl } from "./app-flow"; +import { + appsKey, + deleteGitHubApp, + type GitHubApp, + listGitHubApps, + setInstallationAccess, + startAppInstall, +} from "./github-api"; +import { Badge, Banner, Button, EmptyState, Modal, Skeleton, Switch } from "./ui"; + +/** Instance-wide management of GitHub Apps (requires github:manage_apps). */ +export function AppsPanel({ api }: { api: PluginApiClient }) { + const queryClient = useQueryClient(); + const { data: apps, isLoading } = useQuery({ queryKey: appsKey(), queryFn: () => listGitHubApps(api), retry: false }); + const [createOpen, setCreateOpen] = useState(false); + const [deleting, setDeleting] = useState(null); + + const refresh = () => queryClient.invalidateQueries({ queryKey: appsKey() }); + const install = useMutation({ + mutationFn: (appId: string) => startAppInstall(api, appId, rememberReturnUrl()), + onSuccess: ({ url }) => { + window.location.href = url; + }, + }); + const access = useMutation({ + mutationFn: (v: { app: string; inst: string; all: boolean; ids: string[] }) => setInstallationAccess(api, v.app, v.inst, v.all, v.ids), + onSuccess: refresh, + }); + const remove = useMutation({ + mutationFn: (appId: string) => deleteGitHubApp(api, appId), + onSuccess: async () => { + setDeleting(null); + await refresh(); + }, + }); + + if (isLoading || !apps) return ; + + return ( +
+
+

Instance-wide. Projects connect through one of these installations.

+ {apps.length > 0 && ( + + )} +
+ + {apps.length === 0 ? ( + } + title="No GitHub Apps yet" + action={ + + } + > + Create one in about a minute — Paca pre-fills GitHub for you. + + ) : ( + apps.map((app) => ( +
+
+ +
+ + +
+
+ + {!app.webhook_active && ( +
+ +
+ )} + +
+ {app.installations.length === 0 ? ( +
+

Not installed on any account yet.

+ +
+ ) : ( + app.installations.map((inst) => ( +
+
+

+ + {inst.account_login} + {" "} + {inst.suspended && Suspended} +

+

+ {inst.repository_selection === "all" ? "All repositories" : "Selected repositories"} + {!inst.allow_all_projects && ` · ${inst.project_ids.length} project${inst.project_ids.length === 1 ? "" : "s"} allowed`} +

+
+ + Every project + access.mutate({ app: app.id, inst: inst.id, all, ids: inst.project_ids })} /> + +
+ )) + )} +
+
+ )) + )} + + setCreateOpen(false)} /> + + setDeleting(null)} + title={`Delete ${deleting?.name ?? "app"}?`} + description="Projects connected through this app lose their GitHub connection. This removes the app from Paca only — delete it on GitHub too to revoke its access." + footer={ + <> + + + + } + /> +
+ ); +} diff --git a/frontend/src/Branches.tsx b/frontend/src/Branches.tsx new file mode 100644 index 0000000..46589dd --- /dev/null +++ b/frontend/src/Branches.tsx @@ -0,0 +1,358 @@ +import { type PluginApiClient } from "@paca-ai/plugin-sdk-react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { GitBranch, Link2, Plus } from "lucide-react"; +import { useState } from "react"; +import { + type CreateBranchResult, + createBranch, + ErrorCode, + getPluginErrorCode, + type LinkedRepository, + linkBranchToTask, + listTaskBranches, + type TaskBranch, + taskBranchesKey, +} from "./github-api"; +import { Button, CommandBlock, CopyIconButton, Field, Input, Pill, Section, SectionEmpty, Segmented, Select, Skeleton } from "./ui"; + +// ── Existing branch ─────────────────────────────────────────────────────────── + +function BranchRow({ branch, repoName }: { branch: TaskBranch; repoName?: string }) { + return ( +
+
+ ); +} + +// ── Branch name helpers ─────────────────────────────────────────────────────── + +const BRANCH_TYPES = ["feat", "fix", "chore", "docs", "test", "refactor"] as const; + +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; + } +} + +// ── Create form ─────────────────────────────────────────────────────────────── + +function CreateBranchForm({ + api, + projectId, + taskId, + taskIdPrefix, + taskNumber, + taskTitle, + repos, + onDone, +}: { + api: PluginApiClient; + projectId: string; + taskId: string; + taskIdPrefix: string; + taskNumber: number; + taskTitle?: string; + repos: LinkedRepository[]; + onDone: () => void; +}) { + const queryClient = useQueryClient(); + const taskRef = taskIdPrefix ? `${taskIdPrefix.toUpperCase()}-${taskNumber}` : `${taskNumber}`; + const slug = 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(`feat/${taskRef}${slug}`); + const [repoId, setRepoId] = useState(repos.length === 1 ? repos[0].id : ""); + const [source, setSource] = useState(""); + const [error, setError] = useState(null); + + function changeType(next: (typeof BRANCH_TYPES)[number]) { + setType(next); + setBranchName((prev) => { + const slash = prev.indexOf("/"); + return `${next}${slash >= 0 ? prev.slice(slash) : `/${taskRef}`}`; + }); + } + + const create = useMutation({ + mutationFn: (): Promise => createBranch(api, taskId, repoId, branchName, source.trim() || undefined), + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: taskBranchesKey(projectId, taskId) }); + onDone(); + }, + onError: (err: unknown) => { + const code = getPluginErrorCode(err); + setError( + code === ErrorCode.GitHubIntegrationNotFound + ? "GitHub isn’t connected for this project." + : code === ErrorCode.GitHubRepositoryNotFound + ? "That repository isn’t linked any more." + : code === ErrorCode.GitHubBranchAlreadyLinked + ? "This branch is already linked to the task." + : code === ErrorCode.GitHubTokenInsufficientPermissions + ? "The GitHub connection can’t create branches. It needs write access to repository contents — check Project Settings → GitHub." + : "Couldn’t create the branch. Try again.", + ); + }, + }); + + function submit() { + setError(null); + if (!branchName.trim()) return setError("Branch name is required."); + if (!repoId) return setError("Select a repository."); + create.mutate(); + } + + const local = `git checkout -b ${branchName} && git push -u origin ${branchName}`; + + return ( +
{ + e.preventDefault(); + submit(); + }} + onKeyDown={(e) => e.key === "Escape" && onDone()} + > +
+ Type + +
+ + {(id) => ( + { setBranchName(e.target.value); setError(null); }} /> + )} + + {repos.length > 1 && ( + + {(id) => ( + + )} + + )} + + {(id) => setSource(e.target.value)} />} + + {error &&

{error}

} +
+ + +
+
+ Or create it locally + +
+
+ ); +} + +// ── Link 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 urlRepo = parsed ? (repos.find((r) => r.full_name === parsed.fullName) ?? null) : null; + const repoId = parsed ? (urlRepo?.id ?? "") : selectedRepoId; + const branchName = parsed ? parsed.branchName : value.trim(); + + const link = useMutation({ + mutationFn: () => linkBranchToTask(api, taskId, repoId, branchName), + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: taskBranchesKey(projectId, taskId) }); + onDone(); + }, + onError: (err: unknown) => { + const code = getPluginErrorCode(err); + setError( + code === ErrorCode.GitHubIntegrationNotFound + ? "GitHub isn’t connected for this project." + : code === ErrorCode.GitHubRepositoryNotFound + ? "That repository isn’t linked any more." + : code === ErrorCode.GitHubBranchNotFound + ? `Branch “${branchName}” wasn’t found in that repository.` + : code === ErrorCode.GitHubBranchAlreadyLinked + ? `Branch “${branchName}” is already linked to this task.` + : code === ErrorCode.GitHubTokenInsufficientPermissions + ? "The GitHub connection can’t read branches. Check it in Project Settings → GitHub." + : "Couldn’t link the branch. Try again.", + ); + }, + }); + + function submit() { + if (parsed) { + if (!urlRepo) return setError(`“${parsed.fullName}” isn’t linked to this project.`); + } else { + if (!repoId) return setError("Select a repository."); + if (!branchName) return setError("Enter a branch name or paste a GitHub branch link."); + } + setError(null); + link.mutate(); + } + + return ( +
{ + e.preventDefault(); + submit(); + }} + onKeyDown={(e) => e.key === "Escape" && onDone()} + > + + {(id) => ( + { setValue(e.target.value); setError(null); }} + // biome-ignore lint/a11y/noAutofocus: opened on demand by the user + autoFocus + /> + )} + + {!parsed && repos.length > 1 && ( + + {(id) => ( + + )} + + )} + {error &&

{error}

} +
+ + +
+
+ ); +} + +// ── Section ─────────────────────────────────────────────────────────────────── + +export function BranchesSection({ + api, + projectId, + taskId, + taskIdPrefix, + taskNumber, + taskTitle, + repos, + canEdit, +}: { + api: PluginApiClient; + projectId: string; + taskId: string; + taskIdPrefix: string; + taskNumber: number; + taskTitle?: string; + repos: LinkedRepository[]; + canEdit: boolean; +}) { + 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 repoName = (id: string) => repos.find((r) => r.id === id)?.full_name; + const done = () => setMode(null); + + return ( +
+ setMode("link")}> + + setMode("create")}> + + + ) : undefined + } + > + {isLoading ? ( + + ) : ( + <> + {branches.length > 0 && ( +
+ {branches.map((b) => ( + + ))} +
+ )} + {branches.length === 0 && mode === null && ( + + )} + {mode === "create" && } + {mode === "link" && } + + )} +
+ ); +} diff --git a/frontend/src/ConnectionBar.tsx b/frontend/src/ConnectionBar.tsx new file mode 100644 index 0000000..0e52f9d --- /dev/null +++ b/frontend/src/ConnectionBar.tsx @@ -0,0 +1,132 @@ +import { type PluginApiClient } from "@paca-ai/plugin-sdk-react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { Bot, CheckCircle2, KeyRound, TriangleAlert, Unplug } from "lucide-react"; +import { useState } from "react"; +import { SetupPanel } from "./SetupPanel"; +import { + accessibleReposKey, + agentIdentitiesKey, + deleteGitHubToken, + type GitHubIntegration, + integrationKey, + type LinkedRepository, + linkedReposKey, +} from "./github-api"; +import { Badge, Banner, Button, Modal } from "./ui"; + +/** + * Compact status for a connected project: what it authenticates as, whether + * webhooks are flowing, and the few things you can do about it. A project on a + * personal access token also sees a gentle nudge toward the GitHub App. + */ +export function ConnectionBar({ + api, + projectId, + integration, + repos, + canEdit, +}: { + api: PluginApiClient; + projectId: string; + integration: GitHubIntegration; + repos: LinkedRepository[]; + canEdit: boolean; +}) { + const queryClient = useQueryClient(); + const [confirmOpen, setConfirmOpen] = useState(false); + const [upgradeOpen, setUpgradeOpen] = useState(false); + const isApp = integration.auth_type === "app"; + + const disconnect = useMutation({ + mutationFn: () => deleteGitHubToken(api), + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: integrationKey(projectId) }); + for (const key of [linkedReposKey(projectId), accessibleReposKey(projectId), agentIdentitiesKey(projectId)]) { + queryClient.removeQueries({ queryKey: key }); + } + setConfirmOpen(false); + }, + }); + + // Webhook health is only meaningful once a repository is linked. + const webhookKnown = repos.length > 0; + const webhookOk = repos.every((r) => r.webhook_active); + + return ( + <> +
+
{isApp ?
+
+

+ {isApp ? `${integration.app_name ?? "GitHub App"}${integration.account_login ? ` on ${integration.account_login}` : ""}` : "Personal access token"} + {isApp ? "GitHub App" : "Token"} +

+

+ {isApp ? "Short-lived tokens are minted automatically. No personal credentials are stored." : "Actions are attributed to the token’s owner. The token is stored encrypted."} +

+
+ {webhookKnown && ( + + {webhookOk ? "Webhooks active" : "Webhooks off"} + + )} + {canEdit && ( + + )} +
+ + {webhookKnown && !webhookOk && ( + + )} + + {!isApp && canEdit && ( + + )} + + setConfirmOpen(false)} + title={isApp ? "Disconnect GitHub App?" : "Remove personal access token?"} + description={ + isApp + ? "This unlinks every repository and agent identity from the project. The app itself stays installed on GitHub." + : "This unlinks every repository from the project and removes the webhooks Paca created on GitHub. This can’t be undone." + } + footer={ + <> + + + + } + > + {disconnect.isError && ( + Something went wrong. Try again. + )} + + + setUpgradeOpen(false)} title="Switch to a GitHub App" wide> + setUpgradeOpen(false)} /> + + + ); +} diff --git a/frontend/src/GitHubAppCallbackPage.tsx b/frontend/src/GitHubAppCallbackPage.tsx new file mode 100644 index 0000000..9f83110 --- /dev/null +++ b/frontend/src/GitHubAppCallbackPage.tsx @@ -0,0 +1,136 @@ +import { type PluginApiClient } from "@paca-ai/plugin-sdk-react"; +import { TriangleAlert } from "lucide-react"; +import { useEffect, useState } from "react"; +import { recalledReturnUrl, withFlowResult } from "./app-flow"; +import { + completeAppCreated, + completeAppInstalled, + ErrorCode, + getPluginErrorCode, + type SetupCallbackResult, +} from "./github-api"; +import { Button, GitHubIcon, Spinner } from "./ui"; + +/** + * The page GitHub sends the browser back to during GitHub App setup + * (`/plugin-pages/com.paca.github/github-app-callback`, a plugin `app.page`). + * + * It is a page of the web app, not a plugin API route, on purpose: by the time + * it mounts the host has already restored the user's session (refreshing an + * expired access token, or signing the user in and returning here), so the two + * calls below are ordinary authenticated, permission-gated plugin requests. + * + * GitHub appends different parameters to each leg of the flow: + * created → ?code=…&state=… (manifest accepted) + * installed → ?installation_id=…&state=… (app installed on an account) + */ + +type Step = "created" | "installed"; + +const FLOW_CODES: Record = { + [ErrorCode.GitHubSetupInvalidState]: "invalid_state", + [ErrorCode.GitHubSetupMissingParameter]: "missing_code", + [ErrorCode.GitHubSetupConversionFailed]: "conversion_failed", + [ErrorCode.GitHubSetupStoreFailed]: "store_failed", + [ErrorCode.GitHubSetupInstallationFailed]: "installation_failed", +}; + +const MESSAGES: Record = { + invalid_state: "This setup link has expired or was already used. Start the setup again from Project Settings → GitHub.", + missing_code: "GitHub did not send everything Paca needs. Start the setup again.", + conversion_failed: "GitHub rejected the app setup. Start the setup again.", + store_failed: "The app was created on GitHub but Paca could not save it. Check the server logs.", + installation_failed: "GitHub did not confirm the installation. Install the app again from Project Settings → GitHub.", +}; + +function readParams(): { step: Step; body: Record } | null { + const q = new URLSearchParams(window.location.search); + const state = q.get("state") ?? ""; + const code = q.get("code"); + const installationId = q.get("installation_id"); + if (code) return { step: "created", body: { code, state } }; + if (installationId) return { step: "installed", body: { installation_id: installationId, state } }; + return null; +} + +// Each callback must run exactly once — the code and state are single-use — yet +// React may mount the page twice (StrictMode, a remount). Sharing one promise +// per set of parameters makes the second mount reuse the first result. +const inFlight = new Map>(); + +function run(api: PluginApiClient, step: Step, body: Record): Promise { + const key = `${step}:${JSON.stringify(body)}`; + let p = inFlight.get(key); + if (!p) { + p = + step === "created" + ? completeAppCreated(api, body as { code: string; state: string }) + : completeAppInstalled(api, body as { installation_id: string; state: string }); + inFlight.set(key, p); + } + return p; +} + +export default function GitHubAppCallbackPage({ api }: { api: PluginApiClient }) { + const params = readParams(); + const [error, setError] = useState(null); + + useEffect(() => { + if (!params) { + setError("missing_code"); + return; + } + let cancelled = false; + run(api, params.step, params.body) + .then((result) => { + if (cancelled) return; + // created → on to GitHub to install; installed → back to where it began. + window.location.replace(result.next_url ?? withFlowResult(result.return_url, "ok")); + }) + .catch((err: unknown) => { + if (cancelled) return; + const code = FLOW_CODES[getPluginErrorCode(err) ?? ""] ?? "setup_failed"; + const back = recalledReturnUrl(); + // Send the user home with the explanation shown as a banner there; if we + // don't know where home is, explain it here. + if (back) { + window.location.replace(withFlowResult(back, "error", code)); + return; + } + setError(code); + }); + return () => { + cancelled = true; + }; + // The parameters are read once from the URL; the page is not reused. + // biome-ignore lint/correctness/useExhaustiveDependencies: run once per mount + }, []); + + return ( +
+
+
+ +
+ {error === null ? ( + <> + +

{params?.step === "installed" ? "Confirming the installation…" : "Finishing GitHub App setup…"}

+

+ {params?.step === "installed" ? "Checking with GitHub that the app is installed." : "Saving your new app. You’ll be taken to GitHub to install it next."} +

+ + ) : ( + <> +
+
+ ); +} diff --git a/frontend/src/GitHubSettingsTab.tsx b/frontend/src/GitHubSettingsTab.tsx index c2b52b1..68b8bf4 100644 --- a/frontend/src/GitHubSettingsTab.tsx +++ b/frontend/src/GitHubSettingsTab.tsx @@ -1,900 +1,128 @@ 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 { useQuery } from "@tanstack/react-query"; +import { CheckCircle2, ChevronRight, X } from "lucide-react"; import { useMemo, useState } from "react"; +import { AgentIdentitiesPanel } from "./AgentIdentities"; +import { AppsPanel } from "./AppsPanel"; +import { ConnectionBar } from "./ConnectionBar"; +import { RepositoriesPanel } from "./RepositoriesPanel"; +import { SetupPanel } from "./SetupPanel"; +import { useFlowResult } from "./app-flow"; import { - type AccessibleRepo, - accessibleReposKey, - deleteGitHubToken, - ErrorCode, - getPluginErrorCode, + appsKey, type GitHubIntegration, getGitHubIntegration, integrationKey, type LinkedRepository, linkedReposKey, - listAccessibleRepos, + listGitHubApps, listLinkedRepositories, - linkRepository, - setGitHubToken, - unlinkRepository, } from "./github-api"; +import { Banner, Button, GitHubIcon, Skeleton, TabPanel, Tabs } from "./ui"; -// ── 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" - )} - -
-
-
- - ); -} +function GitHubSettingsInner({ api, projectId, canEdit }: { api: PluginApiClient; projectId: string; canEdit: boolean }) { + const [flow, dismissFlow] = useFlowResult(); + const [tab, setTab] = useState("repositories"); + const [justConnected, setJustConnected] = useState(false); + const [appsOpen, setAppsOpen] = useState(false); -// ── Main Settings ───────────────────────────────────────────────────────────── - -function GitHubSettingsInner({ - api, - projectId, - canEdit, -}: { - api: PluginApiClient; - projectId: string; - canEdit: boolean; -}) { - const { data: integration, isLoading: integrationLoading } = useQuery< - GitHubIntegration | undefined - >({ + const { data: integration, isLoading } = useQuery({ queryKey: integrationKey(projectId), queryFn: () => getGitHubIntegration(api), retry: false, throwOnError: false, }); + const connected = integration?.connected === true; + const isApp = connected && integration?.auth_type === "app"; - const { data: linkedRepos = [], isLoading: reposLoading } = useQuery< - LinkedRepository[] - >({ + const { data: repos = [], isLoading: reposLoading } = useQuery({ queryKey: linkedReposKey(projectId), queryFn: () => listLinkedRepositories(api), retry: false, throwOnError: false, - enabled: !!integration, + enabled: connected, }); - - 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 }, + // Resolves only for holders of the instance-wide github:manage_apps action. + const { data: apps } = useQuery({ queryKey: appsKey(), queryFn: () => listGitHubApps(api), retry: false }); + const isAdmin = apps !== undefined; + + const tabs = [ + ...(connected ? [{ id: "repositories", label: "Repositories", count: repos.length }] : []), + ...(isApp ? [{ id: "identities", label: "Agent identities" }] : []), + ...(isAdmin ? [{ id: "apps", label: "GitHub Apps", count: apps.length }] : []), ]; + const activeTab = tabs.some((t) => t.id === tab) ? tab : (tabs[0]?.id ?? ""); - 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. -

+ const onConnected = () => { + setJustConnected(true); + setTab("repositories"); + }; - {/* Progress steps */} -
- {steps.map((step, idx) => ( -
-
- {step.done ? : step.num} -
- - {step.label} - - {idx < steps.length - 1 && ( -
- )} -
- ))} + return ( +
+
+
+
- - {/* Token section */} -
-
- - -
- {integrationLoading ? ( - - ) : ( - setAddRepoOpen(true)} - canEdit={canEdit} - /> - )} +
+

GitHub

+

Track pull requests, create branches from tasks and let agents work in your repositories.

-
+
+ + {flow && ( + + )} - {/* 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 - - )} + {appsOpen && ( +
+
)}
)} - - {canEdit && ( - + + ) : ( + <> + + + {activeTab === "repositories" && ( + + + )} -
- )} - - {/* 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. -

-
+ {activeTab === "identities" && ( + + + + )} + {activeTab === "apps" && ( + + + + )} + )}
); @@ -907,17 +135,13 @@ interface GitHubSettingsTabProps { canEdit?: boolean; } -export default function GitHubSettingsTab({ - projectId, - canEdit = true, -}: GitHubSettingsTabProps) { +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" }), + fetch: (url, init) => window.fetch(url, { ...init, credentials: "include" }), }), [projectId], ); diff --git a/frontend/src/GitHubTaskSection.tsx b/frontend/src/GitHubTaskSection.tsx index 3e716f0..020984b 100644 --- a/frontend/src/GitHubTaskSection.tsx +++ b/frontend/src/GitHubTaskSection.tsx @@ -1,1094 +1,10 @@ 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 ──────────────────────────────────── +import { useQuery } from "@tanstack/react-query"; +import { useMemo } from "react"; +import { BranchesSection } from "./Branches"; +import { PullRequestsSection } from "./PullRequests"; +import { type LinkedRepository, linkedReposKey, listLinkedRepositories } from "./github-api"; +import "./styles.css"; interface GitHubTaskSectionProps { projectId: string; @@ -1096,90 +12,53 @@ interface GitHubTaskSectionProps { 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({ +function GitHubTaskSectionInner({ api, projectId, taskId, canEdit }: { api: PluginApiClient; projectId: string; taskId: string; canEdit: boolean }) { + // Task and project metadata are only needed to suggest a branch name. + 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: repos, isLoading } = 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; + // Nothing to show until the project has a linked repository. + if (isLoading || !repos || repos.length === 0) return null; + // Two sibling sections, not one wrapper: the task drawer spaces its sections + // as direct children, so each of ours gets the same rhythm as the host's. return ( -
+ <> - -
+ + ); } -export default function GitHubTaskSection({ - projectId, - taskId, - canEdit = true, -}: GitHubTaskSectionProps) { +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" }), + fetch: (url, init) => window.fetch(url, { ...init, credentials: "include" }), }), [projectId], ); return ( - + ); } diff --git a/frontend/src/PullRequests.tsx b/frontend/src/PullRequests.tsx new file mode 100644 index 0000000..ed35afa --- /dev/null +++ b/frontend/src/PullRequests.tsx @@ -0,0 +1,234 @@ +import { type PluginApiClient } from "@paca-ai/plugin-sdk-react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { ArrowRight, GitMerge, GitPullRequest, GitPullRequestClosed, Plus, Trash2 } from "lucide-react"; +import { useState } from "react"; +import { + ErrorCode, + getPluginErrorCode, + type LinkedRepository, + linkPRToTask, + listTaskPRs, + type PullRequest, + taskPRsKey, + unlinkPRFromTask, +} from "./github-api"; +import { Badge, Button, Field, Input, Pill, RowIconButton, Section, SectionEmpty, Select, Skeleton } from "./ui"; + +// ── State presentation ──────────────────────────────────────────────────────── + +const STATE: Record = { + open: { label: "Open", tone: "success", Icon: GitPullRequest }, + merged: { label: "Merged", tone: "merged", Icon: GitMerge }, + closed: { label: "Closed", tone: "danger", Icon: GitPullRequestClosed }, +}; + +function PRRow({ api, pr, repoName, projectId, taskId, canEdit }: { api: PluginApiClient; pr: PullRequest; repoName?: string; projectId: string; taskId: string; canEdit: boolean }) { + const queryClient = useQueryClient(); + const unlink = useMutation({ + mutationFn: () => unlinkPRFromTask(api, taskId, pr.id), + onSuccess: () => queryClient.invalidateQueries({ queryKey: taskPRsKey(projectId, taskId) }), + }); + const { label, tone, Icon } = STATE[pr.state] ?? STATE.open; + + return ( +
+
+ ); +} + +// ── URL parsing ─────────────────────────────────────────────────────────────── + +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 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 urlRepo = parsed ? (repos.find((r) => r.full_name === parsed.fullName) ?? null) : null; + const repoId = parsed ? (urlRepo?.id ?? "") : selectedRepoId; + const display = parsed ? parsed.prNumber : value.trim(); + + const link = useMutation({ + mutationFn: () => linkPRToTask(api, taskId, repoId, parsed ? parsed.prNumber : Number(value)), + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: taskPRsKey(projectId, taskId) }); + onDone(); + }, + onError: (err: unknown) => { + const code = getPluginErrorCode(err); + setError( + code === ErrorCode.GitHubIntegrationNotFound + ? "GitHub isn’t connected for this project." + : code === ErrorCode.GitHubRepositoryNotFound + ? "That repository isn’t linked any more." + : code === ErrorCode.GitHubPRNotFound + ? `PR #${display} wasn’t found in that repository.` + : code === ErrorCode.GitHubPRAlreadyLinked + ? `PR #${display} is already linked to this task.` + : code === ErrorCode.GitHubTokenInsufficientPermissions + ? "The GitHub connection can’t read pull requests. Check it in Project Settings → GitHub." + : "Couldn’t link the pull request. Try again.", + ); + }, + }); + + function submit() { + if (parsed) { + if (!urlRepo) return setError(`“${parsed.fullName}” isn’t linked to this project.`); + } else { + const n = Number(value); + if (!repoId) return setError("Select a repository."); + if (!value.trim() || !Number.isInteger(n) || n <= 0) return setError("Enter a PR number or paste a GitHub PR link."); + } + setError(null); + link.mutate(); + } + + return ( +
{ + e.preventDefault(); + submit(); + }} + onKeyDown={(e) => e.key === "Escape" && onDone()} + > + + {(id) => ( + { + setValue(e.target.value); + setError(null); + }} + // biome-ignore lint/a11y/noAutofocus: opened on demand by the user + autoFocus + /> + )} + + {!parsed && repos.length > 1 && ( + + {(id) => ( + + )} + + )} + {error && ( +

+ {error} +

+ )} +
+ + +
+
+ ); +} + +// ── Section ─────────────────────────────────────────────────────────────────── + +export function PullRequestsSection({ api, projectId, taskId, repos, canEdit }: { api: PluginApiClient; projectId: string; taskId: string; repos: LinkedRepository[]; canEdit: boolean }) { + const { data: prs = [], isLoading } = useQuery({ + queryKey: taskPRsKey(projectId, taskId), + queryFn: () => listTaskPRs(api, taskId), + retry: false, + throwOnError: false, + }); + const [linking, setLinking] = useState(false); + const repoName = (id: string) => repos.find((r) => r.id === id)?.full_name; + + return ( +
setLinking(true)}> +
+ ); +} diff --git a/frontend/src/RepositoriesPanel.tsx b/frontend/src/RepositoriesPanel.tsx new file mode 100644 index 0000000..7b3c536 --- /dev/null +++ b/frontend/src/RepositoriesPanel.tsx @@ -0,0 +1,274 @@ +import { type PluginApiClient } from "@paca-ai/plugin-sdk-react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { BookOpen, GitBranch, Lock, Plus, RefreshCw, Search, Unlink } from "lucide-react"; +import { useState } from "react"; +import { + type AccessibleRepo, + accessibleReposKey, + ErrorCode, + getPluginErrorCode, + type LinkedRepository, + linkedReposKey, + linkRepository, + listAccessibleRepos, + unlinkRepository, +} from "./github-api"; +import { Avatar, Badge, Banner, Button, EmptyState, Input, Modal, Skeleton } from "./ui"; + +// ── Add repository ──────────────────────────────────────────────────────────── + +function AddRepoDialog({ + api, + projectId, + isApp, + open, + onClose, +}: { + api: PluginApiClient; + projectId: string; + isApp: boolean; + open: boolean; + onClose: () => void; +}) { + const queryClient = useQueryClient(); + const [search, setSearch] = useState(""); + const [error, setError] = useState(null); + + const { data: repos = [], isLoading, isFetching } = useQuery({ + queryKey: accessibleReposKey(projectId), + queryFn: () => listAccessibleRepos(api), + enabled: open, + staleTime: 0, + }); + + const link = useMutation({ + mutationFn: (repo: AccessibleRepo) => linkRepository(api, repo.owner, repo.repo_name), + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: linkedReposKey(projectId) }); + close(); + }, + onError: (err: unknown) => { + const code = getPluginErrorCode(err); + setError( + code === ErrorCode.GitHubWebhookURLNotPublic + ? "GitHub can’t reach this server to register the webhook (is it on localhost?). Set PUBLIC_URL to a public HTTPS address and try again." + : code === ErrorCode.GitHubWebhookCreationFailed + ? "Couldn’t create the webhook. The token needs the admin:repo_hook scope and admin access to the repository." + : code === ErrorCode.GitHubRepoAlreadyLinked + ? "This repository is already linked to the project." + : code === ErrorCode.GitHubRepoNotAccessible + ? isApp + ? "The app can’t see this repository. Add it to the app’s installation on GitHub." + : "Repository not found or not accessible with this token." + : "Couldn’t link the repository. Try again.", + ); + }, + }); + + function close() { + setSearch(""); + setError(null); + onClose(); + } + + const q = search.trim().toLowerCase(); + const filtered = repos.filter((r) => r.full_name.toLowerCase().includes(q)); + + return ( + + Close + + } + > +
+
+
+ +
+ + {error && {error}} + +
+ {isLoading ? ( + [0, 1, 2].map((i) => ) + ) : repos.length === 0 ? ( + } title="No repositories found"> + {isApp ? "Install the app on at least one repository, then reload." : "This token can’t see any repositories."} + + ) : filtered.length === 0 ? ( +

+ Nothing matches “{search}”. +

+ ) : ( + filtered.map((repo) => ( + + )) + )} +
+
+ ); +} + +// ── Linked repo row ─────────────────────────────────────────────────────────── + +function LinkedRepoRow({ api, projectId, repo, canEdit, isApp }: { api: PluginApiClient; projectId: string; repo: LinkedRepository; canEdit: boolean; isApp: boolean }) { + const queryClient = useQueryClient(); + const [confirmOpen, setConfirmOpen] = useState(false); + const unlink = 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} +

+
+ + {repo.webhook_active ? "Syncing" : "Not syncing"} + + {canEdit && ( + + )} +
+ + setConfirmOpen(false)} + title="Unlink repository?" + description={`${repo.full_name} will no longer be tracked in this project.${isApp ? "" : " Paca will also try to delete its webhook from GitHub."} Existing task links to its branches and pull requests are kept.`} + footer={ + <> + + + + } + > + {unlink.isError && Couldn’t unlink. Try again.} + + + ); +} + +// ── Panel ───────────────────────────────────────────────────────────────────── + +export function RepositoriesPanel({ + api, + projectId, + repos, + loading, + canEdit, + isApp, + autoOpen, +}: { + api: PluginApiClient; + projectId: string; + repos: LinkedRepository[]; + loading: boolean; + canEdit: boolean; + isApp: boolean; + /** Open the add dialog straight away (right after connecting). */ + autoOpen?: boolean; +}) { + const [addOpen, setAddOpen] = useState(!!autoOpen); + const [filter, setFilter] = useState(""); + const shown = repos.filter((r) => r.full_name.toLowerCase().includes(filter.trim().toLowerCase())); + + return ( +
+
+ {repos.length > 5 ? ( +
+
+ ) : ( +

Pull requests and branches from these repositories can be linked to tasks.

+ )} + {canEdit && repos.length > 0 && ( + + )} +
+ + {loading ? ( +
+ +
+ ) : repos.length === 0 ? ( + } + title="No repositories linked yet" + action={ + canEdit ? ( + + ) : undefined + } + > + Link a repository to track its pull requests and create branches straight from tasks. + + ) : shown.length === 0 ? ( +

+ No linked repositories match “{filter}”. +

+ ) : ( +
+ {shown.map((repo) => ( + + ))} +
+ )} + + {canEdit && setAddOpen(false)} />} +
+ ); +} diff --git a/frontend/src/SetupPanel.tsx b/frontend/src/SetupPanel.tsx new file mode 100644 index 0000000..59761b2 --- /dev/null +++ b/frontend/src/SetupPanel.tsx @@ -0,0 +1,340 @@ +import { type PluginApiClient } from "@paca-ai/plugin-sdk-react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Bot, Check, ChevronRight, KeyRound, ShieldCheck, Sparkles } from "lucide-react"; +import { useState } from "react"; +import { CreateAppDialog, rememberReturnUrl } from "./app-flow"; +import { + appsKey, + bindInstallation, + ErrorCode, + getPluginErrorCode, + integrationKey, + listGitHubApps, + listProjectInstallations, + projectInstallationsKey, + setGitHubToken, + setInstallationAccess, + startAppInstall, +} from "./github-api"; +import { Badge, Banner, Button, Input, Skeleton, cn } from "./ui"; + +type StepState = "done" | "current" | "todo"; + +function Steps({ steps }: { steps: { label: string; state: StepState }[] }) { + return ( +
    + {steps.map((s, i) => ( +
  1. +
    + {s.state === "done" ? + +
    + {s.label} +
  2. + ))} +
+ ); +} + +/** + * First-run experience: connect the project to GitHub. The GitHub App path is + * the recommended one and is shown as a three-step guided setup whose current + * step is derived from what already exists (no app → create; app but no + * installation → install; installation → connect). A personal access token is + * available below as a quieter alternative. + */ +export function SetupPanel({ + api, + projectId, + canEdit, + onConnected, + variant = "page", +}: { + api: PluginApiClient; + projectId: string; + canEdit: boolean; + onConnected: () => void; + /** "modal" omits the hero chrome (used when upgrading from a token). */ + variant?: "page" | "modal"; +}) { + const queryClient = useQueryClient(); + const [createOpen, setCreateOpen] = useState(false); + const [error, setError] = useState(null); + const [patOpen, setPatOpen] = useState(false); + + const { data: installations = [], isLoading } = useQuery({ + queryKey: projectInstallationsKey(projectId), + queryFn: () => listProjectInstallations(api), + retry: false, + }); + // Resolves only for holders of the instance-wide github:manage_apps action. + const { data: apps } = useQuery({ queryKey: appsKey(), queryFn: () => listGitHubApps(api), retry: false }); + const isAdmin = apps !== undefined; + + const hasInstall = installations.length > 0; + const hasApp = hasInstall || (apps?.length ?? 0) > 0; + const current = hasInstall ? 2 : hasApp ? 1 : 0; + const state = (i: number): StepState => (i < current ? "done" : i === current ? "current" : "todo"); + + // An installation exists but is not open to this project (admin-only fix). + const closedInstall = apps?.flatMap((a) => a.installations.map((i) => ({ app: a, inst: i }))).find((x) => !x.inst.suspended); + + const bind = useMutation({ + mutationFn: (installationId: string) => bindInstallation(api, installationId), + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: integrationKey(projectId) }); + setError(null); + onConnected(); + }, + onError: (err: unknown) => { + const code = getPluginErrorCode(err); + setError( + code === ErrorCode.GitHubUnlinkReposFirst + ? "Unlink this project’s repositories first — repositories are tied to how the project connects." + : code === ErrorCode.GitHubInstallationNotAllowed + ? "This installation is not available to the project." + : "GitHub rejected the installation. Check that the app is still installed.", + ); + }, + }); + const install = useMutation({ + mutationFn: (appId: string) => startAppInstall(api, appId, rememberReturnUrl()), + onSuccess: ({ url }) => { + window.location.href = url; + }, + }); + const openAccess = useMutation({ + mutationFn: (v: { app: string; inst: string }) => setInstallationAccess(api, v.app, v.inst, true, []), + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: projectInstallationsKey(projectId) }); + await queryClient.invalidateQueries({ queryKey: appsKey() }); + }, + }); + + if (isLoading) return ; + + const stepAction = (() => { + if (hasInstall) { + return ( +
+
+

Connect this project

+

+ Agents and automations will act as the app’s bot (your-app[bot]), not as a person. +

+
+
+ {installations.map((inst) => ( +
+
+

{inst.account_login}

+

+ {inst.app_name} · {inst.repository_selection === "all" ? "all repositories" : "selected repositories"} +

+
+ {canEdit && ( + + )} +
+ ))} +
+
+ ); + } + if (!isAdmin) { + return ( + + ); + } + if (!hasApp) { + return ( +
+
+

Create your GitHub App

+

+ Takes about a minute. Paca pre-fills everything on GitHub — you confirm once and install it on your account or organization. +

+
+
+ +
+
+ ); + } + // App exists. + if (closedInstall) { + return ( +
+
+

Make the installation available

+

+ {closedInstall.app.name} is installed on {closedInstall.inst.account_login}, but it is not open to this project yet. +

+
+
+ +
+
+ ); + } + const app = apps?.[0]; + return ( +
+
+

Install {app?.name}

+

Choose the account or organization that owns your repositories, and which repositories the app may see.

+
+
+ +
+
+ ); + })(); + + return ( +
+
+
+

+

+

The secure, self-hosted way to connect — no personal token to store, rotate or share.

+
+ +
    +
  • +
  • +
  • +
+ + + + {stepAction} + {error && {error}} +
+ + {variant === "page" && ( + <> +
or
+ setPatOpen((v) => !v)} onConnected={onConnected} /> + + )} + + {isAdmin && setCreateOpen(false)} />} +
+ ); +} + +// ── Personal access token (secondary path) ──────────────────────────────────── + +function TokenAlternative({ + api, + projectId, + canEdit, + open, + onToggle, + onConnected, +}: { + api: PluginApiClient; + projectId: string; + canEdit: boolean; + open: boolean; + onToggle: () => void; + onConnected: () => void; +}) { + const queryClient = useQueryClient(); + const [token, setToken] = useState(""); + const [error, setError] = useState(null); + + const save = useMutation({ + mutationFn: () => setGitHubToken(api, token.trim()), + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: integrationKey(projectId) }); + setToken(""); + setError(null); + onConnected(); + }, + onError: (err: unknown) => { + const code = getPluginErrorCode(err); + setError( + code === ErrorCode.GitHubInvalidToken + ? "GitHub rejected the token. Check it has the repo and admin:repo_hook scopes." + : code === ErrorCode.GitHubUnlinkReposFirst + ? "Unlink this project’s repositories first — repositories are tied to how the project connects." + : "Could not save the token. Try again.", + ); + }, + }); + + return ( +
+ + {open && ( +
+

+ Everything done through this project is attributed to the token’s owner. Needs the repo and admin:repo_hook scopes. The token is stored encrypted and never shown again. +

+
{ + e.preventDefault(); + if (token.trim()) save.mutate(); + }} + > + { + setToken(e.target.value); + setError(null); + }} + /> + +
+ {error && ( +

+ {error} +

+ )} +
+ )} +
+ ); +} + diff --git a/frontend/src/app-flow.tsx b/frontend/src/app-flow.tsx new file mode 100644 index 0000000..3bffb5e --- /dev/null +++ b/frontend/src/app-flow.tsx @@ -0,0 +1,204 @@ +import { type PluginApiClient } from "@paca-ai/plugin-sdk-react"; +import { useMutation } from "@tanstack/react-query"; +import { useEffect, useState } from "react"; +import { createAppManifest, ErrorCode, getPluginErrorCode } from "./github-api"; +import { Button, Field, Input, Modal } from "./ui"; + +// Result codes the backend appends to the return URL after a callback. +const RESULT_MESSAGES: Record = { + invalid_state: "The setup link expired or was already used. Start again.", + missing_code: "GitHub did not return an authorization code. Start again.", + conversion_failed: "GitHub rejected the app setup. Start again.", + store_failed: "The app was created on GitHub but Paca could not save it. Check the server logs.", + installation_failed: "The installation could not be verified. Install the app again.", +}; + +export interface FlowResult { + ok: boolean; + message: string; +} + +/** Reads (and removes) the ?github_app=… outcome the backend appends when it redirects back. */ +export function useFlowResult(): [FlowResult | null, () => void] { + const [result, setResult] = useState(null); + useEffect(() => { + const url = new URL(window.location.href); + const status = url.searchParams.get("github_app"); + if (!status) return; + const code = url.searchParams.get("github_app_code") ?? ""; + setResult( + status === "ok" + ? { ok: true, message: "GitHub App installed. Connect it to this project to continue." } + : { ok: false, message: RESULT_MESSAGES[code] ?? "GitHub App setup failed." }, + ); + url.searchParams.delete("github_app"); + url.searchParams.delete("github_app_code"); + window.history.replaceState(null, "", url.toString()); + }, []); + return [result, () => setResult(null)]; +} + +const RETURN_KEY = "ghp:return-url"; + +// The project settings page opens the tab named by the URL hash +// (`#plugin:` = that plugin's tab), so the flow returns straight to +// the GitHub tab rather than to whichever tab the page opens by default. +const SETTINGS_TAB_HASH = "plugin:com.paca.github"; + +export function returnUrl(): string { + const url = new URL(window.location.href); + url.searchParams.delete("github_app"); + url.searchParams.delete("github_app_code"); + url.hash = SETTINGS_TAB_HASH; + return url.toString(); +} + +/** + * Remembers where the flow started. The server also keeps this, but it can + * only hand it back on success; if a step fails the callback page uses this + * copy to still send the user home with an explanation. + */ +export function rememberReturnUrl(): string { + const url = returnUrl(); + try { + window.sessionStorage.setItem(RETURN_KEY, url); + } catch { + // Storage can be blocked; the callback page then shows its own error panel. + } + return url; +} + +export function recalledReturnUrl(): string | null { + try { + return window.sessionStorage.getItem(RETURN_KEY); + } catch { + return null; + } +} + +/** Appends the flow outcome the settings page turns into a banner. */ +export function withFlowResult(target: string, status: "ok" | "error", code = ""): string { + const url = new URL(target, window.location.origin); + url.searchParams.set("github_app", status); + if (code) url.searchParams.set("github_app_code", code); + return url.toString(); +} + +/** The Manifest Flow requires a browser form POST to GitHub; a fetch would not work. */ +function postManifestToGitHub(postUrl: string, manifest: Record) { + const form = document.createElement("form"); + form.method = "POST"; + form.action = postUrl; + const input = document.createElement("input"); + input.type = "hidden"; + input.name = "manifest"; + input.value = JSON.stringify(manifest); + form.appendChild(input); + document.body.appendChild(form); + form.submit(); +} + +export function CreateAppDialog({ + api, + open, + onClose, +}: { + api: PluginApiClient; + open: boolean; + onClose: () => void; +}) { + const [name, setName] = useState(() => `Paca ${window.location.hostname}`.slice(0, 34)); + const [target, setTarget] = useState<"personal" | "org">("personal"); + const [org, setOrg] = useState(""); + const [allowAll, setAllowAll] = useState(true); + const [error, setError] = useState(null); + + const create = useMutation({ + mutationFn: () => + createAppManifest(api, { + name: name.trim(), + owner_org: target === "org" ? org.trim() : undefined, + allow_all_projects: allowAll, + return_url: rememberReturnUrl(), + }), + onSuccess: (r) => postManifestToGitHub(r.post_url, r.manifest), + onError: (err: unknown) => { + setError( + getPluginErrorCode(err) === ErrorCode.GitHubWebhookURLRequired + ? "PUBLIC_URL is not configured on the server, so GitHub cannot be pointed back at Paca." + : "Could not start the setup. Check the app name and organization.", + ); + }, + }); + + const valid = name.trim().length > 0 && (target === "personal" || org.trim().length > 0); + const submit = () => { + setError(null); + create.mutate(); + }; + + return ( + + + + + } + > + + {(id) => setName(e.target.value)} />} + + +
+ + Create it on + + + + {target === "org" && ( + setOrg(e.target.value)} /> + )} +
+ + + +
+ What happens next +
    +
  1. GitHub shows the app’s settings — you click “Create GitHub App”.
  2. +
  3. GitHub asks where to install it — pick the account or organization.
  4. +
  5. You land back here, ready to connect.
  6. +
+
+ + {error && ( +

+ {error} +

+ )} +
+ ); +} diff --git a/frontend/src/github-api.ts b/frontend/src/github-api.ts index 94ee217..61d9060 100644 --- a/frontend/src/github-api.ts +++ b/frontend/src/github-api.ts @@ -17,6 +17,15 @@ export const ErrorCode = { GitHubBranchAlreadyLinked: "GITHUB_BRANCH_ALREADY_LINKED", GitHubBranchNotFound: "GITHUB_BRANCH_NOT_FOUND", GitHubTokenInsufficientPermissions: "GITHUB_TOKEN_INSUFFICIENT_PERMISSIONS", + GitHubInstallationNotAllowed: "GITHUB_INSTALLATION_NOT_ALLOWED", + GitHubAgentIdentityUnavailable: "GITHUB_AGENT_IDENTITY_UNAVAILABLE", + GitHubUnlinkReposFirst: "GITHUB_UNLINK_REPOS_FIRST", + GitHubWebhookURLRequired: "GITHUB_WEBHOOK_URL_REQUIRED", + GitHubSetupInvalidState: "GITHUB_SETUP_INVALID_STATE", + GitHubSetupMissingParameter: "GITHUB_SETUP_MISSING_PARAMETER", + GitHubSetupConversionFailed: "GITHUB_SETUP_CONVERSION_FAILED", + GitHubSetupStoreFailed: "GITHUB_SETUP_STORE_FAILED", + GitHubSetupInstallationFailed: "GITHUB_SETUP_INSTALLATION_FAILED", BadRequest: "BAD_REQUEST", } as const; @@ -54,6 +63,51 @@ export interface GitHubIntegration { connected: boolean; created_at?: string; updated_at?: string; + /** "pat" or "app"; for "app" the fields below describe the installation. */ + auth_type?: "pat" | "app"; + installation_id?: string; + app_name?: string; + account_login?: string; +} + +/** An app installation a project may bind (no secrets, no other projects). */ +export interface ProjectInstallation { + id: string; + app_name: string; + account_login: string; + account_type: string; + repository_selection: string; +} + +export interface AppInstallation { + id: string; + installation_id: number; + account_login: string; + account_type: string; + repository_selection: string; + allow_all_projects: boolean; + project_ids: string[]; + suspended: boolean; + manage_url: string; +} + +export interface GitHubApp { + id: string; + github_app_id: number; + slug: string; + name: string; + owner_login: string; + html_url: string; + webhook_active: boolean; + created_at: string; + installations: AppInstallation[]; +} + +export interface ManifestResponse { + post_url: string; + state: string; + manifest: Record; + webhook_active: boolean; } export interface AccessibleRepo { @@ -112,6 +166,14 @@ export interface CreateBranchResult { export const integrationKey = (projectId: string) => [PLUGIN_ID, "integration", projectId] as const; +export const projectInstallationsKey = (projectId: string) => + [PLUGIN_ID, "project-installations", projectId] as const; + +export const agentIdentitiesKey = (projectId: string) => + [PLUGIN_ID, "agent-identities", projectId] as const; + +export const appsKey = () => [PLUGIN_ID, "apps"] as const; + export const linkedReposKey = (projectId: string) => [PLUGIN_ID, "linked-repos", projectId] as const; @@ -252,3 +314,171 @@ export async function linkBranchToTask( { repo_id: repoId, branch_name: branchName }, ); } + +// ── GitHub Apps (instance-level) ─────────────────────────────────────────────── + +export async function listProjectInstallations( + api: PluginApiClient, +): Promise { + return api.pluginGet( + PLUGIN_ID, + `/projects/${api.projectId}/integration/installations`, + ); +} + +export async function bindInstallation( + api: PluginApiClient, + installationId: string, +): Promise { + return api.pluginPost( + PLUGIN_ID, + `/projects/${api.projectId}/integration/app`, + { installation_id: installationId }, + ); +} + +/** Requires the instance-wide github:manage_apps action; 403 otherwise. */ +export async function listGitHubApps(api: PluginApiClient): Promise { + return api.pluginGet(PLUGIN_ID, "/apps"); +} + +export async function createAppManifest( + api: PluginApiClient, + body: { + name: string; + owner_org?: string; + allow_all_projects: boolean; + public?: boolean; + return_url: string; + }, +): Promise { + return api.pluginPost(PLUGIN_ID, "/apps/manifest", body); +} + +export async function startAppInstall( + api: PluginApiClient, + appId: string, + returnUrl: string, +): Promise<{ url: string }> { + return api.pluginPost<{ url: string }>(PLUGIN_ID, `/apps/${appId}/install`, { + return_url: returnUrl, + }); +} + +export async function setInstallationAccess( + api: PluginApiClient, + appId: string, + installationId: string, + allowAllProjects: boolean, + projectIds: string[], +): Promise { + await api.pluginPost( + PLUGIN_ID, + `/apps/${appId}/installations/${installationId}/access`, + { allow_all_projects: allowAllProjects, project_ids: projectIds }, + ); +} + +export async function deleteGitHubApp(api: PluginApiClient, appId: string): Promise { + return api.pluginDelete(PLUGIN_ID, `/apps/${appId}`); +} + +// ── Per-agent identities ─────────────────────────────────────────────────────── + +export interface AgentIdentity { + member_id: string; + installation_id: string; + app_name: string; + account_login: string; +} + +export async function listAgentIdentities(api: PluginApiClient): Promise { + return api.pluginGet( + PLUGIN_ID, + `/projects/${api.projectId}/integration/agent-identities`, + ); +} + +export async function setAgentIdentity( + api: PluginApiClient, + memberId: string, + installationId: string, +): Promise { + return api.pluginPost( + PLUGIN_ID, + `/projects/${api.projectId}/integration/agent-identities`, + { member_id: memberId, installation_id: installationId }, + ); +} + +export async function deleteAgentIdentity(api: PluginApiClient, memberId: string): Promise { + return api.pluginDelete( + PLUGIN_ID, + `/projects/${api.projectId}/integration/agent-identities/${memberId}`, + ); +} + +// ── Project members (host API) ───────────────────────────────────────────────── + +export interface MemberLite { + id: string; + name: string; + memberType?: string; +} + +/** + * Reads the host's project-member list directly. The SDK's own listMembers() + * predates the paginated endpoint, so it can't filter by type or id. + */ +export async function fetchMembers( + projectId: string, + opts: { memberType?: string; ids?: string[]; pageSize?: number } = {}, +): Promise { + const params = new URLSearchParams({ page_size: String(opts.pageSize ?? 100) }); + if (opts.memberType) params.set("member_type", opts.memberType); + if (opts.ids?.length) params.set("ids", opts.ids.join(",")); + const res = await window.fetch( + `${window.location.origin}/api/v1/projects/${projectId}/members?${params}`, + { credentials: "include" }, + ); + if (!res.ok) throw new Error(`members request failed: ${res.status}`); + const json = (await res.json()) as { + data?: { + items?: { + id: string; + username: string; + full_name: string; + agent_name?: string; + member_type?: string; + }[]; + }; + }; + return (json.data?.items ?? []).map((m) => ({ + id: m.id, + name: m.agent_name || m.full_name || m.username, + memberType: m.member_type, + })); +} + +// ── GitHub App setup callbacks ───────────────────────────────────────────────── + +export interface SetupCallbackResult { + /** GitHub URL for the next leg of the flow, when there is one. */ + next_url?: string; + /** The Paca page that started the flow. */ + return_url: string; +} + +export async function completeAppCreated( + api: PluginApiClient, + body: { code: string; state: string }, +): Promise { + return api.pluginPost(PLUGIN_ID, "/apps/callback/created", body); +} + +export async function completeAppInstalled( + api: PluginApiClient, + body: { installation_id: string; state: string }, +): Promise { + return api.pluginPost(PLUGIN_ID, "/apps/callback/installed", body); +} diff --git a/frontend/src/styles.css b/frontend/src/styles.css new file mode 100644 index 0000000..f571f70 --- /dev/null +++ b/frontend/src/styles.css @@ -0,0 +1,277 @@ +/* + * Styles for the GitHub plugin's settings UI. + * + * Plugins are loaded into the host page but do not get the host's Tailwind + * build: a utility class only works if the host happens to use it too. So the + * settings UI ships its own small, namespaced (.ghp-) stylesheet, built on the + * host's design tokens (--background, --border, --primary, …) so it follows + * the active theme, light or dark, automatically. + */ + +.ghp { + --ghp-success: oklch(0.62 0.16 152); + --ghp-warning: oklch(0.72 0.15 75); + --ghp-danger: var(--destructive, oklch(0.577 0.245 27.325)); + --ghp-radius: 10px; + --ghp-gap: 16px; + + display: flex; + flex-direction: column; + gap: 20px; + max-width: 880px; + color: var(--foreground); + font-size: 14px; + line-height: 1.5; +} +.ghp *, .ghp *::before, .ghp *::after { box-sizing: border-box; } +.ghp h1, .ghp h2, .ghp h3, .ghp p { margin: 0; } +.ghp code { font-family: "JetBrains Mono", ui-monospace, monospace; font-size: 12px; background: var(--muted); padding: 1px 5px; border-radius: 4px; } +.ghp a { color: inherit; } + +/* ── layout ───────────────────────────────────────────── */ +.ghp-header { display: flex; align-items: center; gap: 12px; } +.ghp-header-icon { width: 40px; height: 40px; border-radius: 10px; display: grid; place-items: center; background: var(--foreground); color: var(--background); flex: none; } +.ghp-title { font-family: "Syne", sans-serif; font-size: 18px; font-weight: 600; } +.ghp-subtitle { color: var(--muted-foreground); font-size: 13px; } +.ghp-card { border: 1px solid var(--border); background: var(--card); border-radius: var(--ghp-radius); } +.ghp-card-pad { padding: 20px; } +.ghp-stack { display: flex; flex-direction: column; gap: var(--ghp-gap); } +.ghp-row { display: flex; align-items: center; gap: 12px; } +.ghp-row-between { display: flex; align-items: center; justify-content: space-between; gap: 12px; } +.ghp-wrap { flex-wrap: wrap; } +.ghp-grow { flex: 1; min-width: 0; } +.ghp-muted { color: var(--muted-foreground); } +.ghp-small { font-size: 12px; } +.ghp-strong { font-weight: 600; } +.ghp-truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.ghp-section-title { font-size: 14px; font-weight: 600; } + +/* ── buttons ──────────────────────────────────────────── */ +.ghp-btn { display: inline-flex; align-items: center; justify-content: center; gap: 6px; height: 36px; padding: 0 14px; border-radius: 8px; border: 1px solid transparent; font: inherit; font-size: 13px; font-weight: 500; cursor: pointer; transition: background .15s, border-color .15s, opacity .15s; white-space: nowrap; text-decoration: none; } +.ghp-input:focus-visible, .ghp-select:focus-visible { outline: 2px solid var(--ring); outline-offset: 0; border-color: var(--ring); } +.ghp-btn:focus-visible, .ghp-tab:focus-visible, .ghp-switch:focus-visible, .ghp-disclosure:focus-visible, .ghp-choice:focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; } +.ghp-btn:disabled { opacity: .5; cursor: not-allowed; } +.ghp-btn-primary { background: var(--primary); color: var(--primary-foreground); } +.ghp-btn-primary:hover:not(:disabled) { opacity: .9; } +.ghp-btn-secondary { background: var(--card); border-color: var(--border); color: var(--foreground); } +.ghp-btn-secondary:hover:not(:disabled) { background: var(--accent); } +.ghp-btn-ghost { background: transparent; color: var(--muted-foreground); } +.ghp-btn-ghost:hover:not(:disabled) { background: var(--accent); color: var(--foreground); } +.ghp-btn-danger { background: var(--ghp-danger); color: #fff; } +.ghp-btn-danger:hover:not(:disabled) { opacity: .9; } +.ghp-btn-sm { height: 30px; padding: 0 10px; font-size: 12px; } +.ghp-btn-icon { width: 30px; padding: 0; } +.ghp-btn-danger-quiet:hover:not(:disabled) { color: var(--ghp-danger); } + +/* ── form controls ────────────────────────────────────── */ +.ghp-field { display: flex; flex-direction: column; gap: 6px; } +.ghp-label { font-size: 13px; font-weight: 500; } +.ghp-hint { font-size: 12px; color: var(--muted-foreground); } +.ghp-input, .ghp-select { height: 36px; width: 100%; padding: 0 12px; border-radius: 8px; border: 1px solid var(--input); background: var(--background); color: var(--foreground); font: inherit; font-size: 13px; } +.ghp-input::placeholder { color: var(--muted-foreground); } +.ghp-input-mono { font-family: "JetBrains Mono", ui-monospace, monospace; } +.ghp-input-wrap { position: relative; flex: 1; } +.ghp-input-wrap .ghp-input { padding-left: 34px; } +.ghp-input-wrap > svg { position: absolute; left: 11px; top: 50%; transform: translateY(-50%); color: var(--muted-foreground); pointer-events: none; } +.ghp-radio { display: flex; align-items: center; gap: 8px; font-size: 13px; cursor: pointer; } +.ghp-check { display: flex; align-items: flex-start; gap: 10px; font-size: 13px; cursor: pointer; } +.ghp-check input { margin-top: 3px; accent-color: var(--primary); } + +.ghp-switch { position: relative; width: 34px; height: 20px; border-radius: 999px; border: 0; background: var(--input); cursor: pointer; flex: none; transition: background .15s; padding: 0; } +.ghp-switch[aria-checked="true"] { background: var(--primary); } +.ghp-switch::after { content: ""; position: absolute; top: 2px; left: 2px; width: 16px; height: 16px; border-radius: 50%; background: #fff; transition: transform .15s; } +.ghp-switch[aria-checked="true"]::after { transform: translateX(14px); } +.ghp-switch:disabled { opacity: .5; cursor: not-allowed; } + +/* ── badges / pills ───────────────────────────────────── */ +.ghp-badge { display: inline-flex; align-items: center; gap: 5px; height: 22px; padding: 0 8px; border-radius: 999px; font-size: 11px; font-weight: 600; background: var(--muted); color: var(--muted-foreground); white-space: nowrap; } +.ghp-badge-primary { background: color-mix(in srgb, var(--primary) 14%, transparent); color: var(--primary); } +.ghp-badge-success { background: color-mix(in srgb, var(--ghp-success) 14%, transparent); color: var(--ghp-success); } +.ghp-badge-warning { background: color-mix(in srgb, var(--ghp-warning) 18%, transparent); color: color-mix(in srgb, var(--ghp-warning) 70%, var(--foreground)); } +.ghp-badge-danger { background: color-mix(in srgb, var(--ghp-danger) 14%, transparent); color: var(--ghp-danger); } +.ghp-dot { width: 7px; height: 7px; border-radius: 50%; background: currentColor; flex: none; } + +/* ── banners ──────────────────────────────────────────── */ +.ghp-banner { display: flex; align-items: flex-start; gap: 10px; padding: 12px 14px; border-radius: 8px; border: 1px solid var(--border); background: var(--muted); font-size: 13px; } +.ghp-banner > svg { flex: none; margin-top: 2px; } +.ghp-banner-success { border-color: color-mix(in srgb, var(--ghp-success) 35%, transparent); background: color-mix(in srgb, var(--ghp-success) 9%, transparent); } +.ghp-banner-success > svg { color: var(--ghp-success); } +.ghp-banner-warning { border-color: color-mix(in srgb, var(--ghp-warning) 45%, transparent); background: color-mix(in srgb, var(--ghp-warning) 10%, transparent); } +.ghp-banner-warning > svg { color: var(--ghp-warning); } +.ghp-banner-danger { border-color: color-mix(in srgb, var(--ghp-danger) 35%, transparent); background: color-mix(in srgb, var(--ghp-danger) 8%, transparent); } +.ghp-banner-danger > svg { color: var(--ghp-danger); } +.ghp-banner-body { flex: 1; min-width: 0; } +.ghp-banner-title { font-weight: 600; } + +/* ── tabs ─────────────────────────────────────────────── */ +.ghp-tabs { display: flex; gap: 4px; border-bottom: 1px solid var(--border); overflow-x: auto; } +.ghp-tab { display: inline-flex; align-items: center; gap: 8px; height: 40px; padding: 0 14px; border: 0; background: none; color: var(--muted-foreground); font: inherit; font-size: 13px; font-weight: 500; cursor: pointer; border-bottom: 2px solid transparent; margin-bottom: -1px; white-space: nowrap; } +.ghp-tab:hover { color: var(--foreground); } +.ghp-tab[aria-selected="true"] { color: var(--foreground); border-bottom-color: var(--primary); } +.ghp-tab-count { font-size: 11px; background: var(--muted); border-radius: 999px; padding: 0 7px; min-width: 20px; text-align: center; } + +/* ── setup (not connected) ────────────────────────────── */ +.ghp-hero { padding: 24px; display: flex; flex-direction: column; gap: 20px; } +.ghp-hero-head { display: flex; flex-direction: column; gap: 6px; } +.ghp-hero-title { font-family: "Syne", sans-serif; font-size: 17px; font-weight: 600; display: flex; align-items: center; gap: 10px; flex-wrap: wrap; } +.ghp-benefits { display: flex; flex-wrap: wrap; gap: 8px 18px; color: var(--muted-foreground); font-size: 13px; } +.ghp-benefits li { display: inline-flex; align-items: center; gap: 6px; list-style: none; } +.ghp-benefits svg { color: var(--ghp-success); } +.ghp-benefits { margin: 0; padding: 0; } + +.ghp-steps { display: grid; grid-template-columns: repeat(3, 1fr); gap: 0; margin: 0; padding: 0; list-style: none; } +.ghp-step { position: relative; display: flex; flex-direction: column; gap: 8px; padding-right: 12px; } +.ghp-step-top { display: flex; align-items: center; gap: 10px; } +.ghp-step-num { width: 26px; height: 26px; flex: none; border-radius: 50%; display: grid; place-items: center; font-size: 12px; font-weight: 700; border: 2px solid var(--border); color: var(--muted-foreground); background: var(--card); } +.ghp-step-line { flex: 1; height: 2px; background: var(--border); border-radius: 2px; } +.ghp-step[data-state="done"] .ghp-step-num { background: var(--ghp-success); border-color: var(--ghp-success); color: #fff; } +.ghp-step[data-state="done"] .ghp-step-line { background: var(--ghp-success); } +.ghp-step[data-state="current"] .ghp-step-num { border-color: var(--primary); color: var(--primary); } +.ghp-step-label { font-size: 13px; font-weight: 600; } +.ghp-step[data-state="todo"] .ghp-step-label { color: var(--muted-foreground); } +.ghp-step:last-child .ghp-step-line { display: none; } + +.ghp-action { border: 1px solid var(--border); border-radius: 8px; padding: 16px; background: var(--background); display: flex; flex-direction: column; gap: 12px; } +.ghp-action-title { font-weight: 600; } + +.ghp-option-list { display: flex; flex-direction: column; gap: 8px; } +.ghp-option { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 10px 12px; border: 1px solid var(--border); border-radius: 8px; background: var(--card); } + +.ghp-divider { display: flex; align-items: center; gap: 12px; color: var(--muted-foreground); font-size: 12px; } +.ghp-divider::before, .ghp-divider::after { content: ""; flex: 1; height: 1px; background: var(--border); } +.ghp-disclosure { display: flex; align-items: center; justify-content: space-between; width: 100%; padding: 14px 16px; border: 1px solid var(--border); border-radius: 8px; background: transparent; color: var(--foreground); font: inherit; font-size: 13px; font-weight: 500; cursor: pointer; text-align: left; } +.ghp-disclosure:hover { background: var(--accent); } +.ghp-disclosure-body { padding: 16px; border: 1px solid var(--border); border-top: 0; border-radius: 0 0 8px 8px; margin-top: -4px; display: flex; flex-direction: column; gap: 12px; } +.ghp-chev { transition: transform .15s; } +.ghp-chev[data-open="true"] { transform: rotate(90deg); } + +/* ── connection bar (connected) ───────────────────────── */ +.ghp-conn { padding: 16px 20px; display: flex; align-items: center; gap: 14px; flex-wrap: wrap; } +.ghp-conn-icon { width: 40px; height: 40px; border-radius: 10px; display: grid; place-items: center; background: color-mix(in srgb, var(--ghp-success) 14%, transparent); color: var(--ghp-success); flex: none; } +.ghp-conn-title { font-weight: 600; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } + +/* ── lists ────────────────────────────────────────────── */ +.ghp-list { display: flex; flex-direction: column; border: 1px solid var(--border); border-radius: var(--ghp-radius); overflow: hidden; background: var(--card); } +.ghp-item { display: flex; align-items: center; gap: 12px; padding: 12px 16px; } +.ghp-item + .ghp-item { border-top: 1px solid var(--border); } +.ghp-avatar { width: 32px; height: 32px; border-radius: 8px; display: grid; place-items: center; background: var(--muted); color: var(--muted-foreground); flex: none; font-size: 12px; font-weight: 700; text-transform: uppercase; } +.ghp-avatar-round { border-radius: 50%; } +.ghp-item-title { font-weight: 500; } +.ghp-item-title a { text-decoration: none; } +.ghp-item-title a:hover { text-decoration: underline; } + +.ghp-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; } +.ghp-toolbar .ghp-input-wrap { max-width: 280px; } + +.ghp-empty { display: flex; flex-direction: column; align-items: center; text-align: center; gap: 10px; padding: 40px 20px; border: 1px dashed var(--border); border-radius: var(--ghp-radius); } +.ghp-empty-icon { width: 44px; height: 44px; border-radius: 12px; display: grid; place-items: center; background: var(--muted); color: var(--muted-foreground); } +.ghp-empty p { max-width: 380px; } + +/* ── identity flow diagram ────────────────────────────── */ +.ghp-flow { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; padding: 14px 16px; border-radius: 8px; background: var(--muted); font-size: 13px; } +.ghp-flow-chip { display: inline-flex; align-items: center; gap: 6px; padding: 4px 10px; border-radius: 999px; background: var(--card); border: 1px solid var(--border); font-weight: 500; } +.ghp-add-row { display: grid; grid-template-columns: 1fr 1fr auto; gap: 8px; align-items: end; } +@media (max-width: 640px) { .ghp-add-row { grid-template-columns: 1fr; } .ghp-step-label { font-size: 12px; } .ghp-step { padding-right: 8px; } } + +/* ── app cards ────────────────────────────────────────── */ +.ghp-app { padding: 0; overflow: hidden; } +.ghp-app-head { padding: 16px 20px; display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; } +.ghp-app-body { border-top: 1px solid var(--border); } +.ghp-app-body .ghp-item { padding: 12px 20px; } + +/* ── modal ────────────────────────────────────────────── */ +.ghp-overlay { position: fixed; inset: 0; z-index: 60; background: rgba(0,0,0,.5); display: flex; align-items: flex-start; justify-content: center; padding: 8vh 16px 16px; overflow-y: auto; } +.ghp-modal { width: 100%; max-width: 480px; max-height: 84vh; display: flex; flex-direction: column; background: var(--card); color: var(--foreground); border: 1px solid var(--border); border-radius: 12px; box-shadow: 0 20px 50px rgba(0,0,0,.25); padding: 22px; font-size: 14px; line-height: 1.5; } +.ghp-modal-wide { max-width: 560px; } +.ghp .ghp-modal-title { font-family: "Syne", sans-serif; font-size: 16px; font-weight: 600; margin: 0 0 4px; } +.ghp .ghp-modal-desc { color: var(--muted-foreground); font-size: 13px; margin: 0 0 16px; } +.ghp-modal-body { overflow-y: auto; min-height: 0; display: flex; flex-direction: column; gap: 14px; } +.ghp-modal-footer { display: flex; justify-content: flex-end; gap: 8px; margin-top: 18px; } +.ghp-modal * { box-sizing: border-box; } +.ghp-repo-pick { width: 100%; display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 10px 12px; border-radius: 8px; border: 1px solid var(--border); background: var(--card); color: inherit; font: inherit; text-align: left; cursor: pointer; } +.ghp-repo-pick:hover:not(:disabled) { background: var(--accent); } +.ghp-repo-pick:disabled { opacity: .6; cursor: not-allowed; } +.ghp-next { display: flex; flex-direction: column; gap: 8px; padding: 12px 14px; border-radius: 8px; background: var(--muted); font-size: 12px; color: var(--muted-foreground); } +.ghp-next ol { margin: 0; padding-left: 18px; } + +/* ── misc ─────────────────────────────────────────────── */ +.ghp-skeleton { border-radius: 8px; background: var(--muted); animation: ghp-pulse 1.4s ease-in-out infinite; } +@keyframes ghp-pulse { 50% { opacity: .5; } } +.ghp-spin { animation: ghp-spin 1s linear infinite; } +@keyframes ghp-spin { to { transform: rotate(360deg); } } +@media (prefers-reduced-motion: reduce) { .ghp-spin, .ghp-skeleton { animation: none; } .ghp * { transition: none !important; } } +.ghp-sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; } + +/* ── task detail sections ───────────────────────────────────────────────────── + * These render inside the task drawer next to the host's own sections + * (Subtasks, Linked tasks, Attachments), so they copy those sections' look — + * same heading, same bordered row list, same hover actions, same empty state — + * rather than the settings UI's. Values mirror the host's Tailwind classes + * (e.g. border-border/25, bg-card/50, text-muted-foreground/70). */ +.ghp.ghp-sec { + --ghp-merged: oklch(0.58 0.19 295); + --ghp-faint: color-mix(in srgb, var(--muted-foreground) 50%, transparent); + --ghp-line: color-mix(in srgb, var(--border) 25%, transparent); + max-width: none; + gap: 12px; +} +.ghp-sec-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; } +.ghp .ghp-sec-title { min-width: 0; display: flex; align-items: center; gap: 8px; font-size: 12px; font-weight: 600; letter-spacing: .08em; text-transform: uppercase; color: color-mix(in srgb, var(--muted-foreground) 70%, transparent); } +.ghp-sec-actions { display: flex; align-items: center; gap: 4px; } + +.ghp-pill { display: inline-flex; align-items: center; gap: 6px; border: 0; border-radius: 8px; padding: 6px 10px; font: inherit; font-size: 12px; font-weight: 600; cursor: pointer; transition: background .15s, color .15s; background: color-mix(in srgb, var(--primary) 8%, transparent); color: color-mix(in srgb, var(--primary) 80%, transparent); } +.ghp-pill:hover { background: color-mix(in srgb, var(--primary) 15%, transparent); color: var(--primary); } +.ghp-pill-quiet { background: transparent; color: color-mix(in srgb, var(--muted-foreground) 70%, transparent); } +.ghp-pill-quiet:hover { background: color-mix(in srgb, var(--muted) 50%, transparent); color: var(--foreground); } +.ghp-pill:focus-visible, .ghp-icon-btn:focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; } + +.ghp-rows { border: 1px solid var(--ghp-line); background: color-mix(in srgb, var(--card) 50%, transparent); border-radius: 12px; overflow: hidden; } +.ghp-row-item { display: flex; align-items: center; gap: 12px; padding: 10px 16px; } +.ghp-row-item + .ghp-row-item { border-top: 1px solid color-mix(in srgb, var(--border) 15%, transparent); } +.ghp-row-icon { flex: none; color: color-mix(in srgb, var(--muted-foreground) 40%, transparent); } +.ghp-row-icon[data-state="open"] { color: var(--ghp-success); } +.ghp-row-icon[data-state="merged"] { color: var(--ghp-merged); } +.ghp-row-icon[data-state="closed"] { color: var(--ghp-danger); } +.ghp-row-ref { flex: none; font-family: "JetBrains Mono", ui-monospace, monospace; font-size: 12px; color: var(--ghp-faint); } +.ghp-row-body { flex: 1; min-width: 0; } +.ghp-row-title { display: block; font-size: 14px; color: var(--foreground); text-decoration: none; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; transition: color .1s; } +a.ghp-row-title:hover { color: var(--primary); } +.ghp-row-title-mono { font-family: "JetBrains Mono", ui-monospace, monospace; font-size: 13px; } +.ghp-row-meta { display: flex; align-items: center; gap: 6px; margin-top: 2px; font-size: 12px; color: var(--ghp-faint); overflow: hidden; white-space: nowrap; } +.ghp-row-meta .ghp-ref { flex: none; } +.ghp-row-actions { display: flex; align-items: center; gap: 4px; opacity: 0; transition: opacity .15s; } +.ghp-row-item:hover .ghp-row-actions, .ghp-row-item:focus-within .ghp-row-actions { opacity: 1; } +@media (hover: none) { .ghp-row-actions { opacity: 1; } } +.ghp-icon-btn { width: 24px; height: 24px; display: inline-grid; place-items: center; border: 0; border-radius: 4px; background: none; color: color-mix(in srgb, var(--muted-foreground) 50%, transparent); cursor: pointer; transition: background .1s, color .1s; padding: 0; } +.ghp-icon-btn:hover { color: var(--foreground); background: color-mix(in srgb, var(--muted) 30%, transparent); } +.ghp-icon-btn-danger:hover { color: var(--ghp-danger); background: color-mix(in srgb, var(--ghp-danger) 10%, transparent); } +.ghp-icon-btn:disabled { opacity: .5; cursor: not-allowed; } + +.ghp-sec-empty { display: flex; align-items: center; gap: 12px; padding: 12px 4px; color: color-mix(in srgb, var(--muted-foreground) 45%, transparent); font-size: 14px; font-style: italic; } +.ghp-sec-error { font-size: 12px; color: var(--ghp-danger); background: color-mix(in srgb, var(--ghp-danger) 10%, transparent); border-radius: 8px; padding: 8px 12px; } + +.ghp-badge-merged { background: color-mix(in srgb, var(--ghp-merged) 14%, transparent); color: var(--ghp-merged); } +.ghp-ref { display: inline-flex; align-items: center; gap: 4px; font-family: "JetBrains Mono", ui-monospace, monospace; font-size: 11px; background: var(--muted); border-radius: 4px; padding: 1px 6px; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + +/* Inline create/link forms: the same bordered panel as the row lists. */ +.ghp-form { display: flex; flex-direction: column; gap: 12px; padding: 16px; border: 1px solid var(--ghp-line, var(--border)); border-radius: 12px; background: color-mix(in srgb, var(--card) 50%, transparent); } +.ghp-form-actions { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } +.ghp-form-error { color: var(--ghp-danger); font-size: 12px; } +.ghp-seg { display: flex; flex-wrap: wrap; gap: 4px; } +.ghp-seg button { height: 26px; padding: 0 10px; border-radius: 999px; border: 1px solid var(--border); background: transparent; color: var(--muted-foreground); font: inherit; font-size: 12px; font-weight: 500; cursor: pointer; } +.ghp-seg button:hover { color: var(--foreground); border-color: color-mix(in srgb, var(--foreground) 28%, var(--border)); } +.ghp-seg button[aria-pressed="true"] { background: color-mix(in srgb, var(--primary) 12%, transparent); border-color: var(--primary); color: var(--primary); } +.ghp-seg button:focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; } + +.ghp-cmd { display: flex; align-items: center; gap: 8px; padding: 8px 10px; border-radius: 6px; background: var(--muted); border: 1px solid var(--border); } +.ghp-cmd code { flex: 1; min-width: 0; background: none; padding: 0; word-break: break-all; color: var(--foreground); } +.ghp-details > summary { cursor: pointer; font-size: 12px; color: var(--muted-foreground); list-style: none; display: inline-flex; align-items: center; gap: 6px; } +.ghp-details > summary::-webkit-details-marker { display: none; } +.ghp-details > summary::before { content: ""; width: 6px; height: 6px; border-right: 1.5px solid currentColor; border-bottom: 1.5px solid currentColor; transform: rotate(-45deg); transition: transform .15s; } +.ghp-details[open] > summary::before { transform: rotate(45deg); } +.ghp-details > summary:hover { color: var(--foreground); } +.ghp-details[open] > summary { margin-bottom: 8px; } + +/* ── GitHub App setup callback page ───────────────────────────────────────── */ +.ghp.ghp-callback { min-height: 60vh; align-items: center; justify-content: center; margin: 0 auto; } +.ghp-callback-card { display: flex; flex-direction: column; align-items: center; text-align: center; gap: 12px; padding: 36px 32px; max-width: 440px; } diff --git a/frontend/src/ui.tsx b/frontend/src/ui.tsx new file mode 100644 index 0000000..25eeee0 --- /dev/null +++ b/frontend/src/ui.tsx @@ -0,0 +1,410 @@ +import { Check, ClipboardCopy, Loader2, Terminal } from "lucide-react"; +import { type ReactNode, useEffect, useId, useRef, useState } from "react"; +import "./styles.css"; + +export function cn(...classes: (string | undefined | null | false)[]): string { + return classes.filter(Boolean).join(" "); +} + +export function GitHubIcon(props: React.SVGProps) { + return ( + + ); +} + +// ── Button ──────────────────────────────────────────────────────────────────── + +type ButtonProps = React.ButtonHTMLAttributes & { + variant?: "primary" | "secondary" | "ghost" | "danger"; + size?: "md" | "sm"; + icon?: boolean; + loading?: boolean; +}; + +export function Button({ + variant = "secondary", + size = "md", + icon, + loading, + className, + children, + disabled, + ...props +}: ButtonProps) { + return ( + + ); +} + +// ── Form controls ───────────────────────────────────────────────────────────── + +export function Field({ + label, + hint, + children, +}: { + label: string; + hint?: string; + children: (id: string) => ReactNode; +}) { + const id = useId(); + return ( +
+ + {children(id)} + {hint &&

{hint}

} +
+ ); +} + +export function Input({ className, mono, ...props }: React.InputHTMLAttributes & { mono?: boolean }) { + return ; +} + +export function Select({ className, children, ...props }: React.SelectHTMLAttributes) { + return ( + + ); +} + +export function Switch({ + checked, + onChange, + label, + disabled, +}: { + checked: boolean; + onChange: (next: boolean) => void; + label: string; + disabled?: boolean; +}) { + return ( + + ))} +
+ ); +} + +export function TabPanel({ id, children }: { id: string; children: ReactNode }) { + return ( +
+ {children} +
+ ); +} + +// ── Modal ───────────────────────────────────────────────────────────────────── + +export function Modal({ + open, + onClose, + title, + description, + wide, + footer, + children, +}: { + open: boolean; + onClose: () => void; + title: string; + description?: string; + wide?: boolean; + footer?: ReactNode; + children?: ReactNode; +}) { + const titleId = useId(); + const ref = useRef(null); + + useEffect(() => { + if (!open) return; + const previous = document.activeElement as HTMLElement | null; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") { + e.stopPropagation(); + onClose(); + } + }; + document.addEventListener("keydown", onKey); + // Move focus into the dialog so keyboard users land inside it. + ref.current?.querySelector("input, select, button:not([data-close])")?.focus(); + return () => { + document.removeEventListener("keydown", onKey); + previous?.focus?.(); + }; + }, [open, onClose]); + + if (!open) return null; + return ( +
e.target === e.currentTarget && onClose()}> +
+

+ {title} +

+ {description &&

{description}

} +
{children}
+ {footer &&
{footer}
} +
+
+ ); +} + +// ── Task-section primitives ─────────────────────────────────────────────────── +// Styled to sit beside the host's own task-detail sections (see styles.css). + +/** A task-detail section: the host's heading, optional action pills, then content. */ +export function Section({ + title, + actions, + children, +}: { + title: string; + actions?: ReactNode; + children: ReactNode; +}) { + return ( +
+
+

{title}

+ {actions &&
{actions}
} +
+ {children} +
+ ); +} + +export function Pill({ + quiet, + className, + children, + ...props +}: React.ButtonHTMLAttributes & { quiet?: boolean }) { + return ( + + ); +} + +export function RowIconButton({ + danger, + className, + children, + ...props +}: React.ButtonHTMLAttributes & { danger?: boolean }) { + return ( + + ); +} + +export function SectionEmpty({ icon, children }: { icon: ReactNode; children: ReactNode }) { + return ( +
+ {icon} +

{children}

+
+ ); +} + +export function Segmented({ options, value, onChange, label }: { options: readonly T[]; value: T; onChange: (v: T) => void; label: string }) { + return ( +
+ {options.map((o) => ( + + ))} +
+ ); +} + +function useCopy(text: string) { + const [copied, setCopied] = useState(false); + return { + copied, + copy: () => { + navigator.clipboard?.writeText(text)?.catch(() => {}); + setCopied(true); + setTimeout(() => setCopied(false), 1800); + }, + }; +} + +/** Copy button for a list row (hover action). */ +export function CopyIconButton({ text, label }: { text: string; label: string }) { + const { copied, copy } = useCopy(text); + return ( + + {copied ? + ); +} + +export function CopyButton({ text, label = "Copy to clipboard" }: { text: string; label?: string }) { + const { copied, copy } = useCopy(text); + return ( + + ); +} + +export function CommandBlock({ command }: { command: string }) { + return ( +
+
+ ); +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 242d6bf..d83da3a 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -11,6 +11,7 @@ export default defineConfig({ exposes: { "./GitHubSettingsTab": "./src/GitHubSettingsTab.tsx", "./GitHubTaskSection": "./src/GitHubTaskSection.tsx", + "./GitHubAppCallbackPage": "./src/GitHubAppCallbackPage.tsx", }, shared: { react: { requiredVersion: "^19.0.0" }, diff --git a/mcp/src/index.ts b/mcp/src/index.ts index 8f081ec..669bed8 100644 --- a/mcp/src/index.ts +++ b/mcp/src/index.ts @@ -12,6 +12,10 @@ import { interface GitHubIntegration { id: string; project_id: string; + connected?: boolean; + auth_type?: "pat" | "app"; + app_name?: string; + account_login?: string; created_at: string; updated_at: string; } @@ -104,6 +108,8 @@ function formatIntegration(integration: GitHubIntegration): string { return `GitHub Integration: ID: ${integration.id} Project ID: ${integration.project_id} +Connected: ${integration.connected ?? true} +Auth: ${integration.auth_type === "app" ? `GitHub App ${integration.app_name ?? ""} (${integration.account_login ?? ""})` : "personal access token"} Created: ${integration.created_at} Updated: ${integration.updated_at}`; } diff --git a/plugin.json b/plugin.json index 31a14b0..0f4ccda 100644 --- a/plugin.json +++ b/plugin.json @@ -2,7 +2,7 @@ "id": "com.paca.github", "displayName": "GitHub Integration", "description": "Integrates GitHub repositories, pull requests, and branches with Paca projects and tasks.", - "version": "0.4.1", + "version": "0.6.0", "minCoreVersion": "v0.19.0", "capabilities": ["repository"], "permissions": ["db.read", "db.write"], @@ -12,6 +12,12 @@ "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" + }, + { + "key": "github:manage_apps", + "label": "Manage GitHub Apps", + "description": "Instance-wide: create GitHub Apps through the manifest flow, install them on GitHub accounts and organizations, choose which projects may use each installation, and delete apps. Project managers can only pick from the installations made available to their project.", + "scope": "global" } ], "backend": { @@ -19,7 +25,8 @@ "allowedOutboundDomains": ["api.github.com"], "sensitiveFields": { "github_integrations": ["access_token_enc"], - "github_repositories": ["webhook_secret_enc"] + "github_repositories": ["webhook_secret_enc"], + "github_apps": ["client_secret_enc", "webhook_secret_enc", "private_key_enc"] }, "routes": [ { @@ -269,6 +276,167 @@ } ] }, + { + "method": "POST", + "path": "/apps/manifest", + "middlewares": [ + { "name": "optionalAuthn" }, + { "name": "requireFreshPassword" }, + { + "name": "requireActions", + "scope": "global", + "actions": ["github:manage_apps"] + } + ] + }, + { + "method": "POST", + "path": "/apps/callback/created", + "middlewares": [ + { "name": "optionalAuthn" }, + { "name": "requireFreshPassword" }, + { + "name": "requireActions", + "scope": "global", + "actions": ["github:manage_apps"] + } + ] + }, + { + "method": "POST", + "path": "/apps/callback/installed", + "middlewares": [ + { "name": "optionalAuthn" }, + { "name": "requireFreshPassword" }, + { + "name": "requireActions", + "scope": "global", + "actions": ["github:manage_apps"] + } + ] + }, + { + "method": "GET", + "path": "/apps", + "middlewares": [ + { "name": "optionalAuthn" }, + { "name": "requireFreshPassword" }, + { + "name": "requireActions", + "scope": "global", + "actions": ["github:manage_apps"] + } + ] + }, + { + "method": "DELETE", + "path": "/apps/:appId", + "middlewares": [ + { "name": "optionalAuthn" }, + { "name": "requireFreshPassword" }, + { + "name": "requireActions", + "scope": "global", + "actions": ["github:manage_apps"] + } + ] + }, + { + "method": "POST", + "path": "/apps/:appId/install", + "middlewares": [ + { "name": "optionalAuthn" }, + { "name": "requireFreshPassword" }, + { + "name": "requireActions", + "scope": "global", + "actions": ["github:manage_apps"] + } + ] + }, + { + "method": "POST", + "path": "/apps/:appId/installations/:installationId/access", + "middlewares": [ + { "name": "optionalAuthn" }, + { "name": "requireFreshPassword" }, + { + "name": "requireActions", + "scope": "global", + "actions": ["github:manage_apps"] + } + ] + }, + { + "method": "GET", + "path": "/projects/:projectId/integration/installations", + "middlewares": [ + { "name": "optionalAuthn" }, + { "name": "requireFreshPassword" }, + { + "name": "requireActions", + "scope": "project", + "actions": ["github:manage"] + } + ] + }, + { + "method": "POST", + "path": "/projects/:projectId/integration/app", + "middlewares": [ + { "name": "optionalAuthn" }, + { "name": "requireFreshPassword" }, + { + "name": "requireActions", + "scope": "project", + "actions": ["github:manage"] + } + ] + }, + { + "method": "GET", + "path": "/projects/:projectId/integration/agent-identities", + "middlewares": [ + { "name": "optionalAuthn" }, + { "name": "requireFreshPassword" }, + { + "name": "requireActions", + "scope": "project", + "actions": ["github:manage"] + } + ] + }, + { + "method": "POST", + "path": "/projects/:projectId/integration/agent-identities", + "middlewares": [ + { "name": "optionalAuthn" }, + { "name": "requireFreshPassword" }, + { + "name": "requireActions", + "scope": "project", + "actions": ["github:manage"] + } + ] + }, + { + "method": "DELETE", + "path": "/projects/:projectId/integration/agent-identities/:memberId", + "middlewares": [ + { "name": "optionalAuthn" }, + { "name": "requireFreshPassword" }, + { + "name": "requireActions", + "scope": "project", + "actions": ["github:manage"] + } + ] + }, + { + "method": "POST", + "path": "/webhooks/apps/:appKey", + "middlewares": [{ "name": "optionalAuthn" }] + }, { "method": "POST", "path": "/projects/:projectId/webhook", @@ -290,6 +458,20 @@ "point": "task.detail.section", "component": "GitHubTaskSection", "order": 5 + }, + { + "point": "app.page", + "component": "GitHubAppCallbackPage", + "requiredPermission": "github:manage_apps" + } + ], + "navItems": [ + { + "scope": "app", + "slug": "github-app-callback", + "label": "GitHub App setup", + "component": "GitHubAppCallbackPage", + "requiredPermission": "github:manage_apps" } ] }, diff --git a/skills/paca-github-workflow/SKILL.md b/skills/paca-github-workflow/SKILL.md index 8cfa141..7622715 100644 --- a/skills/paca-github-workflow/SKILL.md +++ b/skills/paca-github-workflow/SKILL.md @@ -13,11 +13,11 @@ triggers: # 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. +This plugin (`com.paca.github`) connects a Paca project to GitHub via a GitHub App installation (created from Project Settings → GitHub) or a Personal Access Token (PAT), 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. +1. `github_get_integration({projectId})`. If `connected` is false, a GitHub App installation or a GitHub PAT (scopes `repo` + `admin:repo_hook`) must be connected first — you cannot create either yourself. Tell the user to connect one under Project Settings → GitHub (the GitHub App option is recommended), 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. From a13030929ae21e4ab7a29082c84499f97bf19293 Mon Sep 17 00:00:00 2001 From: pikann22 Date: Sun, 25 Oct 2026 05:22:08 +0000 Subject: [PATCH 2/8] feat: enhance github app integration --- README.md | 258 +----- backend/appauth.go | 222 ----- backend/appjwt.go | 62 ++ backend/apps.go | 697 --------------- backend/apps_test.go | 400 --------- backend/automation.go | 187 ---- backend/automation_test.go | 105 --- backend/branches.go | 255 ------ backend/client.go | 520 ----------- backend/client_url_test.go | 67 -- backend/github.go | 125 +++ backend/go.mod | 3 + backend/identities.go | 149 ---- backend/identities_test.go | 111 --- backend/integration.go | 562 ------------ .../migrations/0004_git_provider_sessions.sql | 7 + backend/migrations/0005_drop_legacy.sql | 20 + backend/plugin.go | 150 +--- backend/plugin_test.go | 581 ------------ backend/project_app.go | 314 ------- backend/pull_requests.go | 692 --------------- backend/spi_access.go | 169 ++++ backend/spi_access_test.go | 312 +++++++ backend/spi_api.go | 240 +++++ backend/spi_assert.go | 18 + backend/spi_conformance_test.go | 267 ++++++ backend/spi_dbstore.go | 211 +++++ backend/spi_dbstore_test.go | 121 +++ backend/spi_fakegithub_test.go | 610 +++++++++++++ backend/spi_provider.go | 500 +++++++++++ backend/spi_reads.go | 577 ++++++++++++ backend/spi_setup.go | 197 +++++ backend/spi_store.go | 71 ++ backend/spi_test.go | 685 +++++++++++++++ backend/spi_webhook.go | 307 +++++++ backend/spi_writes.go | 365 ++++++++ backend/webhook.go | 309 ------- frontend/bun.lock | 275 ------ frontend/index.html | 1 - frontend/package.json | 27 - frontend/src/AgentIdentities.tsx | 180 ---- frontend/src/AppsPanel.tsx | 160 ---- frontend/src/Branches.tsx | 358 -------- frontend/src/ConnectionBar.tsx | 132 --- frontend/src/GitHubAppCallbackPage.tsx | 136 --- frontend/src/GitHubSettingsTab.tsx | 154 ---- frontend/src/GitHubTaskSection.tsx | 64 -- frontend/src/PullRequests.tsx | 234 ----- frontend/src/RepositoriesPanel.tsx | 274 ------ frontend/src/SetupPanel.tsx | 340 ------- frontend/src/app-flow.tsx | 204 ----- frontend/src/github-api.ts | 484 ---------- frontend/src/index.ts | 2 - frontend/src/styles.css | 277 ------ frontend/src/ui.tsx | 410 --------- frontend/tsconfig.json | 16 - frontend/vite.config.ts | 28 - mcp/bun.lock | 329 ------- mcp/package.json | 18 - mcp/src/index.ts | 826 ------------------ mcp/tsconfig.json | 10 - mcp/vite.config.ts | 18 - plugin.json | 546 +----------- skills/paca-github-workflow/SKILL.md | 71 -- 64 files changed, 4897 insertions(+), 11123 deletions(-) delete mode 100644 backend/appauth.go create mode 100644 backend/appjwt.go delete mode 100644 backend/apps.go delete mode 100644 backend/apps_test.go delete mode 100644 backend/automation.go delete mode 100644 backend/automation_test.go delete mode 100644 backend/branches.go delete mode 100644 backend/client.go delete mode 100644 backend/client_url_test.go create mode 100644 backend/github.go delete mode 100644 backend/identities.go delete mode 100644 backend/identities_test.go delete mode 100644 backend/integration.go create mode 100644 backend/migrations/0004_git_provider_sessions.sql create mode 100644 backend/migrations/0005_drop_legacy.sql delete mode 100644 backend/plugin_test.go delete mode 100644 backend/project_app.go delete mode 100644 backend/pull_requests.go create mode 100644 backend/spi_access.go create mode 100644 backend/spi_access_test.go create mode 100644 backend/spi_api.go create mode 100644 backend/spi_assert.go create mode 100644 backend/spi_conformance_test.go create mode 100644 backend/spi_dbstore.go create mode 100644 backend/spi_dbstore_test.go create mode 100644 backend/spi_fakegithub_test.go create mode 100644 backend/spi_provider.go create mode 100644 backend/spi_reads.go create mode 100644 backend/spi_setup.go create mode 100644 backend/spi_store.go create mode 100644 backend/spi_test.go create mode 100644 backend/spi_webhook.go create mode 100644 backend/spi_writes.go delete mode 100644 backend/webhook.go delete mode 100644 frontend/bun.lock delete mode 100644 frontend/index.html delete mode 100644 frontend/package.json delete mode 100644 frontend/src/AgentIdentities.tsx delete mode 100644 frontend/src/AppsPanel.tsx delete mode 100644 frontend/src/Branches.tsx delete mode 100644 frontend/src/ConnectionBar.tsx delete mode 100644 frontend/src/GitHubAppCallbackPage.tsx delete mode 100644 frontend/src/GitHubSettingsTab.tsx delete mode 100644 frontend/src/GitHubTaskSection.tsx delete mode 100644 frontend/src/PullRequests.tsx delete mode 100644 frontend/src/RepositoriesPanel.tsx delete mode 100644 frontend/src/SetupPanel.tsx delete mode 100644 frontend/src/app-flow.tsx delete mode 100644 frontend/src/github-api.ts delete mode 100644 frontend/src/index.ts delete mode 100644 frontend/src/styles.css delete mode 100644 frontend/src/ui.tsx delete mode 100644 frontend/tsconfig.json delete mode 100644 frontend/vite.config.ts delete mode 100644 mcp/bun.lock delete mode 100644 mcp/package.json delete mode 100644 mcp/src/index.ts delete mode 100644 mcp/tsconfig.json delete mode 100644 mcp/vite.config.ts delete mode 100644 skills/paca-github-workflow/SKILL.md diff --git a/README.md b/README.md index c98b8ec..91be913 100644 --- a/README.md +++ b/README.md @@ -1,259 +1,33 @@ # 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, PAT-connected projects cannot link repositories (webhook creation needs a reachable callback URL), and GitHub App creation is refused. If PUBLIC_URL is a private/loopback address, a GitHub App is still created but with its webhook disabled. - -## GitHub App connection (self-hosted, Manifest Flow) - -Besides a personal access token, a project can connect through a **GitHub App** created from inside Paca: - -1. A user with the instance-wide `github:manage_apps` action opens Project Settings → GitHub → *Create GitHub App*, picks a name and where to create it (personal account or organization). -2. Paca sends the pre-filled manifest to GitHub; the user confirms once. -3. GitHub redirects the browser back to a **page of the Paca web app** (`/plugin-pages/com.paca.github/github-app-callback`, a plugin `app.page`; needs a Paca release with `app.page` support). That page lives under the authenticated layout, so an expired access token is refreshed (or the user signs in and is returned to it) before it calls the plugin's authenticated `POST /apps/callback/created`, which exchanges the one-time code for the app's credentials and stores them encrypted (`github_apps`). The manifest `state` is single-use, expires after an hour and is bound to the user who started the flow. -4. The user installs the app on the account/organization; GitHub returns to the same page, which calls `POST /apps/callback/installed`. -5. Project managers (`github:manage`) pick an installation that was made available to their project. Installation tokens (1 hour) are minted from the app's private key and cached encrypted; clone-info returns the token with its expiry. - -An app delivers a single webhook for every installed repository (`POST /webhooks/apps/:appKey`, verified with that app's secret and fanned out to every project that linked the repo), so linking a repo through an app needs no `admin:repo_hook` permission and creates no per-repo webhook. - -### Per-agent identities - -In an App-connected project, a project manager can map a member (typically an AI agent) to a different installation under Settings → GitHub → *Agent identities*. Calls made by that account — branches, pull requests, comments, reviews, clone tokens — then use that installation's token, so GitHub attributes them to that app's bot. This is what allows a reviewer agent to formally approve a developer agent's PR. Accounts without a mapping use the project's default installation. Mappings are keyed by account, so they apply to API-key callers (agents) as they do to people. If a mapped installation is suspended or withdrawn from the project, calls by that account fail with `GITHUB_AGENT_IDENTITY_UNAVAILABLE` rather than silently acting as the default identity. Automation actions (merge/comment nodes) have no calling account and use the project default. - -Not yet implemented: review-thread/merge tools, new PR/review event triggers, GitHub Enterprise Server (`host`), importing an existing app. - ---- - -## 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 -- check_run - -Current behavior includes: - -- 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 +- `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, client secrets 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. -# 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 +### Build ```bash -cd frontend - -# Install dependencies -bun install - -# Development build (watch) -bun run dev - -# Production build -bun run build -``` - -### MCP - -```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/appauth.go b/backend/appauth.go deleted file mode 100644 index acfc263..0000000 --- a/backend/appauth.go +++ /dev/null @@ -1,222 +0,0 @@ -package main - -import ( - "context" - "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 -} - -// ─── Installation tokens ───────────────────────────────────────────────────── - -const ( - authTypePAT = "pat" - authTypeApp = "app" - - // installationTokenSkew is how long before GitHub's expiry a cached - // installation token stops being served, so a token handed to a caller - // (or to a git clone) never dies mid-use. - installationTokenSkew = 5 * time.Minute -) - -func installationTokenCacheKey(installationRowID string) string { - return "installation-token:" + installationRowID -} - -type cachedInstallationToken struct { - Token string `json:"token"` - ExpiresAt int64 `json:"expires_at"` -} - -// installationToken returns a valid installation access token for the -// installation row, minting one with the App's private key on a cache miss. -// The cache is only an optimisation: entries are encrypted at rest and a miss -// (or an unavailable cache backend) just mints again. -func (p *githubPlugin) installationToken(installationRowID string) (string, time.Time, error) { - cacheKey := installationTokenCacheKey(installationRowID) - if p.cache != nil { - if enc, hit := p.cache.Get(cacheKey); hit && enc != "" { - if raw, err := p.decrypt(enc); err == nil { - var c cachedInstallationToken - if json.Unmarshal([]byte(raw), &c) == nil && c.Token != "" { - if exp := time.Unix(c.ExpiresAt, 0); time.Until(exp) > installationTokenSkew { - return c.Token, exp, nil - } - } - } - } - } - - inst, err := p.db.Query( - `SELECT app_id, installation_id, suspended_at FROM github_app_installations WHERE id = $1`, - installationRowID, - ) - if err != nil { - return "", time.Time{}, err - } - if len(inst.Rows) == 0 { - return "", time.Time{}, &appError{code: "GITHUB_INSTALLATION_NOT_FOUND", status: 404, msg: "GitHub App installation not found"} - } - isc := newRowScanner(inst.Columns, inst.Rows[0]) - if isc.strPtr("suspended_at") != nil { - return "", time.Time{}, &appError{code: "GITHUB_INSTALLATION_SUSPENDED", status: 422, msg: "GitHub App installation is suspended"} - } - ghInstallationID := isc.int64Val("installation_id") - - jwt, err := p.appJWT(isc.str("app_id")) - if err != nil { - return "", time.Time{}, err - } - - var minted struct { - Token string `json:"token"` - ExpiresAt time.Time `json:"expires_at"` - } - url := fmt.Sprintf("%s/app/installations/%d/access_tokens", ghBaseURL, ghInstallationID) - if err := newGHClient(jwt).post(context.Background(), url, map[string]any{}, &minted); err != nil { - return "", time.Time{}, err - } - if minted.Token == "" { - return "", time.Time{}, errors.New("github app: installation token response had no token") - } - - if p.cache != nil { - payload, _ := json.Marshal(cachedInstallationToken{Token: minted.Token, ExpiresAt: minted.ExpiresAt.Unix()}) - if enc, eErr := p.encrypt(string(payload)); eErr == nil { - if ttl := time.Until(minted.ExpiresAt) - installationTokenSkew; ttl > 0 { - p.cache.Set(cacheKey, enc, ttl) - } - } - } - return minted.Token, minted.ExpiresAt, nil -} - -// appJWT signs an App JWT for the app row with the given id. -func (p *githubPlugin) appJWT(appRowID string) (string, error) { - app, err := p.db.Query(`SELECT github_app_id, private_key_enc FROM github_apps WHERE id = $1`, appRowID) - if err != nil { - return "", err - } - if len(app.Rows) == 0 { - return "", &appError{code: "GITHUB_APP_NOT_FOUND", status: 404, msg: "GitHub App not found"} - } - asc := newRowScanner(app.Columns, app.Rows[0]) - pemStr, err := p.decrypt(asc.str("private_key_enc")) - if err != nil { - return "", err - } - return signAppJWT(asc.int64Val("github_app_id"), pemStr, time.Now()) -} - -// resolveCredential returns the project's default credential. -func (p *githubPlugin) resolveCredential(projectID string) (string, time.Time, error) { - return p.resolveCredentialFor(projectID, "") -} - -// resolveCredentialFor returns the token a call made by accountID should use: -// the stored PAT, or an installation token — the account's own installation -// when the project maps one for it, otherwise the project's default. The -// second value is the token's expiry (zero for a PAT). -// -// A mapping that is no longer usable (installation suspended, or withdrawn -// from the project) fails closed instead of falling back to the default: a -// silent fallback would act on GitHub as a different identity than the one -// the project configured, which is exactly what the mapping exists to prevent. -func (p *githubPlugin) resolveCredentialFor(projectID, accountID string) (string, time.Time, error) { - result, err := p.db.Query( - `SELECT auth_type, access_token_enc, installation_id FROM github_integrations WHERE project_id = $1`, - projectID, - ) - if err != nil { - return "", time.Time{}, err - } - if len(result.Rows) == 0 { - return "", time.Time{}, &appError{code: "GITHUB_INTEGRATION_NOT_FOUND", status: 404, msg: "GitHub integration not found"} - } - sc := newRowScanner(result.Columns, result.Rows[0]) - if sc.str("auth_type") != authTypeApp { - token, err := p.decrypt(sc.str("access_token_enc")) - return token, time.Time{}, err - } - - installationRowID := sc.str("installation_id") - if accountID != "" { - mapped, err := p.db.Query( - `SELECT installation_id FROM github_agent_identities WHERE project_id = $1 AND account_id = $2`, - projectID, accountID) - if err != nil { - return "", time.Time{}, err - } - if len(mapped.Rows) > 0 { - mappedID := newRowScanner(mapped.Columns, mapped.Rows[0]).str("installation_id") - allowed, err := p.projectMayUseInstallation(projectID, mappedID) - if err != nil { - return "", time.Time{}, err - } - if !allowed { - return "", time.Time{}, &appError{code: "GITHUB_AGENT_IDENTITY_UNAVAILABLE", status: 422, - msg: "The GitHub identity mapped to this account is suspended or no longer available to the project"} - } - installationRowID = mappedID - } - } - if installationRowID == "" { - return "", time.Time{}, &appError{code: "GITHUB_INTEGRATION_NOT_FOUND", status: 404, msg: "GitHub integration not found"} - } - return p.installationToken(installationRowID) -} 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/apps.go b/backend/apps.go deleted file mode 100644 index af18273..0000000 --- a/backend/apps.go +++ /dev/null @@ -1,697 +0,0 @@ -package main - -import ( - "context" - "crypto/rand" - "encoding/hex" - "errors" - "fmt" - "net/url" - "regexp" - "strconv" - "strings" - "time" - - plugin "github.com/Paca-AI/plugin-sdk-go" -) - -// GitHub App management: the Manifest Flow that lets an administrator create -// a Paca GitHub App on their own account/org with a couple of clicks, the -// installation callback, and the (instance-level) catalogue of apps and -// installations that projects bind to. -// -// POST /apps/manifest → manifest + GitHub URL for the browser to POST -// GET /apps/callback/created (GitHub redirect: manifest code → credentials) -// GET /apps/callback/installed (GitHub redirect: installation_id) - -const ( - // callbackPageSlug is the slug of the plugin's app.page that GitHub - // redirects back to (plugin.json navItems). - callbackPageSlug = "github-app-callback" - - manifestSessionTTL = time.Hour - - // githubAppNameMaxLen is GitHub's limit on an App's name. - githubAppNameMaxLen = 34 -) - -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 plugin -// uses today and what the planned PR-lifecycle tools need: 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": "read", - "statuses": "read", - "actions": "read", -} - -var manifestEvents = []string{ - "push", - "pull_request", - "pull_request_review", - "pull_request_review_comment", - "pull_request_review_thread", - "issue_comment", - "check_run", - "check_suite", -} - -// ─── DTOs ──────────────────────────────────────────────────────────────────── - -type manifestResponse struct { - PostURL string `json:"post_url"` - State string `json:"state"` - Manifest map[string]any `json:"manifest"` - WebhookActive bool `json:"webhook_active"` -} - -type installationResponse struct { - ID string `json:"id"` - InstallationID int64 `json:"installation_id"` - AccountLogin string `json:"account_login"` - AccountType string `json:"account_type"` - RepositorySelection string `json:"repository_selection"` - AllowAllProjects bool `json:"allow_all_projects"` - ProjectIDs []string `json:"project_ids"` - Suspended bool `json:"suspended"` - ManageURL string `json:"manage_url"` -} - -type appResponse struct { - ID string `json:"id"` - GitHubAppID int64 `json:"github_app_id"` - Slug string `json:"slug"` - Name string `json:"name"` - OwnerLogin string `json:"owner_login"` - HTMLURL string `json:"html_url"` - WebhookActive bool `json:"webhook_active"` - CreatedAt string `json:"created_at"` - Installations []installationResponse `json:"installations"` -} - -// projectInstallationResponse is what a project manager sees when choosing an -// installation: no secrets and no information about other projects. -type projectInstallationResponse struct { - ID string `json:"id"` - AppName string `json:"app_name"` - AccountLogin string `json:"account_login"` - AccountType string `json:"account_type"` - RepositorySelection string `json:"repository_selection"` -} - -// ─── helpers ───────────────────────────────────────────────────────────────── - -func randomHex(nBytes int) (string, error) { - b := make([]byte, nBytes) - if _, err := rand.Read(b); err != nil { - return "", err - } - return hex.EncodeToString(b), nil -} - -// publicBaseURL returns PUBLIC_URL without a trailing slash. -func (p *githubPlugin) publicBaseURL() (string, error) { - publicURL, ok := p.cfg.Get("PUBLIC_URL") - if !ok || strings.TrimSpace(publicURL) == "" { - return "", errors.New("PUBLIC_URL is not configured") - } - return strings.TrimRight(strings.TrimSpace(publicURL), "/"), nil -} - -func (p *githubPlugin) pluginBaseURL() (string, error) { - base, err := p.publicBaseURL() - if err != nil { - return "", err - } - return base + "/api/v1/plugins/" + githubPluginID, nil -} - -// isLocalURL reports whether rawURL points at a host GitHub cannot reach from -// the internet (loopback, private ranges, .local/.internal names), in which -// case webhook delivery is switched off rather than left failing. -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", host == "::1", host == "0.0.0.0": - return true - case strings.HasSuffix(host, ".local"), strings.HasSuffix(host, ".internal"), - strings.HasSuffix(host, ".localhost"), strings.HasSuffix(host, ".lan"): - return true - case strings.HasPrefix(host, "127."), strings.HasPrefix(host, "10."), strings.HasPrefix(host, "192.168."): - return true - case strings.HasPrefix(host, "172."): - parts := strings.Split(host, ".") - if len(parts) > 1 { - if n, err := strconv.Atoi(parts[1]); err == nil && n >= 16 && n <= 31 { - return true - } - } - } - return false -} - -// sameOrigin reports whether candidate shares scheme and host with base. -func sameOrigin(base, candidate string) bool { - b, err1 := url.Parse(base) - c, err2 := url.Parse(candidate) - if err1 != nil || err2 != nil || c.Scheme == "" || c.Host == "" { - return false - } - return strings.EqualFold(b.Scheme, c.Scheme) && strings.EqualFold(b.Host, c.Host) -} - -// header reads a request header regardless of the key casing the host uses. -func header(req *plugin.Request, name string) string { - for k, v := range req.Headers { - if strings.EqualFold(k, name) { - return v - } - } - return "" -} - -// ─── POST /apps/manifest ───────────────────────────────────────────────────── - -type createManifestBody struct { - Name string `json:"name"` - OwnerOrg string `json:"owner_org"` - AllowAllProjects bool `json:"allow_all_projects"` - Public bool `json:"public"` - ReturnURL string `json:"return_url"` -} - -func (p *githubPlugin) createManifest(req *plugin.Request, res *plugin.Response) { - b, err := plugin.JSONBody[createManifestBody](req) - if err != nil { - apiError(res, 400, "BAD_REQUEST", "invalid request body") - return - } - name := strings.TrimSpace(b.Name) - if name == "" || len(name) > githubAppNameMaxLen { - apiError(res, 400, "BAD_REQUEST", fmt.Sprintf("name is required and must be at most %d characters", githubAppNameMaxLen)) - return - } - org := strings.TrimSpace(b.OwnerOrg) - if org != "" && !githubLoginRe.MatchString(org) { - apiError(res, 400, "BAD_REQUEST", "owner_org is not a valid GitHub organization name") - return - } - - pluginBase, err := p.pluginBaseURL() - if err != nil { - apiError(res, 422, "GITHUB_WEBHOOK_URL_REQUIRED", "PUBLIC_URL is not configured") - return - } - publicBase, _ := p.publicBaseURL() - returnURL := strings.TrimSpace(b.ReturnURL) - if returnURL != "" && !sameOrigin(publicBase, returnURL) { - apiError(res, 400, "BAD_REQUEST", "return_url must be on this Paca instance") - return - } - - state, err := randomHex(24) - if err != nil { - apiError(res, 500, "INTERNAL_ERROR", "failed to generate state") - return - } - appKey, err := randomHex(16) - if err != nil { - apiError(res, 500, "INTERNAL_ERROR", "failed to generate app key") - return - } - - now := time.Now().UTC() - if _, err := p.db.Exec(` - INSERT INTO github_manifest_sessions - (state, app_key, created_by, app_name, owner_org, allow_all_projects, return_url, expires_at, created_at) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) - `, state, appKey, req.Caller.UserID, name, org, b.AllowAllProjects, returnURL, - now.Add(manifestSessionTTL).Format(time.RFC3339Nano), now.Format(time.RFC3339Nano)); err != nil { - apiError(res, 500, "INTERNAL_ERROR", err.Error()) - return - } - - webhookActive := !isLocalURL(publicBase) - manifest := buildManifest(name, publicBase, pluginBase, appKey, webhookActive, b.Public) - - postURL := "https://github.com/settings/apps/new" - if org != "" { - postURL = "https://github.com/organizations/" + org + "/settings/apps/new" - } - postURL += "?state=" + url.QueryEscape(state) - - ok(res, manifestResponse{PostURL: postURL, State: state, Manifest: manifest, WebhookActive: webhookActive}) -} - -// buildManifest assembles the GitHub App manifest. The webhook URL carries the -// app_key so deliveries can be verified before the numeric app id is known. -func buildManifest(name, publicBase, pluginBase, appKey string, webhookActive, public bool) map[string]any { - return map[string]any{ - "name": name, - "url": publicBase, - "hook_attributes": map[string]any{ - "url": pluginBase + "/webhooks/apps/" + appKey, - "active": webhookActive, - }, - // Both legs return to the same Paca web-app page (the plugin's app.page); - // it tells them apart by the parameters GitHub appends (code vs - // installation_id). - "redirect_url": publicBase + "/plugin-pages/" + githubPluginID + "/" + callbackPageSlug, - "setup_url": publicBase + "/plugin-pages/" + githubPluginID + "/" + callbackPageSlug, - "setup_on_update": true, - "public": public, - "default_permissions": manifestPermissions, - "default_events": manifestEvents, - } -} - -// ─── POST /apps/callback/created ───────────────────────────────────────────── - -// The two callbacks are not hit by GitHub directly. GitHub redirects the -// browser to a page of the Paca web app (the plugin's app.page, see -// frontend/src/GitHubAppCallbackPage.tsx), which restores the user's session -// if its short-lived cookie lapsed during the detour and then calls these -// routes with the parameters GitHub appended. So they are ordinary -// authenticated, permission-gated JSON routes, and the manifest session is -// additionally bound to the user who started it. - -// Stable codes the callback page turns into messages. -const ( - setupInvalidState = "GITHUB_SETUP_INVALID_STATE" - setupMissingParam = "GITHUB_SETUP_MISSING_PARAMETER" - setupConversionFailed = "GITHUB_SETUP_CONVERSION_FAILED" - setupStoreFailed = "GITHUB_SETUP_STORE_FAILED" - setupInstallationFailed = "GITHUB_SETUP_INSTALLATION_FAILED" -) - -// callbackResult tells the page where to send the browser next. -type callbackResult struct { - // NextURL, when set, is the GitHub URL for the next leg of the flow. - NextURL string `json:"next_url,omitempty"` - // ReturnURL is the Paca page that started the flow. - ReturnURL string `json:"return_url"` -} - -// loadManifestSession returns the live (unexpired) session for state, bound to -// the user who started it: a state that leaks (a URL in a log, a screenshot) -// is useless to anyone else. -func (p *githubPlugin) loadManifestSession(state, userID string) (*scanner, error) { - if state == "" { - return nil, errors.New("missing state") - } - r, err := p.db.Query( - `SELECT state, app_key, created_by, app_name, owner_org, allow_all_projects, return_url, app_id, code_used_at, completed_at, expires_at FROM github_manifest_sessions WHERE state = $1`, - state) - if err != nil { - return nil, err - } - if len(r.Rows) == 0 { - return nil, errors.New("unknown state") - } - sc := newRowScanner(r.Columns, r.Rows[0]) - if userID == "" || sc.str("created_by") != userID { - return nil, errors.New("state belongs to another user") - } - exp, perr := time.Parse(time.RFC3339Nano, sc.str("expires_at")) - if perr != nil || time.Now().After(exp) { - return nil, errors.New("state expired") - } - return sc, nil -} - -// returnTarget is where the browser goes when the flow ends. -func (p *githubPlugin) returnTarget(sess *scanner) string { - if u := sess.str("return_url"); u != "" { - return u - } - base, _ := p.publicBaseURL() - return base + "/home" -} - -type createdCallbackBody struct { - Code string `json:"code"` - State string `json:"state"` -} - -func (p *githubPlugin) callbackCreated(req *plugin.Request, res *plugin.Response) { - b, err := plugin.JSONBody[createdCallbackBody](req) - if err != nil || b.State == "" || b.Code == "" { - apiError(res, 400, setupMissingParam, "code and state are required") - return - } - sess, err := p.loadManifestSession(b.State, req.Caller.UserID) - if err != nil { - apiError(res, 400, setupInvalidState, "The setup link is invalid, expired or belongs to another user") - return - } - - // Claim the code atomically so a replayed callback cannot create a second app. - claimed, err := p.db.Exec( - `UPDATE github_manifest_sessions SET code_used_at = $1 WHERE state = $2 AND code_used_at IS NULL`, - nowStr(), b.State) - if err != nil || claimed == 0 { - apiError(res, 409, setupInvalidState, "This setup link was already used") - return - } - - conv, err := newGHClient("").convertManifestCode(context.Background(), b.Code) - if err != nil { - p.log.Error("github: manifest conversion failed: " + err.Error()) - apiError(res, 502, setupConversionFailed, "GitHub rejected the app setup") - return - } - - appRowID, err := p.storeApp(sess, conv, req.Caller.UserID) - if err != nil { - p.log.Error("github: storing app failed: " + err.Error()) - apiError(res, 500, setupStoreFailed, "The app was created on GitHub but could not be saved") - return - } - if _, err := p.db.Exec(`UPDATE github_manifest_sessions SET app_id = $1 WHERE state = $2`, appRowID, b.State); err != nil { - apiError(res, 500, setupStoreFailed, "The app was created on GitHub but could not be saved") - return - } - - // Leg two: send the user on to install the freshly created app. - ok(res, callbackResult{NextURL: installURL(conv.Slug, b.State), ReturnURL: p.returnTarget(sess)}) -} - -func installURL(slug, state string) string { - return "https://github.com/apps/" + slug + "/installations/new?state=" + url.QueryEscape(state) -} - -func (p *githubPlugin) storeApp(sess *scanner, conv *ghManifestConversion, userID string) (string, error) { - if conv.ID == 0 || conv.PEM == "" { - return "", errors.New("conversion response is missing the app id or private key") - } - // Refuse to store a key we could not later sign with. - if _, err := parseRSAPrivateKey(conv.PEM); err != nil { - return "", err - } - pemEnc, err := p.encrypt(conv.PEM) - if err != nil { - return "", err - } - secretEnc, err := p.encrypt(conv.ClientSecret) - if err != nil { - return "", err - } - hookEnc, err := p.encrypt(conv.WebhookSecret) - if err != nil { - return "", err - } - publicBase, _ := p.publicBaseURL() - now := nowStr() - ins, err := p.db.Query(` - INSERT INTO github_apps - (app_key, github_app_id, slug, name, owner_login, html_url, client_id, client_secret_enc, - webhook_secret_enc, private_key_enc, webhook_active, created_by, created_at, updated_at) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$13) - RETURNING id`, - sess.str("app_key"), conv.ID, conv.Slug, conv.Name, conv.Owner.Login, conv.HTMLURL, conv.ClientID, - secretEnc, hookEnc, pemEnc, !isLocalURL(publicBase), userID, now) - if err != nil { - return "", err - } - if len(ins.Rows) == 0 { - return "", errors.New("insert returned no rows") - } - return newRowScanner(ins.Columns, ins.Rows[0]).str("id"), nil -} - -// ─── POST /apps/callback/installed ─────────────────────────────────────────── - -type installedCallbackBody struct { - InstallationID string `json:"installation_id"` - State string `json:"state"` -} - -func (p *githubPlugin) callbackInstalled(req *plugin.Request, res *plugin.Response) { - b, err := plugin.JSONBody[installedCallbackBody](req) - installationID, perr := strconv.ParseInt(strings.TrimSpace(b.InstallationID), 10, 64) - if err != nil || b.State == "" || perr != nil || installationID <= 0 { - apiError(res, 400, setupMissingParam, "installation_id and state are required") - return - } - sess, err := p.loadManifestSession(b.State, req.Caller.UserID) - if err != nil || sess.str("app_id") == "" { - apiError(res, 400, setupInvalidState, "The setup link is invalid, expired or belongs to another user") - return - } - claimed, err := p.db.Exec( - `UPDATE github_manifest_sessions SET completed_at = $1 WHERE state = $2 AND completed_at IS NULL`, - nowStr(), b.State) - if err != nil || claimed == 0 { - apiError(res, 409, setupInvalidState, "This setup link was already used") - return - } - - appRowID := sess.str("app_id") - jwt, err := p.appJWT(appRowID) - if err != nil { - apiError(res, 500, setupInstallationFailed, "The installation could not be verified") - return - } - inst, err := newGHClient(jwt).getInstallation(context.Background(), installationID) - if err != nil { - p.log.Error("github: fetching installation failed: " + err.Error()) - apiError(res, 502, setupInstallationFailed, "GitHub did not confirm the installation") - return - } - if _, err := p.upsertInstallation(appRowID, inst, sess.str("allow_all_projects") == "true"); err != nil { - p.log.Error("github: storing installation failed: " + err.Error()) - apiError(res, 500, setupStoreFailed, "The installation could not be saved") - return - } - ok(res, callbackResult{ReturnURL: p.returnTarget(sess)}) -} - -// upsertInstallation records an installation. allowAll only applies when the -// installation is first created; later syncs never widen access. -func (p *githubPlugin) upsertInstallation(appRowID string, inst *ghInstallation, allowAll bool) (string, error) { - var suspended any - if inst.SuspendedAt != nil { - suspended = inst.SuspendedAt.UTC().Format(time.RFC3339Nano) - } - now := nowStr() - r, err := p.db.Query(` - INSERT INTO github_app_installations - (app_id, installation_id, account_login, account_type, repository_selection, - allow_all_projects, suspended_at, created_at, updated_at) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$8) - 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 - RETURNING id`, - appRowID, inst.ID, inst.Account.Login, inst.Account.Type, inst.RepositorySelection, allowAll, suspended, now) - if err != nil { - return "", err - } - if len(r.Rows) == 0 { - return "", errors.New("upsert returned no rows") - } - return newRowScanner(r.Columns, r.Rows[0]).str("id"), nil -} - -// ─── GET /apps ─────────────────────────────────────────────────────────────── - -func (p *githubPlugin) listApps(_ *plugin.Request, res *plugin.Response) { - apps, err := p.db.Query(` - SELECT id, github_app_id, slug, name, owner_login, html_url, webhook_active, created_at - FROM github_apps ORDER BY created_at ASC`) - if err != nil { - apiError(res, 500, "INTERNAL_ERROR", err.Error()) - return - } - insts, err := p.db.Query(` - SELECT id, app_id, installation_id, account_login, account_type, repository_selection, - allow_all_projects, suspended_at - FROM github_app_installations ORDER BY created_at ASC`) - if err != nil { - apiError(res, 500, "INTERNAL_ERROR", err.Error()) - return - } - links, err := p.db.Query(`SELECT installation_id, project_id FROM github_installation_projects`) - if err != nil { - apiError(res, 500, "INTERNAL_ERROR", err.Error()) - return - } - projectsByInst := map[string][]string{} - for _, row := range links.Rows { - sc := newRowScanner(links.Columns, row) - k := sc.str("installation_id") - projectsByInst[k] = append(projectsByInst[k], sc.str("project_id")) - } - - items := make([]appResponse, 0, len(apps.Rows)) - index := map[string]int{} - for _, row := range apps.Rows { - sc := newRowScanner(apps.Columns, row) - a := appResponse{ - 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"), - WebhookActive: sc.str("webhook_active") == "true", - CreatedAt: sc.str("created_at"), - Installations: []installationResponse{}, - } - index[a.ID] = len(items) - items = append(items, a) - } - for _, row := range insts.Rows { - sc := newRowScanner(insts.Columns, row) - i, found := index[sc.str("app_id")] - if !found { - continue - } - id := sc.str("id") - pids := projectsByInst[id] - if pids == nil { - pids = []string{} - } - items[i].Installations = append(items[i].Installations, installationResponse{ - ID: id, - InstallationID: sc.int64Val("installation_id"), - AccountLogin: sc.str("account_login"), - AccountType: sc.str("account_type"), - RepositorySelection: sc.str("repository_selection"), - AllowAllProjects: sc.str("allow_all_projects") == "true", - ProjectIDs: pids, - Suspended: sc.strPtr("suspended_at") != nil, - ManageURL: installationManageURL(sc.str("account_type"), sc.str("account_login"), sc.int64Val("installation_id")), - }) - } - ok(res, items) -} - -func installationManageURL(accountType, login string, installationID int64) string { - if strings.EqualFold(accountType, "Organization") { - return fmt.Sprintf("https://github.com/organizations/%s/settings/installations/%d", login, installationID) - } - return fmt.Sprintf("https://github.com/settings/installations/%d", installationID) -} - -// ─── DELETE /apps/:appId ───────────────────────────────────────────────────── - -// deleteApp forgets an app locally. Projects bound to its installations fall -// back to "not connected"; the App itself must be deleted on GitHub (there is -// no API for it), which the UI links to. -func (p *githubPlugin) deleteApp(req *plugin.Request, res *plugin.Response) { - appID := req.PathParam("appId") - n, err := p.db.Exec(`DELETE FROM github_apps WHERE id = $1`, appID) - if err != nil { - apiError(res, 500, "INTERNAL_ERROR", err.Error()) - return - } - if n == 0 { - apiError(res, 404, "GITHUB_APP_NOT_FOUND", "GitHub App not found") - return - } - noContent(res) -} - -// ─── POST /apps/:appId/install ─────────────────────────────────────────────── - -// startInstall returns the GitHub URL for installing an existing app on -// another account/org, with a fresh state so the callback is attributable. -func (p *githubPlugin) startInstall(req *plugin.Request, res *plugin.Response) { - appID := req.PathParam("appId") - b, _ := plugin.JSONBody[createManifestBody](req) - - publicBase, err := p.publicBaseURL() - if err != nil { - apiError(res, 422, "GITHUB_WEBHOOK_URL_REQUIRED", "PUBLIC_URL is not configured") - return - } - returnURL := strings.TrimSpace(b.ReturnURL) - if returnURL != "" && !sameOrigin(publicBase, returnURL) { - apiError(res, 400, "BAD_REQUEST", "return_url must be on this Paca instance") - return - } - app, err := p.db.Query(`SELECT slug, app_key, name FROM github_apps WHERE id = $1`, appID) - if err != nil { - apiError(res, 500, "INTERNAL_ERROR", err.Error()) - return - } - if len(app.Rows) == 0 { - apiError(res, 404, "GITHUB_APP_NOT_FOUND", "GitHub App not found") - return - } - asc := newRowScanner(app.Columns, app.Rows[0]) - state, err := randomHex(24) - if err != nil { - apiError(res, 500, "INTERNAL_ERROR", "failed to generate state") - return - } - now := time.Now().UTC() - if _, err := p.db.Exec(` - INSERT INTO github_manifest_sessions - (state, app_key, created_by, app_name, allow_all_projects, return_url, app_id, code_used_at, expires_at, created_at) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$8) - `, state, asc.str("app_key"), req.Caller.UserID, asc.str("name"), b.AllowAllProjects, returnURL, appID, - now.Format(time.RFC3339Nano), now.Add(manifestSessionTTL).Format(time.RFC3339Nano)); err != nil { - apiError(res, 500, "INTERNAL_ERROR", err.Error()) - return - } - ok(res, map[string]string{"url": installURL(asc.str("slug"), state)}) -} - -// ─── POST /apps/:appId/installations/:installationId/access ────────────────── - -type installationAccessBody struct { - AllowAllProjects bool `json:"allow_all_projects"` - ProjectIDs []string `json:"project_ids"` -} - -func (p *githubPlugin) setInstallationAccess(req *plugin.Request, res *plugin.Response) { - appID := req.PathParam("appId") - instID := req.PathParam("installationId") - b, err := plugin.JSONBody[installationAccessBody](req) - if err != nil { - apiError(res, 400, "BAD_REQUEST", "invalid request body") - return - } - exists, err := p.db.Query(`SELECT id FROM github_app_installations WHERE id = $1 AND app_id = $2`, instID, appID) - if err != nil { - apiError(res, 500, "INTERNAL_ERROR", err.Error()) - return - } - if len(exists.Rows) == 0 { - apiError(res, 404, "GITHUB_INSTALLATION_NOT_FOUND", "GitHub App installation not found") - return - } - if _, err := p.db.Exec( - `UPDATE github_app_installations SET allow_all_projects = $1, updated_at = $2 WHERE id = $3`, - b.AllowAllProjects, nowStr(), instID); err != nil { - apiError(res, 500, "INTERNAL_ERROR", err.Error()) - return - } - if _, err := p.db.Exec(`DELETE FROM github_installation_projects WHERE installation_id = $1`, instID); err != nil { - apiError(res, 500, "INTERNAL_ERROR", err.Error()) - return - } - for _, pid := range b.ProjectIDs { - if _, err := p.db.Exec( - `INSERT INTO github_installation_projects (installation_id, project_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`, - instID, pid); err != nil { - apiError(res, 400, "BAD_REQUEST", "unknown project_id: "+pid) - return - } - } - noContent(res) -} diff --git a/backend/apps_test.go b/backend/apps_test.go deleted file mode 100644 index 95be9ba..0000000 --- a/backend/apps_test.go +++ /dev/null @@ -1,400 +0,0 @@ -package main - -import ( - "crypto" - "crypto/hmac" - "crypto/rand" - "crypto/rsa" - "crypto/sha256" - "crypto/x509" - "encoding/base64" - "encoding/hex" - "encoding/json" - "encoding/pem" - "strings" - "testing" - "time" - - plugin "github.com/Paca-AI/plugin-sdk-go" - "github.com/Paca-AI/plugin-sdk-go/plugintest" -) - -func newTestKey(t *testing.T) *rsa.PrivateKey { - t.Helper() - key, err := rsa.GenerateKey(rand.Reader, 2048) - if err != nil { - t.Fatal(err) - } - return key -} - -func pkcs1PEM(key *rsa.PrivateKey) string { - return string(pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)})) -} - -func TestSignAppJWT_VerifiesAndCarriesClaims(t *testing.T) { - key := newTestKey(t) - now := time.Unix(1_700_000_000, 0) - jwt, err := signAppJWT(12345, pkcs1PEM(key), now) - if err != nil { - t.Fatal(err) - } - parts := strings.Split(jwt, ".") - if len(parts) != 3 { - t.Fatalf("want 3 segments, got %d", len(parts)) - } - sig, err := base64.RawURLEncoding.DecodeString(parts[2]) - if err != nil { - t.Fatal(err) - } - digest := sha256.Sum256([]byte(parts[0] + "." + parts[1])) - if err := rsa.VerifyPKCS1v15(&key.PublicKey, crypto.SHA256, digest[:], sig); err != nil { - t.Fatalf("signature does not verify: %v", err) - } - raw, _ := base64.RawURLEncoding.DecodeString(parts[1]) - var claims struct { - Iss string `json:"iss"` - Iat int64 `json:"iat"` - Exp int64 `json:"exp"` - } - if err := json.Unmarshal(raw, &claims); err != nil { - t.Fatal(err) - } - if claims.Iss != "12345" || claims.Iat != now.Unix()-60 || claims.Exp != now.Unix()+540 { - t.Fatalf("unexpected claims: %+v", claims) - } -} - -func TestParseRSAPrivateKey_AcceptsPKCS8AndRejectsGarbage(t *testing.T) { - key := newTestKey(t) - der, err := x509.MarshalPKCS8PrivateKey(key) - if err != nil { - t.Fatal(err) - } - p8 := string(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der})) - if _, err := parseRSAPrivateKey(p8); err != nil { - t.Fatalf("pkcs8: %v", err) - } - if _, err := parseRSAPrivateKey("not pem"); err == nil { - t.Fatal("expected error for non-PEM input") - } -} - -func TestIsLocalURL(t *testing.T) { - cases := map[string]bool{ - "http://localhost:8080": true, - "http://127.0.0.1": true, - "http://192.168.1.10": true, - "http://10.0.0.5": true, - "http://172.20.0.2": true, - "http://172.32.0.2": false, - "https://paca.example.com": false, - "https://paca.internal": true, - "https://1.2.3.4": false, - } - for u, want := range cases { - if got := isLocalURL(u); got != want { - t.Errorf("isLocalURL(%q) = %v, want %v", u, got, want) - } - } -} - -func TestSameOrigin(t *testing.T) { - base := "https://paca.example.com" - if !sameOrigin(base, "https://paca.example.com/projects/1/settings") { - t.Error("same host should match") - } - for _, bad := range []string{"https://evil.example.com/", "http://paca.example.com/", "/relative", "javascript:alert(1)", ""} { - if sameOrigin(base, bad) { - t.Errorf("sameOrigin accepted %q", bad) - } - } -} - -func TestBuildManifest(t *testing.T) { - m := buildManifest("Paca", "https://paca.example.com", "https://paca.example.com/api/v1/plugins/com.paca.github", "abc", true, false) - hook := m["hook_attributes"].(map[string]any) - if hook["url"] != "https://paca.example.com/api/v1/plugins/com.paca.github/webhooks/apps/abc" || hook["active"] != true { - t.Fatalf("hook_attributes: %v", hook) - } - // Both legs return to a page of the Paca web app, not to a plugin API route. - const page = "https://paca.example.com/plugin-pages/com.paca.github/github-app-callback" - if m["redirect_url"] != page || m["setup_url"] != page { - t.Fatalf("redirect_url=%v setup_url=%v", m["redirect_url"], m["setup_url"]) - } - if m["public"] != false { - t.Fatal("app must be private by default") - } -} - -// ── handlers ────────────────────────────────────────────────────────────────── - -func setupAppsPlugin(t *testing.T) *plugintest.Context { - t.Helper() - tc := setupPlugin(t) - tc.Config.Set("ENCRYPTION_KEY", testEncryptionKey) - tc.Config.Set("PUBLIC_URL", "https://paca.example.com") - tc.DB.SeedRows("github_manifest_sessions", - []string{"state", "app_key", "created_by", "app_name", "owner_org", "allow_all_projects", - "return_url", "app_id", "code_used_at", "completed_at", "expires_at", "created_at"}, nil) - tc.DB.SeedRows("github_apps", - []string{"id", "app_key", "github_app_id", "slug", "name", "private_key_enc", "webhook_secret_enc"}, nil) - return tc -} - -func adminReq() plugintest.Request { - return plugintest.Request{Caller: plugin.CallerIdentity{UserID: "admin-1"}, PathParams: map[string]string{}} -} - -func TestCreateManifest_StoresSessionAndReturnsGitHubURL(t *testing.T) { - tc := setupAppsPlugin(t) - res := tc.Call("POST", "/apps/manifest", adminReq().WithJSONBody(map[string]any{ - "name": "Paca Acme", "owner_org": "acme-inc", "return_url": "https://paca.example.com/projects/1", - })) - if res.StatusCode != 200 { - t.Fatalf("status %d: %s", res.StatusCode, res.BodyString()) - } - var out struct { - Data manifestResponse `json:"data"` - } - if err := json.Unmarshal(res.Body, &out); err != nil { - t.Fatal(err) - } - if !strings.HasPrefix(out.Data.PostURL, "https://github.com/organizations/acme-inc/settings/apps/new?state=") { - t.Fatalf("post_url: %s", out.Data.PostURL) - } - if !out.Data.WebhookActive { - t.Error("public https URL should enable the webhook") - } - if rows := tc.DB.AllRows("github_manifest_sessions"); len(rows) != 1 { - t.Fatalf("want 1 session row, got %d", len(rows)) - } -} - -func TestCreateManifest_Validation(t *testing.T) { - tc := setupAppsPlugin(t) - bad := []map[string]any{ - {"name": ""}, - {"name": strings.Repeat("x", githubAppNameMaxLen+1)}, - {"name": "ok", "owner_org": "bad/org"}, - {"name": "ok", "return_url": "https://evil.example.com/"}, - } - for _, body := range bad { - res := tc.Call("POST", "/apps/manifest", adminReq().WithJSONBody(body)) - if res.StatusCode != 400 { - t.Errorf("body %v: want 400, got %d", body, res.StatusCode) - } - } - if rows := tc.DB.AllRows("github_manifest_sessions"); len(rows) != 0 { - t.Fatalf("rejected requests must not create sessions, got %d", len(rows)) - } -} - -const sessionCols = "state,app_key,created_by,app_name,owner_org,allow_all_projects,return_url,app_id,code_used_at,completed_at,expires_at,created_at" - -func seedSession(tc *plugintest.Context, state, createdBy, appID string, expires time.Time) { - var app any - if appID != "" { - app = appID - } - tc.DB.SeedRows("github_manifest_sessions", strings.Split(sessionCols, ","), - [][]any{{state, "k", createdBy, "Paca", "", false, "https://paca.example.com/projects/1", - app, nil, nil, expires.UTC().Format(time.RFC3339Nano), "now"}}) -} - -func decodeError(t *testing.T, res *plugin.Response) string { - t.Helper() - var out struct { - ErrorCode string `json:"error_code"` - } - if err := json.Unmarshal(res.Body, &out); err != nil { - t.Fatalf("body %q: %v", res.BodyString(), err) - } - return out.ErrorCode -} - -func TestCallbackCreated_RejectsUnknownState(t *testing.T) { - tc := setupAppsPlugin(t) - res := tc.Call("POST", "/apps/callback/created", adminReq().WithJSONBody(map[string]string{"state": "nope", "code": "c"})) - if res.StatusCode != 400 || decodeError(t, res) != setupInvalidState { - t.Fatalf("status=%d body=%s", res.StatusCode, res.BodyString()) - } -} - -func TestCallbackCreated_RequiresCodeAndState(t *testing.T) { - tc := setupAppsPlugin(t) - res := tc.Call("POST", "/apps/callback/created", adminReq().WithJSONBody(map[string]string{"state": "s"})) - if res.StatusCode != 400 || decodeError(t, res) != setupMissingParam { - t.Fatalf("status=%d body=%s", res.StatusCode, res.BodyString()) - } -} - -// A state that leaks must be useless to anyone but the user who started the flow. -func TestCallbacks_StateIsBoundToItsUser(t *testing.T) { - tc := setupAppsPlugin(t) - seedSession(tc, "live", "admin-1", "app-row-1", time.Now().Add(time.Hour)) - other := plugintest.Request{Caller: plugin.CallerIdentity{UserID: "someone-else"}} - - created := tc.Call("POST", "/apps/callback/created", other.WithJSONBody(map[string]string{"state": "live", "code": "c"})) - installed := tc.Call("POST", "/apps/callback/installed", other.WithJSONBody(map[string]string{"state": "live", "installation_id": "1"})) - for name, res := range map[string]*plugin.Response{"created": created, "installed": installed} { - if res.StatusCode != 400 || decodeError(t, res) != setupInvalidState { - t.Errorf("%s: status=%d body=%s", name, res.StatusCode, res.BodyString()) - } - } -} - -func TestCallbacks_NoCallerIsRejected(t *testing.T) { - tc := setupAppsPlugin(t) - seedSession(tc, "live", "admin-1", "app-row-1", time.Now().Add(time.Hour)) - res := tc.Call("POST", "/apps/callback/installed", plugintest.Request{}.WithJSONBody(map[string]string{"state": "live", "installation_id": "1"})) - if res.StatusCode != 400 || decodeError(t, res) != setupInvalidState { - t.Fatalf("status=%d body=%s", res.StatusCode, res.BodyString()) - } -} - -func TestCallbackInstalled_ExpiredStateIsRejected(t *testing.T) { - tc := setupAppsPlugin(t) - seedSession(tc, "old", "admin-1", "app-row-1", time.Now().Add(-time.Minute)) - res := tc.Call("POST", "/apps/callback/installed", adminReq().WithJSONBody(map[string]string{"state": "old", "installation_id": "1"})) - if res.StatusCode != 400 || decodeError(t, res) != setupInvalidState { - t.Fatalf("status=%d body=%s", res.StatusCode, res.BodyString()) - } -} - -func TestCallbackInstalled_RejectsBadInstallationID(t *testing.T) { - tc := setupAppsPlugin(t) - seedSession(tc, "live", "admin-1", "app-row-1", time.Now().Add(time.Hour)) - for _, id := range []string{"", "abc", "0", "-3"} { - res := tc.Call("POST", "/apps/callback/installed", adminReq().WithJSONBody(map[string]string{"state": "live", "installation_id": id})) - if res.StatusCode != 400 || decodeError(t, res) != setupMissingParam { - t.Errorf("id %q: status=%d body=%s", id, res.StatusCode, res.BodyString()) - } - } -} - -// Without an app yet (the created leg never ran) the installed leg has nothing to attach to. -func TestCallbackInstalled_RequiresCreatedLegFirst(t *testing.T) { - tc := setupAppsPlugin(t) - seedSession(tc, "live", "admin-1", "", time.Now().Add(time.Hour)) - res := tc.Call("POST", "/apps/callback/installed", adminReq().WithJSONBody(map[string]string{"state": "live", "installation_id": "7"})) - if res.StatusCode != 400 || decodeError(t, res) != setupInvalidState { - t.Fatalf("status=%d body=%s", res.StatusCode, res.BodyString()) - } -} - -func appWebhookSetup(t *testing.T) (*plugintest.Context, string) { - tc := setupAppsPlugin(t) - p := &githubPlugin{cfg: tc.PluginContext().Config()} - enc, err := p.encrypt("hook-secret") - if err != nil { - t.Fatal(err) - } - tc.DB.SeedRows("github_apps", - []string{"id", "app_key", "github_app_id", "slug", "name", "private_key_enc", "webhook_secret_enc"}, - [][]any{{"app-row-1", "key1", float64(1), "paca", "Paca", "x", enc}}) - return tc, "key1" -} - -func signBody(body []byte, secret string) string { - return "sha256=" + hmacHex(body, secret) -} - -func TestAppWebhook_RejectsBadSignature(t *testing.T) { - tc, key := appWebhookSetup(t) - req := adminReq() - req.PathParams = map[string]string{"appKey": key} - req.Body = []byte(`{"zen":"x"}`) - req.Headers = map[string]string{"x-github-event": "ping", "x-hub-signature-256": "sha256=deadbeef"} - if res := tc.Call("POST", "/webhooks/apps/:appKey", req); res.StatusCode != 401 { - t.Fatalf("want 401, got %d", res.StatusCode) - } -} - -func TestAppWebhook_AcceptsValidPing(t *testing.T) { - tc, key := appWebhookSetup(t) - body := []byte(`{"zen":"x"}`) - req := adminReq() - req.PathParams = map[string]string{"appKey": key} - req.Body = body - req.Headers = map[string]string{"x-github-event": "ping", "x-hub-signature-256": signBody(body, "hook-secret")} - if res := tc.Call("POST", "/webhooks/apps/:appKey", req); res.StatusCode != 204 { - t.Fatalf("want 204, got %d: %s", res.StatusCode, res.BodyString()) - } -} - -func TestAppWebhook_UnknownAppIs404(t *testing.T) { - tc, _ := appWebhookSetup(t) - req := adminReq() - req.PathParams = map[string]string{"appKey": "other"} - req.Body = []byte(`{}`) - if res := tc.Call("POST", "/webhooks/apps/:appKey", req); res.StatusCode != 404 { - t.Fatalf("want 404, got %d", res.StatusCode) - } -} - -// ── installation tokens ─────────────────────────────────────────────────────── - -func TestInstallationToken_MintsCachesAndReuses(t *testing.T) { - tc := setupAppsPlugin(t) - p := &githubPlugin{db: tc.PluginContext().DB(), cfg: tc.PluginContext().Config(), cache: tc.PluginContext().Cache(), log: tc.PluginContext().Log()} - key := newTestKey(t) - pemEnc, err := p.encrypt(pkcs1PEM(key)) - if err != nil { - t.Fatal(err) - } - tc.DB.SeedRows("github_apps", - []string{"id", "app_key", "github_app_id", "slug", "name", "private_key_enc", "webhook_secret_enc"}, - [][]any{{"app-row-1", "k", float64(42), "paca", "Paca", pemEnc, ""}}) - tc.DB.SeedRows("github_app_installations", - []string{"id", "app_id", "installation_id", "suspended_at"}, - [][]any{{"inst-row-1", "app-row-1", float64(777), nil}}) - - calls := 0 - orig := fetch - defer func() { fetch = orig }() - fetch = func(method, rawURL string, headers map[string]string, body string) (*plugin.FetchResponse, error) { - calls++ - if method != "POST" || rawURL != "https://api.github.com/app/installations/777/access_tokens" { - t.Fatalf("unexpected call %s %s", method, rawURL) - } - if !strings.HasPrefix(headers["Authorization"], "Bearer ") || strings.Count(headers["Authorization"], ".") != 2 { - t.Fatalf("expected a JWT bearer, got %q", headers["Authorization"]) - } - exp := time.Now().Add(time.Hour).UTC().Format(time.RFC3339) - return &plugin.FetchResponse{Status: 201, Body: `{"token":"ghs_abc","expires_at":"` + exp + `"}`}, nil - } - - for i := 0; i < 3; i++ { - tok, exp, err := p.installationToken("inst-row-1") - if err != nil { - t.Fatal(err) - } - if tok != "ghs_abc" || time.Until(exp) < 50*time.Minute { - t.Fatalf("token=%q exp=%v", tok, exp) - } - } - if calls != 1 { - t.Fatalf("token should be minted once and then served from cache, minted %d times", calls) - } - if v, ok := tc.Cache.Get(installationTokenCacheKey("inst-row-1")); !ok || strings.Contains(v, "ghs_abc") { - t.Fatal("cached token must be present and encrypted") - } -} - -func TestInstallationToken_SuspendedIsRefused(t *testing.T) { - tc := setupAppsPlugin(t) - p := &githubPlugin{db: tc.PluginContext().DB(), cfg: tc.PluginContext().Config(), cache: tc.PluginContext().Cache(), log: tc.PluginContext().Log()} - tc.DB.SeedRows("github_app_installations", - []string{"id", "app_id", "installation_id", "suspended_at"}, - [][]any{{"inst-row-1", "app-row-1", float64(777), "2026-01-01T00:00:00Z"}}) - if _, _, err := p.installationToken("inst-row-1"); err == nil { - t.Fatal("expected suspended installation to be refused") - } -} - -func hmacHex(body []byte, secret string) string { - mac := hmac.New(sha256.New, []byte(secret)) - mac.Write(body) - return hex.EncodeToString(mac.Sum(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 e250f11..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.decryptTokenFor(projectID, req.Caller.UserID) - 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.decryptTokenFor(projectID, req.Caller.UserID) - 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 395cd6a..0000000 --- a/backend/client.go +++ /dev/null @@ -1,520 +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" -) - -// fetch is the outbound HTTP call; a variable so tests can stub GitHub. -var fetch = plugin.Fetch - -// 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 { - h := map[string]string{ - "Accept": "application/vnd.github+json", - "X-GitHub-Api-Version": ghAPIVersion, - } - // An empty token is for the few unauthenticated endpoints (the manifest - // code conversion); sending "Bearer " there would be rejected. - if c.token != "" { - h["Authorization"] = "Bearer " + c.token - } - return h -} - -func (c *ghClient) get(_ context.Context, rawURL string, out any) error { - resp, err := 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 := 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 := 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 := 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 := 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) -} - -// ─── GitHub App endpoints ──────────────────────────────────────────────────── - -// ghManifestConversion is the response of POST /app-manifests/{code}/conversions. -// It is the only time GitHub ever returns the App's private key, client secret -// 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"` -} - -// convertManifestCode exchanges the one-time manifest code for the App's -// credentials. The endpoint is unauthenticated; the code is the credential. -func (c *ghClient) convertManifestCode(ctx context.Context, code string) (*ghManifestConversion, error) { - var out ghManifestConversion - if err := c.post(ctx, ghBaseURL+"/app-manifests/"+url.PathEscape(code)+"/conversions", map[string]any{}, &out); err != nil { - return nil, err - } - return &out, nil -} - -// 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"` -} - -// getInstallation fetches an installation; the client must hold an App JWT. -func (c *ghClient) getInstallation(ctx context.Context, installationID int64) (*ghInstallation, error) { - var out ghInstallation - if err := c.get(ctx, fmt.Sprintf("%s/app/installations/%d", ghBaseURL, installationID), &out); err != nil { - return nil, err - } - return &out, nil -} - -// listInstallationRepositories lists every repository the installation can -// see; the client must hold an installation token. -func (c *ghClient) listInstallationRepositories(ctx context.Context) ([]ghRepository, error) { - var all []ghRepository - for page := 1; ; page++ { - var batch struct { - Repositories []ghRepository `json:"repositories"` - } - u := fmt.Sprintf("%s/installation/repositories?per_page=100&page=%d", ghBaseURL, page) - if err := c.get(ctx, u, &batch); err != nil { - return nil, err - } - all = append(all, batch.Repositories...) - if len(batch.Repositories) < 100 { - break - } - } - return all, 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..f95a5a3 --- /dev/null +++ b/backend/github.go @@ -0,0 +1,125 @@ +package main + +import ( + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "net/url" + "regexp" + "strconv" + "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", + "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 +} + +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", host == "::1", host == "0.0.0.0": + return true + case strings.HasSuffix(host, ".local"), strings.HasSuffix(host, ".internal"), + strings.HasSuffix(host, ".localhost"), strings.HasSuffix(host, ".lan"): + return true + case strings.HasPrefix(host, "127."), strings.HasPrefix(host, "10."), strings.HasPrefix(host, "192.168."): + return true + case strings.HasPrefix(host, "172."): + parts := strings.Split(host, ".") + if len(parts) > 1 { + if n, err := strconv.Atoi(parts[1]); err == nil && n >= 16 && n <= 31 { + return true + } + } + } + return false +} + +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..31ac41a 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -3,3 +3,6 @@ module github.com/Paca-AI/first-party/github go 1.24 require github.com/Paca-AI/plugin-sdk-go v0.3.3 + +// gitprovider (the Git provider SPI) is not in a published SDK release yet. +replace github.com/Paca-AI/plugin-sdk-go => ../../plugin-sdk-go diff --git a/backend/identities.go b/backend/identities.go deleted file mode 100644 index a6b9eb5..0000000 --- a/backend/identities.go +++ /dev/null @@ -1,149 +0,0 @@ -package main - -import ( - "strings" - - plugin "github.com/Paca-AI/plugin-sdk-go" -) - -// Per-agent GitHub identity: inside an App-connected project, an account (in -// practice an AI agent) can be mapped to its own installation so everything it -// does on GitHub — branches, PRs, reviews, comments — is attributed to that -// app's bot instead of the project's default identity. - -type identityResponse struct { - MemberID string `json:"member_id"` - InstallationID string `json:"installation_id"` - AppName string `json:"app_name"` - AccountLogin string `json:"account_login"` -} - -// ─── GET /integration/agent-identities ─────────────────────────────────────── - -func (p *githubPlugin) listAgentIdentities(req *plugin.Request, res *plugin.Response) { - projectID := req.Caller.ProjectID - r, err := p.db.Query(` - SELECT m.id AS member_id, ai.installation_id, i.account_login, a.name AS app_name - FROM github_agent_identities ai - JOIN project_members m ON m.account_id = ai.account_id AND m.project_id = ai.project_id AND m.deleted_at IS NULL - JOIN github_app_installations i ON i.id = ai.installation_id - JOIN github_apps a ON a.id = i.app_id - WHERE ai.project_id = $1 - ORDER BY a.name ASC, i.account_login ASC`, projectID) - if err != nil { - apiError(res, 500, "INTERNAL_ERROR", err.Error()) - return - } - items := make([]identityResponse, 0, len(r.Rows)) - for _, row := range r.Rows { - sc := newRowScanner(r.Columns, row) - items = append(items, identityResponse{ - MemberID: sc.str("member_id"), - InstallationID: sc.str("installation_id"), - AppName: sc.str("app_name"), - AccountLogin: sc.str("account_login"), - }) - } - ok(res, items) -} - -// ─── POST /integration/agent-identities ────────────────────────────────────── - -type setIdentityBody struct { - MemberID string `json:"member_id"` - InstallationID string `json:"installation_id"` -} - -func (p *githubPlugin) setAgentIdentity(req *plugin.Request, res *plugin.Response) { - projectID := req.Caller.ProjectID - b, err := plugin.JSONBody[setIdentityBody](req) - if err != nil || strings.TrimSpace(b.MemberID) == "" || strings.TrimSpace(b.InstallationID) == "" { - apiError(res, 400, "BAD_REQUEST", "member_id and installation_id are required") - return - } - - pa, err := p.loadProjectAuth(projectID) - if err != nil { - writeAppError(res, err) - return - } - if pa.authType != authTypeApp { - apiError(res, 409, "GITHUB_APP_REQUIRED", "Per-agent identities need the project to be connected through a GitHub App") - return - } - - accountID, found, err := p.memberAccountID(projectID, b.MemberID) - if err != nil { - apiError(res, 500, "INTERNAL_ERROR", err.Error()) - return - } - if !found { - apiError(res, 404, "GITHUB_MEMBER_NOT_FOUND", "Project member not found") - return - } - - allowed, err := p.projectMayUseInstallation(projectID, b.InstallationID) - if err != nil { - apiError(res, 500, "INTERNAL_ERROR", err.Error()) - return - } - if !allowed { - apiError(res, 403, "GITHUB_INSTALLATION_NOT_ALLOWED", "This installation is not available to the project") - return - } - // Prove the identity works before an agent depends on it. - if _, _, err := p.installationToken(b.InstallationID); err != nil { - writeAppError(res, err) - return - } - - now := nowStr() - if _, err := p.db.Exec(` - INSERT INTO github_agent_identities (project_id, account_id, installation_id, created_at, updated_at) - VALUES ($1, $2, $3, $4, $4) - ON CONFLICT (project_id, account_id) DO UPDATE SET - installation_id = EXCLUDED.installation_id, updated_at = EXCLUDED.updated_at - `, projectID, accountID, b.InstallationID, now); err != nil { - apiError(res, 500, "INTERNAL_ERROR", err.Error()) - return - } - p.listAgentIdentities(req, res) -} - -// ─── DELETE /integration/agent-identities/:memberId ────────────────────────── - -func (p *githubPlugin) deleteAgentIdentity(req *plugin.Request, res *plugin.Response) { - projectID := req.Caller.ProjectID - accountID, found, err := p.memberAccountID(projectID, req.PathParam("memberId")) - if err != nil { - apiError(res, 500, "INTERNAL_ERROR", err.Error()) - return - } - if !found { - apiError(res, 404, "GITHUB_MEMBER_NOT_FOUND", "Project member not found") - return - } - if _, err := p.db.Exec( - `DELETE FROM github_agent_identities WHERE project_id = $1 AND account_id = $2`, - projectID, accountID); err != nil { - apiError(res, 500, "INTERNAL_ERROR", err.Error()) - return - } - noContent(res) -} - -// memberAccountID resolves a project membership id to its account id, -// scoped to the project so a member of another project can never be named. -func (p *githubPlugin) memberAccountID(projectID, memberID string) (string, bool, error) { - r, err := p.db.Query( - `SELECT account_id FROM project_members WHERE id = $1 AND project_id = $2 AND deleted_at IS NULL`, - memberID, projectID) - if err != nil { - return "", false, err - } - if len(r.Rows) == 0 { - return "", false, nil - } - id := newRowScanner(r.Columns, r.Rows[0]).str("account_id") - return id, id != "", nil -} diff --git a/backend/identities_test.go b/backend/identities_test.go deleted file mode 100644 index 3eee1f3..0000000 --- a/backend/identities_test.go +++ /dev/null @@ -1,111 +0,0 @@ -package main - -import ( - "strings" - "testing" - "time" - - plugin "github.com/Paca-AI/plugin-sdk-go" - "github.com/Paca-AI/plugin-sdk-go/plugintest" -) - -// seedTwoInstallations connects testProjectID to inst-default and makes -// inst-reviewer available to it, with a stubbed GitHub that hands out a token -// named after the installation it was minted for. -func seedTwoInstallations(t *testing.T, mapped bool, reviewerAllowed bool) (*plugintest.Context, *githubPlugin) { - t.Helper() - tc := setupAppsPlugin(t) - p := &githubPlugin{db: tc.PluginContext().DB(), cfg: tc.PluginContext().Config(), cache: tc.PluginContext().Cache(), log: tc.PluginContext().Log()} - pemEnc, err := p.encrypt(pkcs1PEM(newTestKey(t))) - if err != nil { - t.Fatal(err) - } - tc.DB.SeedRows("github_apps", - []string{"id", "app_key", "github_app_id", "slug", "name", "private_key_enc", "webhook_secret_enc"}, - [][]any{{"app-1", "k", float64(1), "paca", "Paca", pemEnc, ""}}) - tc.DB.SeedRows("github_app_installations", - []string{"id", "app_id", "installation_id", "allow_all_projects", "suspended_at"}, - [][]any{ - {"inst-default", "app-1", float64(100), true, nil}, - {"inst-reviewer", "app-1", float64(200), reviewerAllowed, nil}, - }) - tc.DB.SeedRows("github_installation_projects", []string{"installation_id", "project_id"}, nil) - tc.DB.SeedRows("github_integrations", - []string{"id", "project_id", "auth_type", "installation_id", "access_token_enc"}, - [][]any{{"integ-1", testProjectID, "app", "inst-default", nil}}) - var rows [][]any - if mapped { - rows = [][]any{{testProjectID, "agent-account", "inst-reviewer"}} - } - tc.DB.SeedRows("github_agent_identities", []string{"project_id", "account_id", "installation_id"}, rows) - - orig := fetch - t.Cleanup(func() { fetch = orig }) - fetch = func(method, rawURL string, headers map[string]string, body string) (*plugin.FetchResponse, error) { - name := "default" - if strings.Contains(rawURL, "/200/") { - name = "reviewer" - } - exp := time.Now().Add(time.Hour).UTC().Format(time.RFC3339) - return &plugin.FetchResponse{Status: 201, Body: `{"token":"ghs_` + name + `","expires_at":"` + exp + `"}`}, nil - } - return tc, p -} - -func TestResolveCredentialFor_MappedAccountUsesItsOwnInstallation(t *testing.T) { - _, p := seedTwoInstallations(t, true, true) - tok, _, err := p.resolveCredentialFor(testProjectID, "agent-account") - if err != nil || tok != "ghs_reviewer" { - t.Fatalf("mapped account: token=%q err=%v", tok, err) - } -} - -func TestResolveCredentialFor_UnmappedAccountUsesProjectDefault(t *testing.T) { - _, p := seedTwoInstallations(t, true, true) - for _, account := range []string{"someone-else", ""} { - tok, _, err := p.resolveCredentialFor(testProjectID, account) - if err != nil || tok != "ghs_default" { - t.Fatalf("account %q: token=%q err=%v", account, tok, err) - } - } -} - -func TestResolveCredentialFor_WithdrawnMappingFailsClosed(t *testing.T) { - _, p := seedTwoInstallations(t, true, false) // reviewer installation no longer open to the project - _, _, err := p.resolveCredentialFor(testProjectID, "agent-account") - ae, ok := err.(*appError) - if !ok || ae.code != "GITHUB_AGENT_IDENTITY_UNAVAILABLE" { - t.Fatalf("want GITHUB_AGENT_IDENTITY_UNAVAILABLE, got %v", err) - } -} - -func TestSetAgentIdentity_RequiresAppConnection(t *testing.T) { - tc := setupAppsPlugin(t) - tc.DB.SeedRows("github_integrations", - []string{"id", "project_id", "auth_type", "installation_id", "access_token_enc"}, - [][]any{{"integ-1", testProjectID, "pat", nil, "enc"}}) - req := callerReq().WithJSONBody(map[string]any{"member_id": "m1", "installation_id": "inst-1"}) - if res := tc.Call("POST", "/integration/agent-identities", req); res.StatusCode != 409 { - t.Fatalf("want 409, got %d: %s", res.StatusCode, res.BodyString()) - } -} - -func TestSetAgentIdentity_UnknownMemberIs404(t *testing.T) { - tc, _ := seedTwoInstallations(t, false, true) - tc.DB.SeedRows("project_members", []string{"id", "project_id", "account_id", "deleted_at"}, - [][]any{{"member-other-project", "project-2", "acc-x", nil}}) - req := callerReq().WithJSONBody(map[string]any{"member_id": "member-other-project", "installation_id": "inst-reviewer"}) - if res := tc.Call("POST", "/integration/agent-identities", req); res.StatusCode != 404 { - t.Fatalf("a member of another project must not be addressable, got %d: %s", res.StatusCode, res.BodyString()) - } -} - -func TestSetAgentIdentity_RejectsInstallationNotOpenToProject(t *testing.T) { - tc, _ := seedTwoInstallations(t, false, false) - tc.DB.SeedRows("project_members", []string{"id", "project_id", "account_id", "deleted_at"}, - [][]any{{"member-1", testProjectID, "agent-account", nil}}) - req := callerReq().WithJSONBody(map[string]any{"member_id": "member-1", "installation_id": "inst-reviewer"}) - if res := tc.Call("POST", "/integration/agent-identities", req); res.StatusCode != 403 { - t.Fatalf("want 403, got %d: %s", res.StatusCode, res.BodyString()) - } -} diff --git a/backend/integration.go b/backend/integration.go deleted file mode 100644 index 34ee46b..0000000 --- a/backend/integration.go +++ /dev/null @@ -1,562 +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"` - // AuthType is "pat" or "app". For "app", the fields below describe the - // bound installation. - AuthType string `json:"auth_type,omitempty"` - InstallationID string `json:"installation_id,omitempty"` - AppName string `json:"app_name,omitempty"` - AccountLogin string `json:"account_login,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: credential for a project (PAT or App installation token) ──────── - -func (p *githubPlugin) decryptToken(projectID string) (string, error) { - token, _, err := p.resolveCredential(projectID) - return token, err -} - -// decryptTokenFor is decryptToken for a call made by a specific account, so -// an agent with a mapped GitHub identity acts as that identity. -func (p *githubPlugin) decryptTokenFor(projectID, accountID string) (string, error) { - token, _, err := p.resolveCredentialFor(projectID, accountID) - return token, err -} - -// ─── GET /integration ──────────────────────────────────────────────────────── - -func (p *githubPlugin) getIntegration(req *plugin.Request, res *plugin.Response) { - projectID := req.Caller.ProjectID - result, err := p.db.Query( - `SELECT project_id, auth_type, installation_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") - out := integrationResponse{ - ProjectID: sc.str("project_id"), - Connected: true, - CreatedAt: &ca, - UpdatedAt: &ua, - AuthType: sc.str("auth_type"), - } - if out.AuthType == "" { - out.AuthType = authTypePAT - } - if out.AuthType == authTypeApp { - out.InstallationID = sc.str("installation_id") - if out.InstallationID == "" { - // The installation was removed (app deleted/uninstalled). - out.Connected = false - } else if r, qErr := p.db.Query(` - SELECT i.account_login, a.name AS app_name FROM github_app_installations i - JOIN github_apps a ON a.id = i.app_id WHERE i.id = $1`, out.InstallationID); qErr == nil && len(r.Rows) > 0 { - isc := newRowScanner(r.Columns, r.Rows[0]) - out.AppName = isc.str("app_name") - out.AccountLogin = isc.str("account_login") - } - } - ok(res, out) -} - -// ─── 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 - } - - // Repos linked through an App have no per-repo webhooks, so they cannot - // simply be re-pointed at a PAT. - if pa, aErr := p.loadProjectAuth(projectID); aErr == nil && pa.authType == authTypeApp { - if linked, qErr := p.db.Query(`SELECT id FROM github_repositories WHERE project_id = $1`, projectID); qErr == nil && len(linked.Rows) > 0 { - apiError(res, 409, "GITHUB_UNLINK_REPOS_FIRST", - "Unlink this project's repositories before changing how it connects to GitHub") - return - } - } - - now := nowStr() - _, err = p.db.Exec(` - INSERT INTO github_integrations (project_id, access_token_enc, auth_type, installation_id, created_at, updated_at) - VALUES ($1, $2, 'pat', NULL, $3, $4) - ON CONFLICT (project_id) DO UPDATE SET - access_token_enc = EXCLUDED.access_token_enc, auth_type = 'pat', installation_id = NULL, - 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) - } - } - } - } - - _, _ = p.db.Exec(`DELETE FROM github_agent_identities WHERE project_id = $1`, projectID) - _, 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) - var repos []ghRepository - if pa, aErr := p.loadProjectAuth(projectID); aErr == nil && pa.authType == authTypeApp { - repos, err = ghc.listInstallationRepositories(context.Background()) - } else { - 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 - } - - // Repos linked through an App are covered by the App's single webhook, so - // they have no webhook_id of their own. - appHook := false - if pa, aErr := p.loadProjectAuth(projectID); aErr == nil && pa.authType == authTypeApp { - appHook = p.appWebhookActive(pa.installationID) - } - - 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: appHook || 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 - } - - pa, err := p.loadProjectAuth(projectID) - if err != nil { - writeAppError(res, err) - return - } - useApp := pa.authType == authTypeApp - - // A PAT connection registers a webhook per repo, which needs a public URL. - // An App connection is covered by the App's own webhook. - webhookURL := "" - if !useApp { - 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 - } - - integrationID := pa.integrationID - - var webhookID int64 - encSecret := "" - if !useApp { - // 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", "check_run"}) - 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 { - if webhookID > 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 || (useApp && p.appWebhookActive(pa.installationID)), - 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, expiresAt, err := p.resolveCredentialFor(projectID, req.Caller.UserID) - if err != nil { - writeAppError(res, err) - return - } - var expiresUnix float64 - if !expiresAt.IsZero() { - expiresUnix = float64(expiresAt.Unix()) - } - 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: expiresUnix, - }) -} - -// ─── 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/0004_git_provider_sessions.sql b/backend/migrations/0004_git_provider_sessions.sql new file mode 100644 index 0000000..c43e4c4 --- /dev/null +++ b/backend/migrations/0004_git_provider_sessions.sql @@ -0,0 +1,7 @@ +-- 0004_git_provider_sessions.sql +-- The Git provider SPI drives the GitHub App manifest flow for core, which +-- also needs to know whom the identity is for. Run with +-- search_path = plugin_data_com_paca_github, public. + +ALTER TABLE github_manifest_sessions ADD COLUMN IF NOT EXISTS scope TEXT NOT NULL DEFAULT 'shared'; +ALTER TABLE github_manifest_sessions ADD COLUMN IF NOT EXISTS agent_id TEXT NOT NULL DEFAULT ''; diff --git a/backend/migrations/0005_drop_legacy.sql b/backend/migrations/0005_drop_legacy.sql new file mode 100644 index 0000000..93c2838 --- /dev/null +++ b/backend/migrations/0005_drop_legacy.sql @@ -0,0 +1,20 @@ +-- 0005_drop_legacy.sql +-- The plugin is now only a Git provider (spi_*.go). Everything the old +-- project-bound integration kept (PAT and app connections, linked +-- repositories, cached pull requests, task links, per-agent identities and +-- per-project installation grants) is superseded by core's git_* tables and is +-- dropped; PAT connections cannot be carried over, so no data is migrated. +-- Kept: github_apps, github_app_installations, github_manifest_sessions. +-- Run with search_path = plugin_data_com_paca_github, public. + +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_agent_identities; +DROP TABLE IF EXISTS github_installation_projects; +DROP TABLE IF EXISTS github_integrations; + +ALTER TABLE github_app_installations DROP COLUMN IF EXISTS allow_all_projects; +ALTER TABLE github_manifest_sessions DROP COLUMN IF EXISTS allow_all_projects; +ALTER TABLE github_manifest_sessions DROP COLUMN IF EXISTS return_url; diff --git a/backend/plugin.go b/backend/plugin.go index 5c9e0ff..3aa3bb3 100644 --- a/backend/plugin.go +++ b/backend/plugin.go @@ -1,22 +1,15 @@ -// 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 @@ -25,142 +18,15 @@ type githubPlugin struct { 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() p.cache = ctx.Cache() - - // 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) - - // ── GitHub Apps (instance-level, Manifest Flow) ─────────────────────────── - ctx.Route("POST", "/apps/manifest", p.createManifest) - ctx.Route("POST", "/apps/callback/created", p.callbackCreated) - ctx.Route("POST", "/apps/callback/installed", p.callbackInstalled) - ctx.Route("GET", "/apps", p.listApps) - ctx.Route("DELETE", "/apps/:appId", p.deleteApp) - ctx.Route("POST", "/apps/:appId/install", p.startInstall) - ctx.Route("POST", "/apps/:appId/installations/:installationId/access", p.setInstallationAccess) - ctx.Route("GET", "/integration/installations", p.listProjectInstallations) - ctx.Route("POST", "/integration/app", p.bindInstallation) - ctx.Route("GET", "/integration/agent-identities", p.listAgentIdentities) - ctx.Route("POST", "/integration/agent-identities", p.setAgentIdentity) - ctx.Route("DELETE", "/integration/agent-identities/:memberId", p.deleteAgentIdentity) - ctx.Route("POST", "/webhooks/apps/:appKey", p.receiveAppWebhook) - - // ── Webhook ─────────────────────────────────────────────────────────────── - ctx.Route("POST", "/webhook", p.receiveWebhook) - - // ── Automation graph nodes (Condition/Action) ───────────────────────────── - p.registerAutomationNodes(ctx) - + 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 cb2a3cd..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 — an event that reaches handleWebhookEvent's -// `default:` case only gets there after signature verification succeeds, -// which is what makes "unhandled event type" a reliable positive signal. - -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": "workflow_run", // any type receiveWebhook doesn't specifically handle - "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("unhandled event type") { - 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": "workflow_run", "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("unhandled event type") { - 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/project_app.go b/backend/project_app.go deleted file mode 100644 index f956db5..0000000 --- a/backend/project_app.go +++ /dev/null @@ -1,314 +0,0 @@ -package main - -import ( - "encoding/json" - "strings" - "time" - - plugin "github.com/Paca-AI/plugin-sdk-go" -) - -// ─── GET /projects/:projectId/integration/installations ────────────────────── - -// listProjectInstallations returns the app installations this project may -// bind: those open to every project plus those that name it on their -// allow-list. Suspended installations are left out. -func (p *githubPlugin) listProjectInstallations(req *plugin.Request, res *plugin.Response) { - projectID := req.Caller.ProjectID - r, err := p.db.Query(` - SELECT i.id, i.account_login, i.account_type, i.repository_selection, a.name AS app_name - FROM github_app_installations i - JOIN github_apps a ON a.id = i.app_id - WHERE i.suspended_at IS NULL - AND (i.allow_all_projects = TRUE - OR EXISTS (SELECT 1 FROM github_installation_projects ip - WHERE ip.installation_id = i.id AND ip.project_id = $1)) - ORDER BY a.name ASC, i.account_login ASC`, projectID) - if err != nil { - apiError(res, 500, "INTERNAL_ERROR", err.Error()) - return - } - items := make([]projectInstallationResponse, 0, len(r.Rows)) - for _, row := range r.Rows { - sc := newRowScanner(r.Columns, row) - items = append(items, projectInstallationResponse{ - ID: sc.str("id"), - AppName: sc.str("app_name"), - AccountLogin: sc.str("account_login"), - AccountType: sc.str("account_type"), - RepositorySelection: sc.str("repository_selection"), - }) - } - ok(res, items) -} - -// ─── POST /projects/:projectId/integration/app ─────────────────────────────── - -type bindAppBody struct { - InstallationID string `json:"installation_id"` -} - -func (p *githubPlugin) bindInstallation(req *plugin.Request, res *plugin.Response) { - projectID := req.Caller.ProjectID - b, err := plugin.JSONBody[bindAppBody](req) - if err != nil || b.InstallationID == "" { - apiError(res, 400, "BAD_REQUEST", "installation_id is required") - return - } - - allowed, err := p.projectMayUseInstallation(projectID, b.InstallationID) - if err != nil { - apiError(res, 500, "INTERNAL_ERROR", err.Error()) - return - } - if !allowed { - apiError(res, 403, "GITHUB_INSTALLATION_NOT_ALLOWED", "This installation is not available to the project") - return - } - - // Changing what a connection authenticates as would orphan repositories - // linked through the old one (their per-repo webhooks belong to the PAT). - existing, err := p.db.Query( - `SELECT id, auth_type, installation_id FROM github_integrations WHERE project_id = $1`, projectID) - if err != nil { - apiError(res, 500, "INTERNAL_ERROR", err.Error()) - return - } - if len(existing.Rows) > 0 { - sc := newRowScanner(existing.Columns, existing.Rows[0]) - unchanged := sc.str("auth_type") == authTypeApp && sc.str("installation_id") == b.InstallationID - if !unchanged { - linked, qErr := p.db.Query(`SELECT id FROM github_repositories WHERE project_id = $1`, projectID) - if qErr != nil { - apiError(res, 500, "INTERNAL_ERROR", qErr.Error()) - return - } - if len(linked.Rows) > 0 { - apiError(res, 409, "GITHUB_UNLINK_REPOS_FIRST", - "Unlink this project's repositories before changing how it connects to GitHub") - return - } - } - } - - // Prove the installation works (key decrypts, GitHub accepts the JWT) - // before reporting the project as connected. - if _, _, err := p.installationToken(b.InstallationID); err != nil { - writeAppError(res, err) - return - } - - now := nowStr() - if _, err := p.db.Exec(` - INSERT INTO github_integrations (project_id, auth_type, installation_id, access_token_enc, created_at, updated_at) - VALUES ($1, 'app', $2, NULL, $3, $3) - ON CONFLICT (project_id) DO UPDATE SET - auth_type = 'app', installation_id = EXCLUDED.installation_id, - access_token_enc = NULL, updated_at = EXCLUDED.updated_at - `, projectID, b.InstallationID, now); err != nil { - apiError(res, 500, "INTERNAL_ERROR", err.Error()) - return - } - p.getIntegration(req, res) -} - -func (p *githubPlugin) projectMayUseInstallation(projectID, installationRowID string) (bool, error) { - r, err := p.db.Query( - `SELECT allow_all_projects, suspended_at FROM github_app_installations WHERE id = $1`, - installationRowID) - if err != nil { - return false, err - } - if len(r.Rows) == 0 { - return false, nil - } - sc := newRowScanner(r.Columns, r.Rows[0]) - if sc.strPtr("suspended_at") != nil { - return false, nil - } - if sc.str("allow_all_projects") == "true" { - return true, nil - } - l, err := p.db.Query( - `SELECT project_id FROM github_installation_projects WHERE installation_id = $1 AND project_id = $2`, - installationRowID, projectID) - if err != nil { - return false, err - } - return len(l.Rows) > 0, nil -} - -// projectAuth describes how a project is connected, for handlers whose -// behaviour differs between PAT and App connections. -type projectAuth struct { - integrationID string - authType string - installationID string -} - -func (p *githubPlugin) loadProjectAuth(projectID string) (*projectAuth, error) { - r, err := p.db.Query( - `SELECT id, auth_type, installation_id FROM github_integrations WHERE project_id = $1`, projectID) - if err != nil { - return nil, err - } - if len(r.Rows) == 0 { - return nil, &appError{code: "GITHUB_INTEGRATION_NOT_FOUND", status: 404, msg: "GitHub integration not found"} - } - sc := newRowScanner(r.Columns, r.Rows[0]) - a := &projectAuth{integrationID: sc.str("id"), authType: sc.str("auth_type"), installationID: sc.str("installation_id")} - if a.authType == "" { - a.authType = authTypePAT - } - return a, nil -} - -// appWebhookActive reports whether the app behind installationRowID delivers -// webhooks (false when PUBLIC_URL was not reachable from GitHub at creation). -func (p *githubPlugin) appWebhookActive(installationRowID string) bool { - r, err := p.db.Query(` - SELECT a.webhook_active FROM github_app_installations i - JOIN github_apps a ON a.id = i.app_id WHERE i.id = $1`, installationRowID) - if err != nil || len(r.Rows) == 0 { - return false - } - return newRowScanner(r.Columns, r.Rows[0]).str("webhook_active") == "true" -} - -// ─── POST /webhooks/apps/:appKey ───────────────────────────────────────────── - -// receiveAppWebhook handles every delivery for one GitHub App. Unlike the -// per-project PAT webhook, one App delivers for all installed repos, so the -// payload is verified once against the App's secret and then fanned out to -// every project that linked the repository through that installation. -func (p *githubPlugin) receiveAppWebhook(req *plugin.Request, res *plugin.Response) { - appKey := req.PathParam("appKey") - app, err := p.db.Query(`SELECT id, webhook_secret_enc FROM github_apps WHERE app_key = $1`, appKey) - if err != nil { - p.log.Error("github: app webhook lookup failed: " + err.Error()) - res.NoContent() - return - } - if len(app.Rows) == 0 { - apiError(res, 404, "GITHUB_APP_NOT_FOUND", "unknown app") - return - } - asc := newRowScanner(app.Columns, app.Rows[0]) - secret, err := p.decrypt(asc.str("webhook_secret_enc")) - if err != nil || secret == "" { - p.log.Error("github: app has no usable webhook secret, refusing delivery") - res.NoContent() - return - } - if !verifyHMAC(req.Body, secret, header(req, "X-Hub-Signature-256")) { - apiError(res, 401, "INVALID_SIGNATURE", "invalid webhook signature") - return - } - - event := header(req, "X-GitHub-Event") - if err := p.handleAppEvent(asc.str("id"), event, req.Body); err != nil { - p.log.Error("github: app webhook handler error: " + err.Error() + ", event=" + event) - } - // Always 2xx after authentication so GitHub does not retry on app errors. - res.NoContent() -} - -type appEventPayload struct { - Action string `json:"action"` - Installation 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"` - } `json:"installation"` - Repository struct { - FullName string `json:"full_name"` - } `json:"repository"` -} - -func (p *githubPlugin) handleAppEvent(appRowID, event string, payload []byte) error { - if event == "ping" { - return nil - } - var ev appEventPayload - if err := json.Unmarshal(payload, &ev); err != nil { - return err - } - if ev.Installation.ID == 0 { - return nil - } - - switch event { - case "installation": - return p.syncInstallationEvent(appRowID, &ev) - case "installation_repositories": - return nil - case "pull_request", "push": - return p.fanOutRepoEvent(appRowID, event, &ev, payload) - default: - p.log.Info("github: unhandled app event type, event=" + event) - return nil - } -} - -func (p *githubPlugin) syncInstallationEvent(appRowID string, ev *appEventPayload) error { - switch ev.Action { - case "created", "unsuspend", "suspend", "new_permissions_accepted": - inst := &ghInstallation{ - ID: ev.Installation.ID, - RepositorySelection: ev.Installation.RepositorySelection, - SuspendedAt: ev.Installation.SuspendedAt, - } - inst.Account.Login = ev.Installation.Account.Login - inst.Account.Type = ev.Installation.Account.Type - if ev.Action == "suspend" && inst.SuspendedAt == nil { - now := time.Now().UTC() - inst.SuspendedAt = &now - } - // A "created" event for an installation made outside the Paca flow - // starts closed: an admin must opt it in before projects can use it. - _, err := p.upsertInstallation(appRowID, inst, false) - return err - case "deleted": - _, err := p.db.Exec( - `DELETE FROM github_app_installations WHERE app_id = $1 AND installation_id = $2`, - appRowID, ev.Installation.ID) - return err - } - return nil -} - -func (p *githubPlugin) fanOutRepoEvent(appRowID, event string, ev *appEventPayload, payload []byte) error { - fullName := ev.Repository.FullName - if fullName == "" { - return nil - } - targets, err := p.db.Query(` - SELECT r.id AS repo_id, r.project_id - FROM github_repositories r - JOIN github_integrations gi ON gi.id = r.integration_id - JOIN github_app_installations i ON i.id = gi.installation_id - WHERE i.app_id = $1 AND i.installation_id = $2 AND LOWER(r.full_name) = LOWER($3)`, - appRowID, ev.Installation.ID, strings.TrimSpace(fullName)) - if err != nil { - return err - } - var firstErr error - for _, row := range targets.Rows { - sc := newRowScanner(targets.Columns, row) - var herr error - switch event { - case "pull_request": - herr = p.handlePREvent(sc.str("repo_id"), sc.str("project_id"), payload) - case "push": - herr = p.handlePushEvent(sc.str("repo_id"), sc.str("project_id"), payload) - } - if herr != nil && firstErr == nil { - firstErr = herr - } - } - return firstErr -} diff --git a/backend/pull_requests.go b/backend/pull_requests.go deleted file mode 100644 index 4f63f1e..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.decryptTokenFor(projectID, req.Caller.UserID) - 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.decryptTokenFor(projectID, req.Caller.UserID) - 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.decryptTokenFor(projectID, req.Caller.UserID) - 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.decryptTokenFor(projectID, req.Caller.UserID) - 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.decryptTokenFor(projectID, req.Caller.UserID) - 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.decryptTokenFor(projectID, req.Caller.UserID) - 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/spi_access.go b/backend/spi_access.go new file mode 100644 index 0000000..9ac5dda --- /dev/null +++ b/backend/spi_access.go @@ -0,0 +1,169 @@ +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 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..5770bd1 --- /dev/null +++ b/backend/spi_access_test.go @@ -0,0 +1,312 @@ +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) + } +} 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_conformance_test.go b/backend/spi_conformance_test.go new file mode 100644 index 0000000..4874d68 --- /dev/null +++ b/backend/spi_conformance_test.go @@ -0,0 +1,267 @@ +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) 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: "label-added", + Request: delivery("issues", "d13", 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..94681ee --- /dev/null +++ b/backend/spi_dbstore.go @@ -0,0 +1,211 @@ +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) { + key, err := randomHex(16) + if err != nil { + return "", err + } + 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 (app_key, 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,$9,'git-provider',$10,$10) RETURNING id`, + key, 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, app_key, created_by, app_name, owner_org, scope, agent_id, app_id, expires_at, created_at) + VALUES ($1,$2,'git-provider',$3,$4,$5,$6,$7,$8,$9)`, + x.State, x.AppKey, 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_key, 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"), AppKey: sc.str("app_key"), 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) 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..934afe6 --- /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", AppKey: "k", 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", AppKey: "k", 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..a79f679 --- /dev/null +++ b/backend/spi_fakegithub_test.go @@ -0,0 +1,610 @@ +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 + 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"}}, + } + 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"}) + + // ── 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] == "branches" && len(seg) == 2: + if seg[1] != "main" { + return notFound() + } + return resp(200, map[string]any{"protected": true, "protection": map[string]any{"required_status_checks": map[string]any{"strict": true}}}) + 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..67e279c --- /dev/null +++ b/backend/spi_provider.go @@ -0,0 +1,500 @@ +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 not declared: an ordinary GitHub App bot +// cannot be a requested reviewer or an assignee, so those events never +// address the bot (users mention it or add a label instead); +// - 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.TriggerCIFailure, gp.TriggerOwnCRFeedback, gp.TriggerAutoReview}, + MultipleIdentities: true, + + ResolveThreads: true, + FileRead: true, + Labels: true, + ReviewerRequests: true, + Assignees: true, + Merge: true, + IdentityDeletion: 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 + } + 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..78815df --- /dev/null +++ b/backend/spi_setup.go @@ -0,0 +1,197 @@ +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") + } + appKey, err := randomHex(16) + if err != nil { + return gp.SetupStep{}, gp.Errorf(gp.CodeInternal, "could not generate state") + } + if err := p.store.saveSetup(setupSession{ + State: state, AppKey: appKey, 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) { + if s.CodeUsed { + return gp.Identity{}, gp.Errorf(gp.CodeForbidden, "this setup code was already used") + } + // Mark the code used before exchanging it: GitHub's codes are single-use and + // the response is the only time the private key is ever shown. + s.CodeUsed = true + if err := p.store.updateSetup(s); err != nil { + return gp.Identity{}, gp.Errorf(gp.CodeInternal, "could not record setup progress") + } + 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..488afc0 --- /dev/null +++ b/backend/spi_store.go @@ -0,0 +1,71 @@ +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 + + 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 + AppKey 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..c6b3d2a --- /dev/null +++ b/backend/spi_test.go @@ -0,0 +1,685 @@ +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.TriggerReviewRequest, gp.TriggerAssignment} { + if c.SupportsTrigger(trig) { + t.Errorf("%s must not be declared: an ordinary GitHub App bot cannot be requested or assigned (spike S1)", trig) + } + } + for _, trig := range []gp.TriggerKind{gp.TriggerMention, gp.TriggerLabel} { + 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) + } + 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()) + } +} diff --git a/backend/spi_webhook.go b/backend/spi_webhook.go new file mode 100644 index 0000000..5abdb54 --- /dev/null +++ b/backend/spi_webhook.go @@ -0,0 +1,307 @@ +package main + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "regexp" + "strconv" + "strings" + + 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"` + + 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"` + 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} +} + +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 + 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 + 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 +} + +// 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 + } + re := regexp.MustCompile(`(?i)(?:^|[^\w@])@` + regexp.QuoteMeta(handle) + `(?:\[bot\])?(?:$|[^\w-])`) + return re.MatchString(text) +} 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 292c446..0000000 --- a/backend/webhook.go +++ /dev/null @@ -1,309 +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" -) - -// 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 - } - - 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/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/AgentIdentities.tsx b/frontend/src/AgentIdentities.tsx deleted file mode 100644 index fcedf14..0000000 --- a/frontend/src/AgentIdentities.tsx +++ /dev/null @@ -1,180 +0,0 @@ -import { type PluginApiClient } from "@paca-ai/plugin-sdk-react"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { ArrowRight, Bot, Trash2, UserRound } from "lucide-react"; -import { useState } from "react"; -import { - agentIdentitiesKey, - deleteAgentIdentity, - ErrorCode, - fetchMembers, - getPluginErrorCode, - listAgentIdentities, - listProjectInstallations, - projectInstallationsKey, - setAgentIdentity, -} from "./github-api"; -import { Avatar, Banner, Button, EmptyState, Field, Select, Skeleton } from "./ui"; - -/** - * Maps project members (typically AI agents) to their own GitHub App - * installation, so each acts on GitHub as its own bot. Members without a - * mapping use the project's default connection. - */ -export function AgentIdentitiesPanel({ api, projectId, canEdit }: { api: PluginApiClient; projectId: string; canEdit: boolean }) { - const queryClient = useQueryClient(); - const [memberId, setMemberId] = useState(""); - const [installationId, setInstallationId] = useState(""); - const [error, setError] = useState(null); - - const { data: identities = [], isLoading } = useQuery({ - queryKey: agentIdentitiesKey(projectId), - queryFn: () => listAgentIdentities(api), - retry: false, - }); - const { data: installations = [] } = useQuery({ - queryKey: projectInstallationsKey(projectId), - queryFn: () => listProjectInstallations(api), - retry: false, - }); - const { data: members } = useQuery({ - queryKey: [projectId, "github-agent-members"], - queryFn: () => fetchMembers(projectId, { memberType: "agent" }), - retry: false, - }); - - // Names for mapped members are looked up by id so they resolve even when the - // member is not in the picker's current search page. - const mappedIds = identities.map((i) => i.member_id); - const { data: mapped, isLoading: mappedLoading } = useQuery({ - queryKey: [projectId, "github-mapped-members", mappedIds.join(",")], - queryFn: () => fetchMembers(projectId, { ids: mappedIds }), - enabled: mappedIds.length > 0, - retry: false, - }); - const nameOf = (id: string) => { - const m = mapped?.find((x) => x.id === id) ?? members?.find((x) => x.id === id); - if (m) return m.name; - return mappedLoading ? "…" : "Former member"; - }; - - const refresh = () => queryClient.invalidateQueries({ queryKey: agentIdentitiesKey(projectId) }); - const save = useMutation({ - mutationFn: (v: { member: string; installation: string }) => setAgentIdentity(api, v.member, v.installation), - onSuccess: async () => { - setMemberId(""); - setError(null); - await refresh(); - }, - onError: (err: unknown) => { - setError( - getPluginErrorCode(err) === ErrorCode.GitHubInstallationNotAllowed - ? "That identity isn’t available to this project." - : "GitHub rejected this identity. Check the app is still installed.", - ); - }, - }); - const remove = useMutation({ mutationFn: (id: string) => deleteAgentIdentity(api, id), onSuccess: refresh }); - - if (isLoading) return ; - - return ( -
- -

- Give an agent its own GitHub App so its commits, pull requests and reviews are attributed to that app’s bot — which is what lets one agent formally approve another’s work. Agents without an identity use the project’s connection. -

- - {identities.length === 0 ? ( - } title="No agent identities yet"> - Assign an identity below. You can create more GitHub Apps from the GitHub Apps tab. - - ) : ( -
- {identities.map((i) => ( -
- -
-

{nameOf(i.member_id)}

-

- acts as {i.app_name} on {i.account_login} -

-
- {canEdit && ( - - )} -
- ))} -
- )} - - {canEdit && - (installations.length === 0 ? ( - No installations are available to this project yet. - ) : ( -
{ - e.preventDefault(); - if (memberId && installationId) save.mutate({ member: memberId, installation: installationId }); - }} - > -

Assign an identity

-
- - {(id) => ( - - )} - - - {(id) => ( - - )} - - -
- {error && {error}} -
- ))} -
- ); -} diff --git a/frontend/src/AppsPanel.tsx b/frontend/src/AppsPanel.tsx deleted file mode 100644 index 8e4ddf9..0000000 --- a/frontend/src/AppsPanel.tsx +++ /dev/null @@ -1,160 +0,0 @@ -import { type PluginApiClient } from "@paca-ai/plugin-sdk-react"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { Bot, ExternalLink, Plus, Trash2, TriangleAlert } from "lucide-react"; -import { useState } from "react"; -import { CreateAppDialog, rememberReturnUrl } from "./app-flow"; -import { - appsKey, - deleteGitHubApp, - type GitHubApp, - listGitHubApps, - setInstallationAccess, - startAppInstall, -} from "./github-api"; -import { Badge, Banner, Button, EmptyState, Modal, Skeleton, Switch } from "./ui"; - -/** Instance-wide management of GitHub Apps (requires github:manage_apps). */ -export function AppsPanel({ api }: { api: PluginApiClient }) { - const queryClient = useQueryClient(); - const { data: apps, isLoading } = useQuery({ queryKey: appsKey(), queryFn: () => listGitHubApps(api), retry: false }); - const [createOpen, setCreateOpen] = useState(false); - const [deleting, setDeleting] = useState(null); - - const refresh = () => queryClient.invalidateQueries({ queryKey: appsKey() }); - const install = useMutation({ - mutationFn: (appId: string) => startAppInstall(api, appId, rememberReturnUrl()), - onSuccess: ({ url }) => { - window.location.href = url; - }, - }); - const access = useMutation({ - mutationFn: (v: { app: string; inst: string; all: boolean; ids: string[] }) => setInstallationAccess(api, v.app, v.inst, v.all, v.ids), - onSuccess: refresh, - }); - const remove = useMutation({ - mutationFn: (appId: string) => deleteGitHubApp(api, appId), - onSuccess: async () => { - setDeleting(null); - await refresh(); - }, - }); - - if (isLoading || !apps) return ; - - return ( -
-
-

Instance-wide. Projects connect through one of these installations.

- {apps.length > 0 && ( - - )} -
- - {apps.length === 0 ? ( - } - title="No GitHub Apps yet" - action={ - - } - > - Create one in about a minute — Paca pre-fills GitHub for you. - - ) : ( - apps.map((app) => ( -
-
- -
- - -
-
- - {!app.webhook_active && ( -
- -
- )} - -
- {app.installations.length === 0 ? ( -
-

Not installed on any account yet.

- -
- ) : ( - app.installations.map((inst) => ( -
-
-

- - {inst.account_login} - {" "} - {inst.suspended && Suspended} -

-

- {inst.repository_selection === "all" ? "All repositories" : "Selected repositories"} - {!inst.allow_all_projects && ` · ${inst.project_ids.length} project${inst.project_ids.length === 1 ? "" : "s"} allowed`} -

-
- - Every project - access.mutate({ app: app.id, inst: inst.id, all, ids: inst.project_ids })} /> - -
- )) - )} -
-
- )) - )} - - setCreateOpen(false)} /> - - setDeleting(null)} - title={`Delete ${deleting?.name ?? "app"}?`} - description="Projects connected through this app lose their GitHub connection. This removes the app from Paca only — delete it on GitHub too to revoke its access." - footer={ - <> - - - - } - /> -
- ); -} diff --git a/frontend/src/Branches.tsx b/frontend/src/Branches.tsx deleted file mode 100644 index 46589dd..0000000 --- a/frontend/src/Branches.tsx +++ /dev/null @@ -1,358 +0,0 @@ -import { type PluginApiClient } from "@paca-ai/plugin-sdk-react"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { GitBranch, Link2, Plus } from "lucide-react"; -import { useState } from "react"; -import { - type CreateBranchResult, - createBranch, - ErrorCode, - getPluginErrorCode, - type LinkedRepository, - linkBranchToTask, - listTaskBranches, - type TaskBranch, - taskBranchesKey, -} from "./github-api"; -import { Button, CommandBlock, CopyIconButton, Field, Input, Pill, Section, SectionEmpty, Segmented, Select, Skeleton } from "./ui"; - -// ── Existing branch ─────────────────────────────────────────────────────────── - -function BranchRow({ branch, repoName }: { branch: TaskBranch; repoName?: string }) { - return ( -
-
- ); -} - -// ── Branch name helpers ─────────────────────────────────────────────────────── - -const BRANCH_TYPES = ["feat", "fix", "chore", "docs", "test", "refactor"] as const; - -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; - } -} - -// ── Create form ─────────────────────────────────────────────────────────────── - -function CreateBranchForm({ - api, - projectId, - taskId, - taskIdPrefix, - taskNumber, - taskTitle, - repos, - onDone, -}: { - api: PluginApiClient; - projectId: string; - taskId: string; - taskIdPrefix: string; - taskNumber: number; - taskTitle?: string; - repos: LinkedRepository[]; - onDone: () => void; -}) { - const queryClient = useQueryClient(); - const taskRef = taskIdPrefix ? `${taskIdPrefix.toUpperCase()}-${taskNumber}` : `${taskNumber}`; - const slug = 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(`feat/${taskRef}${slug}`); - const [repoId, setRepoId] = useState(repos.length === 1 ? repos[0].id : ""); - const [source, setSource] = useState(""); - const [error, setError] = useState(null); - - function changeType(next: (typeof BRANCH_TYPES)[number]) { - setType(next); - setBranchName((prev) => { - const slash = prev.indexOf("/"); - return `${next}${slash >= 0 ? prev.slice(slash) : `/${taskRef}`}`; - }); - } - - const create = useMutation({ - mutationFn: (): Promise => createBranch(api, taskId, repoId, branchName, source.trim() || undefined), - onSuccess: async () => { - await queryClient.invalidateQueries({ queryKey: taskBranchesKey(projectId, taskId) }); - onDone(); - }, - onError: (err: unknown) => { - const code = getPluginErrorCode(err); - setError( - code === ErrorCode.GitHubIntegrationNotFound - ? "GitHub isn’t connected for this project." - : code === ErrorCode.GitHubRepositoryNotFound - ? "That repository isn’t linked any more." - : code === ErrorCode.GitHubBranchAlreadyLinked - ? "This branch is already linked to the task." - : code === ErrorCode.GitHubTokenInsufficientPermissions - ? "The GitHub connection can’t create branches. It needs write access to repository contents — check Project Settings → GitHub." - : "Couldn’t create the branch. Try again.", - ); - }, - }); - - function submit() { - setError(null); - if (!branchName.trim()) return setError("Branch name is required."); - if (!repoId) return setError("Select a repository."); - create.mutate(); - } - - const local = `git checkout -b ${branchName} && git push -u origin ${branchName}`; - - return ( -
{ - e.preventDefault(); - submit(); - }} - onKeyDown={(e) => e.key === "Escape" && onDone()} - > -
- Type - -
- - {(id) => ( - { setBranchName(e.target.value); setError(null); }} /> - )} - - {repos.length > 1 && ( - - {(id) => ( - - )} - - )} - - {(id) => setSource(e.target.value)} />} - - {error &&

{error}

} -
- - -
-
- Or create it locally - -
-
- ); -} - -// ── Link 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 urlRepo = parsed ? (repos.find((r) => r.full_name === parsed.fullName) ?? null) : null; - const repoId = parsed ? (urlRepo?.id ?? "") : selectedRepoId; - const branchName = parsed ? parsed.branchName : value.trim(); - - const link = useMutation({ - mutationFn: () => linkBranchToTask(api, taskId, repoId, branchName), - onSuccess: async () => { - await queryClient.invalidateQueries({ queryKey: taskBranchesKey(projectId, taskId) }); - onDone(); - }, - onError: (err: unknown) => { - const code = getPluginErrorCode(err); - setError( - code === ErrorCode.GitHubIntegrationNotFound - ? "GitHub isn’t connected for this project." - : code === ErrorCode.GitHubRepositoryNotFound - ? "That repository isn’t linked any more." - : code === ErrorCode.GitHubBranchNotFound - ? `Branch “${branchName}” wasn’t found in that repository.` - : code === ErrorCode.GitHubBranchAlreadyLinked - ? `Branch “${branchName}” is already linked to this task.` - : code === ErrorCode.GitHubTokenInsufficientPermissions - ? "The GitHub connection can’t read branches. Check it in Project Settings → GitHub." - : "Couldn’t link the branch. Try again.", - ); - }, - }); - - function submit() { - if (parsed) { - if (!urlRepo) return setError(`“${parsed.fullName}” isn’t linked to this project.`); - } else { - if (!repoId) return setError("Select a repository."); - if (!branchName) return setError("Enter a branch name or paste a GitHub branch link."); - } - setError(null); - link.mutate(); - } - - return ( -
{ - e.preventDefault(); - submit(); - }} - onKeyDown={(e) => e.key === "Escape" && onDone()} - > - - {(id) => ( - { setValue(e.target.value); setError(null); }} - // biome-ignore lint/a11y/noAutofocus: opened on demand by the user - autoFocus - /> - )} - - {!parsed && repos.length > 1 && ( - - {(id) => ( - - )} - - )} - {error &&

{error}

} -
- - -
-
- ); -} - -// ── Section ─────────────────────────────────────────────────────────────────── - -export function BranchesSection({ - api, - projectId, - taskId, - taskIdPrefix, - taskNumber, - taskTitle, - repos, - canEdit, -}: { - api: PluginApiClient; - projectId: string; - taskId: string; - taskIdPrefix: string; - taskNumber: number; - taskTitle?: string; - repos: LinkedRepository[]; - canEdit: boolean; -}) { - 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 repoName = (id: string) => repos.find((r) => r.id === id)?.full_name; - const done = () => setMode(null); - - return ( -
- setMode("link")}> - - setMode("create")}> - - - ) : undefined - } - > - {isLoading ? ( - - ) : ( - <> - {branches.length > 0 && ( -
- {branches.map((b) => ( - - ))} -
- )} - {branches.length === 0 && mode === null && ( - - )} - {mode === "create" && } - {mode === "link" && } - - )} -
- ); -} diff --git a/frontend/src/ConnectionBar.tsx b/frontend/src/ConnectionBar.tsx deleted file mode 100644 index 0e52f9d..0000000 --- a/frontend/src/ConnectionBar.tsx +++ /dev/null @@ -1,132 +0,0 @@ -import { type PluginApiClient } from "@paca-ai/plugin-sdk-react"; -import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { Bot, CheckCircle2, KeyRound, TriangleAlert, Unplug } from "lucide-react"; -import { useState } from "react"; -import { SetupPanel } from "./SetupPanel"; -import { - accessibleReposKey, - agentIdentitiesKey, - deleteGitHubToken, - type GitHubIntegration, - integrationKey, - type LinkedRepository, - linkedReposKey, -} from "./github-api"; -import { Badge, Banner, Button, Modal } from "./ui"; - -/** - * Compact status for a connected project: what it authenticates as, whether - * webhooks are flowing, and the few things you can do about it. A project on a - * personal access token also sees a gentle nudge toward the GitHub App. - */ -export function ConnectionBar({ - api, - projectId, - integration, - repos, - canEdit, -}: { - api: PluginApiClient; - projectId: string; - integration: GitHubIntegration; - repos: LinkedRepository[]; - canEdit: boolean; -}) { - const queryClient = useQueryClient(); - const [confirmOpen, setConfirmOpen] = useState(false); - const [upgradeOpen, setUpgradeOpen] = useState(false); - const isApp = integration.auth_type === "app"; - - const disconnect = useMutation({ - mutationFn: () => deleteGitHubToken(api), - onSuccess: async () => { - await queryClient.invalidateQueries({ queryKey: integrationKey(projectId) }); - for (const key of [linkedReposKey(projectId), accessibleReposKey(projectId), agentIdentitiesKey(projectId)]) { - queryClient.removeQueries({ queryKey: key }); - } - setConfirmOpen(false); - }, - }); - - // Webhook health is only meaningful once a repository is linked. - const webhookKnown = repos.length > 0; - const webhookOk = repos.every((r) => r.webhook_active); - - return ( - <> -
-
{isApp ?
-
-

- {isApp ? `${integration.app_name ?? "GitHub App"}${integration.account_login ? ` on ${integration.account_login}` : ""}` : "Personal access token"} - {isApp ? "GitHub App" : "Token"} -

-

- {isApp ? "Short-lived tokens are minted automatically. No personal credentials are stored." : "Actions are attributed to the token’s owner. The token is stored encrypted."} -

-
- {webhookKnown && ( - - {webhookOk ? "Webhooks active" : "Webhooks off"} - - )} - {canEdit && ( - - )} -
- - {webhookKnown && !webhookOk && ( - - )} - - {!isApp && canEdit && ( - - )} - - setConfirmOpen(false)} - title={isApp ? "Disconnect GitHub App?" : "Remove personal access token?"} - description={ - isApp - ? "This unlinks every repository and agent identity from the project. The app itself stays installed on GitHub." - : "This unlinks every repository from the project and removes the webhooks Paca created on GitHub. This can’t be undone." - } - footer={ - <> - - - - } - > - {disconnect.isError && ( - Something went wrong. Try again. - )} - - - setUpgradeOpen(false)} title="Switch to a GitHub App" wide> - setUpgradeOpen(false)} /> - - - ); -} diff --git a/frontend/src/GitHubAppCallbackPage.tsx b/frontend/src/GitHubAppCallbackPage.tsx deleted file mode 100644 index 9f83110..0000000 --- a/frontend/src/GitHubAppCallbackPage.tsx +++ /dev/null @@ -1,136 +0,0 @@ -import { type PluginApiClient } from "@paca-ai/plugin-sdk-react"; -import { TriangleAlert } from "lucide-react"; -import { useEffect, useState } from "react"; -import { recalledReturnUrl, withFlowResult } from "./app-flow"; -import { - completeAppCreated, - completeAppInstalled, - ErrorCode, - getPluginErrorCode, - type SetupCallbackResult, -} from "./github-api"; -import { Button, GitHubIcon, Spinner } from "./ui"; - -/** - * The page GitHub sends the browser back to during GitHub App setup - * (`/plugin-pages/com.paca.github/github-app-callback`, a plugin `app.page`). - * - * It is a page of the web app, not a plugin API route, on purpose: by the time - * it mounts the host has already restored the user's session (refreshing an - * expired access token, or signing the user in and returning here), so the two - * calls below are ordinary authenticated, permission-gated plugin requests. - * - * GitHub appends different parameters to each leg of the flow: - * created → ?code=…&state=… (manifest accepted) - * installed → ?installation_id=…&state=… (app installed on an account) - */ - -type Step = "created" | "installed"; - -const FLOW_CODES: Record = { - [ErrorCode.GitHubSetupInvalidState]: "invalid_state", - [ErrorCode.GitHubSetupMissingParameter]: "missing_code", - [ErrorCode.GitHubSetupConversionFailed]: "conversion_failed", - [ErrorCode.GitHubSetupStoreFailed]: "store_failed", - [ErrorCode.GitHubSetupInstallationFailed]: "installation_failed", -}; - -const MESSAGES: Record = { - invalid_state: "This setup link has expired or was already used. Start the setup again from Project Settings → GitHub.", - missing_code: "GitHub did not send everything Paca needs. Start the setup again.", - conversion_failed: "GitHub rejected the app setup. Start the setup again.", - store_failed: "The app was created on GitHub but Paca could not save it. Check the server logs.", - installation_failed: "GitHub did not confirm the installation. Install the app again from Project Settings → GitHub.", -}; - -function readParams(): { step: Step; body: Record } | null { - const q = new URLSearchParams(window.location.search); - const state = q.get("state") ?? ""; - const code = q.get("code"); - const installationId = q.get("installation_id"); - if (code) return { step: "created", body: { code, state } }; - if (installationId) return { step: "installed", body: { installation_id: installationId, state } }; - return null; -} - -// Each callback must run exactly once — the code and state are single-use — yet -// React may mount the page twice (StrictMode, a remount). Sharing one promise -// per set of parameters makes the second mount reuse the first result. -const inFlight = new Map>(); - -function run(api: PluginApiClient, step: Step, body: Record): Promise { - const key = `${step}:${JSON.stringify(body)}`; - let p = inFlight.get(key); - if (!p) { - p = - step === "created" - ? completeAppCreated(api, body as { code: string; state: string }) - : completeAppInstalled(api, body as { installation_id: string; state: string }); - inFlight.set(key, p); - } - return p; -} - -export default function GitHubAppCallbackPage({ api }: { api: PluginApiClient }) { - const params = readParams(); - const [error, setError] = useState(null); - - useEffect(() => { - if (!params) { - setError("missing_code"); - return; - } - let cancelled = false; - run(api, params.step, params.body) - .then((result) => { - if (cancelled) return; - // created → on to GitHub to install; installed → back to where it began. - window.location.replace(result.next_url ?? withFlowResult(result.return_url, "ok")); - }) - .catch((err: unknown) => { - if (cancelled) return; - const code = FLOW_CODES[getPluginErrorCode(err) ?? ""] ?? "setup_failed"; - const back = recalledReturnUrl(); - // Send the user home with the explanation shown as a banner there; if we - // don't know where home is, explain it here. - if (back) { - window.location.replace(withFlowResult(back, "error", code)); - return; - } - setError(code); - }); - return () => { - cancelled = true; - }; - // The parameters are read once from the URL; the page is not reused. - // biome-ignore lint/correctness/useExhaustiveDependencies: run once per mount - }, []); - - return ( -
-
-
- -
- {error === null ? ( - <> - -

{params?.step === "installed" ? "Confirming the installation…" : "Finishing GitHub App setup…"}

-

- {params?.step === "installed" ? "Checking with GitHub that the app is installed." : "Saving your new app. You’ll be taken to GitHub to install it next."} -

- - ) : ( - <> -
-
- ); -} diff --git a/frontend/src/GitHubSettingsTab.tsx b/frontend/src/GitHubSettingsTab.tsx deleted file mode 100644 index 68b8bf4..0000000 --- a/frontend/src/GitHubSettingsTab.tsx +++ /dev/null @@ -1,154 +0,0 @@ -import { PluginApiClient, PluginQueryClientProvider } from "@paca-ai/plugin-sdk-react"; -import { useQuery } from "@tanstack/react-query"; -import { CheckCircle2, ChevronRight, X } from "lucide-react"; -import { useMemo, useState } from "react"; -import { AgentIdentitiesPanel } from "./AgentIdentities"; -import { AppsPanel } from "./AppsPanel"; -import { ConnectionBar } from "./ConnectionBar"; -import { RepositoriesPanel } from "./RepositoriesPanel"; -import { SetupPanel } from "./SetupPanel"; -import { useFlowResult } from "./app-flow"; -import { - appsKey, - type GitHubIntegration, - getGitHubIntegration, - integrationKey, - type LinkedRepository, - linkedReposKey, - listGitHubApps, - listLinkedRepositories, -} from "./github-api"; -import { Banner, Button, GitHubIcon, Skeleton, TabPanel, Tabs } from "./ui"; - -function GitHubSettingsInner({ api, projectId, canEdit }: { api: PluginApiClient; projectId: string; canEdit: boolean }) { - const [flow, dismissFlow] = useFlowResult(); - const [tab, setTab] = useState("repositories"); - const [justConnected, setJustConnected] = useState(false); - const [appsOpen, setAppsOpen] = useState(false); - - const { data: integration, isLoading } = useQuery({ - queryKey: integrationKey(projectId), - queryFn: () => getGitHubIntegration(api), - retry: false, - throwOnError: false, - }); - const connected = integration?.connected === true; - const isApp = connected && integration?.auth_type === "app"; - - const { data: repos = [], isLoading: reposLoading } = useQuery({ - queryKey: linkedReposKey(projectId), - queryFn: () => listLinkedRepositories(api), - retry: false, - throwOnError: false, - enabled: connected, - }); - // Resolves only for holders of the instance-wide github:manage_apps action. - const { data: apps } = useQuery({ queryKey: appsKey(), queryFn: () => listGitHubApps(api), retry: false }); - const isAdmin = apps !== undefined; - - const tabs = [ - ...(connected ? [{ id: "repositories", label: "Repositories", count: repos.length }] : []), - ...(isApp ? [{ id: "identities", label: "Agent identities" }] : []), - ...(isAdmin ? [{ id: "apps", label: "GitHub Apps", count: apps.length }] : []), - ]; - const activeTab = tabs.some((t) => t.id === tab) ? tab : (tabs[0]?.id ?? ""); - - const onConnected = () => { - setJustConnected(true); - setTab("repositories"); - }; - - return ( -
-
-
- -
-
-

GitHub

-

Track pull requests, create branches from tasks and let agents work in your repositories.

-
-
- - {flow && ( - - )} - - {isLoading ? ( - - ) : !connected ? ( - <> - - {isAdmin && ( -
- - {appsOpen && ( -
- -
- )} -
- )} - - ) : ( - <> - - - {activeTab === "repositories" && ( - - - - )} - {activeTab === "identities" && ( - - - - )} - {activeTab === "apps" && ( - - - - )} - - )} -
- ); -} - -// ── 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 020984b..0000000 --- a/frontend/src/GitHubTaskSection.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import { PluginApiClient, PluginQueryClientProvider } from "@paca-ai/plugin-sdk-react"; -import { useQuery } from "@tanstack/react-query"; -import { useMemo } from "react"; -import { BranchesSection } from "./Branches"; -import { PullRequestsSection } from "./PullRequests"; -import { type LinkedRepository, linkedReposKey, listLinkedRepositories } from "./github-api"; -import "./styles.css"; - -interface GitHubTaskSectionProps { - projectId: string; - taskId: string; - canEdit?: boolean; -} - -function GitHubTaskSectionInner({ api, projectId, taskId, canEdit }: { api: PluginApiClient; projectId: string; taskId: string; canEdit: boolean }) { - // Task and project metadata are only needed to suggest a branch name. - 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: repos, isLoading } = useQuery({ - queryKey: linkedReposKey(projectId), - queryFn: () => listLinkedRepositories(api), - retry: false, - throwOnError: false, - }); - - // Nothing to show until the project has a linked repository. - if (isLoading || !repos || repos.length === 0) return null; - - // Two sibling sections, not one wrapper: the task drawer spaces its sections - // as direct children, so each of ours gets the same rhythm as the host's. - 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/PullRequests.tsx b/frontend/src/PullRequests.tsx deleted file mode 100644 index ed35afa..0000000 --- a/frontend/src/PullRequests.tsx +++ /dev/null @@ -1,234 +0,0 @@ -import { type PluginApiClient } from "@paca-ai/plugin-sdk-react"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { ArrowRight, GitMerge, GitPullRequest, GitPullRequestClosed, Plus, Trash2 } from "lucide-react"; -import { useState } from "react"; -import { - ErrorCode, - getPluginErrorCode, - type LinkedRepository, - linkPRToTask, - listTaskPRs, - type PullRequest, - taskPRsKey, - unlinkPRFromTask, -} from "./github-api"; -import { Badge, Button, Field, Input, Pill, RowIconButton, Section, SectionEmpty, Select, Skeleton } from "./ui"; - -// ── State presentation ──────────────────────────────────────────────────────── - -const STATE: Record = { - open: { label: "Open", tone: "success", Icon: GitPullRequest }, - merged: { label: "Merged", tone: "merged", Icon: GitMerge }, - closed: { label: "Closed", tone: "danger", Icon: GitPullRequestClosed }, -}; - -function PRRow({ api, pr, repoName, projectId, taskId, canEdit }: { api: PluginApiClient; pr: PullRequest; repoName?: string; projectId: string; taskId: string; canEdit: boolean }) { - const queryClient = useQueryClient(); - const unlink = useMutation({ - mutationFn: () => unlinkPRFromTask(api, taskId, pr.id), - onSuccess: () => queryClient.invalidateQueries({ queryKey: taskPRsKey(projectId, taskId) }), - }); - const { label, tone, Icon } = STATE[pr.state] ?? STATE.open; - - return ( -
-
- ); -} - -// ── URL parsing ─────────────────────────────────────────────────────────────── - -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 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 urlRepo = parsed ? (repos.find((r) => r.full_name === parsed.fullName) ?? null) : null; - const repoId = parsed ? (urlRepo?.id ?? "") : selectedRepoId; - const display = parsed ? parsed.prNumber : value.trim(); - - const link = useMutation({ - mutationFn: () => linkPRToTask(api, taskId, repoId, parsed ? parsed.prNumber : Number(value)), - onSuccess: async () => { - await queryClient.invalidateQueries({ queryKey: taskPRsKey(projectId, taskId) }); - onDone(); - }, - onError: (err: unknown) => { - const code = getPluginErrorCode(err); - setError( - code === ErrorCode.GitHubIntegrationNotFound - ? "GitHub isn’t connected for this project." - : code === ErrorCode.GitHubRepositoryNotFound - ? "That repository isn’t linked any more." - : code === ErrorCode.GitHubPRNotFound - ? `PR #${display} wasn’t found in that repository.` - : code === ErrorCode.GitHubPRAlreadyLinked - ? `PR #${display} is already linked to this task.` - : code === ErrorCode.GitHubTokenInsufficientPermissions - ? "The GitHub connection can’t read pull requests. Check it in Project Settings → GitHub." - : "Couldn’t link the pull request. Try again.", - ); - }, - }); - - function submit() { - if (parsed) { - if (!urlRepo) return setError(`“${parsed.fullName}” isn’t linked to this project.`); - } else { - const n = Number(value); - if (!repoId) return setError("Select a repository."); - if (!value.trim() || !Number.isInteger(n) || n <= 0) return setError("Enter a PR number or paste a GitHub PR link."); - } - setError(null); - link.mutate(); - } - - return ( -
{ - e.preventDefault(); - submit(); - }} - onKeyDown={(e) => e.key === "Escape" && onDone()} - > - - {(id) => ( - { - setValue(e.target.value); - setError(null); - }} - // biome-ignore lint/a11y/noAutofocus: opened on demand by the user - autoFocus - /> - )} - - {!parsed && repos.length > 1 && ( - - {(id) => ( - - )} - - )} - {error && ( -

- {error} -

- )} -
- - -
-
- ); -} - -// ── Section ─────────────────────────────────────────────────────────────────── - -export function PullRequestsSection({ api, projectId, taskId, repos, canEdit }: { api: PluginApiClient; projectId: string; taskId: string; repos: LinkedRepository[]; canEdit: boolean }) { - const { data: prs = [], isLoading } = useQuery({ - queryKey: taskPRsKey(projectId, taskId), - queryFn: () => listTaskPRs(api, taskId), - retry: false, - throwOnError: false, - }); - const [linking, setLinking] = useState(false); - const repoName = (id: string) => repos.find((r) => r.id === id)?.full_name; - - return ( -
setLinking(true)}> -
- ); -} diff --git a/frontend/src/RepositoriesPanel.tsx b/frontend/src/RepositoriesPanel.tsx deleted file mode 100644 index 7b3c536..0000000 --- a/frontend/src/RepositoriesPanel.tsx +++ /dev/null @@ -1,274 +0,0 @@ -import { type PluginApiClient } from "@paca-ai/plugin-sdk-react"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { BookOpen, GitBranch, Lock, Plus, RefreshCw, Search, Unlink } from "lucide-react"; -import { useState } from "react"; -import { - type AccessibleRepo, - accessibleReposKey, - ErrorCode, - getPluginErrorCode, - type LinkedRepository, - linkedReposKey, - linkRepository, - listAccessibleRepos, - unlinkRepository, -} from "./github-api"; -import { Avatar, Badge, Banner, Button, EmptyState, Input, Modal, Skeleton } from "./ui"; - -// ── Add repository ──────────────────────────────────────────────────────────── - -function AddRepoDialog({ - api, - projectId, - isApp, - open, - onClose, -}: { - api: PluginApiClient; - projectId: string; - isApp: boolean; - open: boolean; - onClose: () => void; -}) { - const queryClient = useQueryClient(); - const [search, setSearch] = useState(""); - const [error, setError] = useState(null); - - const { data: repos = [], isLoading, isFetching } = useQuery({ - queryKey: accessibleReposKey(projectId), - queryFn: () => listAccessibleRepos(api), - enabled: open, - staleTime: 0, - }); - - const link = useMutation({ - mutationFn: (repo: AccessibleRepo) => linkRepository(api, repo.owner, repo.repo_name), - onSuccess: async () => { - await queryClient.invalidateQueries({ queryKey: linkedReposKey(projectId) }); - close(); - }, - onError: (err: unknown) => { - const code = getPluginErrorCode(err); - setError( - code === ErrorCode.GitHubWebhookURLNotPublic - ? "GitHub can’t reach this server to register the webhook (is it on localhost?). Set PUBLIC_URL to a public HTTPS address and try again." - : code === ErrorCode.GitHubWebhookCreationFailed - ? "Couldn’t create the webhook. The token needs the admin:repo_hook scope and admin access to the repository." - : code === ErrorCode.GitHubRepoAlreadyLinked - ? "This repository is already linked to the project." - : code === ErrorCode.GitHubRepoNotAccessible - ? isApp - ? "The app can’t see this repository. Add it to the app’s installation on GitHub." - : "Repository not found or not accessible with this token." - : "Couldn’t link the repository. Try again.", - ); - }, - }); - - function close() { - setSearch(""); - setError(null); - onClose(); - } - - const q = search.trim().toLowerCase(); - const filtered = repos.filter((r) => r.full_name.toLowerCase().includes(q)); - - return ( - - Close - - } - > -
-
-
- -
- - {error && {error}} - -
- {isLoading ? ( - [0, 1, 2].map((i) => ) - ) : repos.length === 0 ? ( - } title="No repositories found"> - {isApp ? "Install the app on at least one repository, then reload." : "This token can’t see any repositories."} - - ) : filtered.length === 0 ? ( -

- Nothing matches “{search}”. -

- ) : ( - filtered.map((repo) => ( - - )) - )} -
-
- ); -} - -// ── Linked repo row ─────────────────────────────────────────────────────────── - -function LinkedRepoRow({ api, projectId, repo, canEdit, isApp }: { api: PluginApiClient; projectId: string; repo: LinkedRepository; canEdit: boolean; isApp: boolean }) { - const queryClient = useQueryClient(); - const [confirmOpen, setConfirmOpen] = useState(false); - const unlink = 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} -

-
- - {repo.webhook_active ? "Syncing" : "Not syncing"} - - {canEdit && ( - - )} -
- - setConfirmOpen(false)} - title="Unlink repository?" - description={`${repo.full_name} will no longer be tracked in this project.${isApp ? "" : " Paca will also try to delete its webhook from GitHub."} Existing task links to its branches and pull requests are kept.`} - footer={ - <> - - - - } - > - {unlink.isError && Couldn’t unlink. Try again.} - - - ); -} - -// ── Panel ───────────────────────────────────────────────────────────────────── - -export function RepositoriesPanel({ - api, - projectId, - repos, - loading, - canEdit, - isApp, - autoOpen, -}: { - api: PluginApiClient; - projectId: string; - repos: LinkedRepository[]; - loading: boolean; - canEdit: boolean; - isApp: boolean; - /** Open the add dialog straight away (right after connecting). */ - autoOpen?: boolean; -}) { - const [addOpen, setAddOpen] = useState(!!autoOpen); - const [filter, setFilter] = useState(""); - const shown = repos.filter((r) => r.full_name.toLowerCase().includes(filter.trim().toLowerCase())); - - return ( -
-
- {repos.length > 5 ? ( -
-
- ) : ( -

Pull requests and branches from these repositories can be linked to tasks.

- )} - {canEdit && repos.length > 0 && ( - - )} -
- - {loading ? ( -
- -
- ) : repos.length === 0 ? ( - } - title="No repositories linked yet" - action={ - canEdit ? ( - - ) : undefined - } - > - Link a repository to track its pull requests and create branches straight from tasks. - - ) : shown.length === 0 ? ( -

- No linked repositories match “{filter}”. -

- ) : ( -
- {shown.map((repo) => ( - - ))} -
- )} - - {canEdit && setAddOpen(false)} />} -
- ); -} diff --git a/frontend/src/SetupPanel.tsx b/frontend/src/SetupPanel.tsx deleted file mode 100644 index 59761b2..0000000 --- a/frontend/src/SetupPanel.tsx +++ /dev/null @@ -1,340 +0,0 @@ -import { type PluginApiClient } from "@paca-ai/plugin-sdk-react"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { Bot, Check, ChevronRight, KeyRound, ShieldCheck, Sparkles } from "lucide-react"; -import { useState } from "react"; -import { CreateAppDialog, rememberReturnUrl } from "./app-flow"; -import { - appsKey, - bindInstallation, - ErrorCode, - getPluginErrorCode, - integrationKey, - listGitHubApps, - listProjectInstallations, - projectInstallationsKey, - setGitHubToken, - setInstallationAccess, - startAppInstall, -} from "./github-api"; -import { Badge, Banner, Button, Input, Skeleton, cn } from "./ui"; - -type StepState = "done" | "current" | "todo"; - -function Steps({ steps }: { steps: { label: string; state: StepState }[] }) { - return ( -
    - {steps.map((s, i) => ( -
  1. -
    - {s.state === "done" ? - -
    - {s.label} -
  2. - ))} -
- ); -} - -/** - * First-run experience: connect the project to GitHub. The GitHub App path is - * the recommended one and is shown as a three-step guided setup whose current - * step is derived from what already exists (no app → create; app but no - * installation → install; installation → connect). A personal access token is - * available below as a quieter alternative. - */ -export function SetupPanel({ - api, - projectId, - canEdit, - onConnected, - variant = "page", -}: { - api: PluginApiClient; - projectId: string; - canEdit: boolean; - onConnected: () => void; - /** "modal" omits the hero chrome (used when upgrading from a token). */ - variant?: "page" | "modal"; -}) { - const queryClient = useQueryClient(); - const [createOpen, setCreateOpen] = useState(false); - const [error, setError] = useState(null); - const [patOpen, setPatOpen] = useState(false); - - const { data: installations = [], isLoading } = useQuery({ - queryKey: projectInstallationsKey(projectId), - queryFn: () => listProjectInstallations(api), - retry: false, - }); - // Resolves only for holders of the instance-wide github:manage_apps action. - const { data: apps } = useQuery({ queryKey: appsKey(), queryFn: () => listGitHubApps(api), retry: false }); - const isAdmin = apps !== undefined; - - const hasInstall = installations.length > 0; - const hasApp = hasInstall || (apps?.length ?? 0) > 0; - const current = hasInstall ? 2 : hasApp ? 1 : 0; - const state = (i: number): StepState => (i < current ? "done" : i === current ? "current" : "todo"); - - // An installation exists but is not open to this project (admin-only fix). - const closedInstall = apps?.flatMap((a) => a.installations.map((i) => ({ app: a, inst: i }))).find((x) => !x.inst.suspended); - - const bind = useMutation({ - mutationFn: (installationId: string) => bindInstallation(api, installationId), - onSuccess: async () => { - await queryClient.invalidateQueries({ queryKey: integrationKey(projectId) }); - setError(null); - onConnected(); - }, - onError: (err: unknown) => { - const code = getPluginErrorCode(err); - setError( - code === ErrorCode.GitHubUnlinkReposFirst - ? "Unlink this project’s repositories first — repositories are tied to how the project connects." - : code === ErrorCode.GitHubInstallationNotAllowed - ? "This installation is not available to the project." - : "GitHub rejected the installation. Check that the app is still installed.", - ); - }, - }); - const install = useMutation({ - mutationFn: (appId: string) => startAppInstall(api, appId, rememberReturnUrl()), - onSuccess: ({ url }) => { - window.location.href = url; - }, - }); - const openAccess = useMutation({ - mutationFn: (v: { app: string; inst: string }) => setInstallationAccess(api, v.app, v.inst, true, []), - onSuccess: async () => { - await queryClient.invalidateQueries({ queryKey: projectInstallationsKey(projectId) }); - await queryClient.invalidateQueries({ queryKey: appsKey() }); - }, - }); - - if (isLoading) return ; - - const stepAction = (() => { - if (hasInstall) { - return ( -
-
-

Connect this project

-

- Agents and automations will act as the app’s bot (your-app[bot]), not as a person. -

-
-
- {installations.map((inst) => ( -
-
-

{inst.account_login}

-

- {inst.app_name} · {inst.repository_selection === "all" ? "all repositories" : "selected repositories"} -

-
- {canEdit && ( - - )} -
- ))} -
-
- ); - } - if (!isAdmin) { - return ( - - ); - } - if (!hasApp) { - return ( -
-
-

Create your GitHub App

-

- Takes about a minute. Paca pre-fills everything on GitHub — you confirm once and install it on your account or organization. -

-
-
- -
-
- ); - } - // App exists. - if (closedInstall) { - return ( -
-
-

Make the installation available

-

- {closedInstall.app.name} is installed on {closedInstall.inst.account_login}, but it is not open to this project yet. -

-
-
- -
-
- ); - } - const app = apps?.[0]; - return ( -
-
-

Install {app?.name}

-

Choose the account or organization that owns your repositories, and which repositories the app may see.

-
-
- -
-
- ); - })(); - - return ( -
-
-
-

-

-

The secure, self-hosted way to connect — no personal token to store, rotate or share.

-
- -
    -
  • -
  • -
  • -
- - - - {stepAction} - {error && {error}} -
- - {variant === "page" && ( - <> -
or
- setPatOpen((v) => !v)} onConnected={onConnected} /> - - )} - - {isAdmin && setCreateOpen(false)} />} -
- ); -} - -// ── Personal access token (secondary path) ──────────────────────────────────── - -function TokenAlternative({ - api, - projectId, - canEdit, - open, - onToggle, - onConnected, -}: { - api: PluginApiClient; - projectId: string; - canEdit: boolean; - open: boolean; - onToggle: () => void; - onConnected: () => void; -}) { - const queryClient = useQueryClient(); - const [token, setToken] = useState(""); - const [error, setError] = useState(null); - - const save = useMutation({ - mutationFn: () => setGitHubToken(api, token.trim()), - onSuccess: async () => { - await queryClient.invalidateQueries({ queryKey: integrationKey(projectId) }); - setToken(""); - setError(null); - onConnected(); - }, - onError: (err: unknown) => { - const code = getPluginErrorCode(err); - setError( - code === ErrorCode.GitHubInvalidToken - ? "GitHub rejected the token. Check it has the repo and admin:repo_hook scopes." - : code === ErrorCode.GitHubUnlinkReposFirst - ? "Unlink this project’s repositories first — repositories are tied to how the project connects." - : "Could not save the token. Try again.", - ); - }, - }); - - return ( -
- - {open && ( -
-

- Everything done through this project is attributed to the token’s owner. Needs the repo and admin:repo_hook scopes. The token is stored encrypted and never shown again. -

-
{ - e.preventDefault(); - if (token.trim()) save.mutate(); - }} - > - { - setToken(e.target.value); - setError(null); - }} - /> - -
- {error && ( -

- {error} -

- )} -
- )} -
- ); -} - diff --git a/frontend/src/app-flow.tsx b/frontend/src/app-flow.tsx deleted file mode 100644 index 3bffb5e..0000000 --- a/frontend/src/app-flow.tsx +++ /dev/null @@ -1,204 +0,0 @@ -import { type PluginApiClient } from "@paca-ai/plugin-sdk-react"; -import { useMutation } from "@tanstack/react-query"; -import { useEffect, useState } from "react"; -import { createAppManifest, ErrorCode, getPluginErrorCode } from "./github-api"; -import { Button, Field, Input, Modal } from "./ui"; - -// Result codes the backend appends to the return URL after a callback. -const RESULT_MESSAGES: Record = { - invalid_state: "The setup link expired or was already used. Start again.", - missing_code: "GitHub did not return an authorization code. Start again.", - conversion_failed: "GitHub rejected the app setup. Start again.", - store_failed: "The app was created on GitHub but Paca could not save it. Check the server logs.", - installation_failed: "The installation could not be verified. Install the app again.", -}; - -export interface FlowResult { - ok: boolean; - message: string; -} - -/** Reads (and removes) the ?github_app=… outcome the backend appends when it redirects back. */ -export function useFlowResult(): [FlowResult | null, () => void] { - const [result, setResult] = useState(null); - useEffect(() => { - const url = new URL(window.location.href); - const status = url.searchParams.get("github_app"); - if (!status) return; - const code = url.searchParams.get("github_app_code") ?? ""; - setResult( - status === "ok" - ? { ok: true, message: "GitHub App installed. Connect it to this project to continue." } - : { ok: false, message: RESULT_MESSAGES[code] ?? "GitHub App setup failed." }, - ); - url.searchParams.delete("github_app"); - url.searchParams.delete("github_app_code"); - window.history.replaceState(null, "", url.toString()); - }, []); - return [result, () => setResult(null)]; -} - -const RETURN_KEY = "ghp:return-url"; - -// The project settings page opens the tab named by the URL hash -// (`#plugin:` = that plugin's tab), so the flow returns straight to -// the GitHub tab rather than to whichever tab the page opens by default. -const SETTINGS_TAB_HASH = "plugin:com.paca.github"; - -export function returnUrl(): string { - const url = new URL(window.location.href); - url.searchParams.delete("github_app"); - url.searchParams.delete("github_app_code"); - url.hash = SETTINGS_TAB_HASH; - return url.toString(); -} - -/** - * Remembers where the flow started. The server also keeps this, but it can - * only hand it back on success; if a step fails the callback page uses this - * copy to still send the user home with an explanation. - */ -export function rememberReturnUrl(): string { - const url = returnUrl(); - try { - window.sessionStorage.setItem(RETURN_KEY, url); - } catch { - // Storage can be blocked; the callback page then shows its own error panel. - } - return url; -} - -export function recalledReturnUrl(): string | null { - try { - return window.sessionStorage.getItem(RETURN_KEY); - } catch { - return null; - } -} - -/** Appends the flow outcome the settings page turns into a banner. */ -export function withFlowResult(target: string, status: "ok" | "error", code = ""): string { - const url = new URL(target, window.location.origin); - url.searchParams.set("github_app", status); - if (code) url.searchParams.set("github_app_code", code); - return url.toString(); -} - -/** The Manifest Flow requires a browser form POST to GitHub; a fetch would not work. */ -function postManifestToGitHub(postUrl: string, manifest: Record) { - const form = document.createElement("form"); - form.method = "POST"; - form.action = postUrl; - const input = document.createElement("input"); - input.type = "hidden"; - input.name = "manifest"; - input.value = JSON.stringify(manifest); - form.appendChild(input); - document.body.appendChild(form); - form.submit(); -} - -export function CreateAppDialog({ - api, - open, - onClose, -}: { - api: PluginApiClient; - open: boolean; - onClose: () => void; -}) { - const [name, setName] = useState(() => `Paca ${window.location.hostname}`.slice(0, 34)); - const [target, setTarget] = useState<"personal" | "org">("personal"); - const [org, setOrg] = useState(""); - const [allowAll, setAllowAll] = useState(true); - const [error, setError] = useState(null); - - const create = useMutation({ - mutationFn: () => - createAppManifest(api, { - name: name.trim(), - owner_org: target === "org" ? org.trim() : undefined, - allow_all_projects: allowAll, - return_url: rememberReturnUrl(), - }), - onSuccess: (r) => postManifestToGitHub(r.post_url, r.manifest), - onError: (err: unknown) => { - setError( - getPluginErrorCode(err) === ErrorCode.GitHubWebhookURLRequired - ? "PUBLIC_URL is not configured on the server, so GitHub cannot be pointed back at Paca." - : "Could not start the setup. Check the app name and organization.", - ); - }, - }); - - const valid = name.trim().length > 0 && (target === "personal" || org.trim().length > 0); - const submit = () => { - setError(null); - create.mutate(); - }; - - return ( - - - - - } - > - - {(id) => setName(e.target.value)} />} - - -
- - Create it on - - - - {target === "org" && ( - setOrg(e.target.value)} /> - )} -
- - - -
- What happens next -
    -
  1. GitHub shows the app’s settings — you click “Create GitHub App”.
  2. -
  3. GitHub asks where to install it — pick the account or organization.
  4. -
  5. You land back here, ready to connect.
  6. -
-
- - {error && ( -

- {error} -

- )} -
- ); -} diff --git a/frontend/src/github-api.ts b/frontend/src/github-api.ts deleted file mode 100644 index 61d9060..0000000 --- a/frontend/src/github-api.ts +++ /dev/null @@ -1,484 +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", - GitHubInstallationNotAllowed: "GITHUB_INSTALLATION_NOT_ALLOWED", - GitHubAgentIdentityUnavailable: "GITHUB_AGENT_IDENTITY_UNAVAILABLE", - GitHubUnlinkReposFirst: "GITHUB_UNLINK_REPOS_FIRST", - GitHubWebhookURLRequired: "GITHUB_WEBHOOK_URL_REQUIRED", - GitHubSetupInvalidState: "GITHUB_SETUP_INVALID_STATE", - GitHubSetupMissingParameter: "GITHUB_SETUP_MISSING_PARAMETER", - GitHubSetupConversionFailed: "GITHUB_SETUP_CONVERSION_FAILED", - GitHubSetupStoreFailed: "GITHUB_SETUP_STORE_FAILED", - GitHubSetupInstallationFailed: "GITHUB_SETUP_INSTALLATION_FAILED", - 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; - /** "pat" or "app"; for "app" the fields below describe the installation. */ - auth_type?: "pat" | "app"; - installation_id?: string; - app_name?: string; - account_login?: string; -} - -/** An app installation a project may bind (no secrets, no other projects). */ -export interface ProjectInstallation { - id: string; - app_name: string; - account_login: string; - account_type: string; - repository_selection: string; -} - -export interface AppInstallation { - id: string; - installation_id: number; - account_login: string; - account_type: string; - repository_selection: string; - allow_all_projects: boolean; - project_ids: string[]; - suspended: boolean; - manage_url: string; -} - -export interface GitHubApp { - id: string; - github_app_id: number; - slug: string; - name: string; - owner_login: string; - html_url: string; - webhook_active: boolean; - created_at: string; - installations: AppInstallation[]; -} - -export interface ManifestResponse { - post_url: string; - state: string; - manifest: Record; - webhook_active: boolean; -} - -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 projectInstallationsKey = (projectId: string) => - [PLUGIN_ID, "project-installations", projectId] as const; - -export const agentIdentitiesKey = (projectId: string) => - [PLUGIN_ID, "agent-identities", projectId] as const; - -export const appsKey = () => [PLUGIN_ID, "apps"] 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 }, - ); -} - -// ── GitHub Apps (instance-level) ─────────────────────────────────────────────── - -export async function listProjectInstallations( - api: PluginApiClient, -): Promise { - return api.pluginGet( - PLUGIN_ID, - `/projects/${api.projectId}/integration/installations`, - ); -} - -export async function bindInstallation( - api: PluginApiClient, - installationId: string, -): Promise { - return api.pluginPost( - PLUGIN_ID, - `/projects/${api.projectId}/integration/app`, - { installation_id: installationId }, - ); -} - -/** Requires the instance-wide github:manage_apps action; 403 otherwise. */ -export async function listGitHubApps(api: PluginApiClient): Promise { - return api.pluginGet(PLUGIN_ID, "/apps"); -} - -export async function createAppManifest( - api: PluginApiClient, - body: { - name: string; - owner_org?: string; - allow_all_projects: boolean; - public?: boolean; - return_url: string; - }, -): Promise { - return api.pluginPost(PLUGIN_ID, "/apps/manifest", body); -} - -export async function startAppInstall( - api: PluginApiClient, - appId: string, - returnUrl: string, -): Promise<{ url: string }> { - return api.pluginPost<{ url: string }>(PLUGIN_ID, `/apps/${appId}/install`, { - return_url: returnUrl, - }); -} - -export async function setInstallationAccess( - api: PluginApiClient, - appId: string, - installationId: string, - allowAllProjects: boolean, - projectIds: string[], -): Promise { - await api.pluginPost( - PLUGIN_ID, - `/apps/${appId}/installations/${installationId}/access`, - { allow_all_projects: allowAllProjects, project_ids: projectIds }, - ); -} - -export async function deleteGitHubApp(api: PluginApiClient, appId: string): Promise { - return api.pluginDelete(PLUGIN_ID, `/apps/${appId}`); -} - -// ── Per-agent identities ─────────────────────────────────────────────────────── - -export interface AgentIdentity { - member_id: string; - installation_id: string; - app_name: string; - account_login: string; -} - -export async function listAgentIdentities(api: PluginApiClient): Promise { - return api.pluginGet( - PLUGIN_ID, - `/projects/${api.projectId}/integration/agent-identities`, - ); -} - -export async function setAgentIdentity( - api: PluginApiClient, - memberId: string, - installationId: string, -): Promise { - return api.pluginPost( - PLUGIN_ID, - `/projects/${api.projectId}/integration/agent-identities`, - { member_id: memberId, installation_id: installationId }, - ); -} - -export async function deleteAgentIdentity(api: PluginApiClient, memberId: string): Promise { - return api.pluginDelete( - PLUGIN_ID, - `/projects/${api.projectId}/integration/agent-identities/${memberId}`, - ); -} - -// ── Project members (host API) ───────────────────────────────────────────────── - -export interface MemberLite { - id: string; - name: string; - memberType?: string; -} - -/** - * Reads the host's project-member list directly. The SDK's own listMembers() - * predates the paginated endpoint, so it can't filter by type or id. - */ -export async function fetchMembers( - projectId: string, - opts: { memberType?: string; ids?: string[]; pageSize?: number } = {}, -): Promise { - const params = new URLSearchParams({ page_size: String(opts.pageSize ?? 100) }); - if (opts.memberType) params.set("member_type", opts.memberType); - if (opts.ids?.length) params.set("ids", opts.ids.join(",")); - const res = await window.fetch( - `${window.location.origin}/api/v1/projects/${projectId}/members?${params}`, - { credentials: "include" }, - ); - if (!res.ok) throw new Error(`members request failed: ${res.status}`); - const json = (await res.json()) as { - data?: { - items?: { - id: string; - username: string; - full_name: string; - agent_name?: string; - member_type?: string; - }[]; - }; - }; - return (json.data?.items ?? []).map((m) => ({ - id: m.id, - name: m.agent_name || m.full_name || m.username, - memberType: m.member_type, - })); -} - -// ── GitHub App setup callbacks ───────────────────────────────────────────────── - -export interface SetupCallbackResult { - /** GitHub URL for the next leg of the flow, when there is one. */ - next_url?: string; - /** The Paca page that started the flow. */ - return_url: string; -} - -export async function completeAppCreated( - api: PluginApiClient, - body: { code: string; state: string }, -): Promise { - return api.pluginPost(PLUGIN_ID, "/apps/callback/created", body); -} - -export async function completeAppInstalled( - api: PluginApiClient, - body: { installation_id: string; state: string }, -): Promise { - return api.pluginPost(PLUGIN_ID, "/apps/callback/installed", body); -} 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/src/styles.css b/frontend/src/styles.css deleted file mode 100644 index f571f70..0000000 --- a/frontend/src/styles.css +++ /dev/null @@ -1,277 +0,0 @@ -/* - * Styles for the GitHub plugin's settings UI. - * - * Plugins are loaded into the host page but do not get the host's Tailwind - * build: a utility class only works if the host happens to use it too. So the - * settings UI ships its own small, namespaced (.ghp-) stylesheet, built on the - * host's design tokens (--background, --border, --primary, …) so it follows - * the active theme, light or dark, automatically. - */ - -.ghp { - --ghp-success: oklch(0.62 0.16 152); - --ghp-warning: oklch(0.72 0.15 75); - --ghp-danger: var(--destructive, oklch(0.577 0.245 27.325)); - --ghp-radius: 10px; - --ghp-gap: 16px; - - display: flex; - flex-direction: column; - gap: 20px; - max-width: 880px; - color: var(--foreground); - font-size: 14px; - line-height: 1.5; -} -.ghp *, .ghp *::before, .ghp *::after { box-sizing: border-box; } -.ghp h1, .ghp h2, .ghp h3, .ghp p { margin: 0; } -.ghp code { font-family: "JetBrains Mono", ui-monospace, monospace; font-size: 12px; background: var(--muted); padding: 1px 5px; border-radius: 4px; } -.ghp a { color: inherit; } - -/* ── layout ───────────────────────────────────────────── */ -.ghp-header { display: flex; align-items: center; gap: 12px; } -.ghp-header-icon { width: 40px; height: 40px; border-radius: 10px; display: grid; place-items: center; background: var(--foreground); color: var(--background); flex: none; } -.ghp-title { font-family: "Syne", sans-serif; font-size: 18px; font-weight: 600; } -.ghp-subtitle { color: var(--muted-foreground); font-size: 13px; } -.ghp-card { border: 1px solid var(--border); background: var(--card); border-radius: var(--ghp-radius); } -.ghp-card-pad { padding: 20px; } -.ghp-stack { display: flex; flex-direction: column; gap: var(--ghp-gap); } -.ghp-row { display: flex; align-items: center; gap: 12px; } -.ghp-row-between { display: flex; align-items: center; justify-content: space-between; gap: 12px; } -.ghp-wrap { flex-wrap: wrap; } -.ghp-grow { flex: 1; min-width: 0; } -.ghp-muted { color: var(--muted-foreground); } -.ghp-small { font-size: 12px; } -.ghp-strong { font-weight: 600; } -.ghp-truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.ghp-section-title { font-size: 14px; font-weight: 600; } - -/* ── buttons ──────────────────────────────────────────── */ -.ghp-btn { display: inline-flex; align-items: center; justify-content: center; gap: 6px; height: 36px; padding: 0 14px; border-radius: 8px; border: 1px solid transparent; font: inherit; font-size: 13px; font-weight: 500; cursor: pointer; transition: background .15s, border-color .15s, opacity .15s; white-space: nowrap; text-decoration: none; } -.ghp-input:focus-visible, .ghp-select:focus-visible { outline: 2px solid var(--ring); outline-offset: 0; border-color: var(--ring); } -.ghp-btn:focus-visible, .ghp-tab:focus-visible, .ghp-switch:focus-visible, .ghp-disclosure:focus-visible, .ghp-choice:focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; } -.ghp-btn:disabled { opacity: .5; cursor: not-allowed; } -.ghp-btn-primary { background: var(--primary); color: var(--primary-foreground); } -.ghp-btn-primary:hover:not(:disabled) { opacity: .9; } -.ghp-btn-secondary { background: var(--card); border-color: var(--border); color: var(--foreground); } -.ghp-btn-secondary:hover:not(:disabled) { background: var(--accent); } -.ghp-btn-ghost { background: transparent; color: var(--muted-foreground); } -.ghp-btn-ghost:hover:not(:disabled) { background: var(--accent); color: var(--foreground); } -.ghp-btn-danger { background: var(--ghp-danger); color: #fff; } -.ghp-btn-danger:hover:not(:disabled) { opacity: .9; } -.ghp-btn-sm { height: 30px; padding: 0 10px; font-size: 12px; } -.ghp-btn-icon { width: 30px; padding: 0; } -.ghp-btn-danger-quiet:hover:not(:disabled) { color: var(--ghp-danger); } - -/* ── form controls ────────────────────────────────────── */ -.ghp-field { display: flex; flex-direction: column; gap: 6px; } -.ghp-label { font-size: 13px; font-weight: 500; } -.ghp-hint { font-size: 12px; color: var(--muted-foreground); } -.ghp-input, .ghp-select { height: 36px; width: 100%; padding: 0 12px; border-radius: 8px; border: 1px solid var(--input); background: var(--background); color: var(--foreground); font: inherit; font-size: 13px; } -.ghp-input::placeholder { color: var(--muted-foreground); } -.ghp-input-mono { font-family: "JetBrains Mono", ui-monospace, monospace; } -.ghp-input-wrap { position: relative; flex: 1; } -.ghp-input-wrap .ghp-input { padding-left: 34px; } -.ghp-input-wrap > svg { position: absolute; left: 11px; top: 50%; transform: translateY(-50%); color: var(--muted-foreground); pointer-events: none; } -.ghp-radio { display: flex; align-items: center; gap: 8px; font-size: 13px; cursor: pointer; } -.ghp-check { display: flex; align-items: flex-start; gap: 10px; font-size: 13px; cursor: pointer; } -.ghp-check input { margin-top: 3px; accent-color: var(--primary); } - -.ghp-switch { position: relative; width: 34px; height: 20px; border-radius: 999px; border: 0; background: var(--input); cursor: pointer; flex: none; transition: background .15s; padding: 0; } -.ghp-switch[aria-checked="true"] { background: var(--primary); } -.ghp-switch::after { content: ""; position: absolute; top: 2px; left: 2px; width: 16px; height: 16px; border-radius: 50%; background: #fff; transition: transform .15s; } -.ghp-switch[aria-checked="true"]::after { transform: translateX(14px); } -.ghp-switch:disabled { opacity: .5; cursor: not-allowed; } - -/* ── badges / pills ───────────────────────────────────── */ -.ghp-badge { display: inline-flex; align-items: center; gap: 5px; height: 22px; padding: 0 8px; border-radius: 999px; font-size: 11px; font-weight: 600; background: var(--muted); color: var(--muted-foreground); white-space: nowrap; } -.ghp-badge-primary { background: color-mix(in srgb, var(--primary) 14%, transparent); color: var(--primary); } -.ghp-badge-success { background: color-mix(in srgb, var(--ghp-success) 14%, transparent); color: var(--ghp-success); } -.ghp-badge-warning { background: color-mix(in srgb, var(--ghp-warning) 18%, transparent); color: color-mix(in srgb, var(--ghp-warning) 70%, var(--foreground)); } -.ghp-badge-danger { background: color-mix(in srgb, var(--ghp-danger) 14%, transparent); color: var(--ghp-danger); } -.ghp-dot { width: 7px; height: 7px; border-radius: 50%; background: currentColor; flex: none; } - -/* ── banners ──────────────────────────────────────────── */ -.ghp-banner { display: flex; align-items: flex-start; gap: 10px; padding: 12px 14px; border-radius: 8px; border: 1px solid var(--border); background: var(--muted); font-size: 13px; } -.ghp-banner > svg { flex: none; margin-top: 2px; } -.ghp-banner-success { border-color: color-mix(in srgb, var(--ghp-success) 35%, transparent); background: color-mix(in srgb, var(--ghp-success) 9%, transparent); } -.ghp-banner-success > svg { color: var(--ghp-success); } -.ghp-banner-warning { border-color: color-mix(in srgb, var(--ghp-warning) 45%, transparent); background: color-mix(in srgb, var(--ghp-warning) 10%, transparent); } -.ghp-banner-warning > svg { color: var(--ghp-warning); } -.ghp-banner-danger { border-color: color-mix(in srgb, var(--ghp-danger) 35%, transparent); background: color-mix(in srgb, var(--ghp-danger) 8%, transparent); } -.ghp-banner-danger > svg { color: var(--ghp-danger); } -.ghp-banner-body { flex: 1; min-width: 0; } -.ghp-banner-title { font-weight: 600; } - -/* ── tabs ─────────────────────────────────────────────── */ -.ghp-tabs { display: flex; gap: 4px; border-bottom: 1px solid var(--border); overflow-x: auto; } -.ghp-tab { display: inline-flex; align-items: center; gap: 8px; height: 40px; padding: 0 14px; border: 0; background: none; color: var(--muted-foreground); font: inherit; font-size: 13px; font-weight: 500; cursor: pointer; border-bottom: 2px solid transparent; margin-bottom: -1px; white-space: nowrap; } -.ghp-tab:hover { color: var(--foreground); } -.ghp-tab[aria-selected="true"] { color: var(--foreground); border-bottom-color: var(--primary); } -.ghp-tab-count { font-size: 11px; background: var(--muted); border-radius: 999px; padding: 0 7px; min-width: 20px; text-align: center; } - -/* ── setup (not connected) ────────────────────────────── */ -.ghp-hero { padding: 24px; display: flex; flex-direction: column; gap: 20px; } -.ghp-hero-head { display: flex; flex-direction: column; gap: 6px; } -.ghp-hero-title { font-family: "Syne", sans-serif; font-size: 17px; font-weight: 600; display: flex; align-items: center; gap: 10px; flex-wrap: wrap; } -.ghp-benefits { display: flex; flex-wrap: wrap; gap: 8px 18px; color: var(--muted-foreground); font-size: 13px; } -.ghp-benefits li { display: inline-flex; align-items: center; gap: 6px; list-style: none; } -.ghp-benefits svg { color: var(--ghp-success); } -.ghp-benefits { margin: 0; padding: 0; } - -.ghp-steps { display: grid; grid-template-columns: repeat(3, 1fr); gap: 0; margin: 0; padding: 0; list-style: none; } -.ghp-step { position: relative; display: flex; flex-direction: column; gap: 8px; padding-right: 12px; } -.ghp-step-top { display: flex; align-items: center; gap: 10px; } -.ghp-step-num { width: 26px; height: 26px; flex: none; border-radius: 50%; display: grid; place-items: center; font-size: 12px; font-weight: 700; border: 2px solid var(--border); color: var(--muted-foreground); background: var(--card); } -.ghp-step-line { flex: 1; height: 2px; background: var(--border); border-radius: 2px; } -.ghp-step[data-state="done"] .ghp-step-num { background: var(--ghp-success); border-color: var(--ghp-success); color: #fff; } -.ghp-step[data-state="done"] .ghp-step-line { background: var(--ghp-success); } -.ghp-step[data-state="current"] .ghp-step-num { border-color: var(--primary); color: var(--primary); } -.ghp-step-label { font-size: 13px; font-weight: 600; } -.ghp-step[data-state="todo"] .ghp-step-label { color: var(--muted-foreground); } -.ghp-step:last-child .ghp-step-line { display: none; } - -.ghp-action { border: 1px solid var(--border); border-radius: 8px; padding: 16px; background: var(--background); display: flex; flex-direction: column; gap: 12px; } -.ghp-action-title { font-weight: 600; } - -.ghp-option-list { display: flex; flex-direction: column; gap: 8px; } -.ghp-option { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 10px 12px; border: 1px solid var(--border); border-radius: 8px; background: var(--card); } - -.ghp-divider { display: flex; align-items: center; gap: 12px; color: var(--muted-foreground); font-size: 12px; } -.ghp-divider::before, .ghp-divider::after { content: ""; flex: 1; height: 1px; background: var(--border); } -.ghp-disclosure { display: flex; align-items: center; justify-content: space-between; width: 100%; padding: 14px 16px; border: 1px solid var(--border); border-radius: 8px; background: transparent; color: var(--foreground); font: inherit; font-size: 13px; font-weight: 500; cursor: pointer; text-align: left; } -.ghp-disclosure:hover { background: var(--accent); } -.ghp-disclosure-body { padding: 16px; border: 1px solid var(--border); border-top: 0; border-radius: 0 0 8px 8px; margin-top: -4px; display: flex; flex-direction: column; gap: 12px; } -.ghp-chev { transition: transform .15s; } -.ghp-chev[data-open="true"] { transform: rotate(90deg); } - -/* ── connection bar (connected) ───────────────────────── */ -.ghp-conn { padding: 16px 20px; display: flex; align-items: center; gap: 14px; flex-wrap: wrap; } -.ghp-conn-icon { width: 40px; height: 40px; border-radius: 10px; display: grid; place-items: center; background: color-mix(in srgb, var(--ghp-success) 14%, transparent); color: var(--ghp-success); flex: none; } -.ghp-conn-title { font-weight: 600; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } - -/* ── lists ────────────────────────────────────────────── */ -.ghp-list { display: flex; flex-direction: column; border: 1px solid var(--border); border-radius: var(--ghp-radius); overflow: hidden; background: var(--card); } -.ghp-item { display: flex; align-items: center; gap: 12px; padding: 12px 16px; } -.ghp-item + .ghp-item { border-top: 1px solid var(--border); } -.ghp-avatar { width: 32px; height: 32px; border-radius: 8px; display: grid; place-items: center; background: var(--muted); color: var(--muted-foreground); flex: none; font-size: 12px; font-weight: 700; text-transform: uppercase; } -.ghp-avatar-round { border-radius: 50%; } -.ghp-item-title { font-weight: 500; } -.ghp-item-title a { text-decoration: none; } -.ghp-item-title a:hover { text-decoration: underline; } - -.ghp-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; } -.ghp-toolbar .ghp-input-wrap { max-width: 280px; } - -.ghp-empty { display: flex; flex-direction: column; align-items: center; text-align: center; gap: 10px; padding: 40px 20px; border: 1px dashed var(--border); border-radius: var(--ghp-radius); } -.ghp-empty-icon { width: 44px; height: 44px; border-radius: 12px; display: grid; place-items: center; background: var(--muted); color: var(--muted-foreground); } -.ghp-empty p { max-width: 380px; } - -/* ── identity flow diagram ────────────────────────────── */ -.ghp-flow { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; padding: 14px 16px; border-radius: 8px; background: var(--muted); font-size: 13px; } -.ghp-flow-chip { display: inline-flex; align-items: center; gap: 6px; padding: 4px 10px; border-radius: 999px; background: var(--card); border: 1px solid var(--border); font-weight: 500; } -.ghp-add-row { display: grid; grid-template-columns: 1fr 1fr auto; gap: 8px; align-items: end; } -@media (max-width: 640px) { .ghp-add-row { grid-template-columns: 1fr; } .ghp-step-label { font-size: 12px; } .ghp-step { padding-right: 8px; } } - -/* ── app cards ────────────────────────────────────────── */ -.ghp-app { padding: 0; overflow: hidden; } -.ghp-app-head { padding: 16px 20px; display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; } -.ghp-app-body { border-top: 1px solid var(--border); } -.ghp-app-body .ghp-item { padding: 12px 20px; } - -/* ── modal ────────────────────────────────────────────── */ -.ghp-overlay { position: fixed; inset: 0; z-index: 60; background: rgba(0,0,0,.5); display: flex; align-items: flex-start; justify-content: center; padding: 8vh 16px 16px; overflow-y: auto; } -.ghp-modal { width: 100%; max-width: 480px; max-height: 84vh; display: flex; flex-direction: column; background: var(--card); color: var(--foreground); border: 1px solid var(--border); border-radius: 12px; box-shadow: 0 20px 50px rgba(0,0,0,.25); padding: 22px; font-size: 14px; line-height: 1.5; } -.ghp-modal-wide { max-width: 560px; } -.ghp .ghp-modal-title { font-family: "Syne", sans-serif; font-size: 16px; font-weight: 600; margin: 0 0 4px; } -.ghp .ghp-modal-desc { color: var(--muted-foreground); font-size: 13px; margin: 0 0 16px; } -.ghp-modal-body { overflow-y: auto; min-height: 0; display: flex; flex-direction: column; gap: 14px; } -.ghp-modal-footer { display: flex; justify-content: flex-end; gap: 8px; margin-top: 18px; } -.ghp-modal * { box-sizing: border-box; } -.ghp-repo-pick { width: 100%; display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 10px 12px; border-radius: 8px; border: 1px solid var(--border); background: var(--card); color: inherit; font: inherit; text-align: left; cursor: pointer; } -.ghp-repo-pick:hover:not(:disabled) { background: var(--accent); } -.ghp-repo-pick:disabled { opacity: .6; cursor: not-allowed; } -.ghp-next { display: flex; flex-direction: column; gap: 8px; padding: 12px 14px; border-radius: 8px; background: var(--muted); font-size: 12px; color: var(--muted-foreground); } -.ghp-next ol { margin: 0; padding-left: 18px; } - -/* ── misc ─────────────────────────────────────────────── */ -.ghp-skeleton { border-radius: 8px; background: var(--muted); animation: ghp-pulse 1.4s ease-in-out infinite; } -@keyframes ghp-pulse { 50% { opacity: .5; } } -.ghp-spin { animation: ghp-spin 1s linear infinite; } -@keyframes ghp-spin { to { transform: rotate(360deg); } } -@media (prefers-reduced-motion: reduce) { .ghp-spin, .ghp-skeleton { animation: none; } .ghp * { transition: none !important; } } -.ghp-sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; } - -/* ── task detail sections ───────────────────────────────────────────────────── - * These render inside the task drawer next to the host's own sections - * (Subtasks, Linked tasks, Attachments), so they copy those sections' look — - * same heading, same bordered row list, same hover actions, same empty state — - * rather than the settings UI's. Values mirror the host's Tailwind classes - * (e.g. border-border/25, bg-card/50, text-muted-foreground/70). */ -.ghp.ghp-sec { - --ghp-merged: oklch(0.58 0.19 295); - --ghp-faint: color-mix(in srgb, var(--muted-foreground) 50%, transparent); - --ghp-line: color-mix(in srgb, var(--border) 25%, transparent); - max-width: none; - gap: 12px; -} -.ghp-sec-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; } -.ghp .ghp-sec-title { min-width: 0; display: flex; align-items: center; gap: 8px; font-size: 12px; font-weight: 600; letter-spacing: .08em; text-transform: uppercase; color: color-mix(in srgb, var(--muted-foreground) 70%, transparent); } -.ghp-sec-actions { display: flex; align-items: center; gap: 4px; } - -.ghp-pill { display: inline-flex; align-items: center; gap: 6px; border: 0; border-radius: 8px; padding: 6px 10px; font: inherit; font-size: 12px; font-weight: 600; cursor: pointer; transition: background .15s, color .15s; background: color-mix(in srgb, var(--primary) 8%, transparent); color: color-mix(in srgb, var(--primary) 80%, transparent); } -.ghp-pill:hover { background: color-mix(in srgb, var(--primary) 15%, transparent); color: var(--primary); } -.ghp-pill-quiet { background: transparent; color: color-mix(in srgb, var(--muted-foreground) 70%, transparent); } -.ghp-pill-quiet:hover { background: color-mix(in srgb, var(--muted) 50%, transparent); color: var(--foreground); } -.ghp-pill:focus-visible, .ghp-icon-btn:focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; } - -.ghp-rows { border: 1px solid var(--ghp-line); background: color-mix(in srgb, var(--card) 50%, transparent); border-radius: 12px; overflow: hidden; } -.ghp-row-item { display: flex; align-items: center; gap: 12px; padding: 10px 16px; } -.ghp-row-item + .ghp-row-item { border-top: 1px solid color-mix(in srgb, var(--border) 15%, transparent); } -.ghp-row-icon { flex: none; color: color-mix(in srgb, var(--muted-foreground) 40%, transparent); } -.ghp-row-icon[data-state="open"] { color: var(--ghp-success); } -.ghp-row-icon[data-state="merged"] { color: var(--ghp-merged); } -.ghp-row-icon[data-state="closed"] { color: var(--ghp-danger); } -.ghp-row-ref { flex: none; font-family: "JetBrains Mono", ui-monospace, monospace; font-size: 12px; color: var(--ghp-faint); } -.ghp-row-body { flex: 1; min-width: 0; } -.ghp-row-title { display: block; font-size: 14px; color: var(--foreground); text-decoration: none; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; transition: color .1s; } -a.ghp-row-title:hover { color: var(--primary); } -.ghp-row-title-mono { font-family: "JetBrains Mono", ui-monospace, monospace; font-size: 13px; } -.ghp-row-meta { display: flex; align-items: center; gap: 6px; margin-top: 2px; font-size: 12px; color: var(--ghp-faint); overflow: hidden; white-space: nowrap; } -.ghp-row-meta .ghp-ref { flex: none; } -.ghp-row-actions { display: flex; align-items: center; gap: 4px; opacity: 0; transition: opacity .15s; } -.ghp-row-item:hover .ghp-row-actions, .ghp-row-item:focus-within .ghp-row-actions { opacity: 1; } -@media (hover: none) { .ghp-row-actions { opacity: 1; } } -.ghp-icon-btn { width: 24px; height: 24px; display: inline-grid; place-items: center; border: 0; border-radius: 4px; background: none; color: color-mix(in srgb, var(--muted-foreground) 50%, transparent); cursor: pointer; transition: background .1s, color .1s; padding: 0; } -.ghp-icon-btn:hover { color: var(--foreground); background: color-mix(in srgb, var(--muted) 30%, transparent); } -.ghp-icon-btn-danger:hover { color: var(--ghp-danger); background: color-mix(in srgb, var(--ghp-danger) 10%, transparent); } -.ghp-icon-btn:disabled { opacity: .5; cursor: not-allowed; } - -.ghp-sec-empty { display: flex; align-items: center; gap: 12px; padding: 12px 4px; color: color-mix(in srgb, var(--muted-foreground) 45%, transparent); font-size: 14px; font-style: italic; } -.ghp-sec-error { font-size: 12px; color: var(--ghp-danger); background: color-mix(in srgb, var(--ghp-danger) 10%, transparent); border-radius: 8px; padding: 8px 12px; } - -.ghp-badge-merged { background: color-mix(in srgb, var(--ghp-merged) 14%, transparent); color: var(--ghp-merged); } -.ghp-ref { display: inline-flex; align-items: center; gap: 4px; font-family: "JetBrains Mono", ui-monospace, monospace; font-size: 11px; background: var(--muted); border-radius: 4px; padding: 1px 6px; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } - -/* Inline create/link forms: the same bordered panel as the row lists. */ -.ghp-form { display: flex; flex-direction: column; gap: 12px; padding: 16px; border: 1px solid var(--ghp-line, var(--border)); border-radius: 12px; background: color-mix(in srgb, var(--card) 50%, transparent); } -.ghp-form-actions { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } -.ghp-form-error { color: var(--ghp-danger); font-size: 12px; } -.ghp-seg { display: flex; flex-wrap: wrap; gap: 4px; } -.ghp-seg button { height: 26px; padding: 0 10px; border-radius: 999px; border: 1px solid var(--border); background: transparent; color: var(--muted-foreground); font: inherit; font-size: 12px; font-weight: 500; cursor: pointer; } -.ghp-seg button:hover { color: var(--foreground); border-color: color-mix(in srgb, var(--foreground) 28%, var(--border)); } -.ghp-seg button[aria-pressed="true"] { background: color-mix(in srgb, var(--primary) 12%, transparent); border-color: var(--primary); color: var(--primary); } -.ghp-seg button:focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; } - -.ghp-cmd { display: flex; align-items: center; gap: 8px; padding: 8px 10px; border-radius: 6px; background: var(--muted); border: 1px solid var(--border); } -.ghp-cmd code { flex: 1; min-width: 0; background: none; padding: 0; word-break: break-all; color: var(--foreground); } -.ghp-details > summary { cursor: pointer; font-size: 12px; color: var(--muted-foreground); list-style: none; display: inline-flex; align-items: center; gap: 6px; } -.ghp-details > summary::-webkit-details-marker { display: none; } -.ghp-details > summary::before { content: ""; width: 6px; height: 6px; border-right: 1.5px solid currentColor; border-bottom: 1.5px solid currentColor; transform: rotate(-45deg); transition: transform .15s; } -.ghp-details[open] > summary::before { transform: rotate(45deg); } -.ghp-details > summary:hover { color: var(--foreground); } -.ghp-details[open] > summary { margin-bottom: 8px; } - -/* ── GitHub App setup callback page ───────────────────────────────────────── */ -.ghp.ghp-callback { min-height: 60vh; align-items: center; justify-content: center; margin: 0 auto; } -.ghp-callback-card { display: flex; flex-direction: column; align-items: center; text-align: center; gap: 12px; padding: 36px 32px; max-width: 440px; } diff --git a/frontend/src/ui.tsx b/frontend/src/ui.tsx deleted file mode 100644 index 25eeee0..0000000 --- a/frontend/src/ui.tsx +++ /dev/null @@ -1,410 +0,0 @@ -import { Check, ClipboardCopy, Loader2, Terminal } from "lucide-react"; -import { type ReactNode, useEffect, useId, useRef, useState } from "react"; -import "./styles.css"; - -export function cn(...classes: (string | undefined | null | false)[]): string { - return classes.filter(Boolean).join(" "); -} - -export function GitHubIcon(props: React.SVGProps) { - return ( - - ); -} - -// ── Button ──────────────────────────────────────────────────────────────────── - -type ButtonProps = React.ButtonHTMLAttributes & { - variant?: "primary" | "secondary" | "ghost" | "danger"; - size?: "md" | "sm"; - icon?: boolean; - loading?: boolean; -}; - -export function Button({ - variant = "secondary", - size = "md", - icon, - loading, - className, - children, - disabled, - ...props -}: ButtonProps) { - return ( - - ); -} - -// ── Form controls ───────────────────────────────────────────────────────────── - -export function Field({ - label, - hint, - children, -}: { - label: string; - hint?: string; - children: (id: string) => ReactNode; -}) { - const id = useId(); - return ( -
- - {children(id)} - {hint &&

{hint}

} -
- ); -} - -export function Input({ className, mono, ...props }: React.InputHTMLAttributes & { mono?: boolean }) { - return ; -} - -export function Select({ className, children, ...props }: React.SelectHTMLAttributes) { - return ( - - ); -} - -export function Switch({ - checked, - onChange, - label, - disabled, -}: { - checked: boolean; - onChange: (next: boolean) => void; - label: string; - disabled?: boolean; -}) { - return ( - - ))} -
- ); -} - -export function TabPanel({ id, children }: { id: string; children: ReactNode }) { - return ( -
- {children} -
- ); -} - -// ── Modal ───────────────────────────────────────────────────────────────────── - -export function Modal({ - open, - onClose, - title, - description, - wide, - footer, - children, -}: { - open: boolean; - onClose: () => void; - title: string; - description?: string; - wide?: boolean; - footer?: ReactNode; - children?: ReactNode; -}) { - const titleId = useId(); - const ref = useRef(null); - - useEffect(() => { - if (!open) return; - const previous = document.activeElement as HTMLElement | null; - const onKey = (e: KeyboardEvent) => { - if (e.key === "Escape") { - e.stopPropagation(); - onClose(); - } - }; - document.addEventListener("keydown", onKey); - // Move focus into the dialog so keyboard users land inside it. - ref.current?.querySelector("input, select, button:not([data-close])")?.focus(); - return () => { - document.removeEventListener("keydown", onKey); - previous?.focus?.(); - }; - }, [open, onClose]); - - if (!open) return null; - return ( -
e.target === e.currentTarget && onClose()}> -
-

- {title} -

- {description &&

{description}

} -
{children}
- {footer &&
{footer}
} -
-
- ); -} - -// ── Task-section primitives ─────────────────────────────────────────────────── -// Styled to sit beside the host's own task-detail sections (see styles.css). - -/** A task-detail section: the host's heading, optional action pills, then content. */ -export function Section({ - title, - actions, - children, -}: { - title: string; - actions?: ReactNode; - children: ReactNode; -}) { - return ( -
-
-

{title}

- {actions &&
{actions}
} -
- {children} -
- ); -} - -export function Pill({ - quiet, - className, - children, - ...props -}: React.ButtonHTMLAttributes & { quiet?: boolean }) { - return ( - - ); -} - -export function RowIconButton({ - danger, - className, - children, - ...props -}: React.ButtonHTMLAttributes & { danger?: boolean }) { - return ( - - ); -} - -export function SectionEmpty({ icon, children }: { icon: ReactNode; children: ReactNode }) { - return ( -
- {icon} -

{children}

-
- ); -} - -export function Segmented({ options, value, onChange, label }: { options: readonly T[]; value: T; onChange: (v: T) => void; label: string }) { - return ( -
- {options.map((o) => ( - - ))} -
- ); -} - -function useCopy(text: string) { - const [copied, setCopied] = useState(false); - return { - copied, - copy: () => { - navigator.clipboard?.writeText(text)?.catch(() => {}); - setCopied(true); - setTimeout(() => setCopied(false), 1800); - }, - }; -} - -/** Copy button for a list row (hover action). */ -export function CopyIconButton({ text, label }: { text: string; label: string }) { - const { copied, copy } = useCopy(text); - return ( - - {copied ? - ); -} - -export function CopyButton({ text, label = "Copy to clipboard" }: { text: string; label?: string }) { - const { copied, copy } = useCopy(text); - return ( - - ); -} - -export function CommandBlock({ command }: { command: string }) { - return ( -
-
- ); -} 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 d83da3a..0000000 --- a/frontend/vite.config.ts +++ /dev/null @@ -1,28 +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", - "./GitHubAppCallbackPage": "./src/GitHubAppCallbackPage.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 669bed8..0000000 --- a/mcp/src/index.ts +++ /dev/null @@ -1,826 +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; - connected?: boolean; - auth_type?: "pat" | "app"; - app_name?: string; - account_login?: 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} -Connected: ${integration.connected ?? true} -Auth: ${integration.auth_type === "app" ? `GitHub App ${integration.app_name ?? ""} (${integration.account_login ?? ""})` : "personal access token"} -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 0f4ccda..34aeb82 100644 --- a/plugin.json +++ b/plugin.json @@ -1,550 +1,16 @@ { "id": "com.paca.github", - "displayName": "GitHub Integration", - "description": "Integrates GitHub repositories, pull requests, and branches with Paca projects and tasks.", - "version": "0.6.0", + "displayName": "GitHub", + "description": "GitHub provider for Paca's Git integration: GitHub App identities, repository access, credentials and webhooks.", + "version": "0.8.0", "minCoreVersion": "v0.19.0", - "capabilities": ["repository"], + "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" - }, - { - "key": "github:manage_apps", - "label": "Manage GitHub Apps", - "description": "Instance-wide: create GitHub Apps through the manifest flow, install them on GitHub accounts and organizations, choose which projects may use each installation, and delete apps. Project managers can only pick from the installations made available to their project.", - "scope": "global" - } - ], "backend": { - "allowedConfigKeys": ["ENCRYPTION_KEY", "PUBLIC_URL"], + "allowedConfigKeys": ["ENCRYPTION_KEY"], "allowedOutboundDomains": ["api.github.com"], "sensitiveFields": { - "github_integrations": ["access_token_enc"], - "github_repositories": ["webhook_secret_enc"], "github_apps": ["client_secret_enc", "webhook_secret_enc", "private_key_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": "/apps/manifest", - "middlewares": [ - { "name": "optionalAuthn" }, - { "name": "requireFreshPassword" }, - { - "name": "requireActions", - "scope": "global", - "actions": ["github:manage_apps"] - } - ] - }, - { - "method": "POST", - "path": "/apps/callback/created", - "middlewares": [ - { "name": "optionalAuthn" }, - { "name": "requireFreshPassword" }, - { - "name": "requireActions", - "scope": "global", - "actions": ["github:manage_apps"] - } - ] - }, - { - "method": "POST", - "path": "/apps/callback/installed", - "middlewares": [ - { "name": "optionalAuthn" }, - { "name": "requireFreshPassword" }, - { - "name": "requireActions", - "scope": "global", - "actions": ["github:manage_apps"] - } - ] - }, - { - "method": "GET", - "path": "/apps", - "middlewares": [ - { "name": "optionalAuthn" }, - { "name": "requireFreshPassword" }, - { - "name": "requireActions", - "scope": "global", - "actions": ["github:manage_apps"] - } - ] - }, - { - "method": "DELETE", - "path": "/apps/:appId", - "middlewares": [ - { "name": "optionalAuthn" }, - { "name": "requireFreshPassword" }, - { - "name": "requireActions", - "scope": "global", - "actions": ["github:manage_apps"] - } - ] - }, - { - "method": "POST", - "path": "/apps/:appId/install", - "middlewares": [ - { "name": "optionalAuthn" }, - { "name": "requireFreshPassword" }, - { - "name": "requireActions", - "scope": "global", - "actions": ["github:manage_apps"] - } - ] - }, - { - "method": "POST", - "path": "/apps/:appId/installations/:installationId/access", - "middlewares": [ - { "name": "optionalAuthn" }, - { "name": "requireFreshPassword" }, - { - "name": "requireActions", - "scope": "global", - "actions": ["github:manage_apps"] - } - ] - }, - { - "method": "GET", - "path": "/projects/:projectId/integration/installations", - "middlewares": [ - { "name": "optionalAuthn" }, - { "name": "requireFreshPassword" }, - { - "name": "requireActions", - "scope": "project", - "actions": ["github:manage"] - } - ] - }, - { - "method": "POST", - "path": "/projects/:projectId/integration/app", - "middlewares": [ - { "name": "optionalAuthn" }, - { "name": "requireFreshPassword" }, - { - "name": "requireActions", - "scope": "project", - "actions": ["github:manage"] - } - ] - }, - { - "method": "GET", - "path": "/projects/:projectId/integration/agent-identities", - "middlewares": [ - { "name": "optionalAuthn" }, - { "name": "requireFreshPassword" }, - { - "name": "requireActions", - "scope": "project", - "actions": ["github:manage"] - } - ] - }, - { - "method": "POST", - "path": "/projects/:projectId/integration/agent-identities", - "middlewares": [ - { "name": "optionalAuthn" }, - { "name": "requireFreshPassword" }, - { - "name": "requireActions", - "scope": "project", - "actions": ["github:manage"] - } - ] - }, - { - "method": "DELETE", - "path": "/projects/:projectId/integration/agent-identities/:memberId", - "middlewares": [ - { "name": "optionalAuthn" }, - { "name": "requireFreshPassword" }, - { - "name": "requireActions", - "scope": "project", - "actions": ["github:manage"] - } - ] - }, - { - "method": "POST", - "path": "/webhooks/apps/:appKey", - "middlewares": [{ "name": "optionalAuthn" }] - }, - { - "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 - }, - { - "point": "app.page", - "component": "GitHubAppCallbackPage", - "requiredPermission": "github:manage_apps" - } - ], - "navItems": [ - { - "scope": "app", - "slug": "github-app-callback", - "label": "GitHub App setup", - "component": "GitHubAppCallbackPage", - "requiredPermission": "github:manage_apps" - } - ] - }, - "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"] + } } } diff --git a/skills/paca-github-workflow/SKILL.md b/skills/paca-github-workflow/SKILL.md deleted file mode 100644 index 7622715..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 GitHub App installation (created from Project Settings → GitHub) or a Personal Access Token (PAT), 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 App installation or a GitHub PAT (scopes `repo` + `admin:repo_hook`) must be connected first — you cannot create either yourself. Tell the user to connect one under Project Settings → GitHub (the GitHub App option is recommended), 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. From 42959345087240625e52d243c5813f2825570301 Mon Sep 17 00:00:00 2001 From: pikann22 Date: Sun, 25 Oct 2026 07:18:32 +0000 Subject: [PATCH 3/8] feat: update GitHub App integration with branch management and validation --- backend/go.mod | 5 +- backend/go.sum | 4 +- backend/spi_branches.go | 94 ++++++++++++++++++++++++++++++++++ backend/spi_fakegithub_test.go | 29 ++++++++--- backend/spi_provider.go | 10 ++++ backend/spi_test.go | 66 ++++++++++++++++++++++++ backend/spi_webhook.go | 8 +-- plugin.json | 4 +- 8 files changed, 203 insertions(+), 17 deletions(-) create mode 100644 backend/spi_branches.go diff --git a/backend/go.mod b/backend/go.mod index 31ac41a..147f1c6 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -2,7 +2,4 @@ module github.com/Paca-AI/first-party/github go 1.24 -require github.com/Paca-AI/plugin-sdk-go v0.3.3 - -// gitprovider (the Git provider SPI) is not in a published SDK release yet. -replace github.com/Paca-AI/plugin-sdk-go => ../../plugin-sdk-go +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/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_fakegithub_test.go b/backend/spi_fakegithub_test.go index a79f679..198fb73 100644 --- a/backend/spi_fakegithub_test.go +++ b/backend/spi_fakegithub_test.go @@ -44,6 +44,7 @@ type fakeGitHub struct { 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? } @@ -118,7 +119,8 @@ func newFakeGitHub(t *testing.T) *fakeGitHub { 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"}}, + 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", @@ -455,6 +457,26 @@ func (g *fakeGitHub) repoSub(method, sub string, q url.Values, headers map[strin 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]) @@ -532,11 +554,6 @@ func (g *fakeGitHub) repoSub(method, sub string, q url.Values, headers map[strin legacy = "write" } return resp(200, map[string]string{"permission": legacy, "role_name": perm}) - case seg[0] == "branches" && len(seg) == 2: - if seg[1] != "main" { - return notFound() - } - return resp(200, map[string]any{"protected": true, "protection": map[string]any{"required_status_checks": map[string]any{"strict": true}}}) case seg[0] == "contents": if strings.Join(seg[1:], "/") != "README.md" { return notFound() diff --git a/backend/spi_provider.go b/backend/spi_provider.go index 67e279c..202e26e 100644 --- a/backend/spi_provider.go +++ b/backend/spi_provider.go @@ -63,6 +63,7 @@ func (p *ghProvider) Describe() gp.Info { Assignees: true, Merge: true, IdentityDeletion: true, + Branches: true, AccessGrants: true, BatchReview: true, @@ -429,6 +430,15 @@ func (p *ghProvider) MintCredential(_ context.Context, req gp.MintRequest) (gp.C } 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 } diff --git a/backend/spi_test.go b/backend/spi_test.go index c6b3d2a..68b6fdf 100644 --- a/backend/spi_test.go +++ b/backend/spi_test.go @@ -683,3 +683,69 @@ func TestSPIRoutesAreMountedOnTheInternalPrefix(t *testing.T) { 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) + } +} diff --git a/backend/spi_webhook.go b/backend/spi_webhook.go index 5abdb54..f43f635 100644 --- a/backend/spi_webhook.go +++ b/backend/spi_webhook.go @@ -110,8 +110,10 @@ type ghPRPayload struct { Body string `json:"body"` Draft bool `json:"draft"` Merged bool `json:"merged"` - User ghUser `json:"user"` - Head struct { + // 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 { @@ -146,7 +148,7 @@ func prSubject(pr *ghPRPayload, baseRepo string) *gp.EventSubject { } 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} + 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 { diff --git a/plugin.json b/plugin.json index 34aeb82..30561f0 100644 --- a/plugin.json +++ b/plugin.json @@ -2,8 +2,8 @@ "id": "com.paca.github", "displayName": "GitHub", "description": "GitHub provider for Paca's Git integration: GitHub App identities, repository access, credentials and webhooks.", - "version": "0.8.0", - "minCoreVersion": "v0.19.0", + "version": "0.5.0", + "minCoreVersion": "v0.20.0", "capabilities": ["git-provider"], "permissions": ["db.read", "db.write"], "backend": { From 920ced34cd7424e9e56b67bf6ac2233d82e41369 Mon Sep 17 00:00:00 2001 From: pikann22 Date: Sun, 25 Oct 2026 07:32:45 +0000 Subject: [PATCH 4/8] feat: remove unused CI workflows and streamline release process --- .github/workflows/frontend-pr-ci.yml | 53 ---------------------------- .github/workflows/mcp-pr-ci.yml | 53 ---------------------------- .github/workflows/release.yml | 38 +------------------- backend/scanner.go | 4 --- 4 files changed, 1 insertion(+), 147 deletions(-) delete mode 100644 .github/workflows/frontend-pr-ci.yml delete mode 100644 .github/workflows/mcp-pr-ci.yml 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/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)) -} From b8fa4a057d8eb05e1e6f2894becbbade09fcc492 Mon Sep 17 00:00:00 2001 From: pikann22 Date: Sun, 25 Oct 2026 09:21:35 +0000 Subject: [PATCH 5/8] feat: add GitHub App migration scripts and update test for trigger validation --- ..._github_apps.sql => 0002_git_provider.sql} | 38 +++++++++---------- backend/migrations/0003_agent_identities.sql | 17 --------- .../migrations/0004_git_provider_sessions.sql | 7 ---- backend/migrations/0005_drop_legacy.sql | 20 ---------- backend/spi_test.go | 2 +- 5 files changed, 19 insertions(+), 65 deletions(-) rename backend/migrations/{0002_github_apps.sql => 0002_git_provider.sql} (68%) delete mode 100644 backend/migrations/0003_agent_identities.sql delete mode 100644 backend/migrations/0004_git_provider_sessions.sql delete mode 100644 backend/migrations/0005_drop_legacy.sql diff --git a/backend/migrations/0002_github_apps.sql b/backend/migrations/0002_git_provider.sql similarity index 68% rename from backend/migrations/0002_github_apps.sql rename to backend/migrations/0002_git_provider.sql index 765064f..a87715d 100644 --- a/backend/migrations/0002_github_apps.sql +++ b/backend/migrations/0002_git_provider.sql @@ -1,6 +1,9 @@ --- 0002_github_apps.sql --- Adds instance-level GitHub Apps (created through the GitHub App Manifest --- flow), their installations, and the per-project binding to an installation. +-- 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 ( @@ -31,21 +34,12 @@ CREATE TABLE IF NOT EXISTS github_app_installations ( account_login TEXT NOT NULL DEFAULT '', account_type TEXT NOT NULL DEFAULT '', repository_selection TEXT NOT NULL DEFAULT 'selected', - -- When false, only the projects listed in github_installation_projects may - -- bind this installation. - allow_all_projects BOOLEAN NOT NULL DEFAULT FALSE, suspended_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), UNIQUE (app_id, installation_id) ); -CREATE TABLE IF NOT EXISTS github_installation_projects ( - installation_id UUID NOT NULL REFERENCES github_app_installations(id) ON DELETE CASCADE, - project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE, - PRIMARY KEY (installation_id, project_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 ( @@ -54,8 +48,9 @@ CREATE TABLE IF NOT EXISTS github_manifest_sessions ( created_by TEXT NOT NULL, app_name TEXT NOT NULL DEFAULT '', owner_org TEXT NOT NULL DEFAULT '', - allow_all_projects BOOLEAN NOT NULL DEFAULT FALSE, - return_url 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, @@ -65,9 +60,12 @@ CREATE TABLE IF NOT EXISTS github_manifest_sessions ( created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); --- A project is connected either by PAT (access_token_enc) or by binding an --- app installation. -ALTER TABLE github_integrations ALTER COLUMN access_token_enc DROP NOT NULL; -ALTER TABLE github_integrations ADD COLUMN IF NOT EXISTS auth_type TEXT NOT NULL DEFAULT 'pat'; -ALTER TABLE github_integrations - ADD COLUMN IF NOT EXISTS installation_id UUID REFERENCES github_app_installations(id) ON DELETE SET NULL; +-- 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/migrations/0003_agent_identities.sql b/backend/migrations/0003_agent_identities.sql deleted file mode 100644 index 8c4d2d0..0000000 --- a/backend/migrations/0003_agent_identities.sql +++ /dev/null @@ -1,17 +0,0 @@ --- 0003_agent_identities.sql --- Maps an account (typically an AI agent) to the GitHub App installation it --- acts as inside one project. Accounts without a mapping use the project's --- default installation. --- Run with search_path = plugin_data_com_paca_github, public. - -CREATE TABLE IF NOT EXISTS github_agent_identities ( - project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE, - account_id UUID NOT NULL, - installation_id UUID NOT NULL REFERENCES github_app_installations(id) ON DELETE CASCADE, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - PRIMARY KEY (project_id, account_id) -); - -CREATE INDEX IF NOT EXISTS idx_github_agent_identities_installation_id - ON github_agent_identities (installation_id); diff --git a/backend/migrations/0004_git_provider_sessions.sql b/backend/migrations/0004_git_provider_sessions.sql deleted file mode 100644 index c43e4c4..0000000 --- a/backend/migrations/0004_git_provider_sessions.sql +++ /dev/null @@ -1,7 +0,0 @@ --- 0004_git_provider_sessions.sql --- The Git provider SPI drives the GitHub App manifest flow for core, which --- also needs to know whom the identity is for. Run with --- search_path = plugin_data_com_paca_github, public. - -ALTER TABLE github_manifest_sessions ADD COLUMN IF NOT EXISTS scope TEXT NOT NULL DEFAULT 'shared'; -ALTER TABLE github_manifest_sessions ADD COLUMN IF NOT EXISTS agent_id TEXT NOT NULL DEFAULT ''; diff --git a/backend/migrations/0005_drop_legacy.sql b/backend/migrations/0005_drop_legacy.sql deleted file mode 100644 index 93c2838..0000000 --- a/backend/migrations/0005_drop_legacy.sql +++ /dev/null @@ -1,20 +0,0 @@ --- 0005_drop_legacy.sql --- The plugin is now only a Git provider (spi_*.go). Everything the old --- project-bound integration kept (PAT and app connections, linked --- repositories, cached pull requests, task links, per-agent identities and --- per-project installation grants) is superseded by core's git_* tables and is --- dropped; PAT connections cannot be carried over, so no data is migrated. --- Kept: github_apps, github_app_installations, github_manifest_sessions. --- Run with search_path = plugin_data_com_paca_github, public. - -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_agent_identities; -DROP TABLE IF EXISTS github_installation_projects; -DROP TABLE IF EXISTS github_integrations; - -ALTER TABLE github_app_installations DROP COLUMN IF EXISTS allow_all_projects; -ALTER TABLE github_manifest_sessions DROP COLUMN IF EXISTS allow_all_projects; -ALTER TABLE github_manifest_sessions DROP COLUMN IF EXISTS return_url; diff --git a/backend/spi_test.go b/backend/spi_test.go index 68b6fdf..c9e9938 100644 --- a/backend/spi_test.go +++ b/backend/spi_test.go @@ -37,7 +37,7 @@ func TestDescribe_DeclaresWhatGitHubCanDo(t *testing.T) { c := p.Describe().Capabilities for _, trig := range []gp.TriggerKind{gp.TriggerReviewRequest, gp.TriggerAssignment} { if c.SupportsTrigger(trig) { - t.Errorf("%s must not be declared: an ordinary GitHub App bot cannot be requested or assigned (spike S1)", trig) + t.Errorf("%s must not be declared: an ordinary GitHub App bot cannot be requested or assigned", trig) } } for _, trig := range []gp.TriggerKind{gp.TriggerMention, gp.TriggerLabel} { From 028324fa1719acff5a36db5396be165699fa2c49 Mon Sep 17 00:00:00 2001 From: pikann22 Date: Sun, 25 Oct 2026 10:21:05 +0000 Subject: [PATCH 6/8] feat: enhance GitHub App integration with review request and assignment handling --- backend/spi_conformance_test.go | 28 +++++++++++++++++++++++--- backend/spi_provider.go | 2 +- backend/spi_test.go | 16 ++++++--------- backend/spi_webhook.go | 35 ++++++++++++++++++++++++--------- 4 files changed, 58 insertions(+), 23 deletions(-) diff --git a/backend/spi_conformance_test.go b/backend/spi_conformance_test.go index 4874d68..6b947eb 100644 --- a/backend/spi_conformance_test.go +++ b/backend/spi_conformance_test.go @@ -232,10 +232,32 @@ func conformanceFixtures() conformance.Fixtures { 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: "label-added", - Request: delivery("issues", "d13", map[string]any{"action": "labeled", "repository": repoPayload, "sender": userSender, + {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-is-not-a-trigger", + Request: delivery("issues", "d18", 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"}}}, + Want: nil}, {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}, diff --git a/backend/spi_provider.go b/backend/spi_provider.go index 202e26e..b3b8ff4 100644 --- a/backend/spi_provider.go +++ b/backend/spi_provider.go @@ -53,7 +53,7 @@ func (p *ghProvider) Describe() gp.Info { CredentialModel: gp.CredentialEphemeral, WebhookScope: gp.WebhookApp, StatusKind: gp.StatusCheckRun, - Triggers: []gp.TriggerKind{gp.TriggerMention, gp.TriggerLabel, gp.TriggerCIFailure, gp.TriggerOwnCRFeedback, gp.TriggerAutoReview}, + Triggers: []gp.TriggerKind{gp.TriggerMention, gp.TriggerReviewRequest, gp.TriggerAssignment, gp.TriggerCIFailure, gp.TriggerOwnCRFeedback, gp.TriggerAutoReview}, MultipleIdentities: true, ResolveThreads: true, diff --git a/backend/spi_test.go b/backend/spi_test.go index c9e9938..5b6aeef 100644 --- a/backend/spi_test.go +++ b/backend/spi_test.go @@ -35,16 +35,14 @@ func webTarget() gp.Target { func TestDescribe_DeclaresWhatGitHubCanDo(t *testing.T) { p, _, _ := seededProvider(t) c := p.Describe().Capabilities - for _, trig := range []gp.TriggerKind{gp.TriggerReviewRequest, gp.TriggerAssignment} { - if c.SupportsTrigger(trig) { - t.Errorf("%s must not be declared: an ordinary GitHub App bot cannot be requested or assigned", trig) - } - } - for _, trig := range []gp.TriggerKind{gp.TriggerMention, gp.TriggerLabel} { + for _, trig := range []gp.TriggerKind{gp.TriggerMention, gp.TriggerReviewRequest, gp.TriggerAssignment} { if !c.SupportsTrigger(trig) { t.Errorf("%s should be declared", trig) } } + if c.SupportsTrigger(gp.TriggerLabel) { + t.Error("a label is not a trigger") + } if c.CredentialModel != gp.CredentialEphemeral || c.WebhookScope != gp.WebhookApp || c.StatusKind != gp.StatusCheckRun { t.Errorf("unexpected model: %+v", c) } @@ -308,10 +306,8 @@ func TestWebhook_Details(t *testing.T) { 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) - } - for _, a := range []string{"unlabeled", "assigned", "review_requested", "locked"} { + // Requests and assignments that do not name this app are dropped, as are labels. + for _, a := range []string{"labeled", "unlabeled", "assigned", "review_requested", "locked"} { if evs, _ := p.VerifyAndParseWebhook(bg, pull(a, nil)); len(evs) != 0 { t.Errorf("%s should be dropped", a) } diff --git a/backend/spi_webhook.go b/backend/spi_webhook.go index f43f635..53743e7 100644 --- a/backend/spi_webhook.go +++ b/backend/spi_webhook.go @@ -57,9 +57,10 @@ type ghDelivery struct { 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"` @@ -235,11 +236,20 @@ func (d *ghDelivery) normalize(name string, app appRecord) (gp.Event, bool) { if d.PullRequest.Merged { ev.Action = "merged" } - case "labeled": - if d.Label == nil { + 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.Label = gp.EventLabel, "labeled", d.Label.Name + 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 } @@ -255,11 +265,13 @@ func (d *ghDelivery) normalize(name string, app appRecord) (gp.Event, bool) { 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 { + case "assigned": + if !isApp(d.Assignee, app) { return gp.Event{}, false } - ev.Kind, ev.Action, ev.Label = gp.EventLabel, "labeled", d.Label.Name + 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 } @@ -298,6 +310,11 @@ func (d *ghDelivery) normalize(name string, app appRecord) (gp.Event, bool) { 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 { From 6f5997df5ce7fa1e575313a6ae508dc8bf3e0004 Mon Sep 17 00:00:00 2001 From: pikann22 Date: Sun, 25 Oct 2026 11:01:20 +0000 Subject: [PATCH 7/8] feat: refine GitHub App integration by removing label trigger and updating event handling --- backend/github.go | 1 + backend/spi_conformance_test.go | 6 +++--- backend/spi_provider.go | 8 ++++---- backend/spi_test.go | 26 ++++++++++++++++++++------ backend/spi_webhook.go | 14 ++++++++++++++ 5 files changed, 42 insertions(+), 13 deletions(-) diff --git a/backend/github.go b/backend/github.go index f95a5a3..4765099 100644 --- a/backend/github.go +++ b/backend/github.go @@ -47,6 +47,7 @@ var manifestEvents = []string{ "pull_request_review_comment", "pull_request_review_thread", "issue_comment", + "issues", "check_run", "check_suite", } diff --git a/backend/spi_conformance_test.go b/backend/spi_conformance_test.go index 6b947eb..085eda3 100644 --- a/backend/spi_conformance_test.go +++ b/backend/spi_conformance_test.go @@ -254,10 +254,10 @@ func conformanceFixtures() conformance.Fixtures { "assignee": map[string]any{"login": "bob", "type": "User"}, "issue": map[string]any{"number": 7, "title": "Bug", "user": userSender}}), Want: nil}, - {Name: "label-is-not-a-trigger", - Request: delivery("issues", "d18", map[string]any{"action": "labeled", "repository": repoPayload, "sender": userSender, + {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: nil}, + 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}, diff --git a/backend/spi_provider.go b/backend/spi_provider.go index b3b8ff4..317cc83 100644 --- a/backend/spi_provider.go +++ b/backend/spi_provider.go @@ -34,9 +34,9 @@ func newGHProvider(store spiStore) *ghProvider { } // Describe implements gp.Provider. Capabilities are declared honestly: -// - review_request / assignment are not declared: an ordinary GitHub App bot -// cannot be a requested reviewer or an assignee, so those events never -// address the bot (users mention it or add a label instead); +// - 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 { @@ -53,7 +53,7 @@ func (p *ghProvider) Describe() gp.Info { CredentialModel: gp.CredentialEphemeral, WebhookScope: gp.WebhookApp, StatusKind: gp.StatusCheckRun, - Triggers: []gp.TriggerKind{gp.TriggerMention, gp.TriggerReviewRequest, gp.TriggerAssignment, gp.TriggerCIFailure, gp.TriggerOwnCRFeedback, gp.TriggerAutoReview}, + Triggers: []gp.TriggerKind{gp.TriggerMention, gp.TriggerLabel, gp.TriggerReviewRequest, gp.TriggerAssignment, gp.TriggerCIFailure, gp.TriggerOwnCRFeedback, gp.TriggerAutoReview}, MultipleIdentities: true, ResolveThreads: true, diff --git a/backend/spi_test.go b/backend/spi_test.go index 5b6aeef..61f3bca 100644 --- a/backend/spi_test.go +++ b/backend/spi_test.go @@ -35,14 +35,11 @@ func webTarget() gp.Target { func TestDescribe_DeclaresWhatGitHubCanDo(t *testing.T) { p, _, _ := seededProvider(t) c := p.Describe().Capabilities - for _, trig := range []gp.TriggerKind{gp.TriggerMention, gp.TriggerReviewRequest, gp.TriggerAssignment} { + 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.SupportsTrigger(gp.TriggerLabel) { - t.Error("a label is not a trigger") - } if c.CredentialModel != gp.CredentialEphemeral || c.WebhookScope != gp.WebhookApp || c.StatusKind != gp.StatusCheckRun { t.Errorf("unexpected model: %+v", c) } @@ -306,8 +303,11 @@ func TestWebhook_Details(t *testing.T) { if got := parseOne(t, p, pull("ready_for_review", nil)); got.Action != "ready" { t.Errorf("ready: %q", got.Action) } - // Requests and assignments that do not name this app are dropped, as are labels. - for _, a := range []string{"labeled", "unlabeled", "assigned", "review_requested", "locked"} { + 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) } @@ -745,3 +745,17 @@ func TestMintCredential_ReportsTheRequestedLifetimeWhenShorter(t *testing.T) { 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) + } + } +} diff --git a/backend/spi_webhook.go b/backend/spi_webhook.go index 53743e7..c2360e3 100644 --- a/backend/spi_webhook.go +++ b/backend/spi_webhook.go @@ -57,6 +57,10 @@ type ghDelivery struct { 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"` @@ -236,6 +240,11 @@ func (d *ghDelivery) normalize(name string, app appRecord) (gp.Event, bool) { 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. @@ -265,6 +274,11 @@ func (d *ghDelivery) normalize(name string, app appRecord) (gp.Event, bool) { 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 From cbb152010756ad450ef3956e79ab7788775fa4fa Mon Sep 17 00:00:00 2001 From: pikann22 Date: Sun, 25 Oct 2026 11:24:59 +0000 Subject: [PATCH 8/8] feat: update GitHub App integration by refining database schema, enhancing local URL checks, and improving setup code handling --- README.md | 6 +++++- backend/github.go | 26 +++++++++++++----------- backend/migrations/0002_git_provider.sql | 7 ------- backend/spi_access.go | 3 +++ backend/spi_access_test.go | 15 ++++++++++++++ backend/spi_conformance_test.go | 11 ++++++++++ backend/spi_dbstore.go | 25 ++++++++++++----------- backend/spi_dbstore_test.go | 4 ++-- backend/spi_setup.go | 20 ++++++++---------- backend/spi_store.go | 5 ++++- backend/spi_test.go | 25 +++++++++++++++++++++++ backend/spi_webhook.go | 15 +++++++++++++- plugin.json | 2 +- 13 files changed, 116 insertions(+), 48 deletions(-) diff --git a/README.md b/README.md index 91be913..bb4d257 100644 --- a/README.md +++ b/README.md @@ -21,9 +21,13 @@ The plugin has no HTTP routes of its own: core calls the internal-only `POST /_g ### Backend - `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, client secrets and webhook secrets are stored encrypted with AES-256-GCM using the `ENCRYPTION_KEY` config value. +- 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. +### Upgrading from 0.4.x + +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. + ### Build ```bash diff --git a/backend/github.go b/backend/github.go index 4765099..4044aac 100644 --- a/backend/github.go +++ b/backend/github.go @@ -5,9 +5,9 @@ import ( "crypto/rand" "crypto/sha256" "encoding/hex" + "net/netip" "net/url" "regexp" - "strconv" "strings" "time" @@ -90,6 +90,8 @@ func randomHex(nBytes int) (string, error) { 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 { @@ -97,24 +99,24 @@ func isLocalURL(rawURL string) bool { } host := strings.ToLower(u.Hostname()) switch { - case host == "", host == "localhost", host == "::1", host == "0.0.0.0": + 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 - case strings.HasPrefix(host, "127."), strings.HasPrefix(host, "10."), strings.HasPrefix(host, "192.168."): - return true - case strings.HasPrefix(host, "172."): - parts := strings.Split(host, ".") - if len(parts) > 1 { - if n, err := strconv.Atoi(parts[1]); err == nil && n >= 16 && n <= 31 { - return true - } - } } - return false + 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) { diff --git a/backend/migrations/0002_git_provider.sql b/backend/migrations/0002_git_provider.sql index a87715d..1eab48e 100644 --- a/backend/migrations/0002_git_provider.sql +++ b/backend/migrations/0002_git_provider.sql @@ -8,17 +8,11 @@ CREATE TABLE IF NOT EXISTS github_apps ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - -- Random token embedded in the app's webhook URL. It is generated before - -- GitHub assigns the numeric app id, so it is what routes a delivery to - -- the right webhook secret. - app_key TEXT NOT NULL UNIQUE, 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 '', - client_id TEXT NOT NULL DEFAULT '', - client_secret_enc TEXT NOT NULL DEFAULT '', webhook_secret_enc TEXT NOT NULL DEFAULT '', private_key_enc TEXT NOT NULL, webhook_active BOOLEAN NOT NULL DEFAULT TRUE, @@ -44,7 +38,6 @@ CREATE TABLE IF NOT EXISTS github_app_installations ( -- GitHub echoes back on both redirects. CREATE TABLE IF NOT EXISTS github_manifest_sessions ( state TEXT PRIMARY KEY, - app_key TEXT NOT NULL, created_by TEXT NOT NULL, app_name TEXT NOT NULL DEFAULT '', owner_org TEXT NOT NULL DEFAULT '', diff --git a/backend/spi_access.go b/backend/spi_access.go index 9ac5dda..840d8e6 100644 --- a/backend/spi_access.go +++ b/backend/spi_access.go @@ -105,6 +105,9 @@ func (p *ghProvider) CompleteAccessGrant(_ context.Context, req gp.CompleteAcces 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") } diff --git a/backend/spi_access_test.go b/backend/spi_access_test.go index 5770bd1..ec84268 100644 --- a/backend/spi_access_test.go +++ b/backend/spi_access_test.go @@ -310,3 +310,18 @@ func TestUninstalledAppIsForgottenAndCanBeGrantedAgain(t *testing.T) { 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_conformance_test.go b/backend/spi_conformance_test.go index 085eda3..acfac51 100644 --- a/backend/spi_conformance_test.go +++ b/backend/spi_conformance_test.go @@ -125,6 +125,17 @@ func (m *memStore) updateSetup(s setupSession) error { 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() diff --git a/backend/spi_dbstore.go b/backend/spi_dbstore.go index 94681ee..829a684 100644 --- a/backend/spi_dbstore.go +++ b/backend/spi_dbstore.go @@ -65,10 +65,6 @@ func (s dbStore) installations(appID string) ([]installRecord, error) { } func (s dbStore) createApp(a appRecord) (string, error) { - key, err := randomHex(16) - if err != nil { - return "", err - } pem, err := s.p.encrypt(a.PrivateKeyPEM) if err != nil { return "", err @@ -79,9 +75,9 @@ func (s dbStore) createApp(a appRecord) (string, error) { } now := nowStr() r, err := s.db.Query(` - INSERT INTO github_apps (app_key, 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,$9,'git-provider',$10,$10) RETURNING id`, - key, a.GitHubAppID, a.Slug, a.Name, a.OwnerLogin, a.HTMLURL, hook, pem, a.WebhookActive, now) + 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 } @@ -130,14 +126,14 @@ func (s dbStore) saveSetup(x setupSession) error { appID = x.AppID } _, err := s.db.Exec(` - INSERT INTO github_manifest_sessions (state, app_key, created_by, app_name, owner_org, scope, agent_id, app_id, expires_at, created_at) - VALUES ($1,$2,'git-provider',$3,$4,$5,$6,$7,$8,$9)`, - x.State, x.AppKey, x.AppName, x.OwnerOrg, x.Scope, x.AgentID, appID, x.ExpiresAt.UTC().Format(time.RFC3339Nano), nowStr()) + 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_key, app_name, owner_org, scope, agent_id, app_id, code_used_at, completed_at, expires_at FROM github_manifest_sessions WHERE state = $1`, state) + 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 } @@ -147,7 +143,7 @@ func (s dbStore) loadSetup(state string) (setupSession, error) { sc := newRowScanner(r.Columns, r.Rows[0]) exp, _ := time.Parse(time.RFC3339Nano, sc.str("expires_at")) return setupSession{ - State: sc.str("state"), AppKey: sc.str("app_key"), Scope: sc.str("scope"), AgentID: sc.str("agent_id"), + 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 @@ -169,6 +165,11 @@ func (s dbStore) updateSetup(x setupSession) error { 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 diff --git a/backend/spi_dbstore_test.go b/backend/spi_dbstore_test.go index 934afe6..29a349e 100644 --- a/backend/spi_dbstore_test.go +++ b/backend/spi_dbstore_test.go @@ -88,7 +88,7 @@ func newTestDBStore() dbStore { return dbStore{p: &githubPlugin{}, db: &fakeSQL{ func TestDBStore_AccessSessionKeepsItsApp(t *testing.T) { s := newTestDBStore() in := setupSession{ - State: "st-1", AppKey: "k", Scope: accessScope, OwnerOrg: "acme", + 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 { @@ -108,7 +108,7 @@ func TestDBStore_AccessSessionKeepsItsApp(t *testing.T) { func TestDBStore_IdentitySetupSessionHasNoAppYet(t *testing.T) { s := newTestDBStore() - if err := s.saveSetup(setupSession{State: "st-2", AppKey: "k", Scope: "shared", AppName: "Paca", ExpiresAt: time.Now().Add(time.Hour)}); err != nil { + 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") diff --git a/backend/spi_setup.go b/backend/spi_setup.go index 78815df..0b0cc71 100644 --- a/backend/spi_setup.go +++ b/backend/spi_setup.go @@ -52,12 +52,8 @@ func (p *ghProvider) BeginIdentitySetup(_ context.Context, req gp.BeginSetupRequ if err != nil { return gp.SetupStep{}, gp.Errorf(gp.CodeInternal, "could not generate state") } - appKey, err := randomHex(16) - if err != nil { - return gp.SetupStep{}, gp.Errorf(gp.CodeInternal, "could not generate state") - } if err := p.store.saveSetup(setupSession{ - State: state, AppKey: appKey, Scope: string(req.Scope), AgentID: req.AgentID, + 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") @@ -132,15 +128,17 @@ func identityOf(app appRecord) gp.Identity { // completeCreated is leg 1: manifest code → the App's credentials. func (p *ghProvider) completeCreated(s setupSession, code string) (gp.Identity, error) { - if s.CodeUsed { + // 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") } - // Mark the code used before exchanging it: GitHub's codes are single-use and - // the response is the only time the private key is ever shown. s.CodeUsed = true - if err := p.store.updateSetup(s); err != nil { - return gp.Identity{}, gp.Errorf(gp.CodeInternal, "could not record setup progress") - } conv, err := (&ghAPI{}).convertManifest(code) if err != nil { return gp.Identity{}, err diff --git a/backend/spi_store.go b/backend/spi_store.go index 488afc0..e070240 100644 --- a/backend/spi_store.go +++ b/backend/spi_store.go @@ -24,6 +24,10 @@ type spiStore interface { 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) @@ -59,7 +63,6 @@ type installRecord struct { // setupSession is one manifest-flow attempt, keyed by its single-use state. type setupSession struct { State string - AppKey string Scope string // shared | agent AgentID string AppName string diff --git a/backend/spi_test.go b/backend/spi_test.go index 61f3bca..29e3866 100644 --- a/backend/spi_test.go +++ b/backend/spi_test.go @@ -759,3 +759,28 @@ func TestManifest_SubscribesToEveryEventTheAdapterHandles(t *testing.T) { } } } + +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 index c2360e3..b34c443 100644 --- a/backend/spi_webhook.go +++ b/backend/spi_webhook.go @@ -8,6 +8,7 @@ import ( "regexp" "strconv" "strings" + "sync" gp "github.com/Paca-AI/plugin-sdk-go/gitprovider" ) @@ -335,6 +336,18 @@ 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-])`) - return re.MatchString(text) + mentionRegexps.Store(handle, re) + return re } diff --git a/plugin.json b/plugin.json index 30561f0..ea2e064 100644 --- a/plugin.json +++ b/plugin.json @@ -10,7 +10,7 @@ "allowedConfigKeys": ["ENCRYPTION_KEY"], "allowedOutboundDomains": ["api.github.com"], "sensitiveFields": { - "github_apps": ["client_secret_enc", "webhook_secret_enc", "private_key_enc"] + "github_apps": ["webhook_secret_enc", "private_key_enc"] } } }