cairn/internal/models/crash_group.go

265 lines
8.9 KiB
Go

package models
import (
"context"
"fmt"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
type CrashSignature struct {
ID string `json:"id"`
RepositoryID string `json:"repository_id"`
Fingerprint string `json:"fingerprint"`
SampleTrace *string `json:"sample_trace,omitempty"`
FirstSeenAt time.Time `json:"first_seen_at"`
LastSeenAt time.Time `json:"last_seen_at"`
OccurrenceCount int `json:"occurrence_count"`
}
type CrashGroup struct {
ID string `json:"id"`
CrashSignatureID string `json:"crash_signature_id"`
RepositoryID string `json:"repository_id"`
Title string `json:"title"`
Status string `json:"status"`
ForgejoIssueID *int `json:"forgejo_issue_id,omitempty"`
ForgejoIssueURL *string `json:"forgejo_issue_url,omitempty"`
FirstSeenAt time.Time `json:"first_seen_at"`
LastSeenAt time.Time `json:"last_seen_at"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
// Joined fields
RepoName string `json:"repo_name,omitempty"`
Fingerprint string `json:"fingerprint,omitempty"`
OccurrenceCount int `json:"occurrence_count,omitempty"`
}
// GetOrCreateSignature upserts a crash signature, incrementing occurrence count.
func GetOrCreateSignature(ctx context.Context, pool *pgxpool.Pool, repoID, fingerprint string, sampleTrace *string) (*CrashSignature, bool, error) {
sig := &CrashSignature{}
var created bool
// Try insert first.
err := pool.QueryRow(ctx, `
INSERT INTO crash_signatures (repository_id, fingerprint, sample_trace)
VALUES ($1, $2, $3)
ON CONFLICT (repository_id, fingerprint)
DO UPDATE SET
last_seen_at = NOW(),
occurrence_count = crash_signatures.occurrence_count + 1
RETURNING id, repository_id, fingerprint, sample_trace, first_seen_at, last_seen_at, occurrence_count
`, repoID, fingerprint, sampleTrace).Scan(
&sig.ID, &sig.RepositoryID, &sig.Fingerprint, &sig.SampleTrace,
&sig.FirstSeenAt, &sig.LastSeenAt, &sig.OccurrenceCount,
)
if err != nil {
return nil, false, fmt.Errorf("get or create signature: %w", err)
}
// If occurrence count is 1, this is a new signature.
created = sig.OccurrenceCount == 1
return sig, created, nil
}
// CreateCrashGroup creates a crash group for a new signature.
func CreateCrashGroup(ctx context.Context, pool *pgxpool.Pool, sigID, repoID, title string) (*CrashGroup, error) {
cg := &CrashGroup{}
err := pool.QueryRow(ctx, `
INSERT INTO crash_groups (crash_signature_id, repository_id, title)
VALUES ($1, $2, $3)
RETURNING id, crash_signature_id, repository_id, title, status,
forgejo_issue_id, forgejo_issue_url, first_seen_at, last_seen_at, created_at, updated_at
`, sigID, repoID, title).Scan(
&cg.ID, &cg.CrashSignatureID, &cg.RepositoryID, &cg.Title, &cg.Status,
&cg.ForgejoIssueID, &cg.ForgejoIssueURL, &cg.FirstSeenAt, &cg.LastSeenAt,
&cg.CreatedAt, &cg.UpdatedAt,
)
if err != nil {
return nil, fmt.Errorf("creating crash group: %w", err)
}
return cg, nil
}
// ListCrashGroups returns crash groups with joined data.
func ListCrashGroups(ctx context.Context, pool *pgxpool.Pool, repoID, status string, limit, offset int) ([]CrashGroup, int, error) {
if limit <= 0 {
limit = 50
}
baseQuery := `
FROM crash_groups cg
JOIN crash_signatures cs ON cs.id = cg.crash_signature_id
JOIN repositories r ON r.id = cg.repository_id
WHERE 1=1
`
args := []any{}
argN := 1
if repoID != "" {
baseQuery += fmt.Sprintf(" AND cg.repository_id = $%d", argN)
args = append(args, repoID)
argN++
}
if status != "" {
baseQuery += fmt.Sprintf(" AND cg.status = $%d", argN)
args = append(args, status)
argN++
}
var total int
err := pool.QueryRow(ctx, "SELECT COUNT(*) "+baseQuery, args...).Scan(&total)
if err != nil {
return nil, 0, fmt.Errorf("counting crash groups: %w", err)
}
selectQuery := fmt.Sprintf(`
SELECT cg.id, cg.crash_signature_id, cg.repository_id, cg.title, cg.status,
cg.forgejo_issue_id, cg.forgejo_issue_url, cg.first_seen_at, cg.last_seen_at,
cg.created_at, cg.updated_at,
r.name, cs.fingerprint, cs.occurrence_count
%s
ORDER BY cg.last_seen_at DESC
LIMIT $%d OFFSET $%d
`, baseQuery, argN, argN+1)
args = append(args, limit, offset)
rows, err := pool.Query(ctx, selectQuery, args...)
if err != nil {
return nil, 0, fmt.Errorf("listing crash groups: %w", err)
}
defer rows.Close()
var groups []CrashGroup
for rows.Next() {
var cg CrashGroup
if err := rows.Scan(
&cg.ID, &cg.CrashSignatureID, &cg.RepositoryID, &cg.Title, &cg.Status,
&cg.ForgejoIssueID, &cg.ForgejoIssueURL, &cg.FirstSeenAt, &cg.LastSeenAt,
&cg.CreatedAt, &cg.UpdatedAt,
&cg.RepoName, &cg.Fingerprint, &cg.OccurrenceCount,
); err != nil {
return nil, 0, fmt.Errorf("scanning crash group: %w", err)
}
groups = append(groups, cg)
}
return groups, total, nil
}
// GetCrashGroup returns a single crash group by ID.
func GetCrashGroup(ctx context.Context, pool *pgxpool.Pool, id string) (*CrashGroup, error) {
cg := &CrashGroup{}
err := pool.QueryRow(ctx, `
SELECT cg.id, cg.crash_signature_id, cg.repository_id, cg.title, cg.status,
cg.forgejo_issue_id, cg.forgejo_issue_url, cg.first_seen_at, cg.last_seen_at,
cg.created_at, cg.updated_at,
r.name, cs.fingerprint, cs.occurrence_count
FROM crash_groups cg
JOIN crash_signatures cs ON cs.id = cg.crash_signature_id
JOIN repositories r ON r.id = cg.repository_id
WHERE cg.id = $1
`, id).Scan(
&cg.ID, &cg.CrashSignatureID, &cg.RepositoryID, &cg.Title, &cg.Status,
&cg.ForgejoIssueID, &cg.ForgejoIssueURL, &cg.FirstSeenAt, &cg.LastSeenAt,
&cg.CreatedAt, &cg.UpdatedAt,
&cg.RepoName, &cg.Fingerprint, &cg.OccurrenceCount,
)
if err != nil {
return nil, fmt.Errorf("getting crash group: %w", err)
}
return cg, nil
}
// UpdateCrashGroupIssue links a crash group to a Forgejo issue.
func UpdateCrashGroupIssue(ctx context.Context, pool *pgxpool.Pool, groupID string, issueNumber int, issueURL string) error {
_, err := pool.Exec(ctx, `
UPDATE crash_groups SET forgejo_issue_id = $1, forgejo_issue_url = $2, updated_at = NOW() WHERE id = $3
`, issueNumber, issueURL, groupID)
if err != nil {
return fmt.Errorf("updating crash group issue: %w", err)
}
return nil
}
// ResolveCrashGroupByIssue marks a crash group as resolved when its Forgejo issue is closed.
func ResolveCrashGroupByIssue(ctx context.Context, pool *pgxpool.Pool, issueNumber int) error {
_, err := pool.Exec(ctx, `
UPDATE crash_groups SET status = 'resolved', updated_at = NOW() WHERE forgejo_issue_id = $1
`, issueNumber)
if err != nil {
return fmt.Errorf("resolving crash group by issue: %w", err)
}
return nil
}
// ReopenCrashGroupByIssue reopens a crash group when its Forgejo issue is reopened.
func ReopenCrashGroupByIssue(ctx context.Context, pool *pgxpool.Pool, issueNumber int) error {
_, err := pool.Exec(ctx, `
UPDATE crash_groups SET status = 'open', updated_at = NOW() WHERE forgejo_issue_id = $1
`, issueNumber)
if err != nil {
return fmt.Errorf("reopening crash group by issue: %w", err)
}
return nil
}
// UpdateArtifactSignature links an artifact to a signature.
func UpdateArtifactSignature(ctx context.Context, pool *pgxpool.Pool, artifactID, signatureID, fingerprint string) error {
_, err := pool.Exec(ctx, `
UPDATE artifacts SET signature_id = $1, fingerprint = $2 WHERE id = $3
`, signatureID, fingerprint, artifactID)
if err != nil {
return fmt.Errorf("updating artifact signature: %w", err)
}
return nil
}
// SearchArtifacts performs full-text search on artifacts.
func SearchArtifacts(ctx context.Context, pool *pgxpool.Pool, query string, limit, offset int) ([]Artifact, int, error) {
if limit <= 0 {
limit = 50
}
var total int
err := pool.QueryRow(ctx, `
SELECT COUNT(*)
FROM artifacts a
WHERE a.search_vector @@ plainto_tsquery('english', $1)
`, query).Scan(&total)
if err != nil {
return nil, 0, fmt.Errorf("counting search results: %w", err)
}
rows, err := pool.Query(ctx, `
SELECT a.id, a.repository_id, a.commit_id, a.build_id, a.type, a.blob_key, a.blob_size,
a.crash_message, a.stack_trace, a.tags, a.metadata, a.created_at,
r.name, c.sha
FROM artifacts a
JOIN repositories r ON r.id = a.repository_id
JOIN commits c ON c.id = a.commit_id
WHERE a.search_vector @@ plainto_tsquery('english', $1)
ORDER BY ts_rank(a.search_vector, plainto_tsquery('english', $1)) DESC
LIMIT $2 OFFSET $3
`, query, limit, offset)
if err != nil {
return nil, 0, fmt.Errorf("searching artifacts: %w", err)
}
defer rows.Close()
var artifacts []Artifact
for rows.Next() {
var a Artifact
if err := rows.Scan(
&a.ID, &a.RepositoryID, &a.CommitID, &a.BuildID, &a.Type, &a.BlobKey, &a.BlobSize,
&a.CrashMessage, &a.StackTrace, &a.Tags, &a.Metadata, &a.CreatedAt,
&a.RepoName, &a.CommitSHA,
); err != nil {
return nil, 0, fmt.Errorf("scanning search result: %w", err)
}
artifacts = append(artifacts, a)
}
return artifacts, total, nil
}