package projects
import (
"context"
"embed"
"fmt"
"log/slog"
"os"
"strings"
"time"
"github.com/firebase/genkit/go/ai"
"github.com/firebase/genkit/go/genkit"
"github.com/firebase/genkit/go/plugins/compat_oai/openai"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/openai/openai-go/option"
"mimi/internal/persist"
"mimi/internal/provider/github/db"
)
var prompts embed.FS
type Project struct {
Org string
Number int
Title string
Description string
URL string
Closed bool
Category string
CategoryReasoning string
}
type ProjectWithIssues struct {
Project
Issues []db.Issue
}
type Service struct {
ghClient *db.Client
pgPool *pgxpool.Pool
classifierPrompt ai.Prompt
}
func New(ctx context.Context, pgPool *pgxpool.Pool) *Service {
openrouterAPIKey := os.Getenv("OPENROUTER_API_KEY")
if openrouterAPIKey == "" {
panic("OPENROUTER_API_KEY not set")
}
openrouterBaseURL := os.Getenv("OPENROUTER_API_URL")
if openrouterBaseURL == "" {
panic("OPENROUTER_API_URL not set")
}
oai := &openai.OpenAI{
APIKey: openrouterAPIKey,
Opts: []option.RequestOption{
option.WithBaseURL(openrouterBaseURL),
},
}
g := genkit.Init(ctx,
genkit.WithPlugins(oai),
genkit.WithPromptFS(prompts),
genkit.WithPromptDir("prompts"),
genkit.WithDefaultModel("openai/gpt-4o-mini"),
)
oai.DefineModel("openai/gpt-4o-mini", ai.ModelOptions{
Label: "GPT-4o Mini",
Versions: []string{},
Supports: &ai.ModelSupports{
Multiturn: true,
Tools: true,
ToolChoice: true,
SystemRole: true,
Media: false,
Constrained: ai.ConstrainedSupportNone,
},
Stage: ai.ModelStageStable,
})
classifierPrompt := genkit.LookupPrompt(g, "project-classifier")
if classifierPrompt == nil {
panic("project-classifier prompt not found")
}
ghClient := db.New("https://api.github.com/graphql")
return &Service{
ghClient: ghClient,
pgPool: pgPool,
classifierPrompt: classifierPrompt,
}
}
func (s *Service) FetchActiveProjects(ctx context.Context, org string, since time.Time) ([]ProjectWithIssues, error) {
if err := s.ensureProjectsExist(ctx, org); err != nil {
return nil, fmt.Errorf("failed to ensure projects exist: %w", err)
}
projects, err := s.getActiveProjects(ctx, org)
if err != nil {
return nil, fmt.Errorf("failed to get active projects: %w", err)
}
return s.fetchProjectIssues(ctx, org, projects, since, true)
}
func (s *Service) FetchProjectsByCategory(ctx context.Context, org, category string, since time.Time) ([]ProjectWithIssues, error) {
if err := s.ensureProjectsExist(ctx, org); err != nil {
return nil, fmt.Errorf("failed to ensure projects exist: %w", err)
}
projects, err := s.getProjectsByCategory(ctx, org, category)
if err != nil {
return nil, fmt.Errorf("failed to get projects by category: %w", err)
}
return s.fetchProjectIssues(ctx, org, projects, since, true)
}
func (s *Service) fetchProjectIssues(ctx context.Context, org string, projects []Project, since time.Time, allowRetry bool) ([]ProjectWithIssues, error) {
result := make([]ProjectWithIssues, 0, len(projects))
var failedProjects []int
for _, proj := range projects {
issues, err := s.ghClient.GetOrgProject(ctx, org, proj.Number, since)
if err != nil {
if isProjectNotFoundError(err) {
slog.Warn("project not found, marking for resync",
"project", proj.Title,
"number", proj.Number,
"error", err)
failedProjects = append(failedProjects, proj.Number)
continue
}
return nil, fmt.Errorf("failed to fetch issues for project %d (%s): %w", proj.Number, proj.Title, err)
}
slog.Info("fetched project issues",
"project", proj.Title,
"category", proj.Category,
"count", len(issues))
result = append(result, ProjectWithIssues{
Project: proj,
Issues: issues,
})
}
if len(failedProjects) > 0 && allowRetry {
slog.Info("some projects not found, resyncing from GitHub", "failed", failedProjects)
if err := s.syncProjects(ctx, org); err != nil {
return nil, fmt.Errorf("failed to resync projects: %w", err)
}
updatedProjects, err := s.getActiveProjects(ctx, org)
if err != nil {
return nil, fmt.Errorf("failed to get updated projects: %w", err)
}
return s.fetchProjectIssues(ctx, org, updatedProjects, since, false)
}
return result, nil
}
func isProjectNotFoundError(err error) bool {
if err == nil {
return false
}
errStr := err.Error()
return strings.Contains(errStr, "Could not resolve to a ProjectV2") ||
strings.Contains(errStr, "NOT_FOUND")
}
func (s *Service) ensureProjectsExist(ctx context.Context, org string) error {
needsSync, err := s.needsSyncCheck(ctx, org)
if err != nil {
slog.Warn("failed to check if sync needed, forcing sync", "error", err)
needsSync = true
}
if needsSync {
slog.Info("project changes detected, syncing from GitHub")
if err := s.syncProjects(ctx, org); err != nil {
return fmt.Errorf("failed to sync projects: %w", err)
}
}
return nil
}
func (s *Service) needsSyncCheck(ctx context.Context, org string) (bool, error) {
q := persist.New(s.pgPool)
dbProjects, err := q.GetAllGitHubProjects(ctx, org)
if err != nil {
return true, fmt.Errorf("failed to get projects from db: %w", err)
}
if len(dbProjects) == 0 {
slog.Info("no projects in database, sync needed")
return true, nil
}
githubProjects, err := s.ghClient.ListProjects(ctx, org)
if err != nil {
slog.Warn("failed to check GitHub projects, assuming no sync needed", "error", err)
return false, nil
}
dbMap := make(map[int]string)
for _, p := range dbProjects {
dbMap[int(p.Number)] = p.Title
}
ghMap := make(map[int]string)
for _, p := range githubProjects {
ghMap[p.Id] = p.Title
}
if len(dbMap) != len(ghMap) {
slog.Info("project count changed, sync needed", "db", len(dbMap), "github", len(ghMap))
return true, nil
}
for num, title := range ghMap {
dbTitle, exists := dbMap[num]
if !exists {
slog.Info("new project found, sync needed", "number", num, "title", title)
return true, nil
}
if dbTitle != title {
slog.Info("project renamed, sync needed", "number", num, "old", dbTitle, "new", title)
return true, nil
}
}
slog.Info("no project changes detected")
return false, nil
}
func (s *Service) syncProjects(ctx context.Context, org string) error {
slog.Info("syncing GitHub projects", "org", org)
githubProjects, err := s.ghClient.ListProjects(ctx, org)
if err != nil {
return fmt.Errorf("failed to list projects from GitHub: %w", err)
}
slog.Info("fetched projects from GitHub", "count", len(githubProjects))
projects := make([]Project, 0, len(githubProjects))
for _, gp := range githubProjects {
projects = append(projects, Project{
Org: org,
Number: gp.Id,
Title: gp.Title,
Description: gp.ShortDescription,
URL: gp.URL,
Closed: gp.Closed,
})
}
q := persist.New(s.pgPool)
existingProjects, err := q.GetAllGitHubProjects(ctx, org)
if err != nil {
slog.Warn("failed to get existing projects", "error", err)
}
existingClassifications := make(map[int]struct {
Category string
Reasoning string
})
for _, ep := range existingProjects {
if ep.Category.Valid {
existingClassifications[int(ep.Number)] = struct {
Category string
Reasoning string
}{
Category: ep.Category.String,
Reasoning: ep.CategoryReasoning.String,
}
}
}
for i := range projects {
if existing, ok := existingClassifications[projects[i].Number]; ok {
projects[i].Category = existing.Category
projects[i].CategoryReasoning = existing.Reasoning
}
}
unclassified := make([]Project, 0)
for _, proj := range projects {
if proj.Category == "" && !proj.Closed {
unclassified = append(unclassified, proj)
}
}
if len(unclassified) > 0 {
if err := s.classifyProjects(ctx, unclassified); err != nil {
return fmt.Errorf("failed to classify projects: %w", err)
}
classificationMap := make(map[int]Project)
for _, p := range unclassified {
classificationMap[p.Number] = p
}
for i := range projects {
if classified, ok := classificationMap[projects[i].Number]; ok {
projects[i].Category = classified.Category
projects[i].CategoryReasoning = classified.CategoryReasoning
}
}
}
for _, proj := range projects {
var category, categoryReasoning pgtype.Text
if proj.Category != "" {
category = pgtype.Text{String: proj.Category, Valid: true}
categoryReasoning = pgtype.Text{String: proj.CategoryReasoning, Valid: true}
}
err := q.UpsertGitHubProject(ctx, persist.UpsertGitHubProjectParams{
Org: proj.Org,
Number: int32(proj.Number),
Title: proj.Title,
Description: pgtype.Text{String: proj.Description, Valid: proj.Description != ""},
Url: proj.URL,
Closed: proj.Closed,
Category: category,
CategoryReasoning: categoryReasoning,
})
if err != nil {
slog.Error("failed to upsert project", "project", proj.Number, "error", err)
}
}
slog.Info("synced projects to database", "count", len(projects))
return nil
}
func (s *Service) getActiveProjects(ctx context.Context, org string) ([]Project, error) {
q := persist.New(s.pgPool)
rows, err := q.GetActiveGitHubProjects(ctx, org)
if err != nil {
return nil, fmt.Errorf("failed to get active projects: %w", err)
}
projects := make([]Project, 0, len(rows))
for _, row := range rows {
projects = append(projects, Project{
Org: row.Org,
Number: int(row.Number),
Title: row.Title,
Description: row.Description.String,
URL: row.Url,
Closed: row.Closed,
Category: row.Category.String,
CategoryReasoning: row.CategoryReasoning.String,
})
}
return projects, nil
}
func (s *Service) getProjectsByCategory(ctx context.Context, org, category string) ([]Project, error) {
q := persist.New(s.pgPool)
rows, err := q.GetGitHubProjectsByCategory(ctx, persist.GetGitHubProjectsByCategoryParams{
Org: org,
Category: pgtype.Text{String: category, Valid: true},
})
if err != nil {
return nil, fmt.Errorf("failed to get projects by category: %w", err)
}
projects := make([]Project, 0, len(rows))
for _, row := range rows {
projects = append(projects, Project{
Org: row.Org,
Number: int(row.Number),
Title: row.Title,
Description: row.Description.String,
URL: row.Url,
Closed: row.Closed,
Category: row.Category.String,
CategoryReasoning: row.CategoryReasoning.String,
})
}
return projects, nil
}