From 329eed7a225cdfaa5f5abdf0856497c18c148abe Mon Sep 17 00:00:00 2001 From: Jiang Bohan Date: Wed, 29 Apr 2026 14:29:26 +0800 Subject: [PATCH] =?UTF-8?q?fix(github):=20address=20review=20=E2=80=94=20n?= =?UTF-8?q?ull=20actor,=20role=20gating,=20configured=20guard,=20scoped=20?= =?UTF-8?q?uninstall=20broadcast?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - listeners: use optionalUUID(e.ActorID) so the system actor on the github-driven issue:updated event no longer panics activity / notification listeners; merged-PR → issue done now produces a status_changed activity and inbox entry. - IntegrationsTab: gate the admin-only installations query on canManage so members no longer hit /github/installations 403; the configured/not-configured copy is also scoped to admins. - backend: introduce isGitHubConfigured() requiring both GITHUB_APP_SLUG and GITHUB_WEBHOOK_SECRET, and surface that single flag from list-installations + connect endpoints so the frontend Connect button stays disabled until both are set. - DeleteGitHubInstallationByInstallationID now RETURNs workspace_id; webhook handler publishes github_installation:deleted scoped to the right workspace so already-open Settings tabs invalidate in real time. ErrNoRows on a re-fired delete short-circuits cleanly. - tests: focused webhook integration coverage (auto-link + merge → done, cancelled preservation, uninstall returns workspace). Co-authored-by: multica-agent --- .../settings/components/integrations-tab.tsx | 12 +- server/cmd/server/activity_listeners.go | 14 +- server/cmd/server/notification_listeners.go | 6 +- server/cmd/server/router.go | 12 + server/internal/handler/github.go | 25 +- server/internal/handler/github_test.go | 231 ++++++++++++++++++ ...wn.sql => 079_github_integration.down.sql} | 0 ...n.up.sql => 079_github_integration.up.sql} | 0 server/pkg/db/generated/github.sql.go | 16 +- server/pkg/db/queries/github.sql | 5 +- 10 files changed, 296 insertions(+), 25 deletions(-) rename server/migrations/{061_github_integration.down.sql => 079_github_integration.down.sql} (100%) rename server/migrations/{061_github_integration.up.sql => 079_github_integration.up.sql} (100%) diff --git a/packages/views/settings/components/integrations-tab.tsx b/packages/views/settings/components/integrations-tab.tsx index b9d67b3ed..7750e952d 100644 --- a/packages/views/settings/components/integrations-tab.tsx +++ b/packages/views/settings/components/integrations-tab.tsx @@ -27,12 +27,18 @@ export function IntegrationsTab() { const user = useAuthStore((s) => s.user); const qc = useQueryClient(); const { data: members = [] } = useQuery(memberListOptions(wsId)); - const { data, isLoading } = useQuery(githubInstallationsOptions(wsId)); const [connecting, setConnecting] = useState(false); const currentMember = members.find((m) => m.user_id === user?.id) ?? null; const canManage = currentMember?.role === "owner" || currentMember?.role === "admin"; + // The list endpoint is admin-only; non-admins would see a 403 toast and + // an empty configured state. Gate the query on canManage so members get + // a clean read-only view. + const { data, isLoading } = useQuery({ + ...githubInstallationsOptions(wsId), + enabled: !!wsId && canManage, + }); const installations = data?.installations ?? []; const configured = data?.configured ?? false; @@ -93,7 +99,7 @@ export function IntegrationsTab() { )} - {!configured && ( + {canManage && !configured && (

GitHub integration is not configured for this deployment. Operators must set{" "} GITHUB_APP_SLUG{" "} @@ -102,7 +108,7 @@ export function IntegrationsTab() {

)} - {configured && ( + {canManage && configured && (
{isLoading &&

Loading…

} {!isLoading && installations.length === 0 && ( diff --git a/server/cmd/server/activity_listeners.go b/server/cmd/server/activity_listeners.go index 5e8864d59..1339f4606 100644 --- a/server/cmd/server/activity_listeners.go +++ b/server/cmd/server/activity_listeners.go @@ -34,7 +34,7 @@ func registerActivityListeners(bus *events.Bus, queries *db.Queries) { WorkspaceID: parseUUID(issue.WorkspaceID), IssueID: parseUUID(issue.ID), ActorType: util.StrToText(e.ActorType), - ActorID: parseUUID(e.ActorID), + ActorID: optionalUUID(e.ActorID), Action: "created", Details: []byte("{}"), }) @@ -73,7 +73,7 @@ func registerActivityListeners(bus *events.Bus, queries *db.Queries) { WorkspaceID: parseUUID(issue.WorkspaceID), IssueID: parseUUID(issue.ID), ActorType: util.StrToText(e.ActorType), - ActorID: parseUUID(e.ActorID), + ActorID: optionalUUID(e.ActorID), Action: "status_changed", Details: details, }) @@ -95,7 +95,7 @@ func registerActivityListeners(bus *events.Bus, queries *db.Queries) { WorkspaceID: parseUUID(issue.WorkspaceID), IssueID: parseUUID(issue.ID), ActorType: util.StrToText(e.ActorType), - ActorID: parseUUID(e.ActorID), + ActorID: optionalUUID(e.ActorID), Action: "priority_changed", Details: details, }) @@ -130,7 +130,7 @@ func registerActivityListeners(bus *events.Bus, queries *db.Queries) { WorkspaceID: parseUUID(issue.WorkspaceID), IssueID: parseUUID(issue.ID), ActorType: util.StrToText(e.ActorType), - ActorID: parseUUID(e.ActorID), + ActorID: optionalUUID(e.ActorID), Action: "assignee_changed", Details: details, }) @@ -159,7 +159,7 @@ func registerActivityListeners(bus *events.Bus, queries *db.Queries) { WorkspaceID: parseUUID(issue.WorkspaceID), IssueID: parseUUID(issue.ID), ActorType: util.StrToText(e.ActorType), - ActorID: parseUUID(e.ActorID), + ActorID: optionalUUID(e.ActorID), Action: "due_date_changed", Details: details, }) @@ -181,7 +181,7 @@ func registerActivityListeners(bus *events.Bus, queries *db.Queries) { WorkspaceID: parseUUID(issue.WorkspaceID), IssueID: parseUUID(issue.ID), ActorType: util.StrToText(e.ActorType), - ActorID: parseUUID(e.ActorID), + ActorID: optionalUUID(e.ActorID), Action: "title_changed", Details: details, }) @@ -198,7 +198,7 @@ func registerActivityListeners(bus *events.Bus, queries *db.Queries) { WorkspaceID: parseUUID(issue.WorkspaceID), IssueID: parseUUID(issue.ID), ActorType: util.StrToText(e.ActorType), - ActorID: parseUUID(e.ActorID), + ActorID: optionalUUID(e.ActorID), Action: "description_updated", Details: []byte("{}"), }) diff --git a/server/cmd/server/notification_listeners.go b/server/cmd/server/notification_listeners.go index ddcd99051..c84890f02 100644 --- a/server/cmd/server/notification_listeners.go +++ b/server/cmd/server/notification_listeners.go @@ -269,7 +269,7 @@ func notifyIssueSubscribers( Title: title, Body: util.StrToText(body), ActorType: util.StrToText(e.ActorType), - ActorID: parseUUID(e.ActorID), + ActorID: optionalUUID(e.ActorID), Details: details, }) if err != nil { @@ -334,7 +334,7 @@ func notifyDirect( Title: title, Body: util.StrToText(body), ActorType: util.StrToText(e.ActorType), - ActorID: parseUUID(e.ActorID), + ActorID: optionalUUID(e.ActorID), Details: details, }) if err != nil { @@ -421,7 +421,7 @@ func notifyMentionedMembers( IssueID: parseUUID(issueID), Title: title, ActorType: util.StrToText(e.ActorType), - ActorID: parseUUID(e.ActorID), + ActorID: optionalUUID(e.ActorID), Details: details, }) if err != nil { diff --git a/server/cmd/server/router.go b/server/cmd/server/router.go index 13ca334a7..7a3a8323f 100644 --- a/server/cmd/server/router.go +++ b/server/cmd/server/router.go @@ -594,6 +594,18 @@ func parseUUID(s string) pgtype.UUID { return util.MustParseUUID(s) } +// optionalUUID returns a NULL pgtype.UUID for an empty string and otherwise +// behaves like parseUUID. Use this for actor IDs on events where the producer +// may legitimately be a "system" actor with no member/agent attribution +// (e.g. GitHub webhook auto-status sync) — the activity_log and inbox_item +// tables both allow actor_id to be NULL. +func optionalUUID(s string) pgtype.UUID { + if s == "" { + return pgtype.UUID{} + } + return util.MustParseUUID(s) +} + func splitAndTrim(s string) []string { if s == "" { return nil diff --git a/server/internal/handler/github.go b/server/internal/handler/github.go index 1794f245d..eac9e02c3 100644 --- a/server/internal/handler/github.go +++ b/server/internal/handler/github.go @@ -104,6 +104,11 @@ func githubAppSlug() string { return strings.TrimSpace(os.Getenv("GITHUB_APP_SLU // configure one value. func githubWebhookSecret() string { return strings.TrimSpace(os.Getenv("GITHUB_WEBHOOK_SECRET")) } +// isGitHubConfigured returns true only when BOTH the install slug and the +// webhook secret are set. The Connect button uses this single flag, so the +// frontend never offers a flow that the backend would reject. +func isGitHubConfigured() bool { return githubAppSlug() != "" && githubWebhookSecret() != "" } + // signState produces an opaque token that binds a workspace ID to the // install flow so the setup callback can recover the workspace without // trusting query params alone. Format: "..". @@ -154,11 +159,11 @@ func (h *Handler) GitHubConnect(w http.ResponseWriter, r *http.Request) { if _, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace id"); !ok { return } - slug := githubAppSlug() - if slug == "" || githubWebhookSecret() == "" { + if !isGitHubConfigured() { writeJSON(w, http.StatusOK, GitHubConnectResponse{Configured: false}) return } + slug := githubAppSlug() state, err := signState(workspaceID) if err != nil { writeError(w, http.StatusInternalServerError, "failed to sign state") @@ -304,7 +309,7 @@ func (h *Handler) ListGitHubInstallations(w http.ResponseWriter, r *http.Request for _, row := range rows { out = append(out, githubInstallationToResponse(row)) } - writeJSON(w, http.StatusOK, map[string]any{"installations": out, "configured": githubAppSlug() != ""}) + writeJSON(w, http.StatusOK, map[string]any{"installations": out, "configured": isGitHubConfigured()}) } func (h *Handler) DeleteGitHubInstallation(w http.ResponseWriter, r *http.Request) { @@ -433,13 +438,21 @@ func (h *Handler) handleInstallationEvent(ctx context.Context, body []byte) { switch p.Action { case "deleted", "suspend": // User removed the App on GitHub — drop our row so the workspace - // stops trusting this installation_id. - if err := h.Queries.DeleteGitHubInstallationByInstallationID(ctx, p.Installation.ID); err != nil { + // stops trusting this installation_id. We DELETE … RETURNING so + // the broadcast can be scoped to the right workspace; events + // without WorkspaceID are dropped by the realtime listener and + // would leave already-open Settings tabs stale. + deleted, err := h.Queries.DeleteGitHubInstallationByInstallationID(ctx, p.Installation.ID) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return // already gone — nothing to broadcast + } slog.Warn("github: delete installation failed", "err", err, "installation_id", p.Installation.ID) return } - h.publish(protocol.EventGitHubInstallationDeleted, "", "system", "", map[string]any{ + h.publish(protocol.EventGitHubInstallationDeleted, uuidToString(deleted.WorkspaceID), "system", "", map[string]any{ "installation_id": p.Installation.ID, + "id": uuidToString(deleted.ID), }) case "created", "new_permissions_accepted", "unsuspend": // We don't know which workspace this maps to from the webhook diff --git a/server/internal/handler/github_test.go b/server/internal/handler/github_test.go index e2a8d1b60..547cb30f3 100644 --- a/server/internal/handler/github_test.go +++ b/server/internal/handler/github_test.go @@ -1,11 +1,18 @@ package handler import ( + "bytes" + "context" "crypto/hmac" "crypto/sha256" "encoding/hex" + "encoding/json" + "net/http" + "net/http/httptest" "reflect" "testing" + + db "github.com/multica-ai/multica/server/pkg/db/generated" ) func TestExtractIdentifiers(t *testing.T) { @@ -138,3 +145,227 @@ func TestSignStateRequiresSecret(t *testing.T) { t.Error("signState should error when secret is unset") } } + +// TestWebhook_MergedPR_AdvancesLinkedIssueToDone exercises the end-to-end +// auto-link + merge-sync path: install a workspace, fire a `pull_request` +// webhook with the issue identifier in the title, and verify (a) the PR row +// is upserted, (b) it is linked to the issue, (c) the issue transitions to +// 'done'. The system actor on that issue:updated event is what previously +// panicked the activity / notification listeners — having this test pass +// while listeners are wired up is the regression guard. +func TestWebhook_MergedPR_AdvancesLinkedIssueToDone(t *testing.T) { + if testHandler == nil { + t.Skip("handler test fixture not initialized (no DB?)") + } + ctx := context.Background() + secret := "merge-sync-test-secret" + t.Setenv("GITHUB_WEBHOOK_SECRET", secret) + + // Seed an issue we expect the webhook to close out. + w := httptest.NewRecorder() + req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{ + "title": "PR auto-merge test", + "status": "in_progress", + }) + testHandler.CreateIssue(w, req) + if w.Code != http.StatusCreated { + t.Fatalf("CreateIssue: %d %s", w.Code, w.Body.String()) + } + var created IssueResponse + json.NewDecoder(w.Body).Decode(&created) + + t.Cleanup(func() { + testPool.Exec(ctx, `DELETE FROM issue_pull_request WHERE issue_id = $1`, created.ID) + testPool.Exec(ctx, `DELETE FROM github_pull_request WHERE workspace_id = $1`, testWorkspaceID) + testPool.Exec(ctx, `DELETE FROM github_installation WHERE workspace_id = $1`, testWorkspaceID) + testPool.Exec(ctx, `DELETE FROM activity_log WHERE issue_id = $1`, created.ID) + testPool.Exec(ctx, `DELETE FROM issue WHERE id = $1`, created.ID) + }) + + // Wire up an installation row for the webhook to attribute to. + const installationID int64 = 99887766 + if _, err := testHandler.Queries.CreateGitHubInstallation(ctx, db.CreateGitHubInstallationParams{ + WorkspaceID: parseUUID(testWorkspaceID), + InstallationID: installationID, + AccountLogin: "merge-sync-acct", + AccountType: "User", + }); err != nil { + t.Fatalf("CreateGitHubInstallation: %v", err) + } + + // Build a minimal pull_request webhook payload referencing the issue. + body := map[string]any{ + "action": "closed", + "pull_request": map[string]any{ + "number": 1234, + "html_url": "https://github.com/acme/widget/pull/1234", + "title": "Fix login " + created.Identifier, + "body": "", + "state": "closed", + "draft": false, + "merged": true, + "merged_at": "2026-04-29T00:00:00Z", + "closed_at": "2026-04-29T00:00:00Z", + "created_at": "2026-04-28T00:00:00Z", + "updated_at": "2026-04-29T00:00:00Z", + "head": map[string]any{"ref": "fix/login"}, + "user": map[string]any{"login": "octocat", "avatar_url": ""}, + }, + "repository": map[string]any{ + "name": "widget", + "owner": map[string]any{"login": "acme"}, + }, + "installation": map[string]any{"id": installationID}, + } + raw, _ := json.Marshal(body) + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write(raw) + sig := "sha256=" + hex.EncodeToString(mac.Sum(nil)) + + w = httptest.NewRecorder() + req2 := httptest.NewRequest("POST", "/api/webhooks/github", bytes.NewReader(raw)) + req2.Header.Set("X-GitHub-Event", "pull_request") + req2.Header.Set("X-Hub-Signature-256", sig) + testHandler.HandleGitHubWebhook(w, req2) + if w.Code != http.StatusAccepted { + t.Fatalf("webhook: expected 202, got %d (%s)", w.Code, w.Body.String()) + } + + // Verify PR row + link + issue status. + pr, err := testHandler.Queries.GetGitHubPullRequest(ctx, db.GetGitHubPullRequestParams{ + WorkspaceID: parseUUID(testWorkspaceID), + RepoOwner: "acme", + RepoName: "widget", + PrNumber: 1234, + }) + if err != nil { + t.Fatalf("GetGitHubPullRequest: %v", err) + } + if pr.State != "merged" { + t.Errorf("expected pr state merged, got %q", pr.State) + } + + linked, err := testHandler.Queries.ListPullRequestsByIssue(ctx, parseUUID(created.ID)) + if err != nil { + t.Fatalf("ListPullRequestsByIssue: %v", err) + } + if len(linked) != 1 { + t.Fatalf("expected 1 linked PR, got %d", len(linked)) + } + + updated, err := testHandler.Queries.GetIssue(ctx, parseUUID(created.ID)) + if err != nil { + t.Fatalf("GetIssue: %v", err) + } + if updated.Status != "done" { + t.Errorf("expected issue status 'done', got %q", updated.Status) + } +} + +// TestWebhook_MergedPR_PreservesCancelled guards the "do not stomp cancelled" +// rule: cancelling an issue then merging a linked PR must leave the issue +// cancelled. +func TestWebhook_MergedPR_PreservesCancelled(t *testing.T) { + if testHandler == nil { + t.Skip("handler test fixture not initialized (no DB?)") + } + ctx := context.Background() + secret := "cancelled-secret" + t.Setenv("GITHUB_WEBHOOK_SECRET", secret) + + w := httptest.NewRecorder() + req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{ + "title": "Already cancelled", + "status": "cancelled", + }) + testHandler.CreateIssue(w, req) + if w.Code != http.StatusCreated { + t.Fatalf("CreateIssue: %d %s", w.Code, w.Body.String()) + } + var created IssueResponse + json.NewDecoder(w.Body).Decode(&created) + + t.Cleanup(func() { + testPool.Exec(ctx, `DELETE FROM issue_pull_request WHERE issue_id = $1`, created.ID) + testPool.Exec(ctx, `DELETE FROM github_pull_request WHERE workspace_id = $1`, testWorkspaceID) + testPool.Exec(ctx, `DELETE FROM github_installation WHERE workspace_id = $1`, testWorkspaceID) + testPool.Exec(ctx, `DELETE FROM issue WHERE id = $1`, created.ID) + }) + + const installationID int64 = 11223344 + if _, err := testHandler.Queries.CreateGitHubInstallation(ctx, db.CreateGitHubInstallationParams{ + WorkspaceID: parseUUID(testWorkspaceID), + InstallationID: installationID, + AccountLogin: "cancelled-acct", + AccountType: "User", + }); err != nil { + t.Fatalf("CreateGitHubInstallation: %v", err) + } + + body, _ := json.Marshal(map[string]any{ + "action": "closed", + "pull_request": map[string]any{ + "number": 7, "html_url": "https://x", "title": "Closes " + created.Identifier, + "state": "closed", "merged": true, "draft": false, + "merged_at": "2026-04-29T00:00:00Z", "closed_at": "2026-04-29T00:00:00Z", + "created_at": "2026-04-28T00:00:00Z", "updated_at": "2026-04-29T00:00:00Z", + "head": map[string]any{"ref": "x"}, "user": map[string]any{"login": "u"}, + }, + "repository": map[string]any{"name": "r", "owner": map[string]any{"login": "o"}}, + "installation": map[string]any{"id": installationID}, + }) + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write(body) + sig := "sha256=" + hex.EncodeToString(mac.Sum(nil)) + + w = httptest.NewRecorder() + req2 := httptest.NewRequest("POST", "/api/webhooks/github", bytes.NewReader(body)) + req2.Header.Set("X-GitHub-Event", "pull_request") + req2.Header.Set("X-Hub-Signature-256", sig) + testHandler.HandleGitHubWebhook(w, req2) + + updated, err := testHandler.Queries.GetIssue(ctx, parseUUID(created.ID)) + if err != nil { + t.Fatalf("GetIssue: %v", err) + } + if updated.Status != "cancelled" { + t.Errorf("expected status to remain 'cancelled', got %q", updated.Status) + } +} + +// TestWebhook_UninstallReturnsWorkspaceForBroadcast guards #4: the uninstall +// path must look up the workspace_id BEFORE deleting the row so the +// resulting `github_installation:deleted` event is broadcast scoped to that +// workspace (the realtime listener drops events with empty workspace_id). +func TestWebhook_UninstallReturnsWorkspaceForBroadcast(t *testing.T) { + if testHandler == nil { + t.Skip("handler test fixture not initialized (no DB?)") + } + ctx := context.Background() + const installationID int64 = 55443322 + + if _, err := testHandler.Queries.CreateGitHubInstallation(ctx, db.CreateGitHubInstallationParams{ + WorkspaceID: parseUUID(testWorkspaceID), + InstallationID: installationID, + AccountLogin: "uninstall-test", + AccountType: "User", + }); err != nil { + t.Fatalf("CreateGitHubInstallation: %v", err) + } + t.Cleanup(func() { + testPool.Exec(ctx, `DELETE FROM github_installation WHERE workspace_id = $1`, testWorkspaceID) + }) + + deleted, err := testHandler.Queries.DeleteGitHubInstallationByInstallationID(ctx, installationID) + if err != nil { + t.Fatalf("DeleteGitHubInstallationByInstallationID: %v", err) + } + if uuidToString(deleted.WorkspaceID) != testWorkspaceID { + t.Errorf("expected returned workspace_id %s, got %s", testWorkspaceID, uuidToString(deleted.WorkspaceID)) + } + // Re-deleting must surface ErrNoRows so the handler can short-circuit + // the broadcast (and not panic). + if _, err := testHandler.Queries.DeleteGitHubInstallationByInstallationID(ctx, installationID); err == nil { + t.Error("expected ErrNoRows on second delete, got nil") + } +} diff --git a/server/migrations/061_github_integration.down.sql b/server/migrations/079_github_integration.down.sql similarity index 100% rename from server/migrations/061_github_integration.down.sql rename to server/migrations/079_github_integration.down.sql diff --git a/server/migrations/061_github_integration.up.sql b/server/migrations/079_github_integration.up.sql similarity index 100% rename from server/migrations/061_github_integration.up.sql rename to server/migrations/079_github_integration.up.sql diff --git a/server/pkg/db/generated/github.sql.go b/server/pkg/db/generated/github.sql.go index 90539f12a..2a61e4775 100644 --- a/server/pkg/db/generated/github.sql.go +++ b/server/pkg/db/generated/github.sql.go @@ -74,13 +74,21 @@ func (q *Queries) DeleteGitHubInstallation(ctx context.Context, arg DeleteGitHub return err } -const deleteGitHubInstallationByInstallationID = `-- name: DeleteGitHubInstallationByInstallationID :exec +const deleteGitHubInstallationByInstallationID = `-- name: DeleteGitHubInstallationByInstallationID :one DELETE FROM github_installation WHERE installation_id = $1 +RETURNING id, workspace_id ` -func (q *Queries) DeleteGitHubInstallationByInstallationID(ctx context.Context, installationID int64) error { - _, err := q.db.Exec(ctx, deleteGitHubInstallationByInstallationID, installationID) - return err +type DeleteGitHubInstallationByInstallationIDRow struct { + ID pgtype.UUID `json:"id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` +} + +func (q *Queries) DeleteGitHubInstallationByInstallationID(ctx context.Context, installationID int64) (DeleteGitHubInstallationByInstallationIDRow, error) { + row := q.db.QueryRow(ctx, deleteGitHubInstallationByInstallationID, installationID) + var i DeleteGitHubInstallationByInstallationIDRow + err := row.Scan(&i.ID, &i.WorkspaceID) + return i, err } const getGitHubInstallationByID = `-- name: GetGitHubInstallationByID :one diff --git a/server/pkg/db/queries/github.sql b/server/pkg/db/queries/github.sql index d53327116..cb9edf3e5 100644 --- a/server/pkg/db/queries/github.sql +++ b/server/pkg/db/queries/github.sql @@ -33,8 +33,9 @@ RETURNING *; -- name: DeleteGitHubInstallation :exec DELETE FROM github_installation WHERE id = $1 AND workspace_id = $2; --- name: DeleteGitHubInstallationByInstallationID :exec -DELETE FROM github_installation WHERE installation_id = $1; +-- name: DeleteGitHubInstallationByInstallationID :one +DELETE FROM github_installation WHERE installation_id = $1 +RETURNING id, workspace_id; -- ===================== -- GitHub Pull Request