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