mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-05 21:39:54 +02:00
* fix(github): only auto-close issue when all linked PRs have resolved Previously, the webhook handler unconditionally moved an issue to `done` as soon as a single linked PR was merged. If a second PR was also linked to the same issue and still open / draft, the issue would close before the work was actually finished. Add `CountOpenSiblingPullRequestsForIssue` and gate the auto-status transition on it: a merged PR advances its linked issues only when no sibling PR linked to the same issue is still in flight. Issues stay put while siblings are open or draft, and the merge that resolves the last in-flight PR is the one that closes the issue. Adds an integration test that opens two PRs against the same issue, merges the first, asserts the issue stays in_progress, then merges the second and asserts the issue advances to done. Co-authored-by: multica-agent <github@multica.ai> * fix(github): re-evaluate auto-close on closed-without-merge events too GPT-Boy review on #2470: gating only the `state == "merged"` branch left one ordering hole. PR-A merges first → issue stays in_progress because PR-B is open; PR-B later closes WITHOUT merging → no event ever re-runs the auto-close check, so the issue is stuck in_progress. Generalise the trigger to every terminal PR event (`merged` or `closed`) and advance the issue only when: - the issue is not already terminal (done / cancelled); - no sibling PR is still in flight (open / draft); - at least one linked PR — current or sibling — actually merged. Rule (3) preserves "user closed every PR without merging → leave the issue alone": if no work was delivered, the user decides what to do. Replace `CountOpenSiblingPullRequestsForIssue` with `GetSiblingPullRequestStateCountsForIssue`, which returns both the in-flight count and the merged count in a single roundtrip. Adds `TestWebhook_ClosedSiblingAfterMerge` (the regression GPT-Boy flagged) and `TestWebhook_AllClosedWithoutMerge` (the negative case guarding rule 3). Refactors the multi-PR webhook helper out of the existing two-merge test so all three multi-PR scenarios share it. Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: multica-agent <github@multica.ai>
459 lines
14 KiB
Go
459 lines
14 KiB
Go
// Code generated by sqlc. DO NOT EDIT.
|
|
// versions:
|
|
// sqlc v1.30.0
|
|
// source: github.sql
|
|
|
|
package db
|
|
|
|
import (
|
|
"context"
|
|
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
)
|
|
|
|
const createGitHubInstallation = `-- 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, $5, $6
|
|
)
|
|
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 id, workspace_id, installation_id, account_login, account_type, account_avatar_url, connected_by_id, created_at, updated_at
|
|
`
|
|
|
|
type CreateGitHubInstallationParams struct {
|
|
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
|
InstallationID int64 `json:"installation_id"`
|
|
AccountLogin string `json:"account_login"`
|
|
AccountType string `json:"account_type"`
|
|
AccountAvatarUrl pgtype.Text `json:"account_avatar_url"`
|
|
ConnectedByID pgtype.UUID `json:"connected_by_id"`
|
|
}
|
|
|
|
func (q *Queries) CreateGitHubInstallation(ctx context.Context, arg CreateGitHubInstallationParams) (GithubInstallation, error) {
|
|
row := q.db.QueryRow(ctx, createGitHubInstallation,
|
|
arg.WorkspaceID,
|
|
arg.InstallationID,
|
|
arg.AccountLogin,
|
|
arg.AccountType,
|
|
arg.AccountAvatarUrl,
|
|
arg.ConnectedByID,
|
|
)
|
|
var i GithubInstallation
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.WorkspaceID,
|
|
&i.InstallationID,
|
|
&i.AccountLogin,
|
|
&i.AccountType,
|
|
&i.AccountAvatarUrl,
|
|
&i.ConnectedByID,
|
|
&i.CreatedAt,
|
|
&i.UpdatedAt,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const deleteGitHubInstallation = `-- name: DeleteGitHubInstallation :exec
|
|
DELETE FROM github_installation WHERE id = $1 AND workspace_id = $2
|
|
`
|
|
|
|
type DeleteGitHubInstallationParams struct {
|
|
ID pgtype.UUID `json:"id"`
|
|
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
|
}
|
|
|
|
func (q *Queries) DeleteGitHubInstallation(ctx context.Context, arg DeleteGitHubInstallationParams) error {
|
|
_, err := q.db.Exec(ctx, deleteGitHubInstallation, arg.ID, arg.WorkspaceID)
|
|
return err
|
|
}
|
|
|
|
const deleteGitHubInstallationByInstallationID = `-- name: DeleteGitHubInstallationByInstallationID :one
|
|
DELETE FROM github_installation WHERE installation_id = $1
|
|
RETURNING id, workspace_id
|
|
`
|
|
|
|
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
|
|
SELECT id, workspace_id, installation_id, account_login, account_type, account_avatar_url, connected_by_id, created_at, updated_at FROM github_installation
|
|
WHERE id = $1
|
|
`
|
|
|
|
func (q *Queries) GetGitHubInstallationByID(ctx context.Context, id pgtype.UUID) (GithubInstallation, error) {
|
|
row := q.db.QueryRow(ctx, getGitHubInstallationByID, id)
|
|
var i GithubInstallation
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.WorkspaceID,
|
|
&i.InstallationID,
|
|
&i.AccountLogin,
|
|
&i.AccountType,
|
|
&i.AccountAvatarUrl,
|
|
&i.ConnectedByID,
|
|
&i.CreatedAt,
|
|
&i.UpdatedAt,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const getGitHubInstallationByInstallationID = `-- name: GetGitHubInstallationByInstallationID :one
|
|
SELECT id, workspace_id, installation_id, account_login, account_type, account_avatar_url, connected_by_id, created_at, updated_at FROM github_installation
|
|
WHERE installation_id = $1
|
|
`
|
|
|
|
func (q *Queries) GetGitHubInstallationByInstallationID(ctx context.Context, installationID int64) (GithubInstallation, error) {
|
|
row := q.db.QueryRow(ctx, getGitHubInstallationByInstallationID, installationID)
|
|
var i GithubInstallation
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.WorkspaceID,
|
|
&i.InstallationID,
|
|
&i.AccountLogin,
|
|
&i.AccountType,
|
|
&i.AccountAvatarUrl,
|
|
&i.ConnectedByID,
|
|
&i.CreatedAt,
|
|
&i.UpdatedAt,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const getGitHubPullRequest = `-- name: GetGitHubPullRequest :one
|
|
SELECT id, 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, created_at, updated_at FROM github_pull_request
|
|
WHERE workspace_id = $1 AND repo_owner = $2 AND repo_name = $3 AND pr_number = $4
|
|
`
|
|
|
|
type GetGitHubPullRequestParams struct {
|
|
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
|
RepoOwner string `json:"repo_owner"`
|
|
RepoName string `json:"repo_name"`
|
|
PrNumber int32 `json:"pr_number"`
|
|
}
|
|
|
|
func (q *Queries) GetGitHubPullRequest(ctx context.Context, arg GetGitHubPullRequestParams) (GithubPullRequest, error) {
|
|
row := q.db.QueryRow(ctx, getGitHubPullRequest,
|
|
arg.WorkspaceID,
|
|
arg.RepoOwner,
|
|
arg.RepoName,
|
|
arg.PrNumber,
|
|
)
|
|
var i GithubPullRequest
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.WorkspaceID,
|
|
&i.InstallationID,
|
|
&i.RepoOwner,
|
|
&i.RepoName,
|
|
&i.PrNumber,
|
|
&i.Title,
|
|
&i.State,
|
|
&i.HtmlUrl,
|
|
&i.Branch,
|
|
&i.AuthorLogin,
|
|
&i.AuthorAvatarUrl,
|
|
&i.MergedAt,
|
|
&i.ClosedAt,
|
|
&i.PrCreatedAt,
|
|
&i.PrUpdatedAt,
|
|
&i.CreatedAt,
|
|
&i.UpdatedAt,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const getSiblingPullRequestStateCountsForIssue = `-- name: GetSiblingPullRequestStateCountsForIssue :one
|
|
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
|
|
`
|
|
|
|
type GetSiblingPullRequestStateCountsForIssueParams struct {
|
|
IssueID pgtype.UUID `json:"issue_id"`
|
|
ID pgtype.UUID `json:"id"`
|
|
}
|
|
|
|
type GetSiblingPullRequestStateCountsForIssueRow struct {
|
|
OpenCount int64 `json:"open_count"`
|
|
MergedCount int64 `json:"merged_count"`
|
|
}
|
|
|
|
// 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.
|
|
func (q *Queries) GetSiblingPullRequestStateCountsForIssue(ctx context.Context, arg GetSiblingPullRequestStateCountsForIssueParams) (GetSiblingPullRequestStateCountsForIssueRow, error) {
|
|
row := q.db.QueryRow(ctx, getSiblingPullRequestStateCountsForIssue, arg.IssueID, arg.ID)
|
|
var i GetSiblingPullRequestStateCountsForIssueRow
|
|
err := row.Scan(&i.OpenCount, &i.MergedCount)
|
|
return i, err
|
|
}
|
|
|
|
const linkIssueToPullRequest = `-- name: LinkIssueToPullRequest :exec
|
|
|
|
INSERT INTO issue_pull_request (
|
|
issue_id, pull_request_id, linked_by_type, linked_by_id
|
|
) VALUES (
|
|
$1, $2, $3, $4
|
|
)
|
|
ON CONFLICT (issue_id, pull_request_id) DO NOTHING
|
|
`
|
|
|
|
type LinkIssueToPullRequestParams struct {
|
|
IssueID pgtype.UUID `json:"issue_id"`
|
|
PullRequestID pgtype.UUID `json:"pull_request_id"`
|
|
LinkedByType pgtype.Text `json:"linked_by_type"`
|
|
LinkedByID pgtype.UUID `json:"linked_by_id"`
|
|
}
|
|
|
|
// =====================
|
|
// Issue ↔ Pull Request link
|
|
// =====================
|
|
func (q *Queries) LinkIssueToPullRequest(ctx context.Context, arg LinkIssueToPullRequestParams) error {
|
|
_, err := q.db.Exec(ctx, linkIssueToPullRequest,
|
|
arg.IssueID,
|
|
arg.PullRequestID,
|
|
arg.LinkedByType,
|
|
arg.LinkedByID,
|
|
)
|
|
return err
|
|
}
|
|
|
|
const listGitHubInstallationsByWorkspace = `-- name: ListGitHubInstallationsByWorkspace :many
|
|
|
|
SELECT id, workspace_id, installation_id, account_login, account_type, account_avatar_url, connected_by_id, created_at, updated_at FROM github_installation
|
|
WHERE workspace_id = $1
|
|
ORDER BY created_at ASC
|
|
`
|
|
|
|
// =====================
|
|
// GitHub Installation
|
|
// =====================
|
|
func (q *Queries) ListGitHubInstallationsByWorkspace(ctx context.Context, workspaceID pgtype.UUID) ([]GithubInstallation, error) {
|
|
rows, err := q.db.Query(ctx, listGitHubInstallationsByWorkspace, workspaceID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []GithubInstallation{}
|
|
for rows.Next() {
|
|
var i GithubInstallation
|
|
if err := rows.Scan(
|
|
&i.ID,
|
|
&i.WorkspaceID,
|
|
&i.InstallationID,
|
|
&i.AccountLogin,
|
|
&i.AccountType,
|
|
&i.AccountAvatarUrl,
|
|
&i.ConnectedByID,
|
|
&i.CreatedAt,
|
|
&i.UpdatedAt,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const listIssueIDsForPullRequest = `-- name: ListIssueIDsForPullRequest :many
|
|
SELECT issue_id FROM issue_pull_request
|
|
WHERE pull_request_id = $1
|
|
`
|
|
|
|
func (q *Queries) ListIssueIDsForPullRequest(ctx context.Context, pullRequestID pgtype.UUID) ([]pgtype.UUID, error) {
|
|
rows, err := q.db.Query(ctx, listIssueIDsForPullRequest, pullRequestID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []pgtype.UUID{}
|
|
for rows.Next() {
|
|
var issue_id pgtype.UUID
|
|
if err := rows.Scan(&issue_id); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, issue_id)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const listPullRequestsByIssue = `-- name: ListPullRequestsByIssue :many
|
|
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.created_at, pr.updated_at
|
|
FROM github_pull_request pr
|
|
JOIN issue_pull_request ipr ON ipr.pull_request_id = pr.id
|
|
WHERE ipr.issue_id = $1
|
|
ORDER BY pr.pr_created_at DESC
|
|
`
|
|
|
|
func (q *Queries) ListPullRequestsByIssue(ctx context.Context, issueID pgtype.UUID) ([]GithubPullRequest, error) {
|
|
rows, err := q.db.Query(ctx, listPullRequestsByIssue, issueID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []GithubPullRequest{}
|
|
for rows.Next() {
|
|
var i GithubPullRequest
|
|
if err := rows.Scan(
|
|
&i.ID,
|
|
&i.WorkspaceID,
|
|
&i.InstallationID,
|
|
&i.RepoOwner,
|
|
&i.RepoName,
|
|
&i.PrNumber,
|
|
&i.Title,
|
|
&i.State,
|
|
&i.HtmlUrl,
|
|
&i.Branch,
|
|
&i.AuthorLogin,
|
|
&i.AuthorAvatarUrl,
|
|
&i.MergedAt,
|
|
&i.ClosedAt,
|
|
&i.PrCreatedAt,
|
|
&i.PrUpdatedAt,
|
|
&i.CreatedAt,
|
|
&i.UpdatedAt,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const unlinkIssueFromPullRequest = `-- name: UnlinkIssueFromPullRequest :exec
|
|
DELETE FROM issue_pull_request
|
|
WHERE issue_id = $1 AND pull_request_id = $2
|
|
`
|
|
|
|
type UnlinkIssueFromPullRequestParams struct {
|
|
IssueID pgtype.UUID `json:"issue_id"`
|
|
PullRequestID pgtype.UUID `json:"pull_request_id"`
|
|
}
|
|
|
|
func (q *Queries) UnlinkIssueFromPullRequest(ctx context.Context, arg UnlinkIssueFromPullRequestParams) error {
|
|
_, err := q.db.Exec(ctx, unlinkIssueFromPullRequest, arg.IssueID, arg.PullRequestID)
|
|
return err
|
|
}
|
|
|
|
const upsertGitHubPullRequest = `-- name: UpsertGitHubPullRequest :one
|
|
|
|
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
|
|
) VALUES (
|
|
$1, $2, $3, $4, $5,
|
|
$6, $7, $8, $11, $12, $13,
|
|
$14, $15, $9, $10
|
|
)
|
|
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,
|
|
updated_at = now()
|
|
RETURNING id, 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, created_at, updated_at
|
|
`
|
|
|
|
type UpsertGitHubPullRequestParams struct {
|
|
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
|
InstallationID int64 `json:"installation_id"`
|
|
RepoOwner string `json:"repo_owner"`
|
|
RepoName string `json:"repo_name"`
|
|
PrNumber int32 `json:"pr_number"`
|
|
Title string `json:"title"`
|
|
State string `json:"state"`
|
|
HtmlUrl string `json:"html_url"`
|
|
PrCreatedAt pgtype.Timestamptz `json:"pr_created_at"`
|
|
PrUpdatedAt pgtype.Timestamptz `json:"pr_updated_at"`
|
|
Branch pgtype.Text `json:"branch"`
|
|
AuthorLogin pgtype.Text `json:"author_login"`
|
|
AuthorAvatarUrl pgtype.Text `json:"author_avatar_url"`
|
|
MergedAt pgtype.Timestamptz `json:"merged_at"`
|
|
ClosedAt pgtype.Timestamptz `json:"closed_at"`
|
|
}
|
|
|
|
// =====================
|
|
// GitHub Pull Request
|
|
// =====================
|
|
func (q *Queries) UpsertGitHubPullRequest(ctx context.Context, arg UpsertGitHubPullRequestParams) (GithubPullRequest, error) {
|
|
row := q.db.QueryRow(ctx, upsertGitHubPullRequest,
|
|
arg.WorkspaceID,
|
|
arg.InstallationID,
|
|
arg.RepoOwner,
|
|
arg.RepoName,
|
|
arg.PrNumber,
|
|
arg.Title,
|
|
arg.State,
|
|
arg.HtmlUrl,
|
|
arg.PrCreatedAt,
|
|
arg.PrUpdatedAt,
|
|
arg.Branch,
|
|
arg.AuthorLogin,
|
|
arg.AuthorAvatarUrl,
|
|
arg.MergedAt,
|
|
arg.ClosedAt,
|
|
)
|
|
var i GithubPullRequest
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.WorkspaceID,
|
|
&i.InstallationID,
|
|
&i.RepoOwner,
|
|
&i.RepoName,
|
|
&i.PrNumber,
|
|
&i.Title,
|
|
&i.State,
|
|
&i.HtmlUrl,
|
|
&i.Branch,
|
|
&i.AuthorLogin,
|
|
&i.AuthorAvatarUrl,
|
|
&i.MergedAt,
|
|
&i.ClosedAt,
|
|
&i.PrCreatedAt,
|
|
&i.PrUpdatedAt,
|
|
&i.CreatedAt,
|
|
&i.UpdatedAt,
|
|
)
|
|
return i, err
|
|
}
|