mirror of
https://github.com/hanzoai/forge.git
synced 2026-10-30 15:00:39 +00:00
Source files keep their SPDX-License-Identifier and Hanzo copyright line; the Gitea, Gogs and LFS Test Server copyrights they used to repeat now live once in NOTICE, alongside LICENSE. The header lint accepts any copyright line above the SPDX line, so it no longer demands an upstream one. Co-authored-by: Hanzo Dev <[email protected]>
531 lines
15 KiB
Go
531 lines
15 KiB
Go
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package repo
|
|
|
|
import (
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/hanzoai/git/models/db"
|
|
issues_model "github.com/hanzoai/git/models/issues"
|
|
"github.com/hanzoai/git/models/organization"
|
|
project_model "github.com/hanzoai/git/models/project"
|
|
repo_model "github.com/hanzoai/git/models/repo"
|
|
user_model "github.com/hanzoai/git/models/user"
|
|
"github.com/hanzoai/git/modules/container"
|
|
"github.com/hanzoai/git/modules/optional"
|
|
shared_user "github.com/hanzoai/git/routers/web/shared/user"
|
|
"github.com/hanzoai/git/services/context"
|
|
issue_service "github.com/hanzoai/git/services/issue"
|
|
pull_service "github.com/hanzoai/git/services/pull"
|
|
)
|
|
|
|
type issueSidebarMilestoneData struct {
|
|
SelectedMilestoneID int64
|
|
OpenMilestones []*issues_model.Milestone
|
|
ClosedMilestones []*issues_model.Milestone
|
|
}
|
|
|
|
type issueSidebarAssigneesData struct {
|
|
SelectedAssigneeIDs string
|
|
CandidateAssignees []*user_model.User
|
|
}
|
|
|
|
type issueSidebarProjectCardData struct {
|
|
Project *project_model.Project
|
|
Columns []*project_model.Column
|
|
SelectedColumn *project_model.Column
|
|
}
|
|
|
|
type issueSidebarProjectsData struct {
|
|
SelectedProjectIDs []int64
|
|
ProjectCards []*issueSidebarProjectCardData
|
|
|
|
OpenProjects []*project_model.Project
|
|
ClosedProjects []*project_model.Project
|
|
}
|
|
|
|
type IssuePageMetaData struct {
|
|
RepoLink string
|
|
Repository *repo_model.Repository
|
|
Issue *issues_model.Issue
|
|
IsPullRequest bool
|
|
CanModifyIssueOrPull bool
|
|
|
|
ReviewersData *issueSidebarReviewersData
|
|
LabelsData *issueSidebarLabelsData
|
|
MilestonesData *issueSidebarMilestoneData
|
|
ProjectsData *issueSidebarProjectsData
|
|
AssigneesData *issueSidebarAssigneesData
|
|
}
|
|
|
|
func retrieveRepoIssueMetaData(ctx *context.Context, repo *repo_model.Repository, issue *issues_model.Issue, isPull bool) *IssuePageMetaData {
|
|
data := &IssuePageMetaData{
|
|
RepoLink: ctx.Repo.RepoLink,
|
|
Repository: repo,
|
|
Issue: issue,
|
|
IsPullRequest: isPull,
|
|
|
|
ReviewersData: &issueSidebarReviewersData{},
|
|
LabelsData: &issueSidebarLabelsData{},
|
|
MilestonesData: &issueSidebarMilestoneData{},
|
|
ProjectsData: &issueSidebarProjectsData{},
|
|
AssigneesData: &issueSidebarAssigneesData{},
|
|
}
|
|
ctx.Data["IssuePageMetaData"] = data
|
|
|
|
if isPull {
|
|
data.retrieveReviewersData(ctx)
|
|
if ctx.Written() {
|
|
return data
|
|
}
|
|
}
|
|
data.retrieveLabelsData(ctx)
|
|
if ctx.Written() {
|
|
return data
|
|
}
|
|
|
|
// it sets "Branches" template data,
|
|
// it is used to render the "edit PR target branches" dropdown, and the "branch selector" in the issue's sidebar.
|
|
PrepareBranchList(ctx)
|
|
if ctx.Written() {
|
|
return data
|
|
}
|
|
|
|
// it sets the "Assignees" template data, and the data is also used to "mention" users.
|
|
data.retrieveAssigneesData(ctx)
|
|
if ctx.Written() {
|
|
return data
|
|
}
|
|
|
|
data.retrieveProjectData(ctx)
|
|
if ctx.Written() {
|
|
return data
|
|
}
|
|
|
|
// TODO: the issue/pull permissions are quite complex and unclear
|
|
// A reader could create an issue/PR with setting some meta (eg: assignees from issue template, reviewers, target branch)
|
|
// A reader(creator) could update some meta (eg: target branch), but can't change assignees anymore.
|
|
// For non-creator users, only writers could update some meta (eg: assignees, milestone, project)
|
|
// Need to clarify the logic and add some tests in the future
|
|
data.CanModifyIssueOrPull = ctx.Repo.Permission.CanWriteIssuesOrPulls(isPull) && !ctx.Repo.Repository.IsArchived
|
|
if !data.CanModifyIssueOrPull {
|
|
return data
|
|
}
|
|
|
|
data.retrieveMilestonesDataForIssueWriter(ctx)
|
|
if ctx.Written() {
|
|
return data
|
|
}
|
|
|
|
data.retrieveProjectsDataForIssueWriter(ctx)
|
|
if ctx.Written() {
|
|
return data
|
|
}
|
|
|
|
ctx.Data["CanCreateIssueDependencies"] = ctx.Repo.CanCreateIssueDependencies(ctx, ctx.Doer, isPull)
|
|
return data
|
|
}
|
|
|
|
func (d *IssuePageMetaData) retrieveMilestonesDataForIssueWriter(ctx *context.Context) {
|
|
var err error
|
|
if d.Issue != nil {
|
|
d.MilestonesData.SelectedMilestoneID = d.Issue.MilestoneID
|
|
}
|
|
d.MilestonesData.OpenMilestones, err = db.Find[issues_model.Milestone](ctx, issues_model.FindMilestoneOptions{
|
|
RepoID: d.Repository.ID,
|
|
IsClosed: optional.Some(false),
|
|
})
|
|
if err != nil {
|
|
ctx.ServerError("GetMilestones", err)
|
|
return
|
|
}
|
|
d.MilestonesData.ClosedMilestones, err = db.Find[issues_model.Milestone](ctx, issues_model.FindMilestoneOptions{
|
|
RepoID: d.Repository.ID,
|
|
IsClosed: optional.Some(true),
|
|
})
|
|
if err != nil {
|
|
ctx.ServerError("GetMilestones", err)
|
|
return
|
|
}
|
|
}
|
|
|
|
func (d *IssuePageMetaData) retrieveAssigneesData(ctx *context.Context) {
|
|
var err error
|
|
d.AssigneesData.CandidateAssignees, err = repo_model.GetRepoAssignees(ctx, d.Repository)
|
|
if err != nil {
|
|
ctx.ServerError("GetRepoAssignees", err)
|
|
return
|
|
}
|
|
d.AssigneesData.CandidateAssignees = shared_user.MakeSelfOnTop(ctx.Doer, d.AssigneesData.CandidateAssignees)
|
|
if d.Issue != nil {
|
|
_ = d.Issue.LoadAssignees(ctx)
|
|
ids := make([]string, 0, len(d.Issue.Assignees))
|
|
for _, a := range d.Issue.Assignees {
|
|
ids = append(ids, strconv.FormatInt(a.ID, 10))
|
|
}
|
|
d.AssigneesData.SelectedAssigneeIDs = strings.Join(ids, ",")
|
|
}
|
|
ctx.Data["Assignees"] = d.AssigneesData.CandidateAssignees
|
|
}
|
|
|
|
func (d *IssuePageMetaData) retrieveProjectCardsForExistingIssue(ctx *context.Context) {
|
|
if err := d.Issue.LoadProjects(ctx); err != nil {
|
|
ctx.ServerError("LoadProjects", err)
|
|
return
|
|
}
|
|
|
|
// Load column mappings for all projects
|
|
projectColumnMap, err := d.Issue.ProjectColumnMap(ctx)
|
|
if err != nil {
|
|
ctx.ServerError("ProjectColumnMap", err)
|
|
return
|
|
}
|
|
|
|
// Build project cards for each project
|
|
d.ProjectsData.ProjectCards = make([]*issueSidebarProjectCardData, 0, len(d.Issue.Projects))
|
|
for _, project := range d.Issue.Projects {
|
|
columns, err := project.GetColumns(ctx)
|
|
if err != nil {
|
|
ctx.ServerError("GetProjectColumns", err)
|
|
return
|
|
}
|
|
|
|
var selectedColumn *project_model.Column
|
|
columnID := projectColumnMap[project.ID]
|
|
for _, col := range columns {
|
|
if col.ID == columnID {
|
|
selectedColumn = col
|
|
break
|
|
}
|
|
}
|
|
|
|
if selectedColumn == nil {
|
|
selectedColumn, err = project.MustDefaultColumn(ctx)
|
|
if err != nil {
|
|
ctx.ServerError("MustDefaultColumn", err)
|
|
return
|
|
}
|
|
}
|
|
d.ProjectsData.ProjectCards = append(d.ProjectsData.ProjectCards, &issueSidebarProjectCardData{
|
|
Project: project,
|
|
Columns: columns,
|
|
SelectedColumn: selectedColumn,
|
|
})
|
|
}
|
|
d.ProjectsData.SelectedProjectIDs = make([]int64, 0, len(d.ProjectsData.ProjectCards))
|
|
for _, card := range d.ProjectsData.ProjectCards {
|
|
d.ProjectsData.SelectedProjectIDs = append(d.ProjectsData.SelectedProjectIDs, card.Project.ID)
|
|
}
|
|
}
|
|
|
|
func (d *IssuePageMetaData) retrieveProjectData(ctx *context.Context) {
|
|
if d.Issue == nil {
|
|
return
|
|
}
|
|
d.retrieveProjectCardsForExistingIssue(ctx)
|
|
}
|
|
|
|
func (d *IssuePageMetaData) SetSelectedProjectIDs(ids []int64) {
|
|
allProjects := map[int64]*project_model.Project{}
|
|
for _, p := range d.ProjectsData.OpenProjects {
|
|
allProjects[p.ID] = p
|
|
}
|
|
for _, p := range d.ProjectsData.ClosedProjects {
|
|
allProjects[p.ID] = p
|
|
}
|
|
for _, id := range ids {
|
|
if project, ok := allProjects[id]; ok {
|
|
d.ProjectsData.ProjectCards = append(d.ProjectsData.ProjectCards, &issueSidebarProjectCardData{Project: project})
|
|
}
|
|
}
|
|
d.ProjectsData.SelectedProjectIDs = ids
|
|
}
|
|
|
|
func (d *IssuePageMetaData) retrieveProjectsDataForIssueWriter(ctx *context.Context) {
|
|
d.ProjectsData.OpenProjects, d.ProjectsData.ClosedProjects = retrieveProjectsInternal(ctx, ctx.Repo.Repository)
|
|
}
|
|
|
|
// repoReviewerSelection items to bee shown
|
|
type repoReviewerSelection struct {
|
|
IsTeam bool
|
|
Team *organization.Team
|
|
User *user_model.User
|
|
Review *issues_model.Review
|
|
CanBeDismissed bool
|
|
CanChange bool
|
|
Requested bool
|
|
ItemID int64
|
|
}
|
|
|
|
type issueSidebarReviewersData struct {
|
|
CanChooseReviewer bool
|
|
OriginalReviews issues_model.ReviewList
|
|
TeamReviewers []*repoReviewerSelection
|
|
Reviewers []*repoReviewerSelection
|
|
CurrentPullReviewers []*repoReviewerSelection
|
|
}
|
|
|
|
// RetrieveRepoReviewers find all reviewers of a repository. If issue is nil, it means the doer is creating a new PR.
|
|
func (d *IssuePageMetaData) retrieveReviewersData(ctx *context.Context) {
|
|
data := d.ReviewersData
|
|
repo := d.Repository
|
|
if ctx.Doer != nil && ctx.IsSigned {
|
|
if d.Issue == nil {
|
|
data.CanChooseReviewer = true
|
|
} else {
|
|
data.CanChooseReviewer = issue_service.CanDoerChangeReviewRequests(ctx, ctx.Doer, repo, d.Issue.PosterID)
|
|
}
|
|
}
|
|
|
|
var posterID int64
|
|
var isClosed bool
|
|
var reviews issues_model.ReviewList
|
|
var err error
|
|
|
|
if d.Issue == nil {
|
|
if ctx.Doer != nil {
|
|
posterID = ctx.Doer.ID
|
|
}
|
|
} else {
|
|
posterID = d.Issue.PosterID
|
|
if d.Issue.OriginalAuthorID > 0 {
|
|
posterID = 0 // for migrated PRs, no poster ID
|
|
}
|
|
|
|
isClosed = d.Issue.IsClosed || d.Issue.PullRequest.HasMerged
|
|
|
|
reviews, data.OriginalReviews, err = issues_model.GetReviewsByIssueID(ctx, d.Issue.ID)
|
|
if err != nil {
|
|
ctx.ServerError("GetReviewersByIssueID", err)
|
|
return
|
|
}
|
|
if len(reviews) == 0 && !data.CanChooseReviewer {
|
|
return
|
|
}
|
|
}
|
|
|
|
var (
|
|
pullReviews []*repoReviewerSelection
|
|
reviewersResult []*repoReviewerSelection
|
|
teamReviewersResult []*repoReviewerSelection
|
|
teamReviewers []*organization.Team
|
|
reviewers []*user_model.User
|
|
)
|
|
|
|
if data.CanChooseReviewer {
|
|
var err error
|
|
reviewers, err = pull_service.GetReviewers(ctx, repo, ctx.Doer.ID, posterID)
|
|
if err != nil {
|
|
ctx.ServerError("GetReviewers", err)
|
|
return
|
|
}
|
|
|
|
teamReviewers, err = pull_service.GetReviewerTeams(ctx, repo)
|
|
if err != nil {
|
|
ctx.ServerError("GetReviewerTeams", err)
|
|
return
|
|
}
|
|
|
|
if len(reviewers) > 0 {
|
|
reviewersResult = make([]*repoReviewerSelection, 0, len(reviewers))
|
|
}
|
|
|
|
if len(teamReviewers) > 0 {
|
|
teamReviewersResult = make([]*repoReviewerSelection, 0, len(teamReviewers))
|
|
}
|
|
}
|
|
|
|
pullReviews = make([]*repoReviewerSelection, 0, len(reviews))
|
|
|
|
for _, review := range reviews {
|
|
tmp := &repoReviewerSelection{
|
|
Requested: review.Type == issues_model.ReviewTypeRequest,
|
|
Review: review,
|
|
ItemID: review.ReviewerID,
|
|
}
|
|
if review.ReviewerTeamID > 0 {
|
|
tmp.IsTeam = true
|
|
tmp.ItemID = -review.ReviewerTeamID
|
|
}
|
|
|
|
if data.CanChooseReviewer {
|
|
// Users who can choose reviewers can also remove review requests
|
|
tmp.CanChange = true
|
|
} else if ctx.Doer != nil && ctx.Doer.ID == review.ReviewerID && review.Type == issues_model.ReviewTypeRequest {
|
|
// A user can refuse review requests
|
|
tmp.CanChange = true
|
|
}
|
|
|
|
pullReviews = append(pullReviews, tmp)
|
|
|
|
if data.CanChooseReviewer {
|
|
if tmp.IsTeam {
|
|
teamReviewersResult = append(teamReviewersResult, tmp)
|
|
} else {
|
|
reviewersResult = append(reviewersResult, tmp)
|
|
}
|
|
}
|
|
}
|
|
|
|
if len(pullReviews) > 0 {
|
|
// Drop all non-existing users and teams from the reviews
|
|
currentPullReviewers := make([]*repoReviewerSelection, 0, len(pullReviews))
|
|
for _, item := range pullReviews {
|
|
if item.Review.ReviewerID > 0 {
|
|
if err := item.Review.LoadReviewer(ctx); err != nil {
|
|
if user_model.IsErrUserNotExist(err) {
|
|
continue
|
|
}
|
|
ctx.ServerError("LoadReviewer", err)
|
|
return
|
|
}
|
|
item.User = item.Review.Reviewer
|
|
} else if item.Review.ReviewerTeamID > 0 {
|
|
if err := item.Review.LoadReviewerTeam(ctx); err != nil {
|
|
if organization.IsErrTeamNotExist(err) {
|
|
continue
|
|
}
|
|
ctx.ServerError("LoadReviewerTeam", err)
|
|
return
|
|
}
|
|
item.Team = item.Review.ReviewerTeam
|
|
} else {
|
|
continue
|
|
}
|
|
item.CanBeDismissed = ctx.Repo.Permission.IsAdmin() && !isClosed &&
|
|
(item.Review.Type == issues_model.ReviewTypeApprove || item.Review.Type == issues_model.ReviewTypeReject)
|
|
currentPullReviewers = append(currentPullReviewers, item)
|
|
}
|
|
data.CurrentPullReviewers = currentPullReviewers
|
|
}
|
|
|
|
if data.CanChooseReviewer && reviewersResult != nil {
|
|
preadded := len(reviewersResult)
|
|
for _, reviewer := range reviewers {
|
|
found := false
|
|
reviewAddLoop:
|
|
for _, tmp := range reviewersResult[:preadded] {
|
|
if tmp.ItemID == reviewer.ID {
|
|
tmp.User = reviewer
|
|
found = true
|
|
break reviewAddLoop
|
|
}
|
|
}
|
|
|
|
if found {
|
|
continue
|
|
}
|
|
|
|
reviewersResult = append(reviewersResult, &repoReviewerSelection{
|
|
IsTeam: false,
|
|
CanChange: true,
|
|
User: reviewer,
|
|
ItemID: reviewer.ID,
|
|
})
|
|
}
|
|
|
|
data.Reviewers = reviewersResult
|
|
}
|
|
|
|
if data.CanChooseReviewer && teamReviewersResult != nil {
|
|
preadded := len(teamReviewersResult)
|
|
for _, team := range teamReviewers {
|
|
found := false
|
|
teamReviewAddLoop:
|
|
for _, tmp := range teamReviewersResult[:preadded] {
|
|
if tmp.ItemID == -team.ID {
|
|
tmp.Team = team
|
|
found = true
|
|
break teamReviewAddLoop
|
|
}
|
|
}
|
|
|
|
if found {
|
|
continue
|
|
}
|
|
|
|
teamReviewersResult = append(teamReviewersResult, &repoReviewerSelection{
|
|
IsTeam: true,
|
|
CanChange: true,
|
|
Team: team,
|
|
ItemID: -team.ID,
|
|
})
|
|
}
|
|
|
|
data.TeamReviewers = teamReviewersResult
|
|
}
|
|
}
|
|
|
|
type issueSidebarLabelsData struct {
|
|
AllLabels []*issues_model.Label
|
|
RepoLabels []*issues_model.Label
|
|
OrgLabels []*issues_model.Label
|
|
SelectedLabelIDs string
|
|
}
|
|
|
|
func makeSelectedStringIDs[KeyType, ItemType comparable](
|
|
allLabels []*issues_model.Label, candidateKey func(candidate *issues_model.Label) KeyType,
|
|
selectedItems []ItemType, selectedKey func(selected ItemType) KeyType,
|
|
) string {
|
|
selectedIDSet := make(container.Set[string])
|
|
allLabelMap := map[KeyType]*issues_model.Label{}
|
|
for _, label := range allLabels {
|
|
allLabelMap[candidateKey(label)] = label
|
|
}
|
|
for _, item := range selectedItems {
|
|
if label, ok := allLabelMap[selectedKey(item)]; ok {
|
|
label.IsChecked = true
|
|
selectedIDSet.Add(strconv.FormatInt(label.ID, 10))
|
|
}
|
|
}
|
|
ids := selectedIDSet.Values()
|
|
sort.Strings(ids)
|
|
return strings.Join(ids, ",")
|
|
}
|
|
|
|
func (d *issueSidebarLabelsData) SetSelectedLabels(labels []*issues_model.Label) {
|
|
d.SelectedLabelIDs = makeSelectedStringIDs(
|
|
d.AllLabels, func(label *issues_model.Label) int64 { return label.ID },
|
|
labels, func(label *issues_model.Label) int64 { return label.ID },
|
|
)
|
|
}
|
|
|
|
func (d *issueSidebarLabelsData) SetSelectedLabelNames(labelNames []string) {
|
|
d.SelectedLabelIDs = makeSelectedStringIDs(
|
|
d.AllLabels, func(label *issues_model.Label) string { return strings.ToLower(label.Name) },
|
|
labelNames, strings.ToLower,
|
|
)
|
|
}
|
|
|
|
func (d *issueSidebarLabelsData) SetSelectedLabelIDs(labelIDs []int64) {
|
|
d.SelectedLabelIDs = makeSelectedStringIDs(
|
|
d.AllLabels, func(label *issues_model.Label) int64 { return label.ID },
|
|
labelIDs, func(labelID int64) int64 { return labelID },
|
|
)
|
|
}
|
|
|
|
func (d *IssuePageMetaData) retrieveLabelsData(ctx *context.Context) {
|
|
repo := d.Repository
|
|
labelsData := d.LabelsData
|
|
|
|
labels, err := issues_model.GetLabelsByRepoID(ctx, repo.ID, "", db.ListOptions{})
|
|
if err != nil {
|
|
ctx.ServerError("GetLabelsByRepoID", err)
|
|
return
|
|
}
|
|
labelsData.RepoLabels = labels
|
|
|
|
if repo.Owner.IsOrganization() {
|
|
orgLabels, err := issues_model.GetLabelsByOrgID(ctx, repo.Owner.ID, ctx.FormString("sort"), db.ListOptions{})
|
|
if err != nil {
|
|
return
|
|
}
|
|
labelsData.OrgLabels = orgLabels
|
|
}
|
|
labelsData.AllLabels = append(labelsData.AllLabels, labelsData.RepoLabels...)
|
|
labelsData.AllLabels = append(labelsData.AllLabels, labelsData.OrgLabels...)
|
|
}
|