Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
202 changes: 185 additions & 17 deletions models/issues/graph_cache.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,12 @@ package issues

import (
"context"
"sort"
"time"

"code.gitea.io/gitea/models/db"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/optional"
)

// GraphCache stores pre-computed PageRank and graph metrics for issues
Expand Down Expand Up @@ -53,53 +57,93 @@ func UpdatePageRank(ctx context.Context, repoID, issueID int64, pageRank float64
return err
}

// DependencyWithRepo joins IssueDependency with Issue to get repo information
type DependencyWithRepo struct {
IssueID int64 `xorm:"issue_dependency.issue_id"`
DependencyID int64 `xorm:"issue_dependency.dependency_id"`
IsClosed bool `xorm:"issue.is_closed"`
}

// CalculatePageRank computes PageRank for all issues in a repository
// Uses existing IssueDependency model from Gitea
// Excludes closed issues from the graph (per specification interview)
func CalculatePageRank(ctx context.Context, repoID int64, dampingFactor float64, iterations int) error {
// Get all dependencies for this repo
deps := make([]*IssueDependency, 0)
err := db.GetEngine(ctx).Find(&deps)
startTime := time.Now()

// Get all dependencies for this repo, joined with issue info to filter by repoID and closed status
// This query filters by repoID and excludes closed issues
var deps []DependencyWithRepo
err := db.GetEngine(ctx).
Table("issue_dependency").
Join("INNER", "issue", "issue.id = issue_dependency.issue_id AND issue.repo_id = ?", repoID).
Where("issue.is_closed = ?", false).
Find(&deps)
if err != nil {
return err
}

// Also get dependencies where the issue is the dependency (blocked by)
var deps2 []DependencyWithRepo
err = db.GetEngine(ctx).
Table("issue_dependency").
Join("INNER", "issue", "issue.id = issue_dependency.dependency_id AND issue.repo_id = ?", repoID).
Where("issue.is_closed = ?", false).
Find(&deps2)
if err != nil {
return err
}

// Merge both dependency lists
deps = append(deps, deps2...)

if len(deps) == 0 {
log.Info("PageRank: No dependencies found for repo %d", repoID)
return nil
}

// Build issue set and adjacency list
allIssues := make(map[int64]bool)
// Track which issues actually exist (not orphans)
validIssues := make(map[int64]bool)
// adj[depID] = list of issues that depend on it (blocked by it)
adj := make(map[int64][]int64)

for _, dep := range deps {
allIssues[dep.IssueID] = true
allIssues[dep.DependencyID] = true
// Skip orphan references - if issue doesn't exist in deps, it's an orphan
validIssues[dep.IssueID] = true
validIssues[dep.DependencyID] = true
adj[dep.DependencyID] = append(adj[dep.DependencyID], dep.IssueID)
}

if len(allIssues) == 0 {
issueCount := len(validIssues)
if issueCount == 0 {
log.Info("PageRank: No valid issues for repo %d", repoID)
return nil
}

// Initialize PageRank scores
pageRanks := make(map[int64]float64)
n := len(allIssues)
for issueID := range allIssues {
pageRanks[issueID] = 1.0 / float64(n)
for issueID := range validIssues {
pageRanks[issueID] = 1.0 / float64(issueCount)
}

// Power iteration
for i := 0; i < iterations; i++ {
newRanks := make(map[int64]float64)

for issueID := range allIssues {
newRank := (1.0 - dampingFactor) / float64(n)
for issueID := range validIssues {
newRank := (1.0 - dampingFactor) / float64(issueCount)

// Sum contributions from blockers (upstream)
// Find all issues that block this one
for _, dep := range deps {
if dep.IssueID == issueID {
blockerID := dep.DependencyID
outDegree := len(adj[blockerID])
if outDegree > 0 {
newRank += dampingFactor * pageRanks[blockerID] / float64(outDegree)
// Skip if blocker doesn't have valid PageRank
if currentRank, ok := pageRanks[blockerID]; ok {
outDegree := len(adj[blockerID])
if outDegree > 0 {
newRank += dampingFactor * currentRank / float64(outDegree)
}
}
}
}
Expand All @@ -109,12 +153,136 @@ func CalculatePageRank(ctx context.Context, repoID int64, dampingFactor float64,
pageRanks = newRanks
}

// Update cache
// Update cache - log errors but continue with remaining issues
// (per specification interview: partial failure returns partial results)
successCount := 0
errorCount := 0
for issueID, rank := range pageRanks {
if err := UpdatePageRank(ctx, repoID, issueID, rank); err != nil {
return err
log.Error("Failed to update PageRank for issue %d in repo %d: %v", issueID, repoID, err)
errorCount++
} else {
successCount++
}
}

elapsed := time.Since(startTime)
log.Info("PageRank calculated for repo %d: %d issues, %d dependencies, %d successes, %d errors, took %v",
repoID, issueCount, len(deps), successCount, errorCount, elapsed)

return nil
}

// EnsureRepoPageRankComputed ensures PageRank is computed for a repository
// Calculates if not already cached, otherwise returns cached data
func EnsureRepoPageRankComputed(ctx context.Context, repoID int64, dampingFactor float64, iterations int) error {
// Check if we have cached PageRank data for this repo
hasCache, err := hasPageRankCache(ctx, repoID)
if err != nil {
return err
}

if !hasCache {
// Calculate PageRank - lazy calculation per spec interview
return CalculatePageRank(ctx, repoID, dampingFactor, iterations)
}

return nil
}

// hasPageRankCache checks if PageRank cache exists for a repository
func hasPageRankCache(ctx context.Context, repoID int64) (bool, error) {
count, err := db.GetEngine(ctx).Where("repo_id = ?", repoID).Count(&GraphCache{})
if err != nil {
return false, err
}
return count > 0, nil
}

// GetRankedIssues returns issues sorted by PageRank score
// Hybrid approach: issues with dependencies get calculated PageRank,
// issues without get baseline score (1-damping)
func GetRankedIssues(ctx context.Context, repoID int64, limit int) ([]*Issue, error) {
// Get all open issues for the repo using the Issues function
issues, err := Issues(ctx, &IssuesOptions{
RepoIDs: []int64{repoID},
IsClosed: optional.Some(false),
IsPull: optional.Some(false),
})
if err != nil {
return nil, err
}

if len(issues) == 0 {
return issues, nil
}

// Get PageRank scores from cache
pageRanks := make(map[int64]float64)
for _, issue := range issues {
// Get cached PageRank or use baseline
pageRank, err := GetPageRank(ctx, repoID, issue.ID)
if err != nil {
log.Warn("Failed to get PageRank for issue %d: %v", issue.ID, err)
continue
}
if pageRank > 0 {
pageRanks[issue.ID] = pageRank
}
// If pageRank is 0, it will get baseline score below
}

// Baseline score for issues without PageRank
baseline := 1.0 - 0.85 // (1 - dampingFactor)

// Sort issues by PageRank (descending)
// Issues without PageRank get baseline score at the end
sortByPageRank(issues, pageRanks, baseline)

if limit > 0 && len(issues) > limit {
issues = issues[:limit]
}

return issues, nil
}

// sortByPageRank sorts issues by PageRank in descending order
func sortByPageRank(issues []*Issue, pageRanks map[int64]float64, baseline float64) {
sort.Slice(issues, func(i, j int) bool {
rankI := pageRanks[issues[i].ID]
if rankI == 0 {
rankI = baseline
}
rankJ := pageRanks[issues[j].ID]
if rankJ == 0 {
rankJ = baseline
}
return rankI > rankJ // descending order
})
}

// InvalidateCache clears PageRank cache for a repository
func InvalidateCache(ctx context.Context, repoID int64) error {
_, err := db.GetEngine(ctx).Where("repo_id = ?", repoID).Delete(&GraphCache{})
if err != nil {
log.Error("Failed to invalidate PageRank cache for repo %d: %v", repoID, err)
return err
}
log.Info("PageRank cache invalidated for repo %d", repoID)
return nil
}

// GetPageRanksForRepo returns all PageRank scores for a repository
func GetPageRanksForRepo(ctx context.Context, repoID int64) (map[int64]float64, error) {
caches := make([]*GraphCache, 0)
err := db.GetEngine(ctx).Where("repo_id = ?", repoID).Find(&caches)
if err != nil {
return nil, err
}

ranks := make(map[int64]float64)
for _, cache := range caches {
ranks[cache.IssueID] = cache.PageRank
}
return ranks, nil
}
9 changes: 8 additions & 1 deletion modules/setting/graph.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
package setting

import (
"strconv"

"code.gitea.io/gitea/modules/log"
)

Expand Down Expand Up @@ -35,7 +37,12 @@ func loadIssueGraphFrom(rootCfg ConfigProvider) {

// Core settings
IssueGraphSettings.Enabled = sec.Key("ENABLED").MustBool(true)
IssueGraphSettings.DampingFactor = sec.Key("DAMPING_FACTOR").MustFloat64(0.85)
// Parse float manually as ConfigKey doesn't have MustFloat64
if val, err := strconv.ParseFloat(sec.Key("DAMPING_FACTOR").String(), 64); err == nil {
IssueGraphSettings.DampingFactor = val
} else {
IssueGraphSettings.DampingFactor = 0.85
}
IssueGraphSettings.Iterations = sec.Key("ITERATIONS").MustInt(100)

// Security settings (new)
Expand Down
12 changes: 6 additions & 6 deletions routers/api/v1/repo/issue_dep.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import (

// GetIssueDependencies lists dependencies for an issue
func GetIssueDependencies(ctx *context.APIContext) {
if !setting.IssueGraph.Enabled {
if !setting.IssueGraphSettings.Enabled {
ctx.APIErrorNotFound("Issue graph features are disabled")
return
}
Expand All @@ -21,7 +21,7 @@ func GetIssueDependencies(ctx *context.APIContext) {

// CreateIssueDependency creates a dependency
func CreateIssueDependency(ctx *context.APIContext) {
if !setting.IssueGraph.Enabled {
if !setting.IssueGraphSettings.Enabled {
ctx.APIErrorNotFound("Issue graph features are disabled")
return
}
Expand All @@ -30,7 +30,7 @@ func CreateIssueDependency(ctx *context.APIContext) {

// RemoveIssueDependency removes a dependency
func RemoveIssueDependency(ctx *context.APIContext) {
if !setting.IssueGraph.Enabled {
if !setting.IssueGraphSettings.Enabled {
ctx.APIErrorNotFound("Issue graph features are disabled")
return
}
Expand All @@ -39,7 +39,7 @@ func RemoveIssueDependency(ctx *context.APIContext) {

// GetIssueBlocks lists blocking issues
func GetIssueBlocks(ctx *context.APIContext) {
if !setting.IssueGraph.Enabled {
if !setting.IssueGraphSettings.Enabled {
ctx.APIErrorNotFound("Issue graph features are disabled")
return
}
Expand All @@ -48,7 +48,7 @@ func GetIssueBlocks(ctx *context.APIContext) {

// CreateIssueBlocking creates a blocking relationship
func CreateIssueBlocking(ctx *context.APIContext) {
if !setting.IssueGraph.Enabled {
if !setting.IssueGraphSettings.Enabled {
ctx.APIErrorNotFound("Issue graph features are disabled")
return
}
Expand All @@ -57,7 +57,7 @@ func CreateIssueBlocking(ctx *context.APIContext) {

// RemoveIssueBlocking removes a blocking relationship
func RemoveIssueBlocking(ctx *context.APIContext) {
if !setting.IssueGraph.Enabled {
if !setting.IssueGraphSettings.Enabled {
ctx.APIErrorNotFound("Issue graph features are disabled")
return
}
Expand Down
2 changes: 1 addition & 1 deletion routers/api/v1/repo/issue_dependency.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import (

// ListIssueDependencies lists all dependencies for an issue
func ListIssueDependencies(ctx *context.APIContext) {
if !setting.IssueGraph.Enabled {
if !setting.IssueGraphSettings.Enabled {
ctx.APIErrorNotFound("Issue graph features are disabled")
return
}
Expand Down
Loading