Files
multica/server/pkg/db/queries/github.sql
Jiayuan Zhang 668cab6022 feat(github): mirror PR CI checks and merge conflict status (MUL-2228) (#2632)
* feat(github): mirror PR CI checks and merge conflict status (MUL-2228)

Surface "checks passed/failed" and "conflicts/no conflicts" badges under
each linked PR on the issue page so users can judge readiness without
flipping over to GitHub. CI state is fed by check_suite webhooks
(GitHub Actions + apps using the Checks API; legacy status events are
out of scope for MVP); conflicts are read from pull_request.mergeable_state.

Data model:
  * github_pull_request: add head_sha + mergeable_state
  * github_pull_request_check_suite: per-suite rows keyed by (pr_id, suite_id)
  * Aggregation done at query time, filtering by current head_sha so
    late-arriving suites for a stale head can't contaminate the new head's
    pending view; per-app latest suite chosen first so a single app firing
    multiple suites isn't counted N times.

Webhook hardening:
  * synchronize/opened/reopened/edited(base) explicitly clear mergeable_state
  * single-row ordering protection on the check_suite upsert prevents a
    late-delivered older event from overwriting a newer one
  * check_suite.pull_requests is iterated; unknown PRs are logged and dropped

UI:
  * PR row shows Checks + Conflicts badges; opaque mergeable values
    (blocked/behind/unstable/...) render as no badge, not as conflicts.
  * Terminal PR states (merged/closed) suppress the status row entirely.

Tests: * Pure unit coverage for derivePRMergeableState + aggregateChecksConclusion
  * Webhook integration tests: multi-app aggregation, old-head ignore,
    late-older-event ignore, synchronize clears mergeable_state
  * Vitest coverage for pull-request-list badge rendering across CI/conflict
    combinations and the legacy (null) fallback.
Co-authored-by: multica-agent <github@multica.ai>

* fix(github): scope check_suite PR lookup; preserve mergeable on metadata

Addresses code review on PR #2632.

1. check_suite handler now resolves the PR through the workspace-scoped
   GetGitHubPullRequest query instead of GetGitHubPullRequestByRepoNumber.
   The (workspace_id, repo_owner, repo_name, pr_number) tuple is the real
   uniqueness key, so a bare (owner, repo, number) lookup could return a
   stale row from another workspace and either land the suite on the wrong
   PR or skip the right one when the installation ids drifted. The old
   unscoped query is removed.

2. derivePRMergeableState now returns (value, clear) and the upsert SQL
   distinguishes three cases: state-changing actions clear the column to
   NULL, non-empty payloads write the value, and metadata events with an
   empty payload preserve the existing column. Previously every empty
   payload became NULL, so a labeled/assigned event silently wiped a
   known clean/dirty verdict in violation of the RFC's "metadata empty
   payload preserves" rule.

3. ListPullRequestsByIssue narrows to the issue's PR ids before running
   the per-app check_suite aggregation, avoiding a full-table scan over
   github_pull_request_check_suite when only a handful of rows belong to
   the requested issue.

New helper test covers labeled+empty preserves; new integration test
verifies a metadata event after a known mergeable_state keeps the value.

Co-authored-by: multica-agent <github@multica.ai>

* feat(github): PR card layout v3 increment — stats + segmented progress bar

Replaces the row + badge layout under "Pull requests" on the issue
detail sidebar with a card that mirrors the GitHub PR summary look:
title, author/avatar, +N −M · K files diff stats, segmented progress
bar (failed → pending → passed, failure leftmost), and a one-line
status caption following an explicit priority pass-through.

Backend
- Migration 092: github_pull_request adds additions / deletions /
  changed_files (INT NOT NULL DEFAULT 0). Zero defaults are what the
  new frontend treats as "legacy backend — hide the stats row" so old
  PR rows that pre-date this migration don't render "+0 −0 · 0 files".
- pull_request webhook handler reads stats off the top-level payload.
- ListPullRequestsByIssue now surfaces per-suite counts
  (checks_passed / failed / pending) alongside the existing aggregate
  conclusion, so the segmented bar reuses the already-computed counts
  with no new aggregation.

Frontend (packages)
- core/github/pull-request-status.{ts,test.ts}: pure-function module
  for the status-kind priority table and the segment derivation; 15
  cases covered, includes the "all-zero → hide stats" guard.
- views/issues/components/pull-request-list.tsx: PullRequestCard plus
  a compact-row fallback used when count > 4 (first 3 as cards, the
  remainder collapsed behind a Show more toggle).
- i18n: new `pull_request_card_*` keys in en + zh-Hans.

Tests
- 12 component tests covering each rule of the priority table, the
  legacy-zero stats fallback, and the collapse threshold.
- Reuse of the v3 webhook handler tests confirmed.

Verification
- pnpm typecheck + pnpm test green (60 test files, 536 tests).
- go build ./... + go vet ./... clean.
- 6 demo issues (DEV-2..DEV-7) screenshotted via Playwright; see the
  PR comments for the visual check matrix.

Co-authored-by: multica-agent <github@multica.ai>

* fix(views): collapse PR cards at N>=4, not N>4

The card-vs-collapse threshold used `>` so 4 PRs slipped past it and
all rendered as full cards, contrary to RFC v3 (N >= 4 collapses to
3 cards + compact tail). Switch to `>=` and update the threshold-
boundary test to expect "Show 1 more".

Co-authored-by: multica-agent <github@multica.ai>

* fix(views): align PR sidebar rows with existing list style

Co-authored-by: multica-agent <github@multica.ai>

* fix(views): hide terminal PR status badges

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-16 21:26:30 +02:00

208 lines
8.4 KiB
SQL

-- =====================
-- GitHub Installation
-- =====================
-- name: ListGitHubInstallationsByWorkspace :many
SELECT * FROM github_installation
WHERE workspace_id = $1
ORDER BY created_at ASC;
-- name: GetGitHubInstallationByInstallationID :one
SELECT * FROM github_installation
WHERE installation_id = $1;
-- name: GetGitHubInstallationByID :one
SELECT * FROM github_installation
WHERE id = $1;
-- name: CreateGitHubInstallation :one
INSERT INTO github_installation (
workspace_id, installation_id, account_login, account_type, account_avatar_url, connected_by_id
) VALUES (
$1, $2, $3, $4, sqlc.narg('account_avatar_url'), sqlc.narg('connected_by_id')
)
ON CONFLICT (installation_id) DO UPDATE SET
workspace_id = EXCLUDED.workspace_id,
account_login = EXCLUDED.account_login,
account_type = EXCLUDED.account_type,
account_avatar_url = EXCLUDED.account_avatar_url,
connected_by_id = EXCLUDED.connected_by_id,
updated_at = now()
RETURNING *;
-- name: DeleteGitHubInstallation :exec
DELETE FROM github_installation WHERE id = $1 AND workspace_id = $2;
-- name: DeleteGitHubInstallationByInstallationID :one
DELETE FROM github_installation WHERE installation_id = $1
RETURNING id, workspace_id;
-- =====================
-- GitHub Pull Request
-- =====================
-- name: UpsertGitHubPullRequest :one
-- mergeable_state has three-state semantics on UPDATE:
-- 1. clear_mergeable_state=true → write NULL (state-changing actions like
-- opened/synchronize/reopened/edited(base) invalidate the prior verdict).
-- 2. clear_mergeable_state=false, mergeable_state non-null → write the value.
-- 3. clear_mergeable_state=false, mergeable_state null → preserve existing
-- column. Metadata events (labeled/assigned/etc.) ship payloads without
-- mergeability, and silently clobbering a known clean/dirty would lose
-- information that GitHub only re-computes lazily.
-- INSERT path always writes the incoming value (NULL acceptable for a new row).
INSERT INTO github_pull_request (
workspace_id, installation_id, repo_owner, repo_name, pr_number,
title, state, html_url, branch, author_login, author_avatar_url,
merged_at, closed_at, pr_created_at, pr_updated_at,
head_sha, mergeable_state,
additions, deletions, changed_files
) VALUES (
$1, $2, $3, $4, $5,
$6, $7, $8, sqlc.narg('branch'), sqlc.narg('author_login'), sqlc.narg('author_avatar_url'),
sqlc.narg('merged_at'), sqlc.narg('closed_at'), $9, $10,
$11, sqlc.narg('mergeable_state'),
$12, $13, $14
)
ON CONFLICT (workspace_id, repo_owner, repo_name, pr_number) DO UPDATE SET
installation_id = EXCLUDED.installation_id,
title = EXCLUDED.title,
state = EXCLUDED.state,
html_url = EXCLUDED.html_url,
branch = EXCLUDED.branch,
author_login = EXCLUDED.author_login,
author_avatar_url = EXCLUDED.author_avatar_url,
merged_at = EXCLUDED.merged_at,
closed_at = EXCLUDED.closed_at,
pr_updated_at = EXCLUDED.pr_updated_at,
head_sha = EXCLUDED.head_sha,
mergeable_state = CASE
WHEN COALESCE(sqlc.narg('clear_mergeable_state')::boolean, FALSE) THEN NULL
WHEN EXCLUDED.mergeable_state IS NOT NULL THEN EXCLUDED.mergeable_state
ELSE github_pull_request.mergeable_state
END,
additions = EXCLUDED.additions,
deletions = EXCLUDED.deletions,
changed_files = EXCLUDED.changed_files,
updated_at = now()
RETURNING *;
-- name: GetGitHubPullRequest :one
SELECT * FROM github_pull_request
WHERE workspace_id = $1 AND repo_owner = $2 AND repo_name = $3 AND pr_number = $4;
-- name: ListPullRequestsByIssue :many
-- Returns the issue's linked PRs with the aggregated check-suite counts for
-- the PR's CURRENT head SHA. The `issue_prs` CTE narrows to this issue's PR
-- ids first so the per-app aggregation only touches suite rows for those
-- PRs — without that scoping the planner has to scan/aggregate every PR's
-- suites in the workspace before joining on issue. Per-app latest suite is
-- selected so a single app firing multiple suites on the same head doesn't
-- get counted N times. Late-arriving suites for an OLD head are stored but
-- excluded by the head_sha filter, so they can't override the new head's
-- pending view.
WITH issue_prs AS (
SELECT pr.id, pr.head_sha
FROM github_pull_request pr
JOIN issue_pull_request ipr ON ipr.pull_request_id = pr.id
WHERE ipr.issue_id = sqlc.arg('issue_id')
),
per_app_latest AS (
SELECT DISTINCT ON (cs.pr_id, cs.app_id)
cs.pr_id, cs.app_id, cs.conclusion, cs.status
FROM github_pull_request_check_suite cs
JOIN issue_prs ip ON ip.id = cs.pr_id
WHERE cs.head_sha = ip.head_sha AND ip.head_sha <> ''
ORDER BY cs.pr_id, cs.app_id, cs.updated_at DESC
),
checks AS (
SELECT
pr_id,
COUNT(*)::bigint AS total,
SUM(CASE WHEN status = 'completed' AND conclusion IN
('failure','cancelled','timed_out','action_required','startup_failure','stale')
THEN 1 ELSE 0 END)::bigint AS failed,
SUM(CASE WHEN status = 'completed' AND conclusion IN
('success','neutral','skipped')
THEN 1 ELSE 0 END)::bigint AS passed,
SUM(CASE WHEN status <> 'completed' OR conclusion IS NULL
THEN 1 ELSE 0 END)::bigint AS pending
FROM per_app_latest
GROUP BY pr_id
)
SELECT
pr.id, pr.workspace_id, pr.installation_id, pr.repo_owner, pr.repo_name,
pr.pr_number, pr.title, pr.state, pr.html_url, pr.branch, pr.author_login,
pr.author_avatar_url, pr.merged_at, pr.closed_at, pr.pr_created_at,
pr.pr_updated_at, pr.head_sha, pr.mergeable_state,
pr.additions, pr.deletions, pr.changed_files,
pr.created_at, pr.updated_at,
COALESCE(c.total, 0)::bigint AS checks_total,
COALESCE(c.passed, 0)::bigint AS checks_passed,
COALESCE(c.failed, 0)::bigint AS checks_failed,
COALESCE(c.pending, 0)::bigint AS checks_pending
FROM github_pull_request pr
JOIN issue_pull_request ipr ON ipr.pull_request_id = pr.id
LEFT JOIN checks c ON c.pr_id = pr.id
WHERE ipr.issue_id = sqlc.arg('issue_id')
ORDER BY pr.pr_created_at DESC;
-- name: ListIssueIDsForPullRequest :many
SELECT issue_id FROM issue_pull_request
WHERE pull_request_id = $1;
-- name: GetSiblingPullRequestStateCountsForIssue :one
-- Returns, for the PRs linked to an issue excluding one PR by id (the PR
-- currently being processed by the webhook handler), how many are still in
-- flight (open or draft) and how many have already merged. The webhook
-- handler combines these with the current event's state to decide whether
-- to auto-advance the issue: the issue moves to done only when there is no
-- in-flight sibling AND at least one linked PR (current or sibling) merged.
SELECT
COALESCE(SUM(CASE WHEN pr.state IN ('open', 'draft') THEN 1 ELSE 0 END), 0)::bigint AS open_count,
COALESCE(SUM(CASE WHEN pr.state = 'merged' THEN 1 ELSE 0 END), 0)::bigint AS merged_count
FROM github_pull_request pr
JOIN issue_pull_request ipr ON ipr.pull_request_id = pr.id
WHERE ipr.issue_id = $1
AND pr.id <> $2;
-- =====================
-- GitHub PR check suite
-- =====================
-- name: UpsertPullRequestCheckSuite :exec
-- Upserts a single check_suite row keyed by (pr_id, suite_id). The WHERE
-- clause on the DO UPDATE branch prevents a late-arriving older event from
-- overwriting a newer one — same-PR/same-suite ordering protection. Late
-- events targeting an old head still land here (their head_sha is stored
-- on the row); the head_sha filter in ListPullRequestsByIssue keeps them
-- out of the current aggregate.
INSERT INTO github_pull_request_check_suite (
pr_id, suite_id, head_sha, app_id, conclusion, status, updated_at
) VALUES (
$1, $2, $3, $4, sqlc.narg('conclusion'), $5, $6
)
ON CONFLICT (pr_id, suite_id) DO UPDATE SET
head_sha = EXCLUDED.head_sha,
app_id = EXCLUDED.app_id,
conclusion = EXCLUDED.conclusion,
status = EXCLUDED.status,
updated_at = EXCLUDED.updated_at
WHERE EXCLUDED.updated_at >= github_pull_request_check_suite.updated_at;
-- =====================
-- Issue ↔ Pull Request link
-- =====================
-- name: LinkIssueToPullRequest :exec
INSERT INTO issue_pull_request (
issue_id, pull_request_id, linked_by_type, linked_by_id
) VALUES (
$1, $2, sqlc.narg('linked_by_type'), sqlc.narg('linked_by_id')
)
ON CONFLICT (issue_id, pull_request_id) DO NOTHING;
-- name: UnlinkIssueFromPullRequest :exec
DELETE FROM issue_pull_request
WHERE issue_id = $1 AND pull_request_id = $2;