0
0
Fork 0
mirror of https://github.com/go-gitea/gitea synced 2024-11-25 06:02:49 +01:00

Merge branch 'main' into xormigrate

This commit is contained in:
qwerty287 2024-11-10 17:54:38 +02:00 committed by GitHub
commit 0ebc2257e5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
43 changed files with 774 additions and 506 deletions

View file

@ -86,7 +86,9 @@ type CreatePullRequestOption struct {
Milestone int64 `json:"milestone"` Milestone int64 `json:"milestone"`
Labels []int64 `json:"labels"` Labels []int64 `json:"labels"`
// swagger:strfmt date-time // swagger:strfmt date-time
Deadline *time.Time `json:"due_date"` Deadline *time.Time `json:"due_date"`
Reviewers []string `json:"reviewers"`
TeamReviewers []string `json:"team_reviewers"`
} }
// EditPullRequestOption options when modify pull request // EditPullRequestOption options when modify pull request

View file

@ -1462,7 +1462,7 @@ issues.new.closed_milestone = Closed Milestones
issues.new.assignees = Assignees issues.new.assignees = Assignees
issues.new.clear_assignees = Clear assignees issues.new.clear_assignees = Clear assignees
issues.new.no_assignees = No Assignees issues.new.no_assignees = No Assignees
issues.new.no_reviewers = No reviewers issues.new.no_reviewers = No Reviewers
issues.new.blocked_user = Cannot create issue because you are blocked by the repository owner. issues.new.blocked_user = Cannot create issue because you are blocked by the repository owner.
issues.edit.already_changed = Unable to save changes to the issue. It appears the content has already been changed by another user. Please refresh the page and try editing again to avoid overwriting their changes issues.edit.already_changed = Unable to save changes to the issue. It appears the content has already been changed by another user. Please refresh the page and try editing again to avoid overwriting their changes
issues.edit.blocked_user = Cannot edit content because you are blocked by the poster or repository owner. issues.edit.blocked_user = Cannot edit content because you are blocked by the poster or repository owner.

View file

@ -554,7 +554,19 @@ func CreatePullRequest(ctx *context.APIContext) {
} }
} }
if err := pull_service.NewPullRequest(ctx, repo, prIssue, labelIDs, []string{}, pr, assigneeIDs); err != nil { prOpts := &pull_service.NewPullRequestOptions{
Repo: repo,
Issue: prIssue,
LabelIDs: labelIDs,
PullRequest: pr,
AssigneeIDs: assigneeIDs,
}
prOpts.Reviewers, prOpts.TeamReviewers = parseReviewersByNames(ctx, form.Reviewers, form.TeamReviewers)
if ctx.Written() {
return
}
if err := pull_service.NewPullRequest(ctx, prOpts); err != nil {
if repo_model.IsErrUserDoesNotHaveAccessToRepo(err) { if repo_model.IsErrUserDoesNotHaveAccessToRepo(err) {
ctx.Error(http.StatusBadRequest, "UserDoesNotHaveAccessToRepo", err) ctx.Error(http.StatusBadRequest, "UserDoesNotHaveAccessToRepo", err)
} else if errors.Is(err, user_model.ErrBlockedUser) { } else if errors.Is(err, user_model.ErrBlockedUser) {

View file

@ -656,6 +656,47 @@ func DeleteReviewRequests(ctx *context.APIContext) {
apiReviewRequest(ctx, *opts, false) apiReviewRequest(ctx, *opts, false)
} }
func parseReviewersByNames(ctx *context.APIContext, reviewerNames, teamReviewerNames []string) (reviewers []*user_model.User, teamReviewers []*organization.Team) {
var err error
for _, r := range reviewerNames {
var reviewer *user_model.User
if strings.Contains(r, "@") {
reviewer, err = user_model.GetUserByEmail(ctx, r)
} else {
reviewer, err = user_model.GetUserByName(ctx, r)
}
if err != nil {
if user_model.IsErrUserNotExist(err) {
ctx.NotFound("UserNotExist", fmt.Sprintf("User '%s' not exist", r))
return nil, nil
}
ctx.Error(http.StatusInternalServerError, "GetUser", err)
return nil, nil
}
reviewers = append(reviewers, reviewer)
}
if ctx.Repo.Repository.Owner.IsOrganization() && len(teamReviewerNames) > 0 {
for _, t := range teamReviewerNames {
var teamReviewer *organization.Team
teamReviewer, err = organization.GetTeam(ctx, ctx.Repo.Owner.ID, t)
if err != nil {
if organization.IsErrTeamNotExist(err) {
ctx.NotFound("TeamNotExist", fmt.Sprintf("Team '%s' not exist", t))
return nil, nil
}
ctx.Error(http.StatusInternalServerError, "ReviewRequest", err)
return nil, nil
}
teamReviewers = append(teamReviewers, teamReviewer)
}
}
return reviewers, teamReviewers
}
func apiReviewRequest(ctx *context.APIContext, opts api.PullReviewRequestOptions, isAdd bool) { func apiReviewRequest(ctx *context.APIContext, opts api.PullReviewRequestOptions, isAdd bool) {
pr, err := issues_model.GetPullRequestByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64(":index")) pr, err := issues_model.GetPullRequestByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64(":index"))
if err != nil { if err != nil {
@ -672,42 +713,15 @@ func apiReviewRequest(ctx *context.APIContext, opts api.PullReviewRequestOptions
return return
} }
reviewers := make([]*user_model.User, 0, len(opts.Reviewers))
permDoer, err := access_model.GetUserRepoPermission(ctx, pr.Issue.Repo, ctx.Doer) permDoer, err := access_model.GetUserRepoPermission(ctx, pr.Issue.Repo, ctx.Doer)
if err != nil { if err != nil {
ctx.Error(http.StatusInternalServerError, "GetUserRepoPermission", err) ctx.Error(http.StatusInternalServerError, "GetUserRepoPermission", err)
return return
} }
for _, r := range opts.Reviewers { reviewers, teamReviewers := parseReviewersByNames(ctx, opts.Reviewers, opts.TeamReviewers)
var reviewer *user_model.User if ctx.Written() {
if strings.Contains(r, "@") { return
reviewer, err = user_model.GetUserByEmail(ctx, r)
} else {
reviewer, err = user_model.GetUserByName(ctx, r)
}
if err != nil {
if user_model.IsErrUserNotExist(err) {
ctx.NotFound("UserNotExist", fmt.Sprintf("User '%s' not exist", r))
return
}
ctx.Error(http.StatusInternalServerError, "GetUser", err)
return
}
err = issue_service.IsValidReviewRequest(ctx, reviewer, ctx.Doer, isAdd, pr.Issue, &permDoer)
if err != nil {
if issues_model.IsErrNotValidReviewRequest(err) {
ctx.Error(http.StatusUnprocessableEntity, "NotValidReviewRequest", err)
return
}
ctx.Error(http.StatusInternalServerError, "IsValidReviewRequest", err)
return
}
reviewers = append(reviewers, reviewer)
} }
var reviews []*issues_model.Review var reviews []*issues_model.Review
@ -716,12 +730,16 @@ func apiReviewRequest(ctx *context.APIContext, opts api.PullReviewRequestOptions
} }
for _, reviewer := range reviewers { for _, reviewer := range reviewers {
comment, err := issue_service.ReviewRequest(ctx, pr.Issue, ctx.Doer, reviewer, isAdd) comment, err := issue_service.ReviewRequest(ctx, pr.Issue, ctx.Doer, &permDoer, reviewer, isAdd)
if err != nil { if err != nil {
if issues_model.IsErrReviewRequestOnClosedPR(err) { if issues_model.IsErrReviewRequestOnClosedPR(err) {
ctx.Error(http.StatusForbidden, "", err) ctx.Error(http.StatusForbidden, "", err)
return return
} }
if issues_model.IsErrNotValidReviewRequest(err) {
ctx.Error(http.StatusUnprocessableEntity, "", err)
return
}
ctx.Error(http.StatusInternalServerError, "ReviewRequest", err) ctx.Error(http.StatusInternalServerError, "ReviewRequest", err)
return return
} }
@ -736,35 +754,17 @@ func apiReviewRequest(ctx *context.APIContext, opts api.PullReviewRequestOptions
} }
if ctx.Repo.Repository.Owner.IsOrganization() && len(opts.TeamReviewers) > 0 { if ctx.Repo.Repository.Owner.IsOrganization() && len(opts.TeamReviewers) > 0 {
teamReviewers := make([]*organization.Team, 0, len(opts.TeamReviewers))
for _, t := range opts.TeamReviewers {
var teamReviewer *organization.Team
teamReviewer, err = organization.GetTeam(ctx, ctx.Repo.Owner.ID, t)
if err != nil {
if organization.IsErrTeamNotExist(err) {
ctx.NotFound("TeamNotExist", fmt.Sprintf("Team '%s' not exist", t))
return
}
ctx.Error(http.StatusInternalServerError, "ReviewRequest", err)
return
}
err = issue_service.IsValidTeamReviewRequest(ctx, teamReviewer, ctx.Doer, isAdd, pr.Issue)
if err != nil {
if issues_model.IsErrNotValidReviewRequest(err) {
ctx.Error(http.StatusUnprocessableEntity, "NotValidReviewRequest", err)
return
}
ctx.Error(http.StatusInternalServerError, "IsValidTeamReviewRequest", err)
return
}
teamReviewers = append(teamReviewers, teamReviewer)
}
for _, teamReviewer := range teamReviewers { for _, teamReviewer := range teamReviewers {
comment, err := issue_service.TeamReviewRequest(ctx, pr.Issue, ctx.Doer, teamReviewer, isAdd) comment, err := issue_service.TeamReviewRequest(ctx, pr.Issue, ctx.Doer, teamReviewer, isAdd)
if err != nil { if err != nil {
if issues_model.IsErrReviewRequestOnClosedPR(err) {
ctx.Error(http.StatusForbidden, "", err)
return
}
if issues_model.IsErrNotValidReviewRequest(err) {
ctx.Error(http.StatusUnprocessableEntity, "", err)
return
}
ctx.ServerError("TeamReviewRequest", err) ctx.ServerError("TeamReviewRequest", err)
return return
} }

View file

@ -788,10 +788,22 @@ func CompareDiff(ctx *context.Context) {
if !nothingToCompare { if !nothingToCompare {
// Setup information for new form. // Setup information for new form.
RetrieveRepoMetas(ctx, ctx.Repo.Repository, true) retrieveRepoMetasForIssueWriter(ctx, ctx.Repo.Repository, true)
if ctx.Written() { if ctx.Written() {
return return
} }
labelsData := retrieveRepoLabels(ctx, ctx.Repo.Repository, 0, true)
if ctx.Written() {
return
}
RetrieveRepoReviewers(ctx, ctx.Repo.Repository, nil, true)
if ctx.Written() {
return
}
_, templateErrs := setTemplateIfExists(ctx, pullRequestTemplateKey, pullRequestTemplateCandidates, labelsData)
if len(templateErrs) > 0 {
ctx.Flash.Warning(renderErrorOfTemplates(ctx, templateErrs), true)
}
} }
} }
beforeCommitID := ctx.Data["BeforeCommitID"].(string) beforeCommitID := ctx.Data["BeforeCommitID"].(string)
@ -804,11 +816,6 @@ func CompareDiff(ctx *context.Context) {
ctx.Data["Title"] = "Comparing " + base.ShortSha(beforeCommitID) + separator + base.ShortSha(afterCommitID) ctx.Data["Title"] = "Comparing " + base.ShortSha(beforeCommitID) + separator + base.ShortSha(afterCommitID)
ctx.Data["IsDiffCompare"] = true ctx.Data["IsDiffCompare"] = true
_, templateErrs := setTemplateIfExists(ctx, pullRequestTemplateKey, pullRequestTemplateCandidates)
if len(templateErrs) > 0 {
ctx.Flash.Warning(renderErrorOfTemplates(ctx, templateErrs), true)
}
if content, ok := ctx.Data["content"].(string); ok && content != "" { if content, ok := ctx.Data["content"].(string); ok && content != "" {
// If a template content is set, prepend the "content". In this case that's only // If a template content is set, prepend the "content". In this case that's only

View file

@ -654,34 +654,66 @@ func retrieveProjects(ctx *context.Context, repo *repo_model.Repository) {
// repoReviewerSelection items to bee shown // repoReviewerSelection items to bee shown
type repoReviewerSelection struct { type repoReviewerSelection struct {
IsTeam bool IsTeam bool
Team *organization.Team Team *organization.Team
User *user_model.User User *user_model.User
Review *issues_model.Review Review *issues_model.Review
CanChange bool CanBeDismissed bool
Checked bool CanChange bool
ItemID int64 Requested bool
ItemID int64
} }
// RetrieveRepoReviewers find all reviewers of a repository type issueSidebarReviewersData struct {
Repository *repo_model.Repository
RepoOwnerName string
RepoLink string
IssueID int64
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 RetrieveRepoReviewers(ctx *context.Context, repo *repo_model.Repository, issue *issues_model.Issue, canChooseReviewer bool) { func RetrieveRepoReviewers(ctx *context.Context, repo *repo_model.Repository, issue *issues_model.Issue, canChooseReviewer bool) {
ctx.Data["CanChooseReviewer"] = canChooseReviewer data := &issueSidebarReviewersData{}
data.RepoLink = ctx.Repo.RepoLink
data.Repository = repo
data.RepoOwnerName = repo.OwnerName
data.CanChooseReviewer = canChooseReviewer
originalAuthorReviews, err := issues_model.GetReviewersFromOriginalAuthorsByIssueID(ctx, issue.ID) var posterID int64
if err != nil { var isClosed bool
ctx.ServerError("GetReviewersFromOriginalAuthorsByIssueID", err) var reviews issues_model.ReviewList
return
}
ctx.Data["OriginalReviews"] = originalAuthorReviews
reviews, err := issues_model.GetReviewsByIssueID(ctx, issue.ID) if issue == nil {
if err != nil { posterID = ctx.Doer.ID
ctx.ServerError("GetReviewersByIssueID", err) } else {
return posterID = issue.PosterID
} if issue.OriginalAuthorID > 0 {
posterID = 0 // for migrated PRs, no poster ID
}
if len(reviews) == 0 && !canChooseReviewer { data.IssueID = issue.ID
return isClosed = issue.IsClosed || issue.PullRequest.HasMerged
originalAuthorReviews, err := issues_model.GetReviewersFromOriginalAuthorsByIssueID(ctx, issue.ID)
if err != nil {
ctx.ServerError("GetReviewersFromOriginalAuthorsByIssueID", err)
return
}
data.OriginalReviews = originalAuthorReviews
reviews, err = issues_model.GetReviewsByIssueID(ctx, issue.ID)
if err != nil {
ctx.ServerError("GetReviewersByIssueID", err)
return
}
if len(reviews) == 0 && !canChooseReviewer {
return
}
} }
var ( var (
@ -693,11 +725,7 @@ func RetrieveRepoReviewers(ctx *context.Context, repo *repo_model.Repository, is
) )
if canChooseReviewer { if canChooseReviewer {
posterID := issue.PosterID var err error
if issue.OriginalAuthorID > 0 {
posterID = 0
}
reviewers, err = repo_model.GetReviewers(ctx, repo, ctx.Doer.ID, posterID) reviewers, err = repo_model.GetReviewers(ctx, repo, ctx.Doer.ID, posterID)
if err != nil { if err != nil {
ctx.ServerError("GetReviewers", err) ctx.ServerError("GetReviewers", err)
@ -723,9 +751,9 @@ func RetrieveRepoReviewers(ctx *context.Context, repo *repo_model.Repository, is
for _, review := range reviews { for _, review := range reviews {
tmp := &repoReviewerSelection{ tmp := &repoReviewerSelection{
Checked: review.Type == issues_model.ReviewTypeRequest, Requested: review.Type == issues_model.ReviewTypeRequest,
Review: review, Review: review,
ItemID: review.ReviewerID, ItemID: review.ReviewerID,
} }
if review.ReviewerTeamID > 0 { if review.ReviewerTeamID > 0 {
tmp.IsTeam = true tmp.IsTeam = true
@ -756,7 +784,7 @@ func RetrieveRepoReviewers(ctx *context.Context, repo *repo_model.Repository, is
currentPullReviewers := make([]*repoReviewerSelection, 0, len(pullReviews)) currentPullReviewers := make([]*repoReviewerSelection, 0, len(pullReviews))
for _, item := range pullReviews { for _, item := range pullReviews {
if item.Review.ReviewerID > 0 { if item.Review.ReviewerID > 0 {
if err = item.Review.LoadReviewer(ctx); err != nil { if err := item.Review.LoadReviewer(ctx); err != nil {
if user_model.IsErrUserNotExist(err) { if user_model.IsErrUserNotExist(err) {
continue continue
} }
@ -765,7 +793,7 @@ func RetrieveRepoReviewers(ctx *context.Context, repo *repo_model.Repository, is
} }
item.User = item.Review.Reviewer item.User = item.Review.Reviewer
} else if item.Review.ReviewerTeamID > 0 { } else if item.Review.ReviewerTeamID > 0 {
if err = item.Review.LoadReviewerTeam(ctx); err != nil { if err := item.Review.LoadReviewerTeam(ctx); err != nil {
if organization.IsErrTeamNotExist(err) { if organization.IsErrTeamNotExist(err) {
continue continue
} }
@ -776,10 +804,11 @@ func RetrieveRepoReviewers(ctx *context.Context, repo *repo_model.Repository, is
} else { } else {
continue continue
} }
item.CanBeDismissed = ctx.Repo.Permission.IsAdmin() && !isClosed &&
(item.Review.Type == issues_model.ReviewTypeApprove || item.Review.Type == issues_model.ReviewTypeReject)
currentPullReviewers = append(currentPullReviewers, item) currentPullReviewers = append(currentPullReviewers, item)
} }
ctx.Data["PullReviewers"] = currentPullReviewers data.CurrentPullReviewers = currentPullReviewers
} }
if canChooseReviewer && reviewersResult != nil { if canChooseReviewer && reviewersResult != nil {
@ -807,7 +836,7 @@ func RetrieveRepoReviewers(ctx *context.Context, repo *repo_model.Repository, is
}) })
} }
ctx.Data["Reviewers"] = reviewersResult data.Reviewers = reviewersResult
} }
if canChooseReviewer && teamReviewersResult != nil { if canChooseReviewer && teamReviewersResult != nil {
@ -835,55 +864,118 @@ func RetrieveRepoReviewers(ctx *context.Context, repo *repo_model.Repository, is
}) })
} }
ctx.Data["TeamReviewers"] = teamReviewersResult data.TeamReviewers = teamReviewersResult
} }
ctx.Data["IssueSidebarReviewersData"] = data
} }
// RetrieveRepoMetas find all the meta information of a repository type issueSidebarLabelsData struct {
func RetrieveRepoMetas(ctx *context.Context, repo *repo_model.Repository, isPull bool) []*issues_model.Label { Repository *repo_model.Repository
if !ctx.Repo.CanWriteIssuesOrPulls(isPull) { RepoLink string
return nil IssueID int64
IsPullRequest bool
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 retrieveRepoLabels(ctx *context.Context, repo *repo_model.Repository, issueID int64, isPull bool) *issueSidebarLabelsData {
labelsData := &issueSidebarLabelsData{
Repository: repo,
RepoLink: ctx.Repo.RepoLink,
IssueID: issueID,
IsPullRequest: isPull,
}
ctx.Data["IssueSidebarLabelsData"] = labelsData
labels, err := issues_model.GetLabelsByRepoID(ctx, repo.ID, "", db.ListOptions{}) labels, err := issues_model.GetLabelsByRepoID(ctx, repo.ID, "", db.ListOptions{})
if err != nil { if err != nil {
ctx.ServerError("GetLabelsByRepoID", err) ctx.ServerError("GetLabelsByRepoID", err)
return nil return nil
} }
ctx.Data["Labels"] = labels labelsData.RepoLabels = labels
if repo.Owner.IsOrganization() { if repo.Owner.IsOrganization() {
orgLabels, err := issues_model.GetLabelsByOrgID(ctx, repo.Owner.ID, ctx.FormString("sort"), db.ListOptions{}) orgLabels, err := issues_model.GetLabelsByOrgID(ctx, repo.Owner.ID, ctx.FormString("sort"), db.ListOptions{})
if err != nil { if err != nil {
return nil return nil
} }
labelsData.OrgLabels = orgLabels
}
labelsData.AllLabels = append(labelsData.AllLabels, labelsData.RepoLabels...)
labelsData.AllLabels = append(labelsData.AllLabels, labelsData.OrgLabels...)
return labelsData
}
ctx.Data["OrgLabels"] = orgLabels // retrieveRepoMetasForIssueWriter finds some the meta information of a repository for an issue/pr writer
labels = append(labels, orgLabels...) func retrieveRepoMetasForIssueWriter(ctx *context.Context, repo *repo_model.Repository, isPull bool) {
if !ctx.Repo.CanWriteIssuesOrPulls(isPull) {
return
} }
RetrieveRepoMilestonesAndAssignees(ctx, repo) RetrieveRepoMilestonesAndAssignees(ctx, repo)
if ctx.Written() { if ctx.Written() {
return nil return
} }
retrieveProjects(ctx, repo) retrieveProjects(ctx, repo)
if ctx.Written() { if ctx.Written() {
return nil return
} }
PrepareBranchList(ctx) PrepareBranchList(ctx)
if ctx.Written() { if ctx.Written() {
return nil return
} }
// Contains true if the user can create issue dependencies // Contains true if the user can create issue dependencies
ctx.Data["CanCreateIssueDependencies"] = ctx.Repo.CanCreateIssueDependencies(ctx, ctx.Doer, isPull) ctx.Data["CanCreateIssueDependencies"] = ctx.Repo.CanCreateIssueDependencies(ctx, ctx.Doer, isPull)
return labels
} }
// Tries to load and set an issue template. The first return value indicates if a template was loaded. // Tries to load and set an issue template. The first return value indicates if a template was loaded.
func setTemplateIfExists(ctx *context.Context, ctxDataKey string, possibleFiles []string) (bool, map[string]error) { func setTemplateIfExists(ctx *context.Context, ctxDataKey string, possibleFiles []string, labelsData *issueSidebarLabelsData) (bool, map[string]error) {
commit, err := ctx.Repo.GitRepo.GetBranchCommit(ctx.Repo.Repository.DefaultBranch) commit, err := ctx.Repo.GitRepo.GetBranchCommit(ctx.Repo.Repository.DefaultBranch)
if err != nil { if err != nil {
return false, nil return false, nil
@ -920,26 +1012,9 @@ func setTemplateIfExists(ctx *context.Context, ctxDataKey string, possibleFiles
ctx.Data["Fields"] = template.Fields ctx.Data["Fields"] = template.Fields
ctx.Data["TemplateFile"] = template.FileName ctx.Data["TemplateFile"] = template.FileName
} }
labelIDs := make([]string, 0, len(template.Labels))
if repoLabels, err := issues_model.GetLabelsByRepoID(ctx, ctx.Repo.Repository.ID, "", db.ListOptions{}); err == nil {
ctx.Data["Labels"] = repoLabels
if ctx.Repo.Owner.IsOrganization() {
if orgLabels, err := issues_model.GetLabelsByOrgID(ctx, ctx.Repo.Owner.ID, ctx.FormString("sort"), db.ListOptions{}); err == nil {
ctx.Data["OrgLabels"] = orgLabels
repoLabels = append(repoLabels, orgLabels...)
}
}
for _, metaLabel := range template.Labels { labelsData.SetSelectedLabelNames(template.Labels)
for _, repoLabel := range repoLabels {
if strings.EqualFold(repoLabel.Name, metaLabel) {
repoLabel.IsChecked = true
labelIDs = append(labelIDs, strconv.FormatInt(repoLabel.ID, 10))
break
}
}
}
}
selectedAssigneeIDs := make([]int64, 0, len(template.Assignees)) selectedAssigneeIDs := make([]int64, 0, len(template.Assignees))
selectedAssigneeIDStrings := make([]string, 0, len(template.Assignees)) selectedAssigneeIDStrings := make([]string, 0, len(template.Assignees))
if userIDs, err := user_model.GetUserIDsByNames(ctx, template.Assignees, false); err == nil { if userIDs, err := user_model.GetUserIDsByNames(ctx, template.Assignees, false); err == nil {
@ -952,8 +1027,7 @@ func setTemplateIfExists(ctx *context.Context, ctxDataKey string, possibleFiles
if template.Ref != "" && !strings.HasPrefix(template.Ref, "refs/") { // Assume that the ref intended is always a branch - for tags users should use refs/tags/<ref> if template.Ref != "" && !strings.HasPrefix(template.Ref, "refs/") { // Assume that the ref intended is always a branch - for tags users should use refs/tags/<ref>
template.Ref = git.BranchPrefix + template.Ref template.Ref = git.BranchPrefix + template.Ref
} }
ctx.Data["HasSelectedLabel"] = len(labelIDs) > 0
ctx.Data["label_ids"] = strings.Join(labelIDs, ",")
ctx.Data["HasSelectedAssignee"] = len(selectedAssigneeIDs) > 0 ctx.Data["HasSelectedAssignee"] = len(selectedAssigneeIDs) > 0
ctx.Data["assignee_ids"] = strings.Join(selectedAssigneeIDStrings, ",") ctx.Data["assignee_ids"] = strings.Join(selectedAssigneeIDStrings, ",")
ctx.Data["SelectedAssigneeIDs"] = selectedAssigneeIDs ctx.Data["SelectedAssigneeIDs"] = selectedAssigneeIDs
@ -1011,8 +1085,14 @@ func NewIssue(ctx *context.Context) {
} }
} }
RetrieveRepoMetas(ctx, ctx.Repo.Repository, false) retrieveRepoMetasForIssueWriter(ctx, ctx.Repo.Repository, false)
if ctx.Written() {
return
}
labelsData := retrieveRepoLabels(ctx, ctx.Repo.Repository, 0, false)
if ctx.Written() {
return
}
tags, err := repo_model.GetTagNamesByRepoID(ctx, ctx.Repo.Repository.ID) tags, err := repo_model.GetTagNamesByRepoID(ctx, ctx.Repo.Repository.ID)
if err != nil { if err != nil {
ctx.ServerError("GetTagNamesByRepoID", err) ctx.ServerError("GetTagNamesByRepoID", err)
@ -1021,7 +1101,7 @@ func NewIssue(ctx *context.Context) {
ctx.Data["Tags"] = tags ctx.Data["Tags"] = tags
ret := issue_service.ParseTemplatesFromDefaultBranch(ctx.Repo.Repository, ctx.Repo.GitRepo) ret := issue_service.ParseTemplatesFromDefaultBranch(ctx.Repo.Repository, ctx.Repo.GitRepo)
templateLoaded, errs := setTemplateIfExists(ctx, issueTemplateKey, IssueTemplateCandidates) templateLoaded, errs := setTemplateIfExists(ctx, issueTemplateKey, IssueTemplateCandidates, labelsData)
for k, v := range errs { for k, v := range errs {
ret.TemplateErrors[k] = v ret.TemplateErrors[k] = v
} }
@ -1117,51 +1197,49 @@ func DeleteIssue(ctx *context.Context) {
} }
// ValidateRepoMetas check and returns repository's meta information // ValidateRepoMetas check and returns repository's meta information
func ValidateRepoMetas(ctx *context.Context, form forms.CreateIssueForm, isPull bool) ([]int64, []int64, int64, int64) { func ValidateRepoMetas(ctx *context.Context, form forms.CreateIssueForm, isPull bool) (ret struct {
LabelIDs, AssigneeIDs []int64
MilestoneID, ProjectID int64
Reviewers []*user_model.User
TeamReviewers []*organization.Team
},
) {
var ( var (
repo = ctx.Repo.Repository repo = ctx.Repo.Repository
err error err error
) )
labels := RetrieveRepoMetas(ctx, ctx.Repo.Repository, isPull) retrieveRepoMetasForIssueWriter(ctx, ctx.Repo.Repository, isPull)
if ctx.Written() { if ctx.Written() {
return nil, nil, 0, 0 return ret
}
labelsData := retrieveRepoLabels(ctx, ctx.Repo.Repository, 0, isPull)
if ctx.Written() {
return ret
} }
var labelIDs []int64 var labelIDs []int64
hasSelected := false
// Check labels. // Check labels.
if len(form.LabelIDs) > 0 { if len(form.LabelIDs) > 0 {
labelIDs, err = base.StringsToInt64s(strings.Split(form.LabelIDs, ",")) labelIDs, err = base.StringsToInt64s(strings.Split(form.LabelIDs, ","))
if err != nil { if err != nil {
return nil, nil, 0, 0 return ret
}
labelIDMark := make(container.Set[int64])
labelIDMark.AddMultiple(labelIDs...)
for i := range labels {
if labelIDMark.Contains(labels[i].ID) {
labels[i].IsChecked = true
hasSelected = true
}
} }
labelsData.SetSelectedLabelIDs(labelIDs)
} }
ctx.Data["Labels"] = labels
ctx.Data["HasSelectedLabel"] = hasSelected
ctx.Data["label_ids"] = form.LabelIDs
// Check milestone. // Check milestone.
milestoneID := form.MilestoneID milestoneID := form.MilestoneID
if milestoneID > 0 { if milestoneID > 0 {
milestone, err := issues_model.GetMilestoneByRepoID(ctx, ctx.Repo.Repository.ID, milestoneID) milestone, err := issues_model.GetMilestoneByRepoID(ctx, ctx.Repo.Repository.ID, milestoneID)
if err != nil { if err != nil {
ctx.ServerError("GetMilestoneByID", err) ctx.ServerError("GetMilestoneByID", err)
return nil, nil, 0, 0 return ret
} }
if milestone.RepoID != repo.ID { if milestone.RepoID != repo.ID {
ctx.ServerError("GetMilestoneByID", err) ctx.ServerError("GetMilestoneByID", err)
return nil, nil, 0, 0 return ret
} }
ctx.Data["Milestone"] = milestone ctx.Data["Milestone"] = milestone
ctx.Data["milestone_id"] = milestoneID ctx.Data["milestone_id"] = milestoneID
@ -1171,11 +1249,11 @@ func ValidateRepoMetas(ctx *context.Context, form forms.CreateIssueForm, isPull
p, err := project_model.GetProjectByID(ctx, form.ProjectID) p, err := project_model.GetProjectByID(ctx, form.ProjectID)
if err != nil { if err != nil {
ctx.ServerError("GetProjectByID", err) ctx.ServerError("GetProjectByID", err)
return nil, nil, 0, 0 return ret
} }
if p.RepoID != ctx.Repo.Repository.ID && p.OwnerID != ctx.Repo.Repository.OwnerID { if p.RepoID != ctx.Repo.Repository.ID && p.OwnerID != ctx.Repo.Repository.OwnerID {
ctx.NotFound("", nil) ctx.NotFound("", nil)
return nil, nil, 0, 0 return ret
} }
ctx.Data["Project"] = p ctx.Data["Project"] = p
@ -1187,7 +1265,7 @@ func ValidateRepoMetas(ctx *context.Context, form forms.CreateIssueForm, isPull
if len(form.AssigneeIDs) > 0 { if len(form.AssigneeIDs) > 0 {
assigneeIDs, err = base.StringsToInt64s(strings.Split(form.AssigneeIDs, ",")) assigneeIDs, err = base.StringsToInt64s(strings.Split(form.AssigneeIDs, ","))
if err != nil { if err != nil {
return nil, nil, 0, 0 return ret
} }
// Check if the passed assignees actually exists and is assignable // Check if the passed assignees actually exists and is assignable
@ -1195,18 +1273,18 @@ func ValidateRepoMetas(ctx *context.Context, form forms.CreateIssueForm, isPull
assignee, err := user_model.GetUserByID(ctx, aID) assignee, err := user_model.GetUserByID(ctx, aID)
if err != nil { if err != nil {
ctx.ServerError("GetUserByID", err) ctx.ServerError("GetUserByID", err)
return nil, nil, 0, 0 return ret
} }
valid, err := access_model.CanBeAssigned(ctx, assignee, repo, isPull) valid, err := access_model.CanBeAssigned(ctx, assignee, repo, isPull)
if err != nil { if err != nil {
ctx.ServerError("CanBeAssigned", err) ctx.ServerError("CanBeAssigned", err)
return nil, nil, 0, 0 return ret
} }
if !valid { if !valid {
ctx.ServerError("canBeAssigned", repo_model.ErrUserDoesNotHaveAccessToRepo{UserID: aID, RepoName: repo.Name}) ctx.ServerError("canBeAssigned", repo_model.ErrUserDoesNotHaveAccessToRepo{UserID: aID, RepoName: repo.Name})
return nil, nil, 0, 0 return ret
} }
} }
} }
@ -1216,7 +1294,39 @@ func ValidateRepoMetas(ctx *context.Context, form forms.CreateIssueForm, isPull
assigneeIDs = append(assigneeIDs, form.AssigneeID) assigneeIDs = append(assigneeIDs, form.AssigneeID)
} }
return labelIDs, assigneeIDs, milestoneID, form.ProjectID // Check reviewers
var reviewers []*user_model.User
var teamReviewers []*organization.Team
if isPull && len(form.ReviewerIDs) > 0 {
reviewerIDs, err := base.StringsToInt64s(strings.Split(form.ReviewerIDs, ","))
if err != nil {
return ret
}
// Check if the passed reviewers (user/team) actually exist
for _, rID := range reviewerIDs {
// negative reviewIDs represent team requests
if rID < 0 {
teamReviewer, err := organization.GetTeamByID(ctx, -rID)
if err != nil {
ctx.ServerError("GetTeamByID", err)
return ret
}
teamReviewers = append(teamReviewers, teamReviewer)
continue
}
reviewer, err := user_model.GetUserByID(ctx, rID)
if err != nil {
ctx.ServerError("GetUserByID", err)
return ret
}
reviewers = append(reviewers, reviewer)
}
}
ret.LabelIDs, ret.AssigneeIDs, ret.MilestoneID, ret.ProjectID = labelIDs, assigneeIDs, milestoneID, form.ProjectID
ret.Reviewers, ret.TeamReviewers = reviewers, teamReviewers
return ret
} }
// NewIssuePost response for creating new issue // NewIssuePost response for creating new issue
@ -1234,11 +1344,13 @@ func NewIssuePost(ctx *context.Context) {
attachments []string attachments []string
) )
labelIDs, assigneeIDs, milestoneID, projectID := ValidateRepoMetas(ctx, *form, false) validateRet := ValidateRepoMetas(ctx, *form, false)
if ctx.Written() { if ctx.Written() {
return return
} }
labelIDs, assigneeIDs, milestoneID, projectID := validateRet.LabelIDs, validateRet.AssigneeIDs, validateRet.MilestoneID, validateRet.ProjectID
if projectID > 0 { if projectID > 0 {
if !ctx.Repo.CanRead(unit.TypeProjects) { if !ctx.Repo.CanRead(unit.TypeProjects) {
// User must also be able to see the project. // User must also be able to see the project.
@ -1507,38 +1619,15 @@ func ViewIssue(ctx *context.Context) {
} }
} }
// Metas. retrieveRepoMetasForIssueWriter(ctx, repo, issue.IsPull)
// Check labels. if ctx.Written() {
labelIDMark := make(container.Set[int64])
for _, label := range issue.Labels {
labelIDMark.Add(label.ID)
}
labels, err := issues_model.GetLabelsByRepoID(ctx, repo.ID, "", db.ListOptions{})
if err != nil {
ctx.ServerError("GetLabelsByRepoID", err)
return return
} }
ctx.Data["Labels"] = labels labelsData := retrieveRepoLabels(ctx, repo, issue.ID, issue.IsPull)
if ctx.Written() {
if repo.Owner.IsOrganization() { return
orgLabels, err := issues_model.GetLabelsByOrgID(ctx, repo.Owner.ID, ctx.FormString("sort"), db.ListOptions{})
if err != nil {
ctx.ServerError("GetLabelsByOrgID", err)
return
}
ctx.Data["OrgLabels"] = orgLabels
labels = append(labels, orgLabels...)
} }
labelsData.SetSelectedLabels(issue.Labels)
hasSelected := false
for i := range labels {
if labelIDMark.Contains(labels[i].ID) {
labels[i].IsChecked = true
hasSelected = true
}
}
ctx.Data["HasSelectedLabel"] = hasSelected
// Check milestone and assignee. // Check milestone and assignee.
if ctx.Repo.CanWriteIssuesOrPulls(issue.IsPull) { if ctx.Repo.CanWriteIssuesOrPulls(issue.IsPull) {
@ -2479,7 +2568,7 @@ func UpdatePullReviewRequest(ctx *context.Context) {
return return
} }
err = issue_service.IsValidTeamReviewRequest(ctx, team, ctx.Doer, action == "attach", issue) _, err = issue_service.TeamReviewRequest(ctx, issue, ctx.Doer, team, action == "attach")
if err != nil { if err != nil {
if issues_model.IsErrNotValidReviewRequest(err) { if issues_model.IsErrNotValidReviewRequest(err) {
log.Warn( log.Warn(
@ -2490,12 +2579,6 @@ func UpdatePullReviewRequest(ctx *context.Context) {
ctx.Status(http.StatusForbidden) ctx.Status(http.StatusForbidden)
return return
} }
ctx.ServerError("IsValidTeamReviewRequest", err)
return
}
_, err = issue_service.TeamReviewRequest(ctx, issue, ctx.Doer, team, action == "attach")
if err != nil {
ctx.ServerError("TeamReviewRequest", err) ctx.ServerError("TeamReviewRequest", err)
return return
} }
@ -2517,7 +2600,7 @@ func UpdatePullReviewRequest(ctx *context.Context) {
return return
} }
err = issue_service.IsValidReviewRequest(ctx, reviewer, ctx.Doer, action == "attach", issue, nil) _, err = issue_service.ReviewRequest(ctx, issue, ctx.Doer, &ctx.Repo.Permission, reviewer, action == "attach")
if err != nil { if err != nil {
if issues_model.IsErrNotValidReviewRequest(err) { if issues_model.IsErrNotValidReviewRequest(err) {
log.Warn( log.Warn(
@ -2528,12 +2611,6 @@ func UpdatePullReviewRequest(ctx *context.Context) {
ctx.Status(http.StatusForbidden) ctx.Status(http.StatusForbidden)
return return
} }
ctx.ServerError("isValidReviewRequest", err)
return
}
_, err = issue_service.ReviewRequest(ctx, issue, ctx.Doer, reviewer, action == "attach")
if err != nil {
if issues_model.IsErrReviewRequestOnClosedPR(err) { if issues_model.IsErrReviewRequestOnClosedPR(err) {
ctx.Status(http.StatusForbidden) ctx.Status(http.StatusForbidden)
return return

View file

@ -53,11 +53,11 @@ func InitializeLabels(ctx *context.Context) {
ctx.Redirect(ctx.Repo.RepoLink + "/labels") ctx.Redirect(ctx.Repo.RepoLink + "/labels")
} }
// RetrieveLabels find all the labels of a repository and organization // RetrieveLabelsForList find all the labels of a repository and organization, it is only used by "/labels" page to list all labels
func RetrieveLabels(ctx *context.Context) { func RetrieveLabelsForList(ctx *context.Context) {
labels, err := issues_model.GetLabelsByRepoID(ctx, ctx.Repo.Repository.ID, ctx.FormString("sort"), db.ListOptions{}) labels, err := issues_model.GetLabelsByRepoID(ctx, ctx.Repo.Repository.ID, ctx.FormString("sort"), db.ListOptions{})
if err != nil { if err != nil {
ctx.ServerError("RetrieveLabels.GetLabels", err) ctx.ServerError("RetrieveLabelsForList.GetLabels", err)
return return
} }

View file

@ -62,7 +62,7 @@ func TestRetrieveLabels(t *testing.T) {
contexttest.LoadUser(t, ctx, 2) contexttest.LoadUser(t, ctx, 2)
contexttest.LoadRepo(t, ctx, testCase.RepoID) contexttest.LoadRepo(t, ctx, testCase.RepoID)
ctx.Req.Form.Set("sort", testCase.Sort) ctx.Req.Form.Set("sort", testCase.Sort)
RetrieveLabels(ctx) RetrieveLabelsForList(ctx)
assert.False(t, ctx.Written()) assert.False(t, ctx.Written())
labels, ok := ctx.Data["Labels"].([]*issues_model.Label) labels, ok := ctx.Data["Labels"].([]*issues_model.Label)
assert.True(t, ok) assert.True(t, ok)

View file

@ -1269,11 +1269,13 @@ func CompareAndPullRequestPost(ctx *context.Context) {
return return
} }
labelIDs, assigneeIDs, milestoneID, projectID := ValidateRepoMetas(ctx, *form, true) validateRet := ValidateRepoMetas(ctx, *form, true)
if ctx.Written() { if ctx.Written() {
return return
} }
labelIDs, assigneeIDs, milestoneID, projectID := validateRet.LabelIDs, validateRet.AssigneeIDs, validateRet.MilestoneID, validateRet.ProjectID
if setting.Attachment.Enabled { if setting.Attachment.Enabled {
attachments = form.Files attachments = form.Files
} }
@ -1318,8 +1320,17 @@ func CompareAndPullRequestPost(ctx *context.Context) {
} }
// FIXME: check error in the case two people send pull request at almost same time, give nice error prompt // FIXME: check error in the case two people send pull request at almost same time, give nice error prompt
// instead of 500. // instead of 500.
prOpts := &pull_service.NewPullRequestOptions{
if err := pull_service.NewPullRequest(ctx, repo, pullIssue, labelIDs, attachments, pullRequest, assigneeIDs); err != nil { Repo: repo,
Issue: pullIssue,
LabelIDs: labelIDs,
AttachmentUUIDs: attachments,
PullRequest: pullRequest,
AssigneeIDs: assigneeIDs,
Reviewers: validateRet.Reviewers,
TeamReviewers: validateRet.TeamReviewers,
}
if err := pull_service.NewPullRequest(ctx, prOpts); err != nil {
switch { switch {
case repo_model.IsErrUserDoesNotHaveAccessToRepo(err): case repo_model.IsErrUserDoesNotHaveAccessToRepo(err):
ctx.Error(http.StatusBadRequest, "UserDoesNotHaveAccessToRepo", err.Error()) ctx.Error(http.StatusBadRequest, "UserDoesNotHaveAccessToRepo", err.Error())

View file

@ -1163,7 +1163,7 @@ func registerRoutes(m *web.Router) {
m.Get("/issues/posters", repo.IssuePosters) // it can't use {type:issues|pulls} because it would conflict with other routes like "/pulls/{index}" m.Get("/issues/posters", repo.IssuePosters) // it can't use {type:issues|pulls} because it would conflict with other routes like "/pulls/{index}"
m.Get("/pulls/posters", repo.PullPosters) m.Get("/pulls/posters", repo.PullPosters)
m.Get("/comments/{id}/attachments", repo.GetCommentAttachments) m.Get("/comments/{id}/attachments", repo.GetCommentAttachments)
m.Get("/labels", repo.RetrieveLabels, repo.Labels) m.Get("/labels", repo.RetrieveLabelsForList, repo.Labels)
m.Get("/milestones", repo.Milestones) m.Get("/milestones", repo.Milestones)
m.Get("/milestone/{id}", context.RepoRef(), repo.MilestoneIssuesAndPulls) m.Get("/milestone/{id}", context.RepoRef(), repo.MilestoneIssuesAndPulls)
m.Group("/{type:issues|pulls}", func() { m.Group("/{type:issues|pulls}", func() {

View file

@ -137,8 +137,12 @@ func ProcReceive(ctx context.Context, repo *repo_model.Repository, gitRepo *git.
Type: issues_model.PullRequestGitea, Type: issues_model.PullRequestGitea,
Flow: issues_model.PullRequestFlowAGit, Flow: issues_model.PullRequestFlowAGit,
} }
prOpts := &pull_service.NewPullRequestOptions{
if err := pull_service.NewPullRequest(ctx, repo, prIssue, []int64{}, []string{}, pr, []int64{}); err != nil { Repo: repo,
Issue: prIssue,
PullRequest: pr,
}
if err := pull_service.NewPullRequest(ctx, prOpts); err != nil {
return nil, err return nil, err
} }

View file

@ -447,6 +447,7 @@ type CreateIssueForm struct {
Title string `binding:"Required;MaxSize(255)"` Title string `binding:"Required;MaxSize(255)"`
LabelIDs string `form:"label_ids"` LabelIDs string `form:"label_ids"`
AssigneeIDs string `form:"assignee_ids"` AssigneeIDs string `form:"assignee_ids"`
ReviewerIDs string `form:"reviewer_ids"`
Ref string `form:"ref"` Ref string `form:"ref"`
MilestoneID int64 MilestoneID int64
ProjectID int64 ProjectID int64

View file

@ -61,7 +61,12 @@ func ToggleAssigneeWithNotify(ctx context.Context, issue *issues_model.Issue, do
} }
// ReviewRequest add or remove a review request from a user for this PR, and make comment for it. // ReviewRequest add or remove a review request from a user for this PR, and make comment for it.
func ReviewRequest(ctx context.Context, issue *issues_model.Issue, doer, reviewer *user_model.User, isAdd bool) (comment *issues_model.Comment, err error) { func ReviewRequest(ctx context.Context, issue *issues_model.Issue, doer *user_model.User, permDoer *access_model.Permission, reviewer *user_model.User, isAdd bool) (comment *issues_model.Comment, err error) {
err = isValidReviewRequest(ctx, reviewer, doer, isAdd, issue, permDoer)
if err != nil {
return nil, err
}
if isAdd { if isAdd {
comment, err = issues_model.AddReviewRequest(ctx, issue, reviewer, doer) comment, err = issues_model.AddReviewRequest(ctx, issue, reviewer, doer)
} else { } else {
@ -79,8 +84,8 @@ func ReviewRequest(ctx context.Context, issue *issues_model.Issue, doer, reviewe
return comment, err return comment, err
} }
// IsValidReviewRequest Check permission for ReviewRequest // isValidReviewRequest Check permission for ReviewRequest
func IsValidReviewRequest(ctx context.Context, reviewer, doer *user_model.User, isAdd bool, issue *issues_model.Issue, permDoer *access_model.Permission) error { func isValidReviewRequest(ctx context.Context, reviewer, doer *user_model.User, isAdd bool, issue *issues_model.Issue, permDoer *access_model.Permission) error {
if reviewer.IsOrganization() { if reviewer.IsOrganization() {
return issues_model.ErrNotValidReviewRequest{ return issues_model.ErrNotValidReviewRequest{
Reason: "Organization can't be added as reviewer", Reason: "Organization can't be added as reviewer",
@ -109,7 +114,7 @@ func IsValidReviewRequest(ctx context.Context, reviewer, doer *user_model.User,
} }
} }
lastreview, err := issues_model.GetReviewByIssueIDAndUserID(ctx, issue.ID, reviewer.ID) lastReview, err := issues_model.GetReviewByIssueIDAndUserID(ctx, issue.ID, reviewer.ID)
if err != nil && !issues_model.IsErrReviewNotExist(err) { if err != nil && !issues_model.IsErrReviewNotExist(err) {
return err return err
} }
@ -137,7 +142,7 @@ func IsValidReviewRequest(ctx context.Context, reviewer, doer *user_model.User,
return nil return nil
} }
if doer.ID == issue.PosterID && issue.OriginalAuthorID == 0 && lastreview != nil && lastreview.Type != issues_model.ReviewTypeRequest { if doer.ID == issue.PosterID && issue.OriginalAuthorID == 0 && lastReview != nil && lastReview.Type != issues_model.ReviewTypeRequest {
return nil return nil
} }
@ -152,7 +157,7 @@ func IsValidReviewRequest(ctx context.Context, reviewer, doer *user_model.User,
return nil return nil
} }
if lastreview != nil && lastreview.Type == issues_model.ReviewTypeRequest && lastreview.ReviewerID == doer.ID { if lastReview != nil && lastReview.Type == issues_model.ReviewTypeRequest && lastReview.ReviewerID == doer.ID {
return nil return nil
} }
@ -163,8 +168,8 @@ func IsValidReviewRequest(ctx context.Context, reviewer, doer *user_model.User,
} }
} }
// IsValidTeamReviewRequest Check permission for ReviewRequest Team // isValidTeamReviewRequest Check permission for ReviewRequest Team
func IsValidTeamReviewRequest(ctx context.Context, reviewer *organization.Team, doer *user_model.User, isAdd bool, issue *issues_model.Issue) error { func isValidTeamReviewRequest(ctx context.Context, reviewer *organization.Team, doer *user_model.User, isAdd bool, issue *issues_model.Issue) error {
if doer.IsOrganization() { if doer.IsOrganization() {
return issues_model.ErrNotValidReviewRequest{ return issues_model.ErrNotValidReviewRequest{
Reason: "Organization can't be doer to add reviewer", Reason: "Organization can't be doer to add reviewer",
@ -212,6 +217,10 @@ func IsValidTeamReviewRequest(ctx context.Context, reviewer *organization.Team,
// TeamReviewRequest add or remove a review request from a team for this PR, and make comment for it. // TeamReviewRequest add or remove a review request from a team for this PR, and make comment for it.
func TeamReviewRequest(ctx context.Context, issue *issues_model.Issue, doer *user_model.User, reviewer *organization.Team, isAdd bool) (comment *issues_model.Comment, err error) { func TeamReviewRequest(ctx context.Context, issue *issues_model.Issue, doer *user_model.User, reviewer *organization.Team, isAdd bool) (comment *issues_model.Comment, err error) {
err = isValidTeamReviewRequest(ctx, reviewer, doer, isAdd, issue)
if err != nil {
return nil, err
}
if isAdd { if isAdd {
comment, err = issues_model.AddTeamReviewRequest(ctx, issue, reviewer, doer) comment, err = issues_model.AddTeamReviewRequest(ctx, issue, reviewer, doer)
} else { } else {
@ -268,6 +277,9 @@ func teamReviewRequestNotify(ctx context.Context, issue *issues_model.Issue, doe
// CanDoerChangeReviewRequests returns if the doer can add/remove review requests of a PR // CanDoerChangeReviewRequests returns if the doer can add/remove review requests of a PR
func CanDoerChangeReviewRequests(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, issue *issues_model.Issue) bool { func CanDoerChangeReviewRequests(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, issue *issues_model.Issue) bool {
if repo.IsArchived {
return false
}
// The poster of the PR can change the reviewers // The poster of the PR can change the reviewers
if doer.ID == issue.PosterID { if doer.ID == issue.PosterID {
return true return true

View file

@ -382,7 +382,7 @@ func (s *dummySender) Send(from string, to []string, msg io.WriterTo) error {
if _, err := msg.WriteTo(&buf); err != nil { if _, err := msg.WriteTo(&buf); err != nil {
return err return err
} }
log.Info("Mail From: %s To: %v Body: %s", from, to, buf.String()) log.Debug("Mail From: %s To: %v Body: %s", from, to, buf.String())
return nil return nil
} }

View file

@ -17,6 +17,7 @@ import (
"code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/db"
git_model "code.gitea.io/gitea/models/git" git_model "code.gitea.io/gitea/models/git"
issues_model "code.gitea.io/gitea/models/issues" issues_model "code.gitea.io/gitea/models/issues"
"code.gitea.io/gitea/models/organization"
access_model "code.gitea.io/gitea/models/perm/access" access_model "code.gitea.io/gitea/models/perm/access"
repo_model "code.gitea.io/gitea/models/repo" repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unit" "code.gitea.io/gitea/models/unit"
@ -41,8 +42,20 @@ func getPullWorkingLockKey(prID int64) string {
return fmt.Sprintf("pull_working_%d", prID) return fmt.Sprintf("pull_working_%d", prID)
} }
type NewPullRequestOptions struct {
Repo *repo_model.Repository
Issue *issues_model.Issue
LabelIDs []int64
AttachmentUUIDs []string
PullRequest *issues_model.PullRequest
AssigneeIDs []int64
Reviewers []*user_model.User
TeamReviewers []*organization.Team
}
// NewPullRequest creates new pull request with labels for repository. // NewPullRequest creates new pull request with labels for repository.
func NewPullRequest(ctx context.Context, repo *repo_model.Repository, issue *issues_model.Issue, labelIDs []int64, uuids []string, pr *issues_model.PullRequest, assigneeIDs []int64) error { func NewPullRequest(ctx context.Context, opts *NewPullRequestOptions) error {
repo, issue, labelIDs, uuids, pr, assigneeIDs := opts.Repo, opts.Issue, opts.LabelIDs, opts.AttachmentUUIDs, opts.PullRequest, opts.AssigneeIDs
if err := issue.LoadPoster(ctx); err != nil { if err := issue.LoadPoster(ctx); err != nil {
return err return err
} }
@ -197,7 +210,17 @@ func NewPullRequest(ctx context.Context, repo *repo_model.Repository, issue *iss
} }
notify_service.IssueChangeAssignee(ctx, issue.Poster, issue, assignee, false, assigneeCommentMap[assigneeID]) notify_service.IssueChangeAssignee(ctx, issue.Poster, issue, assignee, false, assigneeCommentMap[assigneeID])
} }
permDoer, err := access_model.GetUserRepoPermission(ctx, repo, issue.Poster)
for _, reviewer := range opts.Reviewers {
if _, err = issue_service.ReviewRequest(ctx, pr.Issue, issue.Poster, &permDoer, reviewer, true); err != nil {
return err
}
}
for _, teamReviewer := range opts.TeamReviewers {
if _, err = issue_service.TeamReviewRequest(ctx, pr.Issue, issue.Poster, teamReviewer, true); err != nil {
return err
}
}
return nil return nil
} }

View file

@ -44,4 +44,5 @@
</div> </div>
</div> </div>
</div> </div>
<div class="divider"></div>
{{end}} {{end}}

View file

@ -1,7 +0,0 @@
<a
class="item {{if not .label.IsChecked}}tw-hidden{{end}}"
id="label_{{.label.ID}}"
href="{{.root.RepoLink}}/{{if or .root.IsPull .root.Issue.IsPull}}pulls{{else}}issues{{end}}?labels={{.label.ID}}"{{/* FIXME: use .root.Issue.Link or create .root.Link */}}
>
{{- ctx.RenderUtils.RenderLabel .label -}}
</a>

View file

@ -1,46 +0,0 @@
<div class="ui {{if or (not .HasIssuesOrPullsWritePermission) .Repository.IsArchived}}disabled{{end}} floating jump select-label dropdown">
<span class="text muted flex-text-block">
<strong>{{ctx.Locale.Tr "repo.issues.new.labels"}}</strong>
{{if and .HasIssuesOrPullsWritePermission (not .Repository.IsArchived)}}
{{svg "octicon-gear" 16 "tw-ml-1"}}
{{end}}
</span>
<div class="filter menu" {{if .Issue}}data-action="update" data-issue-id="{{$.Issue.ID}}" data-update-url="{{$.RepoLink}}/issues/labels"{{else}}data-id="#label_ids"{{end}}>
{{if or .Labels .OrgLabels}}
<div class="ui icon search input">
<i class="icon">{{svg "octicon-search" 16}}</i>
<input type="text" placeholder="{{ctx.Locale.Tr "repo.issues.filter_labels"}}">
</div>
{{end}}
<a class="no-select item" href="#">{{ctx.Locale.Tr "repo.issues.new.clear_labels"}}</a>
{{if or .Labels .OrgLabels}}
{{$previousExclusiveScope := "_no_scope"}}
{{range .Labels}}
{{$exclusiveScope := .ExclusiveScope}}
{{if and (ne $previousExclusiveScope "_no_scope") (ne $previousExclusiveScope $exclusiveScope)}}
<div class="divider"></div>
{{end}}
{{$previousExclusiveScope = $exclusiveScope}}
<a class="{{if .IsChecked}}checked{{end}} item" href="#" data-id="{{.ID}}" {{if .IsArchived}}data-is-archived{{end}} data-id-selector="#label_{{.ID}}" data-scope="{{$exclusiveScope}}"><span class="octicon-check {{if not .IsChecked}}tw-invisible{{end}}">{{svg (Iif $exclusiveScope "octicon-dot-fill" "octicon-check")}}</span>&nbsp;&nbsp;{{ctx.RenderUtils.RenderLabel .}}
{{if .Description}}<br><small class="desc">{{.Description | ctx.RenderUtils.RenderEmoji}}</small>{{end}}
<p class="archived-label-hint">{{template "repo/issue/labels/label_archived" .}}</p>
</a>
{{end}}
<div class="divider"></div>
{{$previousExclusiveScope = "_no_scope"}}
{{range .OrgLabels}}
{{$exclusiveScope := .ExclusiveScope}}
{{if and (ne $previousExclusiveScope "_no_scope") (ne $previousExclusiveScope $exclusiveScope)}}
<div class="divider"></div>
{{end}}
{{$previousExclusiveScope = $exclusiveScope}}
<a class="{{if .IsChecked}}checked{{end}} item" href="#" data-id="{{.ID}}" {{if .IsArchived}}data-is-archived{{end}} data-id-selector="#label_{{.ID}}" data-scope="{{$exclusiveScope}}"><span class="octicon-check {{if not .IsChecked}}tw-invisible{{end}}">{{svg (Iif $exclusiveScope "octicon-dot-fill" "octicon-check")}}</span>&nbsp;&nbsp;{{ctx.RenderUtils.RenderLabel .}}
{{if .Description}}<br><small class="desc">{{.Description | ctx.RenderUtils.RenderEmoji}}</small>{{end}}
<p class="archived-label-hint">{{template "repo/issue/labels/label_archived" .}}</p>
</a>
{{end}}
{{else}}
<div class="disabled item">{{ctx.Locale.Tr "repo.issues.new.no_items"}}</div>
{{end}}
</div>
</div>

View file

@ -1,11 +0,0 @@
<div class="ui labels list">
<span class="labels-list">
<span class="no-select {{if .root.HasSelectedLabel}}tw-hidden{{end}}">{{ctx.Locale.Tr "repo.issues.new.no_label"}}</span>
{{range .root.Labels}}
{{template "repo/issue/labels/label" dict "root" $.root "label" .}}
{{end}}
{{range .root.OrgLabels}}
{{template "repo/issue/labels/label" dict "root" $.root "label" .}}
{{end}}
</span>
</div>

View file

@ -18,15 +18,15 @@
<input type="hidden" name="template-file" value="{{.TemplateFile}}"> <input type="hidden" name="template-file" value="{{.TemplateFile}}">
{{range .Fields}} {{range .Fields}}
{{if eq .Type "input"}} {{if eq .Type "input"}}
{{template "repo/issue/fields/input" "item" .}} {{template "repo/issue/fields/input" dict "item" .}}
{{else if eq .Type "markdown"}} {{else if eq .Type "markdown"}}
{{template "repo/issue/fields/markdown" "item" .}} {{template "repo/issue/fields/markdown" dict "item" .}}
{{else if eq .Type "textarea"}} {{else if eq .Type "textarea"}}
{{template "repo/issue/fields/textarea" "item" . "root" $}} {{template "repo/issue/fields/textarea" dict "item" . "root" $}}
{{else if eq .Type "dropdown"}} {{else if eq .Type "dropdown"}}
{{template "repo/issue/fields/dropdown" "item" .}} {{template "repo/issue/fields/dropdown" dict "item" .}}
{{else if eq .Type "checkboxes"}} {{else if eq .Type "checkboxes"}}
{{template "repo/issue/fields/checkboxes" "item" .}} {{template "repo/issue/fields/checkboxes" dict "item" .}}
{{end}} {{end}}
{{end}} {{end}}
{{else}} {{else}}
@ -47,11 +47,13 @@
</div> </div>
<div class="issue-content-right ui segment"> <div class="issue-content-right ui segment">
{{template "repo/issue/branch_selector_field" .}} {{template "repo/issue/branch_selector_field" $}}
{{if .PageIsComparePull}}
{{template "repo/issue/sidebar/reviewer_list" $.IssueSidebarReviewersData}}
<div class="divider"></div>
{{end}}
<input id="label_ids" name="label_ids" type="hidden" value="{{.label_ids}}"> {{template "repo/issue/sidebar/label_list" $.IssueSidebarLabelsData}}
{{template "repo/issue/labels/labels_selector_field" .}}
{{template "repo/issue/labels/labels_sidebar" dict "root" $}}
<div class="divider"></div> <div class="divider"></div>

View file

@ -1,15 +1,13 @@
{{if and .Issue.IsPull .IsIssuePoster (not .Issue.IsClosed) .Issue.PullRequest.HeadRepo}} {{if and .Issue.IsPull .IsIssuePoster (not .Issue.IsClosed) .Issue.PullRequest.HeadRepo}}
{{if and (not (eq .Issue.PullRequest.HeadRepo.FullName .Issue.PullRequest.BaseRepo.FullName)) .CanWriteToHeadRepo}} {{if and (not (eq .Issue.PullRequest.HeadRepo.FullName .Issue.PullRequest.BaseRepo.FullName)) .CanWriteToHeadRepo}}
<div class="divider"></div> <div class="divider"></div>
<div class="inline field"> <div class="ui checkbox loading-icon-2px" id="allow-edits-from-maintainers"
<div class="ui checkbox loading-icon-2px" id="allow-edits-from-maintainers" data-url="{{.Issue.Link}}"
data-url="{{.Issue.Link}}" data-tooltip-content="{{ctx.Locale.Tr "repo.pulls.allow_edits_from_maintainers_desc"}}"
data-tooltip-content="{{ctx.Locale.Tr "repo.pulls.allow_edits_from_maintainers_desc"}}" data-prompt-error="{{ctx.Locale.Tr "repo.pulls.allow_edits_from_maintainers_err"}}"
data-prompt-error="{{ctx.Locale.Tr "repo.pulls.allow_edits_from_maintainers_err"}}" >
> <label><strong>{{ctx.Locale.Tr "repo.pulls.allow_edits_from_maintainers"}}</strong></label>
<label><strong>{{ctx.Locale.Tr "repo.pulls.allow_edits_from_maintainers"}}</strong></label> <input type="checkbox" {{if .Issue.PullRequest.AllowMaintainerEdit}}checked{{end}}>
<input type="checkbox" {{if .Issue.PullRequest.AllowMaintainerEdit}}checked{{end}}>
</div>
</div> </div>
{{end}} {{end}}
{{end}} {{end}}

View file

@ -18,11 +18,11 @@
<span class="text" data-tooltip-content="{{if .Issue.IsPull}}{{ctx.Locale.Tr "repo.issues.dependency.pr_close_blocks"}}{{else}}{{ctx.Locale.Tr "repo.issues.dependency.issue_close_blocks"}}{{end}}"> <span class="text" data-tooltip-content="{{if .Issue.IsPull}}{{ctx.Locale.Tr "repo.issues.dependency.pr_close_blocks"}}{{else}}{{ctx.Locale.Tr "repo.issues.dependency.issue_close_blocks"}}{{end}}">
<strong>{{ctx.Locale.Tr "repo.issues.dependency.blocks_short"}}</strong> <strong>{{ctx.Locale.Tr "repo.issues.dependency.blocks_short"}}</strong>
</span> </span>
<div class="ui relaxed divided list"> <div class="ui divided list">
{{range .BlockingDependencies}} {{range .BlockingDependencies}}
<div class="item dependency{{if .Issue.IsClosed}} is-closed{{end}} tw-flex tw-items-center tw-justify-between"> <div class="item dependency{{if .Issue.IsClosed}} is-closed{{end}} tw-flex tw-items-center tw-justify-between">
<div class="item-left tw-flex tw-justify-center tw-flex-col tw-flex-1 gt-ellipsis"> <div class="item-left tw-flex tw-justify-center tw-flex-col tw-flex-1 gt-ellipsis">
<a class="title muted" href="{{.Issue.Link}}" data-tooltip-content="#{{.Issue.Index}} {{.Issue.Title | ctx.RenderUtils.RenderEmoji}}"> <a class="muted gt-ellipsis" href="{{.Issue.Link}}" data-tooltip-content="#{{.Issue.Index}} {{.Issue.Title | ctx.RenderUtils.RenderEmoji}}">
#{{.Issue.Index}} {{.Issue.Title | ctx.RenderUtils.RenderEmoji}} #{{.Issue.Index}} {{.Issue.Title | ctx.RenderUtils.RenderEmoji}}
</a> </a>
<div class="text small gt-ellipsis" data-tooltip-content="{{.Repository.OwnerName}}/{{.Repository.Name}}"> <div class="text small gt-ellipsis" data-tooltip-content="{{.Repository.OwnerName}}/{{.Repository.Name}}">
@ -50,11 +50,11 @@
<span class="text" data-tooltip-content="{{if .Issue.IsPull}}{{ctx.Locale.Tr "repo.issues.dependency.pr_closing_blockedby"}}{{else}}{{ctx.Locale.Tr "repo.issues.dependency.issue_closing_blockedby"}}{{end}}"> <span class="text" data-tooltip-content="{{if .Issue.IsPull}}{{ctx.Locale.Tr "repo.issues.dependency.pr_closing_blockedby"}}{{else}}{{ctx.Locale.Tr "repo.issues.dependency.issue_closing_blockedby"}}{{end}}">
<strong>{{ctx.Locale.Tr "repo.issues.dependency.blocked_by_short"}}</strong> <strong>{{ctx.Locale.Tr "repo.issues.dependency.blocked_by_short"}}</strong>
</span> </span>
<div class="ui relaxed divided list"> <div class="ui divided list">
{{range .BlockedByDependencies}} {{range .BlockedByDependencies}}
<div class="item dependency{{if .Issue.IsClosed}} is-closed{{end}} tw-flex tw-items-center tw-justify-between"> <div class="item dependency{{if .Issue.IsClosed}} is-closed{{end}} tw-flex tw-items-center tw-justify-between">
<div class="item-left tw-flex tw-justify-center tw-flex-col tw-flex-1 gt-ellipsis"> <div class="item-left tw-flex tw-justify-center tw-flex-col tw-flex-1 gt-ellipsis">
<a class="title muted" href="{{.Issue.Link}}" data-tooltip-content="#{{.Issue.Index}} {{.Issue.Title | ctx.RenderUtils.RenderEmoji}}"> <a class="muted gt-ellipsis" href="{{.Issue.Link}}" data-tooltip-content="#{{.Issue.Index}} {{.Issue.Title | ctx.RenderUtils.RenderEmoji}}">
#{{.Issue.Index}} {{.Issue.Title | ctx.RenderUtils.RenderEmoji}} #{{.Issue.Index}} {{.Issue.Title | ctx.RenderUtils.RenderEmoji}}
</a> </a>
<div class="text small gt-ellipsis" data-tooltip-content="{{.Repository.OwnerName}}/{{.Repository.Name}}"> <div class="text small gt-ellipsis" data-tooltip-content="{{.Repository.OwnerName}}/{{.Repository.Name}}">
@ -76,7 +76,7 @@
<div class="item-left tw-flex tw-justify-center tw-flex-col tw-flex-1 gt-ellipsis"> <div class="item-left tw-flex tw-justify-center tw-flex-col tw-flex-1 gt-ellipsis">
<div class="gt-ellipsis"> <div class="gt-ellipsis">
<span data-tooltip-content="{{ctx.Locale.Tr "repo.issues.dependency.no_permission.can_remove"}}">{{svg "octicon-lock" 16}}</span> <span data-tooltip-content="{{ctx.Locale.Tr "repo.issues.dependency.no_permission.can_remove"}}">{{svg "octicon-lock" 16}}</span>
<span class="title" data-tooltip-content="#{{.Issue.Index}} {{.Issue.Title | ctx.RenderUtils.RenderEmoji}}"> <span class="gt-ellipsis" data-tooltip-content="#{{.Issue.Index}} {{.Issue.Title | ctx.RenderUtils.RenderEmoji}}">
#{{.Issue.Index}} {{.Issue.Title | ctx.RenderUtils.RenderEmoji}} #{{.Issue.Index}} {{.Issue.Title | ctx.RenderUtils.RenderEmoji}}
</span> </span>
</div> </div>

View file

@ -0,0 +1,51 @@
{{$data := .}}
{{$canChange := and ctx.RootData.HasIssuesOrPullsWritePermission (not $data.Repository.IsArchived)}}
<div class="issue-sidebar-combo" {{if $data.IssueID}}data-update-url="{{$data.RepoLink}}/issues/labels?issue_ids={{$data.IssueID}}"{{end}}>
<input class="combo-value" name="label_ids" type="hidden" value="{{$data.SelectedLabelIDs}}">
<div class="ui dropdown {{if not $canChange}}disabled{{end}}">
<a class="text muted">
<strong>{{ctx.Locale.Tr "repo.issues.new.labels"}}</strong> {{if $canChange}}{{svg "octicon-gear"}}{{end}}
</a>
<div class="menu">
{{if not $data.AllLabels}}
<div class="item disabled">{{ctx.Locale.Tr "repo.issues.new.no_items"}}</div>
{{else}}
<div class="ui icon search input">
<i class="icon">{{svg "octicon-search" 16}}</i>
<input type="text" placeholder="{{ctx.Locale.Tr "repo.issues.filter_labels"}}">
</div>
<a class="item clear-selection" href="#">{{ctx.Locale.Tr "repo.issues.new.clear_labels"}}</a>
{{$previousExclusiveScope := "_no_scope"}}
{{range .RepoLabels}}
{{$exclusiveScope := .ExclusiveScope}}
{{if and (ne $previousExclusiveScope "_no_scope") (ne $previousExclusiveScope $exclusiveScope)}}
<div class="divider"></div>
{{end}}
{{$previousExclusiveScope = $exclusiveScope}}
{{template "repo/issue/sidebar/label_list_item" dict "Label" .}}
{{end}}
<div class="divider"></div>
{{$previousExclusiveScope = "_no_scope"}}
{{range .OrgLabels}}
{{$exclusiveScope := .ExclusiveScope}}
{{if and (ne $previousExclusiveScope "_no_scope") (ne $previousExclusiveScope $exclusiveScope)}}
<div class="divider"></div>
{{end}}
{{$previousExclusiveScope = $exclusiveScope}}
{{template "repo/issue/sidebar/label_list_item" dict "Label" .}}
{{end}}
{{end}}
</div>
</div>
<div class="ui list labels-list tw-my-2 tw-flex tw-gap-2">
<span class="item empty-list {{if $data.SelectedLabelIDs}}tw-hidden{{end}}">{{ctx.Locale.Tr "repo.issues.new.no_label"}}</span>
{{range $data.AllLabels}}
{{if .IsChecked}}
<a class="item" href="{{$data.RepoLink}}/{{if $data.IsPullRequest}}pulls{{else}}issues{{end}}?labels={{.ID}}">
{{- ctx.RenderUtils.RenderLabel . -}}
</a>
{{end}}
{{end}}
</div>
</div>

View file

@ -0,0 +1,11 @@
{{$label := .Label}}
<a class="item {{if $label.IsChecked}}checked{{else if $label.IsArchived}}tw-hidden{{end}}" href="#"
data-scope="{{$label.ExclusiveScope}}" data-value="{{$label.ID}}" {{if $label.IsArchived}}data-is-archived{{end}}
>
<span class="item-check-mark">{{svg (Iif $label.ExclusiveScope "octicon-dot-fill" "octicon-check")}}</span>
{{ctx.RenderUtils.RenderLabel $label}}
<div class="item-secondary-info">
{{if $label.Description}}<div class="tw-pl-[20px]"><small>{{$label.Description | ctx.RenderUtils.RenderEmoji}}</small></div>{{end}}
<div class="archived-label-hint">{{template "repo/issue/labels/label_archived" $label}}</div>
</div>
</a>

View file

@ -1,95 +1,79 @@
<input id="reviewer_id" name="reviewer_id" type="hidden" value="{{.reviewer_id}}"> {{$data := .}}
<div class="ui {{if or (and (not .Reviewers) (not .TeamReviewers)) (not .CanChooseReviewer) .Repository.IsArchived}}disabled{{end}} floating jump select-reviewers-modify dropdown"> {{$hasCandidates := or $data.Reviewers $data.TeamReviewers}}
<a class="text tw-flex tw-items-center muted"> <div class="issue-sidebar-combo" {{if $data.IssueID}}data-update-url="{{$data.RepoLink}}/issues/request_review?issue_ids={{$data.IssueID}}"{{end}}>
<strong>{{ctx.Locale.Tr "repo.issues.review.reviewers"}}</strong> <input type="hidden" class="combo-value" name="reviewer_ids">{{/* match CreateIssueForm */}}
{{if and .CanChooseReviewer (not .Repository.IsArchived)}} <div class="ui dropdown {{if or (not $hasCandidates) (not $data.CanChooseReviewer)}}disabled{{end}}">
{{svg "octicon-gear" 16 "tw-ml-1"}} <a class="text muted">
{{end}} <strong>{{ctx.Locale.Tr "repo.issues.review.reviewers"}}</strong> {{if and $data.CanChooseReviewer}}{{svg "octicon-gear"}}{{end}}
</a> </a>
<div class="filter menu" data-action="update" data-issue-id="{{$.Issue.ID}}" data-update-url="{{$.RepoLink}}/issues/request_review"> <div class="menu flex-items-menu">
{{if .Reviewers}} {{if $hasCandidates}}
<div class="ui icon search input"> <div class="ui icon search input">
<i class="icon">{{svg "octicon-search" 16}}</i> <i class="icon">{{svg "octicon-search"}}</i>
<input type="text" placeholder="{{ctx.Locale.Tr "repo.issues.filter_reviewers"}}"> <input type="text" placeholder="{{ctx.Locale.Tr "repo.issues.filter_reviewers"}}">
</div> </div>
{{end}} {{end}}
{{if .Reviewers}} {{range $data.Reviewers}}
{{range .Reviewers}}
{{if .User}} {{if .User}}
<a class="{{if not .CanChange}}ui{{end}} item {{if .Checked}}checked{{end}} {{if not .CanChange}}ban-change{{end}}" href="#" data-id="{{.ItemID}}" data-id-selector="#review_request_{{.ItemID}}" {{if not .CanChange}} data-tooltip-content="{{ctx.Locale.Tr "repo.issues.remove_request_review_block"}}"{{end}}> <a class="item muted {{if .Requested}}checked{{end}}" href="{{.User.HomeLink}}" data-value="{{.ItemID}}" data-can-change="{{.CanChange}}"
<span class="octicon-check {{if not .Checked}}tw-invisible{{end}}">{{svg "octicon-check"}}</span> {{if not .CanChange}}data-tooltip-content="{{ctx.Locale.Tr "repo.issues.remove_request_review_block"}}"{{end}}>
<span class="text"> <span class="item-check-mark">{{svg "octicon-check"}}</span>
{{ctx.AvatarUtils.Avatar .User 28 "tw-mr-2"}}{{template "repo/search_name" .User}} {{ctx.AvatarUtils.Avatar .User 20}} {{template "repo/search_name" .User}}
</span>
</a> </a>
{{end}} {{end}}
{{end}} {{end}}
{{end}} {{if $data.TeamReviewers}}
{{if .TeamReviewers}} {{if $data.Reviewers}}<div class="divider"></div>{{end}}
{{if .Reviewers}} {{range $data.TeamReviewers}}
<div class="divider"></div> {{if .Team}}
{{end}} <a class="item muted {{if .Requested}}checked{{end}}" href="#" data-value="{{.ItemID}}" data-can-change="{{.CanChange}}"
{{range .TeamReviewers}} {{if not .CanChange}} data-tooltip-content="{{ctx.Locale.Tr "repo.issues.remove_request_review_block"}}"{{end}}>
{{if .Team}} <span class="item-check-mark">{{svg "octicon-check"}}</span>
<a class="{{if not .CanChange}}ui{{end}} item {{if .Checked}}checked{{end}} {{if not .CanChange}}ban-change{{end}}" href="#" data-id="{{.ItemID}}" data-id-selector="#review_request_team_{{.Team.ID}}" {{if not .CanChange}} data-tooltip-content="{{ctx.Locale.Tr "repo.issues.remove_request_review_block"}}"{{end}}> {{svg "octicon-people" 20}} {{$data.RepoOwnerName}}/{{.Team.Name}}
<span class="octicon-check {{if not .Checked}}tw-invisible{{end}}">{{svg "octicon-check" 16}}</span> </a>
<span class="text"> {{end}}
{{svg "octicon-people" 16 "tw-ml-4 tw-mr-1"}}{{$.Issue.Repo.OwnerName}}/{{.Team.Name}}
</span>
</a>
{{end}} {{end}}
{{end}} {{end}}
{{end}} </div>
</div> </div>
</div>
<div class="ui assignees list"> <div class="ui relaxed list flex-items-block tw-my-4">
<span class="no-select item {{if or .OriginalReviews .PullReviewers}}tw-hidden{{end}}">{{ctx.Locale.Tr "repo.issues.new.no_reviewers"}}</span> <span class="item empty-list {{if or $data.OriginalReviews $data.CurrentPullReviewers}}tw-hidden{{end}}">
<div class="selected"> {{ctx.Locale.Tr "repo.issues.new.no_reviewers"}}
{{range .PullReviewers}} </span>
<div class="item tw-flex tw-items-center tw-py-2"> {{range $data.CurrentPullReviewers}}
<div class="tw-flex tw-items-center tw-flex-1"> <div class="item">
<div class="flex-text-inline tw-flex-1">
{{if .User}} {{if .User}}
<a class="muted sidebar-item-link" href="{{.User.HomeLink}}">{{ctx.AvatarUtils.Avatar .User 20 "tw-mr-2"}}{{.User.GetDisplayName}}</a> <a class="muted flex-text-inline" href="{{.User.HomeLink}}">{{ctx.AvatarUtils.Avatar .User 20}} {{.User.GetDisplayName}}</a>
{{else if .Team}} {{else if .Team}}
<span class="text">{{svg "octicon-people" 20 "tw-mr-2"}}{{$.Issue.Repo.OwnerName}}/{{.Team.Name}}</span> {{svg "octicon-people" 20}} {{$data.RepoOwnerName}}/{{.Team.Name}}
{{end}} {{end}}
</div> </div>
<div class="tw-flex tw-items-center tw-gap-2"> <div class="flex-text-inline">
{{if (and $.Permission.IsAdmin (or (eq .Review.Type 1) (eq .Review.Type 3)) (not $.Issue.IsClosed) (not $.Issue.PullRequest.HasMerged))}} {{if .CanBeDismissed}}
<a href="#" class="ui muted icon tw-flex tw-items-center show-modal" data-tooltip-content="{{ctx.Locale.Tr "repo.issues.dismiss_review"}}" data-modal="#dismiss-review-modal-{{.Review.ID}}"> <a href="#" class="ui muted icon show-modal" data-tooltip-content="{{ctx.Locale.Tr "repo.issues.dismiss_review"}}"
data-modal="#issue-sidebar-dismiss-review-modal" data-modal-reviewer-id="{{.Review.ID}}">
{{svg "octicon-x" 20}} {{svg "octicon-x" 20}}
</a> </a>
<div class="ui small modal" id="dismiss-review-modal-{{.Review.ID}}">
<div class="header">
{{ctx.Locale.Tr "repo.issues.dismiss_review"}}
</div>
<div class="content">
<div class="ui warning message">
{{ctx.Locale.Tr "repo.issues.dismiss_review_warning"}}
</div>
<form class="ui form dismiss-review-form" id="dismiss-review-{{.Review.ID}}" action="{{$.RepoLink}}/issues/dismiss_review" method="post">
{{$.CsrfTokenHtml}}
<input type="hidden" name="review_id" value="{{.Review.ID}}">
<div class="field">
<label for="message">{{ctx.Locale.Tr "action.review_dismissed_reason"}}</label>
<input id="message" name="message">
</div>
<div class="text right actions">
<button class="ui cancel button">{{ctx.Locale.Tr "settings.cancel"}}</button>
<button class="ui red button" type="submit">{{ctx.Locale.Tr "ok"}}</button>
</div>
</form>
</div>
</div>
{{end}} {{end}}
{{if .Review.Stale}} {{if .Review.Stale}}
<span data-tooltip-content="{{ctx.Locale.Tr "repo.issues.is_stale"}}"> <span data-tooltip-content="{{ctx.Locale.Tr "repo.issues.is_stale"}}">{{svg "octicon-hourglass" 16}}</span>
{{svg "octicon-hourglass" 16}}
</span>
{{end}} {{end}}
{{if and .CanChange (or .Checked (and (not $.Issue.IsClosed) (not $.Issue.PullRequest.HasMerged)))}} {{if and .CanChange $data.CanChooseReviewer}}
<a href="#" class="ui muted icon re-request-review{{if .Checked}} checked{{end}}" data-tooltip-content="{{if .Checked}}{{ctx.Locale.Tr "repo.issues.remove_request_review"}}{{else}}{{ctx.Locale.Tr "repo.issues.re_request_review"}}{{end}}" data-issue-id="{{$.Issue.ID}}" data-id="{{.ItemID}}" data-update-url="{{$.RepoLink}}/issues/request_review">{{svg (Iif .Checked "octicon-trash" "octicon-sync")}}</a> {{if .Requested}}
<a href="#" class="ui muted icon link-action"
data-tooltip-content="{{ctx.Locale.Tr "repo.issues.remove_request_review"}}"
data-url="{{$data.RepoLink}}/issues/request_review?action=detach&issue_ids={{$data.IssueID}}&id={{.ItemID}}">
{{svg "octicon-trash"}}
</a>
{{else}}
<a href="#" class="ui muted icon link-action"
data-tooltip-content="{{ctx.Locale.Tr "repo.issues.re_request_review"}}"
data-url="{{$data.RepoLink}}/issues/request_review?action=attach&issue_ids={{$data.IssueID}}&id={{.ItemID}}">
{{svg "octicon-sync"}}
</a>
{{end}}
{{end}} {{end}}
<span {{if .Review.TooltipContent}}data-tooltip-content="{{ctx.Locale.Tr .Review.TooltipContent}}"{{end}}> <span {{if .Review.TooltipContent}}data-tooltip-content="{{ctx.Locale.Tr .Review.TooltipContent}}"{{end}}>
{{svg (printf "octicon-%s" .Review.Type.Icon) 16 (printf "text %s" (.Review.HTMLTypeColorName))}} {{svg (printf "octicon-%s" .Review.Type.Icon) 16 (printf "text %s" (.Review.HTMLTypeColorName))}}
@ -97,15 +81,16 @@
</div> </div>
</div> </div>
{{end}} {{end}}
{{range .OriginalReviews}} {{range $data.OriginalReviews}}
<div class="item tw-flex tw-items-center tw-py-2"> <div class="item">
<div class="tw-flex tw-items-center tw-flex-1"> <div class="flex-text-inline tw-flex-1">
<a class="muted" href="{{$.Repository.OriginalURL}}" data-tooltip-content="{{ctx.Locale.Tr "repo.migrated_from_fake" $.Repository.GetOriginalURLHostname}}"> {{$originalURLHostname := $data.Repository.GetOriginalURLHostname}}
{{svg (MigrationIcon $.Repository.GetOriginalURLHostname) 20 "tw-mr-2"}} {{$originalURL := $data.Repository.OriginalURL}}
{{.OriginalAuthor}} <a class="muted flex-text-inline" href="{{$originalURL}}" data-tooltip-content="{{ctx.Locale.Tr "repo.migrated_from_fake" $originalURLHostname}}">
{{svg (MigrationIcon $originalURLHostname) 20}} {{.OriginalAuthor}}
</a> </a>
</div> </div>
<div class="tw-flex tw-items-center tw-gap-2"> <div class="flex-text-inline">
<span {{if .TooltipContent}}data-tooltip-content="{{ctx.Locale.Tr .TooltipContent}}"{{end}}> <span {{if .TooltipContent}}data-tooltip-content="{{ctx.Locale.Tr .TooltipContent}}"{{end}}>
{{svg (printf "octicon-%s" .Type.Icon) 16 (printf "text %s" (.HTMLTypeColorName))}} {{svg (printf "octicon-%s" .Type.Icon) 16 (printf "text %s" (.HTMLTypeColorName))}}
</span> </span>
@ -113,4 +98,29 @@
</div> </div>
{{end}} {{end}}
</div> </div>
{{if $data.CurrentPullReviewers}}
<div class="ui small modal" id="issue-sidebar-dismiss-review-modal">
<div class="header">
{{ctx.Locale.Tr "repo.issues.dismiss_review"}}
</div>
<div class="content">
<div class="ui warning message">
{{ctx.Locale.Tr "repo.issues.dismiss_review_warning"}}
</div>
<form class="ui form" action="{{$data.RepoLink}}/issues/dismiss_review" method="post">
{{ctx.RootData.CsrfTokenHtml}}
<input type="hidden" class="reviewer-id" name="review_id">
<div class="field">
<label for="issue-sidebar-dismiss-review-message">{{ctx.Locale.Tr "action.review_dismissed_reason"}}</label>
<input id="issue-sidebar-dismiss-review-message" name="message">
</div>
<div class="text right actions">
<button class="ui cancel button">{{ctx.Locale.Tr "settings.cancel"}}</button>
<button class="ui red button" type="submit">{{ctx.Locale.Tr "ok"}}</button>
</div>
</form>
</div>
</div>
{{end}}
</div> </div>

View file

@ -1,5 +1,5 @@
{{if and (or .HasIssuesOrPullsWritePermission .IsIssuePoster) (not .HasMerged) (not .Issue.IsClosed) (not .IsPullWorkInProgress)}} {{if and (or .HasIssuesOrPullsWritePermission .IsIssuePoster) (not .HasMerged) (not .Issue.IsClosed) (not .IsPullWorkInProgress)}}
<div class="toggle-wip" data-title="{{.Issue.Title}}" data-wip-prefix="{{index .PullRequestWorkInProgressPrefixes 0}}" data-update-url="{{.Issue.Link}}/title"> <div class="toggle-wip tw-mt-2" data-title="{{.Issue.Title}}" data-wip-prefix="{{index .PullRequestWorkInProgressPrefixes 0}}" data-update-url="{{.Issue.Link}}/title">
<a class="muted"> <a class="muted">
{{ctx.Locale.Tr "repo.pulls.still_in_progress"}} {{ctx.Locale.Tr "repo.pulls.add_prefix" (index .PullRequestWorkInProgressPrefixes 0)}} {{ctx.Locale.Tr "repo.pulls.still_in_progress"}} {{ctx.Locale.Tr "repo.pulls.add_prefix" (index .PullRequestWorkInProgressPrefixes 0)}}
</a> </a>

View file

@ -2,13 +2,12 @@
{{template "repo/issue/branch_selector_field" $}} {{template "repo/issue/branch_selector_field" $}}
{{if .Issue.IsPull}} {{if .Issue.IsPull}}
{{template "repo/issue/sidebar/reviewer_list" $}} {{template "repo/issue/sidebar/reviewer_list" $.IssueSidebarReviewersData}}
{{template "repo/issue/sidebar/wip_switch" $}} {{template "repo/issue/sidebar/wip_switch" $}}
<div class="divider"></div> <div class="divider"></div>
{{end}} {{end}}
{{template "repo/issue/labels/labels_selector_field" $}} {{template "repo/issue/sidebar/label_list" $.IssueSidebarLabelsData}}
{{template "repo/issue/labels/labels_sidebar" dict "root" $}}
{{template "repo/issue/sidebar/milestone_list" $}} {{template "repo/issue/sidebar/milestone_list" $}}
{{template "repo/issue/sidebar/project_list" $}} {{template "repo/issue/sidebar/project_list" $}}

View file

@ -20095,6 +20095,20 @@
"format": "int64", "format": "int64",
"x-go-name": "Milestone" "x-go-name": "Milestone"
}, },
"reviewers": {
"type": "array",
"items": {
"type": "string"
},
"x-go-name": "Reviewers"
},
"team_reviewers": {
"type": "array",
"items": {
"type": "string"
},
"x-go-name": "TeamReviewers"
},
"title": { "title": {
"type": "string", "type": "string",
"x-go-name": "Title" "x-go-name": "Title"

View file

@ -145,7 +145,8 @@ func TestPullRequestTargetEvent(t *testing.T) {
BaseRepo: baseRepo, BaseRepo: baseRepo,
Type: issues_model.PullRequestGitea, Type: issues_model.PullRequestGitea,
} }
err = pull_service.NewPullRequest(git.DefaultContext, baseRepo, pullIssue, nil, nil, pullRequest, nil) prOpts := &pull_service.NewPullRequestOptions{Repo: baseRepo, Issue: pullIssue, PullRequest: pullRequest}
err = pull_service.NewPullRequest(git.DefaultContext, prOpts)
assert.NoError(t, err) assert.NoError(t, err)
// load and compare ActionRun // load and compare ActionRun
@ -199,7 +200,8 @@ func TestPullRequestTargetEvent(t *testing.T) {
BaseRepo: baseRepo, BaseRepo: baseRepo,
Type: issues_model.PullRequestGitea, Type: issues_model.PullRequestGitea,
} }
err = pull_service.NewPullRequest(git.DefaultContext, baseRepo, pullIssue, nil, nil, pullRequest, nil) prOpts = &pull_service.NewPullRequestOptions{Repo: baseRepo, Issue: pullIssue, PullRequest: pullRequest}
err = pull_service.NewPullRequest(git.DefaultContext, prOpts)
assert.NoError(t, err) assert.NoError(t, err)
// the new pull request cannot trigger actions, so there is still only 1 record // the new pull request cannot trigger actions, so there is still only 1 record

View file

@ -11,6 +11,7 @@ import (
auth_model "code.gitea.io/gitea/models/auth" auth_model "code.gitea.io/gitea/models/auth"
"code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/db"
issues_model "code.gitea.io/gitea/models/issues" issues_model "code.gitea.io/gitea/models/issues"
access_model "code.gitea.io/gitea/models/perm/access"
repo_model "code.gitea.io/gitea/models/repo" repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unittest" "code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user" user_model "code.gitea.io/gitea/models/user"
@ -422,7 +423,9 @@ func TestAPIPullReviewStayDismissed(t *testing.T) {
pullIssue.ID, user8.ID, 1, 1, 2, false) pullIssue.ID, user8.ID, 1, 1, 2, false)
// user8 dismiss review // user8 dismiss review
_, err = issue_service.ReviewRequest(db.DefaultContext, pullIssue, user8, user8, false) permUser8, err := access_model.GetUserRepoPermission(db.DefaultContext, pullIssue.Repo, user8)
assert.NoError(t, err)
_, err = issue_service.ReviewRequest(db.DefaultContext, pullIssue, user8, &permUser8, user8, false)
assert.NoError(t, err) assert.NoError(t, err)
reviewsCountCheck(t, reviewsCountCheck(t,

View file

@ -520,7 +520,8 @@ func TestConflictChecking(t *testing.T) {
BaseRepo: baseRepo, BaseRepo: baseRepo,
Type: issues_model.PullRequestGitea, Type: issues_model.PullRequestGitea,
} }
err = pull.NewPullRequest(git.DefaultContext, baseRepo, pullIssue, nil, nil, pullRequest, nil) prOpts := &pull.NewPullRequestOptions{Repo: baseRepo, Issue: pullIssue, PullRequest: pullRequest}
err = pull.NewPullRequest(git.DefaultContext, prOpts)
assert.NoError(t, err) assert.NoError(t, err)
issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{Title: "PR with conflict!"}) issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{Title: "PR with conflict!"})

View file

@ -173,7 +173,8 @@ func createOutdatedPR(t *testing.T, actor, forkOrg *user_model.User) *issues_mod
BaseRepo: baseRepo, BaseRepo: baseRepo,
Type: issues_model.PullRequestGitea, Type: issues_model.PullRequestGitea,
} }
err = pull_service.NewPullRequest(git.DefaultContext, baseRepo, pullIssue, nil, nil, pullRequest, nil) prOpts := &pull_service.NewPullRequestOptions{Repo: baseRepo, Issue: pullIssue, PullRequest: pullRequest}
err = pull_service.NewPullRequest(git.DefaultContext, prOpts)
assert.NoError(t, err) assert.NoError(t, err)
issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{Title: "Test Pull -to-update-"}) issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{Title: "Test Pull -to-update-"})

View file

@ -1381,6 +1381,7 @@ table th[data-sortt-desc] .svg {
align-items: stretch; align-items: stretch;
} }
.ui.list.flex-items-block > .item,
.flex-items-block > .item, .flex-items-block > .item,
.flex-text-block { .flex-text-block {
display: flex; display: flex;

View file

@ -50,9 +50,20 @@
width: 300px; width: 300px;
} }
.issue-sidebar-combo .ui.dropdown .item:not(.checked) .item-check-mark {
visibility: hidden;
}
/* ideally, we should move these styles to ".ui.dropdown .menu.flex-items-menu > .item ...", could be done later */
.issue-sidebar-combo .ui.dropdown .menu > .item > img,
.issue-sidebar-combo .ui.dropdown .menu > .item > svg {
margin: 0;
}
.issue-content-right .dropdown > .menu { .issue-content-right .dropdown > .menu {
max-width: 270px; max-width: 270px;
min-width: 0; min-width: 0;
max-height: 500px;
overflow-x: auto;
} }
@media (max-width: 767.98px) { @media (max-width: 767.98px) {
@ -62,23 +73,6 @@
} }
} }
.repository .issue-content-right .ui.list .dependency {
padding: 0;
white-space: nowrap;
}
.repository .issue-content-right .ui.list .title {
overflow: hidden;
text-overflow: ellipsis;
}
.repository .issue-content-right #deadlineForm input {
width: 12.8rem;
border-radius: var(--border-radius) 0 0 var(--border-radius);
border-right: 0;
white-space: nowrap;
}
.repository .issue-content-right .filter.menu { .repository .issue-content-right .filter.menu {
max-height: 500px; max-height: 500px;
overflow-x: auto; overflow-x: auto;
@ -118,10 +112,6 @@
left: 0; left: 0;
} }
.repository .select-label .desc {
padding-left: 23px;
}
/* For the secondary pointing menu, respect its own border-bottom */ /* For the secondary pointing menu, respect its own border-bottom */
/* style reference: https://semantic-ui.com/collections/menu.html#pointing */ /* style reference: https://semantic-ui.com/collections/menu.html#pointing */
.repository .ui.tabs.container .ui.menu:not(.secondary.pointing) { .repository .ui.tabs.container .ui.menu:not(.secondary.pointing) {

View file

@ -47,6 +47,7 @@
} }
.archived-label-hint { .archived-label-hint {
float: right; position: absolute;
margin: -12px; top: 10px;
right: 5px;
} }

View file

@ -32,13 +32,13 @@ export function initGlobalDropdown() {
const $uiDropdowns = fomanticQuery('.ui.dropdown'); const $uiDropdowns = fomanticQuery('.ui.dropdown');
// do not init "custom" dropdowns, "custom" dropdowns are managed by their own code. // do not init "custom" dropdowns, "custom" dropdowns are managed by their own code.
$uiDropdowns.filter(':not(.custom)').dropdown(); $uiDropdowns.filter(':not(.custom)').dropdown({hideDividers: 'empty'});
// The "jump" means this dropdown is mainly used for "menu" purpose, // The "jump" means this dropdown is mainly used for "menu" purpose,
// clicking an item will jump to somewhere else or trigger an action/function. // clicking an item will jump to somewhere else or trigger an action/function.
// When a dropdown is used for non-refresh actions with tippy, // When a dropdown is used for non-refresh actions with tippy,
// it must have this "jump" class to hide the tippy when dropdown is closed. // it must have this "jump" class to hide the tippy when dropdown is closed.
$uiDropdowns.filter('.jump').dropdown({ $uiDropdowns.filter('.jump').dropdown('setting', {
action: 'hide', action: 'hide',
onShow() { onShow() {
// hide associated tooltip while dropdown is open // hide associated tooltip while dropdown is open

View file

@ -0,0 +1,105 @@
import {fomanticQuery} from '../modules/fomantic/base.ts';
import {POST} from '../modules/fetch.ts';
import {queryElemChildren, queryElems, toggleElem} from '../utils/dom.ts';
// if there are draft comments, confirm before reloading, to avoid losing comments
export function issueSidebarReloadConfirmDraftComment() {
const commentTextareas = [
document.querySelector<HTMLTextAreaElement>('.edit-content-zone:not(.tw-hidden) textarea'),
document.querySelector<HTMLTextAreaElement>('#comment-form textarea'),
];
for (const textarea of commentTextareas) {
// Most users won't feel too sad if they lose a comment with 10 chars, they can re-type these in seconds.
// But if they have typed more (like 50) chars and the comment is lost, they will be very unhappy.
if (textarea && textarea.value.trim().length > 10) {
textarea.parentElement.scrollIntoView();
if (!window.confirm('Page will be reloaded, but there are draft comments. Continuing to reload will discard the comments. Continue?')) {
return;
}
break;
}
}
window.location.reload();
}
function collectCheckedValues(elDropdown: HTMLElement) {
return Array.from(elDropdown.querySelectorAll('.menu > .item.checked'), (el) => el.getAttribute('data-value'));
}
export function initIssueSidebarComboList(container: HTMLElement) {
const updateUrl = container.getAttribute('data-update-url');
const elDropdown = container.querySelector<HTMLElement>(':scope > .ui.dropdown');
const elList = container.querySelector<HTMLElement>(':scope > .ui.list');
const elComboValue = container.querySelector<HTMLInputElement>(':scope > .combo-value');
let initialValues = collectCheckedValues(elDropdown);
elDropdown.addEventListener('click', (e) => {
const elItem = (e.target as HTMLElement).closest('.item');
if (!elItem) return;
e.preventDefault();
if (elItem.hasAttribute('data-can-change') && elItem.getAttribute('data-can-change') !== 'true') return;
if (elItem.matches('.clear-selection')) {
queryElems(elDropdown, '.menu > .item', (el) => el.classList.remove('checked'));
elComboValue.value = '';
return;
}
const scope = elItem.getAttribute('data-scope');
if (scope) {
// scoped items could only be checked one at a time
const elSelected = elDropdown.querySelector<HTMLElement>(`.menu > .item.checked[data-scope="${CSS.escape(scope)}"]`);
if (elSelected === elItem) {
elItem.classList.toggle('checked');
} else {
queryElems(elDropdown, `.menu > .item[data-scope="${CSS.escape(scope)}"]`, (el) => el.classList.remove('checked'));
elItem.classList.toggle('checked', true);
}
} else {
elItem.classList.toggle('checked');
}
elComboValue.value = collectCheckedValues(elDropdown).join(',');
});
const updateToBackend = async (changedValues) => {
let changed = false;
for (const value of initialValues) {
if (!changedValues.includes(value)) {
await POST(updateUrl, {data: new URLSearchParams({action: 'detach', id: value})});
changed = true;
}
}
for (const value of changedValues) {
if (!initialValues.includes(value)) {
await POST(updateUrl, {data: new URLSearchParams({action: 'attach', id: value})});
changed = true;
}
}
if (changed) issueSidebarReloadConfirmDraftComment();
};
const syncUiList = (changedValues) => {
const elEmptyTip = elList.querySelector('.item.empty-list');
queryElemChildren(elList, '.item:not(.empty-list)', (el) => el.remove());
for (const value of changedValues) {
const el = elDropdown.querySelector<HTMLElement>(`.menu > .item[data-value="${CSS.escape(value)}"]`);
const listItem = el.cloneNode(true) as HTMLElement;
queryElems(listItem, '.item-check-mark, .item-secondary-info', (el) => el.remove());
elList.append(listItem);
}
const hasItems = Boolean(elList.querySelector('.item:not(.empty-list)'));
toggleElem(elEmptyTip, !hasItems);
};
fomanticQuery(elDropdown).dropdown('setting', {
action: 'nothing', // do not hide the menu if user presses Enter
fullTextSearch: 'exact',
async onHide() {
// TODO: support "Esc" to cancel the selection. Use partial page loading to avoid losing inputs.
const changedValues = collectCheckedValues(elDropdown);
syncUiList(changedValues);
if (updateUrl) await updateToBackend(changedValues);
initialValues = changedValues;
},
});
}

View file

@ -0,0 +1,27 @@
A sidebar combo (dropdown+list) is like this:
```html
<div class="issue-sidebar-combo" data-update-url="...">
<input class="combo-value" name="..." type="hidden" value="...">
<div class="ui dropdown">
<div class="menu">
<div class="item clear-selection">clear</div>
<div class="item" data-value="..." data-scope="...">
<span class="item-check-mark">...</span>
...
</div>
</div>
</div>
<div class="ui list">
<span class="item empty-list">no item</span>
<span class="item">...</span>
</div>
</div>
```
When the selected items change, the `combo-value` input will be updated.
If there is `data-update-url`, it also calls backend to attach/detach the changed items.
Also, the changed items will be syncronized to the `ui list` items.
The items with the same data-scope only allow one selected at a time.

View file

@ -3,27 +3,8 @@ import {POST} from '../modules/fetch.ts';
import {updateIssuesMeta} from './repo-common.ts'; import {updateIssuesMeta} from './repo-common.ts';
import {svg} from '../svg.ts'; import {svg} from '../svg.ts';
import {htmlEscape} from 'escape-goat'; import {htmlEscape} from 'escape-goat';
import {toggleElem} from '../utils/dom.ts'; import {queryElems, toggleElem} from '../utils/dom.ts';
import {initIssueSidebarComboList, issueSidebarReloadConfirmDraftComment} from './repo-issue-sidebar-combolist.ts';
// if there are draft comments, confirm before reloading, to avoid losing comments
function reloadConfirmDraftComment() {
const commentTextareas = [
document.querySelector('.edit-content-zone:not(.tw-hidden) textarea'),
document.querySelector('#comment-form textarea'),
];
for (const textarea of commentTextareas) {
// Most users won't feel too sad if they lose a comment with 10 chars, they can re-type these in seconds.
// But if they have typed more (like 50) chars and the comment is lost, they will be very unhappy.
if (textarea && textarea.value.trim().length > 10) {
textarea.parentElement.scrollIntoView();
if (!window.confirm('Page will be reloaded, but there are draft comments. Continuing to reload will discard the comments. Continue?')) {
return;
}
break;
}
}
window.location.reload();
}
function initBranchSelector() { function initBranchSelector() {
const elSelectBranch = document.querySelector('.ui.dropdown.select-branch'); const elSelectBranch = document.querySelector('.ui.dropdown.select-branch');
@ -47,7 +28,7 @@ function initBranchSelector() {
} else { } else {
// for new issue, only update UI&form, do not send request/reload // for new issue, only update UI&form, do not send request/reload
const selectedHiddenSelector = this.getAttribute('data-id-selector'); const selectedHiddenSelector = this.getAttribute('data-id-selector');
document.querySelector(selectedHiddenSelector).value = selectedValue; document.querySelector<HTMLInputElement>(selectedHiddenSelector).value = selectedValue;
elSelectBranch.querySelector('.text-branch-name').textContent = selectedText; elSelectBranch.querySelector('.text-branch-name').textContent = selectedText;
} }
}); });
@ -72,13 +53,13 @@ function initListSubmits(selector, outerSelector) {
for (const [elementId, item] of itemEntries) { for (const [elementId, item] of itemEntries) {
await updateIssuesMeta( await updateIssuesMeta(
item['update-url'], item['update-url'],
item.action, item['action'],
item['issue-id'], item['issue-id'],
elementId, elementId,
); );
} }
if (itemEntries.length) { if (itemEntries.length) {
reloadConfirmDraftComment(); issueSidebarReloadConfirmDraftComment();
} }
} }
}, },
@ -99,14 +80,14 @@ function initListSubmits(selector, outerSelector) {
if (scope) { if (scope) {
// Enable only clicked item for scoped labels // Enable only clicked item for scoped labels
if (this.getAttribute('data-scope') !== scope) { if (this.getAttribute('data-scope') !== scope) {
return true; return;
} }
if (this !== clickedItem && !this.classList.contains('checked')) { if (this !== clickedItem && !this.classList.contains('checked')) {
return true; return;
} }
} else if (this !== clickedItem) { } else if (this !== clickedItem) {
// Toggle for other labels // Toggle for other labels
return true; return;
} }
if (this.classList.contains('checked')) { if (this.classList.contains('checked')) {
@ -142,7 +123,7 @@ function initListSubmits(selector, outerSelector) {
// TODO: Which thing should be done for choosing review requests // TODO: Which thing should be done for choosing review requests
// to make chosen items be shown on time here? // to make chosen items be shown on time here?
if (selector === 'select-reviewers-modify' || selector === 'select-assignees-modify') { if (selector === 'select-assignees-modify') {
return false; return false;
} }
@ -173,7 +154,7 @@ function initListSubmits(selector, outerSelector) {
$listMenu.data('issue-id'), $listMenu.data('issue-id'),
'', '',
); );
reloadConfirmDraftComment(); issueSidebarReloadConfirmDraftComment();
})(); })();
} }
@ -182,7 +163,7 @@ function initListSubmits(selector, outerSelector) {
$(this).find('.octicon-check').addClass('tw-invisible'); $(this).find('.octicon-check').addClass('tw-invisible');
}); });
if (selector === 'select-reviewers-modify' || selector === 'select-assignees-modify') { if (selector === 'select-assignees-modify') {
return false; return false;
} }
@ -213,7 +194,7 @@ function selectItem(select_id, input_id) {
$menu.data('issue-id'), $menu.data('issue-id'),
$(this).data('id'), $(this).data('id'),
); );
reloadConfirmDraftComment(); issueSidebarReloadConfirmDraftComment();
})(); })();
} }
@ -249,7 +230,7 @@ function selectItem(select_id, input_id) {
$menu.data('issue-id'), $menu.data('issue-id'),
$(this).data('id'), $(this).data('id'),
); );
reloadConfirmDraftComment(); issueSidebarReloadConfirmDraftComment();
})(); })();
} }
@ -276,14 +257,14 @@ export function initRepoIssueSidebar() {
initBranchSelector(); initBranchSelector();
initRepoIssueDue(); initRepoIssueDue();
// Init labels and assignees // TODO: refactor the legacy initListSubmits&selectItem to initIssueSidebarComboList
initListSubmits('select-label', 'labels');
initListSubmits('select-assignees', 'assignees'); initListSubmits('select-assignees', 'assignees');
initListSubmits('select-assignees-modify', 'assignees'); initListSubmits('select-assignees-modify', 'assignees');
initListSubmits('select-reviewers-modify', 'assignees'); selectItem('.select-assignee', '#assignee_id');
// Milestone, Assignee, Project
selectItem('.select-project', '#project_id'); selectItem('.select-project', '#project_id');
selectItem('.select-milestone', '#milestone_id'); selectItem('.select-milestone', '#milestone_id');
selectItem('.select-assignee', '#assignee_id');
// init the combo list: a dropdown for selecting items, and a list for showing selected items and related actions
queryElems<HTMLElement>(document, '.issue-sidebar-combo', (el) => initIssueSidebarComboList(el));
} }

View file

@ -8,7 +8,6 @@ import {parseIssuePageInfo, toAbsoluteUrl} from '../utils.ts';
import {GET, POST} from '../modules/fetch.ts'; import {GET, POST} from '../modules/fetch.ts';
import {showErrorToast} from '../modules/toast.ts'; import {showErrorToast} from '../modules/toast.ts';
import {initRepoIssueSidebar} from './repo-issue-sidebar.ts'; import {initRepoIssueSidebar} from './repo-issue-sidebar.ts';
import {updateIssuesMeta} from './repo-common.ts';
const {appSubUrl} = window.config; const {appSubUrl} = window.config;
@ -99,6 +98,7 @@ export function initRepoIssueSidebarList() {
}); });
}); });
// FIXME: it is wrong place to init ".ui.dropdown.label-filter"
$('.menu .ui.dropdown.label-filter').on('keydown', (e) => { $('.menu .ui.dropdown.label-filter').on('keydown', (e) => {
if (e.altKey && e.key === 'Enter') { if (e.altKey && e.key === 'Enter') {
const selectedItem = document.querySelector('.menu .ui.dropdown.label-filter .menu .item.selected'); const selectedItem = document.querySelector('.menu .ui.dropdown.label-filter .menu .item.selected');
@ -107,7 +107,6 @@ export function initRepoIssueSidebarList() {
} }
} }
}); });
$('.ui.dropdown.label-filter, .ui.dropdown.select-label').dropdown('setting', {'hideDividers': 'empty'}).dropdown('refreshItems');
} }
export function initRepoIssueCommentDelete() { export function initRepoIssueCommentDelete() {
@ -326,17 +325,6 @@ export function initRepoIssueWipTitle() {
export function initRepoIssueComments() { export function initRepoIssueComments() {
if (!$('.repository.view.issue .timeline').length) return; if (!$('.repository.view.issue .timeline').length) return;
$('.re-request-review').on('click', async function (e) {
e.preventDefault();
const url = this.getAttribute('data-update-url');
const issueId = this.getAttribute('data-issue-id');
const id = this.getAttribute('data-id');
const isChecked = this.classList.contains('checked');
await updateIssuesMeta(url, isChecked ? 'detach' : 'attach', issueId, id);
window.location.reload();
});
document.addEventListener('click', (e) => { document.addEventListener('click', (e) => {
const urlTarget = document.querySelector(':target'); const urlTarget = document.querySelector(':target');
if (!urlTarget) return; if (!urlTarget) return;
@ -664,19 +652,6 @@ function initIssueTemplateCommentEditors($commentForm) {
} }
} }
// This function used to show and hide archived label on issue/pr
// page in the sidebar where we select the labels
// If we have any archived label tagged to issue and pr. We will show that
// archived label with checked classed otherwise we will hide it
// with the help of this function.
// This function runs globally.
export function initArchivedLabelHandler() {
if (!document.querySelector('.archived-label-hint')) return;
for (const label of document.querySelectorAll('[data-is-archived]')) {
toggleElem(label, label.classList.contains('checked'));
}
}
export function initRepoCommentFormAndSidebar() { export function initRepoCommentFormAndSidebar() {
const $commentForm = $('.comment.form'); const $commentForm = $('.comment.form');
if (!$commentForm.length) return; if (!$commentForm.length) return;

View file

@ -30,7 +30,7 @@ import {
initRepoIssueWipTitle, initRepoIssueWipTitle,
initRepoPullRequestMergeInstruction, initRepoPullRequestMergeInstruction,
initRepoPullRequestAllowMaintainerEdit, initRepoPullRequestAllowMaintainerEdit,
initRepoPullRequestReview, initRepoIssueSidebarList, initArchivedLabelHandler, initRepoPullRequestReview, initRepoIssueSidebarList,
} from './features/repo-issue.ts'; } from './features/repo-issue.ts';
import {initRepoEllipsisButton, initCommitStatuses} from './features/repo-commit.ts'; import {initRepoEllipsisButton, initCommitStatuses} from './features/repo-commit.ts';
import {initRepoTopicBar} from './features/repo-home.ts'; import {initRepoTopicBar} from './features/repo-home.ts';
@ -182,7 +182,6 @@ onDomReady(() => {
initRepoIssueContentHistory, initRepoIssueContentHistory,
initRepoIssueList, initRepoIssueList,
initRepoIssueSidebarList, initRepoIssueSidebarList,
initArchivedLabelHandler,
initRepoIssueReferenceRepositorySearch, initRepoIssueReferenceRepositorySearch,
initRepoIssueTimeTracking, initRepoIssueTimeTracking,
initRepoIssueWipTitle, initRepoIssueWipTitle,

View file

@ -57,10 +57,21 @@ export async function renderMermaid() {
btn.setAttribute('data-clipboard-text', source); btn.setAttribute('data-clipboard-text', source);
mermaidBlock.append(btn); mermaidBlock.append(btn);
const updateIframeHeight = () => {
iframe.style.height = `${iframe.contentWindow.document.body.clientHeight}px`;
};
// update height when element's visibility state changes, for example when the diagram is inside
// a <details> + <summary> block and the <details> block becomes visible upon user interaction, it
// would initially set a incorrect height and the correct height is set during this callback.
(new IntersectionObserver(() => {
updateIframeHeight();
}, {root: document.documentElement})).observe(iframe);
iframe.addEventListener('load', () => { iframe.addEventListener('load', () => {
pre.replaceWith(mermaidBlock); pre.replaceWith(mermaidBlock);
mermaidBlock.classList.remove('tw-hidden'); mermaidBlock.classList.remove('tw-hidden');
iframe.style.height = `${iframe.contentWindow.document.body.clientHeight}px`; updateIframeHeight();
setTimeout(() => { // avoid flash of iframe background setTimeout(() => { // avoid flash of iframe background
mermaidBlock.classList.remove('is-loading'); mermaidBlock.classList.remove('is-loading');
iframe.classList.remove('tw-invisible'); iframe.classList.remove('tw-invisible');

View file

@ -3,7 +3,7 @@ import type {Promisable} from 'type-fest';
import type $ from 'jquery'; import type $ from 'jquery';
type ElementArg = Element | string | NodeListOf<Element> | Array<Element> | ReturnType<typeof $>; type ElementArg = Element | string | NodeListOf<Element> | Array<Element> | ReturnType<typeof $>;
type ElementsCallback = (el: Element) => Promisable<any>; type ElementsCallback<T extends Element> = (el: T) => Promisable<any>;
type ElementsCallbackWithArgs = (el: Element, ...args: any[]) => Promisable<any>; type ElementsCallbackWithArgs = (el: Element, ...args: any[]) => Promisable<any>;
type ArrayLikeIterable<T> = ArrayLike<T> & Iterable<T>; // for NodeListOf and Array type ArrayLikeIterable<T> = ArrayLike<T> & Iterable<T>; // for NodeListOf and Array
@ -58,7 +58,7 @@ export function isElemHidden(el: ElementArg) {
return res[0]; return res[0];
} }
function applyElemsCallback<T extends Element>(elems: ArrayLikeIterable<T>, fn?: ElementsCallback): ArrayLikeIterable<T> { function applyElemsCallback<T extends Element>(elems: ArrayLikeIterable<T>, fn?: ElementsCallback<T>): ArrayLikeIterable<T> {
if (fn) { if (fn) {
for (const el of elems) { for (const el of elems) {
fn(el); fn(el);
@ -67,7 +67,7 @@ function applyElemsCallback<T extends Element>(elems: ArrayLikeIterable<T>, fn?:
return elems; return elems;
} }
export function queryElemSiblings<T extends Element>(el: Element, selector = '*', fn?: ElementsCallback): ArrayLikeIterable<T> { export function queryElemSiblings<T extends Element>(el: Element, selector = '*', fn?: ElementsCallback<T>): ArrayLikeIterable<T> {
const elems = Array.from(el.parentNode.children) as T[]; const elems = Array.from(el.parentNode.children) as T[];
return applyElemsCallback<T>(elems.filter((child: Element) => { return applyElemsCallback<T>(elems.filter((child: Element) => {
return child !== el && child.matches(selector); return child !== el && child.matches(selector);
@ -75,13 +75,13 @@ export function queryElemSiblings<T extends Element>(el: Element, selector = '*'
} }
// it works like jQuery.children: only the direct children are selected // it works like jQuery.children: only the direct children are selected
export function queryElemChildren<T extends Element>(parent: Element | ParentNode, selector = '*', fn?: ElementsCallback): ArrayLikeIterable<T> { export function queryElemChildren<T extends Element>(parent: Element | ParentNode, selector = '*', fn?: ElementsCallback<T>): ArrayLikeIterable<T> {
return applyElemsCallback<T>(parent.querySelectorAll(`:scope > ${selector}`), fn); return applyElemsCallback<T>(parent.querySelectorAll(`:scope > ${selector}`), fn);
} }
// it works like parent.querySelectorAll: all descendants are selected // it works like parent.querySelectorAll: all descendants are selected
// in the future, all "queryElems(document, ...)" should be refactored to use a more specific parent // in the future, all "queryElems(document, ...)" should be refactored to use a more specific parent
export function queryElems<T extends Element>(parent: Element | ParentNode, selector: string, fn?: ElementsCallback): ArrayLikeIterable<T> { export function queryElems<T extends Element>(parent: Element | ParentNode, selector: string, fn?: ElementsCallback<T>): ArrayLikeIterable<T> {
return applyElemsCallback<T>(parent.querySelectorAll(selector), fn); return applyElemsCallback<T>(parent.querySelectorAll(selector), fn);
} }