Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 10 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ First-party Paca plugin that is the GitHub provider for Paca's platform-neutral

Core owns repositories, project links, identities, change requests and audit. This plugin only adapts GitHub to the provider SPI:

- 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).
- Connection setup, either through the GitHub App manifest flow (an App per workspace, project or agent) or by pasting a personal access token (the webhook secret is generated). A workspace or project may hold several connections of either kind.
- Apps: per-owner installation grants, requested when a repository is linked, and short-lived installation tokens for read/write repository credentials (never stored in remotes).
- Tokens: static credentials handed to git per command by core, a webhook registered on each linked repository with a generated secret (`webhook_management`; the token needs the webhook permission), and commit statuses instead of check runs. A token acts as its owner on GitHub, so a dedicated machine user is recommended.
- Repository listing, webhook verification and event normalisation.

## Structure
Expand All @@ -21,12 +21,16 @@ 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 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.
- Owns the schema `plugin_data_com_paca_github` (`github_apps`, `github_app_installations`, `github_tokens`, `github_manifest_sessions`); App private keys, tokens and webhook secrets are stored encrypted with AES-256-GCM using the `ENCRYPTION_KEY` config value.
- `spi_token.go` holds everything specific to token connections; the kind a setup creates is chosen by `Params["kind"]` (`app` by default, `token`). The provider reports per-kind behaviour in `capabilities.kinds`.

### 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.
Migration `0002_git_provider.sql` moves the previous release's data into core and drops its tables (`github_integrations`, `github_repositories`, `github_pull_requests`, `github_task_branches`, `github_task_pr_links`):

- Each project's access token becomes a **project-scoped token connection** (the ciphertext is copied as is, so the same `ENCRYPTION_KEY` must be in place), with its linked repositories, branch links and pull request links.
- The token's owner login and a webhook secret are not known to the migration; they are filled in the first time the token is used (GitHub is asked who owns it, a secret is generated). Until then the connection shows no `@handle`.
- The webhooks the old release registered on GitHub pointed at the old plugin and are left there. Open the project's Git settings and press *Try again* on each repository to register a new one (the token needs the webhook permission), and delete the old hooks on GitHub.

### Build

Expand Down
2 changes: 1 addition & 1 deletion backend/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@ module github.com/Paca-AI/first-party/github

go 1.24

require github.com/Paca-AI/plugin-sdk-go v0.4.0
require github.com/Paca-AI/plugin-sdk-go v0.4.1
4 changes: 2 additions & 2 deletions backend/go.sum
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
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=
github.com/Paca-AI/plugin-sdk-go v0.4.1 h1:Fid4T5OryhJca+9dBtM09iTipli9KouY/3G5ju2Sz5Y=
github.com/Paca-AI/plugin-sdk-go v0.4.1/go.mod h1:5WeC6cSEf2wM1ovICZbDaVky9oi5id/Qpdfc5LDAQnw=
107 changes: 98 additions & 9 deletions backend/migrations/0002_git_provider.sql
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
-- 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).
-- The GitHub provider's own state: GitHub Apps (created through the GitHub App
-- manifest flow, key material encrypted), the accounts they are installed on,
-- personal access token connections (token and webhook secret encrypted), and
-- the setup sessions that tie a setup's redirects or token form together. The
-- previous release's project-bound tables are migrated and dropped (see the end).
-- Run with search_path = plugin_data_com_paca_github, public.

CREATE TABLE IF NOT EXISTS github_apps (
Expand Down Expand Up @@ -43,6 +43,8 @@ CREATE TABLE IF NOT EXISTS github_manifest_sessions (
owner_org TEXT NOT NULL DEFAULT '',
-- Whom the identity is for: the shared one, or one agent's own.
scope TEXT NOT NULL DEFAULT 'shared',
-- Which kind of identity the attempt creates: an App (manifest flow) or a token.
kind TEXT NOT NULL DEFAULT 'app',
agent_id TEXT NOT NULL DEFAULT '',
-- Set once the manifest code has been exchanged; the second leg
-- (installation) then runs against this app.
Expand All @@ -53,10 +55,97 @@ CREATE TABLE IF NOT EXISTS github_manifest_sessions (
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- The plugin is a Git provider: repositories, pull requests, task links and
-- the per-project connection now live in Paca core (git_* tables). The
-- project-bound tables of the previous release are dropped; PAT connections
-- cannot back a provider identity, so no data is migrated.
-- Personal access token identities, next to the Apps: a user pastes a token and
-- a webhook secret; the token is checked against GitHub and stored encrypted.
CREATE TABLE IF NOT EXISTS github_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
login TEXT NOT NULL,
name TEXT NOT NULL DEFAULT '',
token_enc TEXT NOT NULL,
webhook_secret_enc TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- ── Migrating the previous release ──────────────────────────────────────────
-- The plugin is a Git provider: repositories, pull requests, task links and the
-- per-project connection now live in Paca core (git_* tables). The previous
-- release kept one personal access token per project (github_integrations) and
-- the repositories linked with it. Each becomes a project-scoped token
-- connection in core with its repositories, branch links and pull request links,
-- and then the old tables go.
--
-- What cannot be known here is completed on the token's first use (see
-- completeMigratedToken in spi_token.go): the owner's login is asked of GitHub
-- and a webhook secret is generated. The webhooks the old release registered on
-- GitHub pointed at the old plugin and used per-repository secrets; they are
-- left on GitHub, and "Try again" on a repository registers a new one.
-- Core's tables are created before any plugin migration runs; if they are not
-- there (a plugin-only database) the old data is only dropped.

-- The ciphertexts are copied as they are: the same ENCRYPTION_KEY protects both.
INSERT INTO github_tokens (id, login, name, token_enc, webhook_secret_enc)
SELECT i.id, '', '', i.access_token_enc, ''
FROM github_integrations i
ON CONFLICT (id) DO NOTHING;

DO $$
BEGIN
IF to_regclass('public.git_identities') IS NULL THEN
RETURN;
END IF;

INSERT INTO public.git_identities (provider_id, host, provider_identity_id, kind, scope, project_id, handle, display_name)
SELECT 'com.paca.github', 'github.com', i.id::text, 'token', 'project', i.project_id, '', 'GitHub access token'
FROM github_integrations i
WHERE NOT EXISTS (
SELECT 1 FROM public.git_identities g
WHERE g.provider_id = 'com.paca.github' AND g.provider_identity_id = i.id::text
);

-- Webhook deliveries for a connection arrive at its own endpoint.
INSERT INTO public.git_webhook_endpoints (key, provider_id, identity_id)
SELECT replace(gen_random_uuid()::text || gen_random_uuid()::text, '-', ''), g.provider_id, g.id
FROM public.git_identities g
WHERE g.provider_id = 'com.paca.github' AND g.kind = 'token' AND g.scope = 'project'
AND NOT EXISTS (SELECT 1 FROM public.git_webhook_endpoints e WHERE e.identity_id = g.id);

INSERT INTO public.git_repositories (project_id, provider_id, host, external_id, full_path, default_branch, clone_url, identity_id)
SELECT r.project_id, 'com.paca.github', 'github.com', '', r.full_name, r.default_branch,
'https://github.com/' || r.full_name || '.git', g.id
FROM github_repositories r
JOIN public.git_identities g ON g.provider_id = 'com.paca.github' AND g.provider_identity_id = r.integration_id::text
ON CONFLICT DO NOTHING;

INSERT INTO public.git_change_requests (repository_id, number, title, state, head_ref, base_ref, author, html_url, merged_at)
SELECT cr.id, p.pr_number::text, p.title,
CASE WHEN p.state IN ('open', 'closed', 'merged') THEN p.state ELSE 'open' END,
p.head_branch, p.base_branch, p.author, p.html_url, p.merged_at
FROM github_pull_requests p
JOIN github_repositories r ON r.id = p.repo_id
JOIN public.git_repositories cr ON cr.project_id = r.project_id AND cr.provider_id = 'com.paca.github'
AND cr.host = 'github.com' AND lower(cr.full_path) = lower(r.full_name)
ON CONFLICT DO NOTHING;

INSERT INTO public.git_task_links (task_id, change_request_id)
SELECT l.task_id, cr.id
FROM github_task_pr_links l
JOIN github_pull_requests p ON p.id = l.pull_request_id
JOIN github_repositories r ON r.id = p.repo_id
JOIN public.git_repositories gr ON gr.project_id = r.project_id AND gr.provider_id = 'com.paca.github'
AND gr.host = 'github.com' AND lower(gr.full_path) = lower(r.full_name)
JOIN public.git_change_requests cr ON cr.repository_id = gr.id AND cr.number = p.pr_number::text
ON CONFLICT DO NOTHING;

INSERT INTO public.git_task_branches (task_id, repository_id, branch_name)
SELECT b.task_id, gr.id, b.branch_name
FROM github_task_branches b
JOIN github_repositories r ON r.id = b.repo_id
JOIN public.git_repositories gr ON gr.project_id = r.project_id AND gr.provider_id = 'com.paca.github'
AND gr.host = 'github.com' AND lower(gr.full_path) = lower(r.full_name)
ON CONFLICT DO NOTHING;
END $$;

DROP TABLE IF EXISTS github_task_pr_links;
DROP TABLE IF EXISTS github_task_branches;
DROP TABLE IF EXISTS github_pull_requests;
Expand Down
4 changes: 2 additions & 2 deletions backend/plugin.go
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
// 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.
// per-project data and no frontend. A connection is a GitHub App (manifest
// flow, installed per account) or a personal access token (spi_token.go).
package main

import (
Expand Down
3 changes: 3 additions & 0 deletions backend/spi_api.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ import (
// error type; GitHub's wording is kept short and never carries credentials.
type ghAPI struct {
token string
// user is set for a personal access token: it acts as a person, so it has no
// check runs (only GitHub Apps create those).
user bool
// 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.
Expand Down
1 change: 1 addition & 0 deletions backend/spi_assert.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,6 @@ var (
_ gp.AssigneeSetter = (*ghProvider)(nil)
_ gp.Merger = (*ghProvider)(nil)
_ gp.IdentityDeleter = (*ghProvider)(nil)
_ gp.WebhookManager = (*ghProvider)(nil)
_ gp.AccessGranter = (*ghProvider)(nil)
)
42 changes: 41 additions & 1 deletion backend/spi_conformance_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ type memStore struct {
apps map[string]appRecord
installs map[string][]installRecord
setups map[string]setupSession
tokens map[string]tokenRecord
cache map[string]memToken
seq int
}
Expand All @@ -30,7 +31,7 @@ type memToken struct {
}

func newMemStore() *memStore {
return &memStore{apps: map[string]appRecord{}, installs: map[string][]installRecord{}, setups: map[string]setupSession{}, cache: map[string]memToken{}}
return &memStore{apps: map[string]appRecord{}, installs: map[string][]installRecord{}, setups: map[string]setupSession{}, tokens: map[string]tokenRecord{}, cache: map[string]memToken{}}
}

func (m *memStore) loadApp(id string) (appRecord, error) {
Expand All @@ -42,6 +43,45 @@ func (m *memStore) loadApp(id string) (appRecord, error) {
}
return a, nil
}
func (m *memStore) loadToken(id string) (tokenRecord, error) {
m.mu.Lock()
defer m.mu.Unlock()
t, ok := m.tokens[id]
if !ok {
return tokenRecord{}, errNoSuchToken
}
return t, nil
}
func (m *memStore) createToken(t tokenRecord) (string, error) {
m.mu.Lock()
defer m.mu.Unlock()
m.seq++
t.ID = "created-token-" + string(rune('0'+m.seq))
m.tokens[t.ID] = t
return t.ID, nil
}
func (m *memStore) updateToken(t tokenRecord) error {
m.mu.Lock()
defer m.mu.Unlock()
if _, ok := m.tokens[t.ID]; !ok {
return errNoSuchToken
}
cur := m.tokens[t.ID]
if cur.Login == "" {
cur.Login, cur.Name = t.Login, t.Name
}
if cur.WebhookSecret == "" {
cur.WebhookSecret = t.WebhookSecret
}
m.tokens[t.ID] = cur
return nil
}
func (m *memStore) deleteToken(id string) error {
m.mu.Lock()
defer m.mu.Unlock()
delete(m.tokens, id)
return nil
}
func (m *memStore) installations(appID string) ([]installRecord, error) {
m.mu.Lock()
defer m.mu.Unlock()
Expand Down
79 changes: 74 additions & 5 deletions backend/spi_dbstore.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,75 @@ func (s dbStore) loadApp(id string) (appRecord, error) {
}, nil
}

func (s dbStore) loadToken(id string) (tokenRecord, error) {
r, err := s.db.Query(`SELECT id, login, name, token_enc, webhook_secret_enc FROM github_tokens WHERE id = $1`, id)
if err != nil {
return tokenRecord{}, err
}
if len(r.Rows) == 0 {
return tokenRecord{}, errNoSuchToken
}
sc := newRowScanner(r.Columns, r.Rows[0])
tok, err := s.p.decrypt(sc.str("token_enc"))
if err != nil {
return tokenRecord{}, err
}
// A token migrated from the previous release has no webhook secret yet.
secret := ""
if enc := sc.str("webhook_secret_enc"); enc != "" {
if secret, err = s.p.decrypt(enc); err != nil {
return tokenRecord{}, err
}
}
return tokenRecord{ID: sc.str("id"), Login: sc.str("login"), Name: sc.str("name"), Token: tok, WebhookSecret: secret}, nil
}

func (s dbStore) createToken(t tokenRecord) (string, error) {
tok, err := s.p.encrypt(t.Token)
if err != nil {
return "", err
}
hook, err := s.p.encrypt(t.WebhookSecret)
if err != nil {
return "", err
}
now := nowStr()
r, err := s.db.Query(`
INSERT INTO github_tokens (login, name, token_enc, webhook_secret_enc, created_at, updated_at)
VALUES ($1,$2,$3,$4,$5,$5) RETURNING id`, t.Login, t.Name, tok, hook, now)
if err != nil {
return "", err
}
if len(r.Rows) == 0 {
return "", errNoSuchToken
}
return newRowScanner(r.Columns, r.Rows[0]).str("id"), nil
}

// updateToken fills a token's blanks (login, name, webhook secret) and never
// overwrites a value that is already there, so two first uses cannot disagree.
func (s dbStore) updateToken(t tokenRecord) error {
hook := ""
if t.WebhookSecret != "" {
var err error
if hook, err = s.p.encrypt(t.WebhookSecret); err != nil {
return err
}
}
_, err := s.db.Exec(`UPDATE github_tokens SET
login = CASE WHEN login = '' THEN $2 ELSE login END,
name = CASE WHEN login = '' THEN $3 ELSE name END,
webhook_secret_enc = CASE WHEN webhook_secret_enc = '' THEN $4 ELSE webhook_secret_enc END,
updated_at = $5 WHERE id = $1`,
t.ID, t.Login, t.Name, hook, nowStr())
return err
}

func (s dbStore) deleteToken(id string) error {
_, err := s.db.Exec(`DELETE FROM github_tokens WHERE id = $1`, id)
return err
}

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 {
Expand Down Expand Up @@ -126,14 +195,14 @@ func (s dbStore) saveSetup(x setupSession) error {
appID = x.AppID
}
_, err := s.db.Exec(`
INSERT INTO github_manifest_sessions (state, created_by, app_name, owner_org, scope, agent_id, app_id, expires_at, created_at)
VALUES ($1,'git-provider',$2,$3,$4,$5,$6,$7,$8)`,
x.State, x.AppName, x.OwnerOrg, x.Scope, x.AgentID, appID, x.ExpiresAt.UTC().Format(time.RFC3339Nano), nowStr())
INSERT INTO github_manifest_sessions (state, created_by, app_name, owner_org, scope, kind, agent_id, app_id, expires_at, created_at)
VALUES ($1,'git-provider',$2,$3,$4,$5,$6,$7,$8,$9)`,
x.State, x.AppName, x.OwnerOrg, x.Scope, x.Kind, x.AgentID, appID, x.ExpiresAt.UTC().Format(time.RFC3339Nano), nowStr())
return err
}

func (s dbStore) loadSetup(state string) (setupSession, error) {
r, err := s.db.Query(`SELECT state, app_name, owner_org, scope, agent_id, app_id, code_used_at, completed_at, expires_at FROM github_manifest_sessions WHERE state = $1`, state)
r, err := s.db.Query(`SELECT state, app_name, owner_org, scope, kind, 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
}
Expand All @@ -143,7 +212,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"), Scope: sc.str("scope"), AgentID: sc.str("agent_id"),
State: sc.str("state"), Scope: sc.str("scope"), Kind: sc.str("kind"), 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
Expand Down
Loading
Loading