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 }