Files
kandev/apps/backend/internal/github/store_task_pr_ownership.go
2026-09-05 15:30:20 +00:00

247 lines
9.6 KiB
Go

package github
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
"github.com/jmoiron/sqlx"
)
// TaskPROwnershipReconciler moves or detaches an old watch-sourced association
// when a session now resolves to a different workspace-group owner. The
// operation is kept in the store so the TaskPR row and its task-scoped
// automation state change together, without publishing an intermediate event.
type TaskPROwnershipReconciler interface {
ReconcileTaskPROwnership(
ctx context.Context, memberTaskID, ownerTaskID, repositoryID string, prNumber int,
) (bool, error)
}
type taskPROwnerState int
const (
taskPROwnerAbsent taskPROwnerState = iota
taskPROwnerActive
taskPROwnerDetached
)
// ReconcileTaskPROwnership repairs a legacy member-owned TaskPR for one exact
// watch-derived PR. If an active owner row exists, the member row becomes a
// detached tombstone. If no owner row exists, the row and its automation state
// move to the owner in one transaction. Explicit URL links are not changed.
// The bool reports whether an active member row was reconciled.
func (s *Store) ReconcileTaskPROwnership(
ctx context.Context, memberTaskID, ownerTaskID, repositoryID string, prNumber int,
) (bool, error) {
if !validTaskPROwnershipArgs(s, memberTaskID, ownerTaskID, prNumber) {
return false, nil
}
tx, err := s.db.BeginTxx(ctx, nil)
if err != nil {
return false, err
}
defer func() { _ = tx.Rollback() }()
memberID, source, found, err := findTaskPRMemberTx(ctx, tx, memberTaskID, repositoryID, prNumber)
if err != nil {
return false, err
}
if !found || source == TaskPRSourceURLLink {
return false, nil
}
ownerState, err := findTaskPROwnerStateTx(ctx, tx, ownerTaskID, repositoryID, prNumber)
if err != nil {
return false, err
}
if ownerState == taskPROwnerActive {
if err := reconcileExistingOwnerTaskPRTx(ctx, tx, memberID, memberTaskID, ownerTaskID, repositoryID, prNumber); err != nil {
return false, err
}
return true, tx.Commit()
}
if ownerState == taskPROwnerDetached {
// A detached owner tombstone is an explicit opt-out. Do not resurrect
// it through a watch, but still remove the active duplicate member row.
if err := detachTaskPROwnershipRowTx(ctx, tx, memberID, memberTaskID, repositoryID, prNumber); err != nil {
return false, err
}
return true, tx.Commit()
}
if err := moveTaskPROwnershipTx(ctx, tx, memberID, memberTaskID, ownerTaskID, repositoryID, prNumber); err != nil {
return false, err
}
return true, tx.Commit()
}
func validTaskPROwnershipArgs(s *Store, memberTaskID, ownerTaskID string, prNumber int) bool {
return s != nil && memberTaskID != "" && ownerTaskID != "" && memberTaskID != ownerTaskID && prNumber != 0
}
func findTaskPRMemberTx(
ctx context.Context, tx *sqlx.Tx, memberTaskID, repositoryID string, prNumber int,
) (string, string, bool, error) {
var memberID, source string
err := tx.QueryRowxContext(ctx, tx.Rebind(`
SELECT id, source
FROM github_task_prs
WHERE task_id = ? AND repository_id = ? AND pr_number = ? AND detached_at IS NULL
LIMIT 1`), memberTaskID, repositoryID, prNumber).Scan(&memberID, &source)
if errors.Is(err, sql.ErrNoRows) {
return "", "", false, nil
}
if source == TaskPRSourceURLLink {
return memberID, source, true, nil
}
if err != nil {
return "", "", false, fmt.Errorf("load member task PR: %w", err)
}
return memberID, source, true, nil
}
func reconcileExistingOwnerTaskPRTx(
ctx context.Context, tx *sqlx.Tx, memberID, memberTaskID, ownerTaskID, repositoryID string, prNumber int,
) error {
if err := copyTaskPROwnershipStateTx(ctx, tx, memberTaskID, ownerTaskID, repositoryID, prNumber); err != nil {
return err
}
return detachTaskPROwnershipRowTx(ctx, tx, memberID, memberTaskID, repositoryID, prNumber)
}
func findTaskPROwnerStateTx(
ctx context.Context, tx *sqlx.Tx, ownerTaskID, repositoryID string, prNumber int,
) (taskPROwnerState, error) {
var ownerID string
err := tx.QueryRowxContext(ctx, tx.Rebind(`
SELECT id
FROM github_task_prs
WHERE task_id = ? AND repository_id = ? AND pr_number = ? AND detached_at IS NULL
LIMIT 1`), ownerTaskID, repositoryID, prNumber).Scan(&ownerID)
if err == nil {
return taskPROwnerActive, nil
}
if !errors.Is(err, sql.ErrNoRows) {
return taskPROwnerAbsent, fmt.Errorf("load owner task PR: %w", err)
}
err = tx.QueryRowxContext(ctx, tx.Rebind(`
SELECT id
FROM github_task_prs
WHERE task_id = ? AND repository_id = ? AND pr_number = ?
LIMIT 1`), ownerTaskID, repositoryID, prNumber).Scan(&ownerID)
if err == nil {
return taskPROwnerDetached, nil
}
if errors.Is(err, sql.ErrNoRows) {
return taskPROwnerAbsent, nil
}
return taskPROwnerAbsent, fmt.Errorf("load owner task PR tombstone: %w", err)
}
func moveTaskPROwnershipTx(
ctx context.Context, tx *sqlx.Tx, memberID, memberTaskID, ownerTaskID, repositoryID string, prNumber int,
) error {
if err := copyTaskPROwnershipStateTx(ctx, tx, memberTaskID, ownerTaskID, repositoryID, prNumber); err != nil {
return err
}
result, err := tx.ExecContext(ctx, tx.Rebind(`
UPDATE github_task_prs
SET task_id = ?, updated_at = ?
WHERE id = ? AND task_id = ? AND repository_id = ? AND pr_number = ? AND detached_at IS NULL`,
), ownerTaskID, time.Now().UTC(), memberID, memberTaskID, repositoryID, prNumber)
if err != nil {
return fmt.Errorf("move task PR to owner: %w", err)
}
if rows, rowsErr := result.RowsAffected(); rowsErr != nil {
return fmt.Errorf("inspect moved task PR: %w", rowsErr)
} else if rows != 1 {
return fmt.Errorf("move task PR to owner: affected %d rows", rows)
}
return moveTaskPROwnershipStateTx(ctx, tx, memberTaskID, ownerTaskID, repositoryID, prNumber)
}
func detachTaskPROwnershipRowTx(
ctx context.Context, tx *sqlx.Tx, memberID, memberTaskID, repositoryID string, prNumber int,
) error {
if _, err := tx.ExecContext(ctx, tx.Rebind(`
UPDATE github_task_prs
SET detached_at = ?, updated_at = ?
WHERE id = ? AND task_id = ? AND repository_id = ? AND pr_number = ? AND detached_at IS NULL`),
time.Now().UTC(), time.Now().UTC(), memberID, memberTaskID, repositoryID, prNumber); err != nil {
return fmt.Errorf("detach duplicate task PR: %w", err)
}
if _, err := tx.ExecContext(ctx, tx.Rebind(`
DELETE FROM github_task_pr_automation_options
WHERE task_id = ? AND repository_id = ? AND pr_number = ?`), memberTaskID, repositoryID, prNumber); err != nil {
return fmt.Errorf("remove duplicate task PR automation options: %w", err)
}
if _, err := tx.ExecContext(ctx, tx.Rebind(`
DELETE FROM github_task_ci_pr_state
WHERE task_id = ? AND repository_id = ? AND pr_number = ?`), memberTaskID, repositoryID, prNumber); err != nil {
return fmt.Errorf("remove duplicate task PR automation state: %w", err)
}
return nil
}
func copyTaskPROwnershipStateTx(
ctx context.Context, tx *sqlx.Tx, memberTaskID, ownerTaskID, repositoryID string, prNumber int,
) error {
if _, err := tx.ExecContext(ctx, tx.Rebind(`
INSERT INTO github_task_pr_automation_options (
task_id, repository_id, pr_number, auto_fix_enabled, auto_merge_enabled,
prompt_on_review_requested, prompt_on_merged, prompt_on_closed, created_at, updated_at
)
SELECT ?, repository_id, pr_number, auto_fix_enabled, auto_merge_enabled,
prompt_on_review_requested, prompt_on_merged, prompt_on_closed, created_at, updated_at
FROM github_task_pr_automation_options
WHERE task_id = ? AND repository_id = ? AND pr_number = ?
ON CONFLICT(task_id, repository_id, pr_number) DO NOTHING`),
ownerTaskID, memberTaskID, repositoryID, prNumber); err != nil {
return fmt.Errorf("copy task PR automation options: %w", err)
}
if _, err := tx.ExecContext(ctx, tx.Rebind(`
INSERT INTO github_task_ci_pr_state (
task_id, repository_id, pr_number, last_fix_signature, last_fix_checkpoint_json,
last_fix_enqueued_at, last_fix_session_id, auto_fix_round_count, auto_fix_exhausted_at,
last_merge_signature, last_merge_attempt_at, last_merge_result, merge_retry_pending,
last_queue_attempt_head_sha, last_queue_fix_event_id, last_queue_removal_cause,
review_request_initialized, last_review_requested, last_observed_pr_state,
last_lifecycle_event, last_lifecycle_prompt_at, last_lifecycle_session_id,
last_error, last_error_kind, created_at, updated_at
)
SELECT ?, repository_id, pr_number, last_fix_signature, last_fix_checkpoint_json,
last_fix_enqueued_at, last_fix_session_id, auto_fix_round_count, auto_fix_exhausted_at,
last_merge_signature, last_merge_attempt_at, last_merge_result, merge_retry_pending,
last_queue_attempt_head_sha, last_queue_fix_event_id, last_queue_removal_cause,
review_request_initialized, last_review_requested, last_observed_pr_state,
last_lifecycle_event, last_lifecycle_prompt_at, last_lifecycle_session_id,
last_error, last_error_kind, created_at, updated_at
FROM github_task_ci_pr_state
WHERE task_id = ? AND repository_id = ? AND pr_number = ?
ON CONFLICT(task_id, repository_id, pr_number) DO NOTHING`),
ownerTaskID, memberTaskID, repositoryID, prNumber); err != nil {
return fmt.Errorf("copy task PR automation state: %w", err)
}
return nil
}
func moveTaskPROwnershipStateTx(
ctx context.Context, tx *sqlx.Tx, memberTaskID, ownerTaskID, repositoryID string, prNumber int,
) error {
if _, err := tx.ExecContext(ctx, tx.Rebind(`
DELETE FROM github_task_pr_automation_options
WHERE task_id = ? AND repository_id = ? AND pr_number = ?`), memberTaskID, repositoryID, prNumber); err != nil {
return fmt.Errorf("remove moved task PR automation options: %w", err)
}
if _, err := tx.ExecContext(ctx, tx.Rebind(`
DELETE FROM github_task_ci_pr_state
WHERE task_id = ? AND repository_id = ? AND pr_number = ?`), memberTaskID, repositoryID, prNumber); err != nil {
return fmt.Errorf("remove moved task PR automation state: %w", err)
}
return nil
}