Files
gitea/routers/web/repo/issue_view.go
T
Zettat123 f46c9a9769 feat(actions): support owner-level and global scoped workflows (#38154)
## Summary

This PR adds **scoped workflows** to Gitea Actions. Workflows defined
centrally in a "source" repository that automatically run on every
repository in scope: an organization's repositories, or (for instance
admins) every repository on the instance. Each scoped run executes in
the consuming repository's own context (its runners, secrets, and
branch) while its content is read from the source repository, so an org
or instance can mandate shared CI across many repositories without
copying workflow files into each one.

An owner or instance admin registers source repositories on a settings
page and can mark individual workflows as **required**. A required
scoped workflow cannot be opted out by a consuming repository and gates
its pull-request merges; an optional one can be disabled per repository.
Scoped workflows live under a dedicated `SCOPED_WORKFLOW_DIRS` (default
`.gitea/scoped_workflows`), kept separate from regular `WORKFLOW_DIRS`.

## Main changes

### Configuration 
New `SCOPED_WORKFLOW_DIRS` setting, validated to not overlap with
`WORKFLOW_DIRS`. Default: `.gitea/scoped_workflows`

### Data model & migration
- New `action_scoped_workflow_source` table mapping a registering owner
(`owner_id`, where `0` = instance-level) to a source repository, with a
per-workflow `WorkflowConfigs` map.
- `ActionRun` gains `WorkflowRepoID` / `WorkflowCommitSHA` (the pinned
content source) and an `IsScopedRun` flag.

###  Detection & run creation
On consumer events, scoped workflows from the effective sources (the
owner's own sources plus instance-level ones) are matched and turned
into runs that execute in the consumer's context, with content pinned to
the source repo's default-branch commit.

`on: workflow_run` and `on: schedule` are currently not supported.

###  Opt-out
A consuming repository can disable an optional scoped workflow (tracked
separately from regular `DisabledWorkflows`); required scoped workflows
can never be disabled, opted out, or bypassed.

###  Commit status 
A scoped run's status context format is `"<source repo full name>:
<workflow display name> / <job> (<event>)"`
(for example: `my-org/scoped-workflows: db-tests / test-sqlite
(pull_request)`),
keeping it distinct from a same-named repo-level workflow and from other
sources.

###  Required status checks
Admins mark workflows required and supply status-check patterns.
`EffectiveRequiredContexts` appends those patterns to the branch
protection's required contexts and they are matched
must-present-and-pass. If the status checks from scoped workflows fail,
the PR cannot be merged.

NOTE: scoped workflows' required status checks patterns can protect any
target branch that has a protection rule, even though the rule's "Status
Check" is disabled. A target branch with no protection rule cannot be
protected.

<details>
  <summary>Screenshots</summary>

<img width="1400" alt="image"
src="https://github.com/user-attachments/assets/a5d1db33-15ec-487e-93be-2bc04b4e6643"
/>

</details>


###  Reusable workflows (`uses:`)
A scoped workflow's local `uses: ./...` resolves against the source
repository. `uses:` directory validation honors the
instance-configurable `WORKFLOW_DIRS` and `SCOPED_WORKFLOW_DIRS`
(previously hardcoded to `.gitea`/`.github/workflows`).

###  Manual dispatch
`workflow_dispatch` is supported for scoped workflows (web and API),
resolving inputs/content from the source repo.

###  Performance
A process-local LRU cache keyed by source repo ID for the per-source
workflow parse, so instance-level and owner-level sources don't open the
source repo and parse workflow files on every event.

### UI
Org / user / admin pages to register and remove sources, search
repositories, and mark workflows required with their status-check
patterns. The repository Actions sidebar groups scoped workflows by
source with owner/instance labels and required/disabled badges.

<details>
  <summary>Screenshots</summary>

Scoped workflows setting page:

<img width="1600" alt="image"
src="https://github.com/user-attachments/assets/9d19f667-97a5-4935-92b2-e53f105e3642"
/>


Consumer repo's Actions runs list:

<img width="1600" alt="image"
src="https://github.com/user-attachments/assets/a77241f9-0aa9-41aa-ba73-12a9a688cb64"
/>

- `Owner`: this is a owner-level scoped workflows source repo
- `Global`: this is a global scoped workflows source repo
- `Required`: this scoped workflow is required, repo admin cannot
disable it

</details>

---

Docs: https://gitea.com/gitea/docs/pulls/447

---------

Co-authored-by: bircni <bircni@icloud.com>
2026-06-28 09:31:35 +00:00

1101 lines
36 KiB
Go

// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package repo
import (
"errors"
"fmt"
"math/big"
"net/http"
"sort"
"strconv"
activities_model "gitea.dev/models/activities"
asymkey_model "gitea.dev/models/asymkey"
"gitea.dev/models/db"
git_model "gitea.dev/models/git"
issues_model "gitea.dev/models/issues"
"gitea.dev/models/organization"
access_model "gitea.dev/models/perm/access"
project_model "gitea.dev/models/project"
"gitea.dev/models/renderhelper"
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unit"
user_model "gitea.dev/models/user"
"gitea.dev/modules/emoji"
"gitea.dev/modules/git"
"gitea.dev/modules/log"
"gitea.dev/modules/markup"
"gitea.dev/modules/markup/markdown"
"gitea.dev/modules/setting"
"gitea.dev/modules/svg"
"gitea.dev/modules/templates/vars"
"gitea.dev/modules/util"
"gitea.dev/modules/web/middleware"
asymkey_service "gitea.dev/services/asymkey"
"gitea.dev/services/context"
"gitea.dev/services/context/upload"
issue_service "gitea.dev/services/issue"
pull_service "gitea.dev/services/pull"
user_service "gitea.dev/services/user"
)
// roleDescriptor returns the role descriptor for a comment in/with the given repo, poster and issue
func roleDescriptor(ctx *context.Context, repo *repo_model.Repository, poster *user_model.User, permsCache map[int64]access_model.Permission, issue *issues_model.Issue, hasOriginalAuthor bool) (roleDesc issues_model.RoleDescriptor, err error) {
if hasOriginalAuthor {
// the poster is a migrated user, so no need to detect the role
return roleDesc, nil
}
if poster.IsGhost() || !poster.IsIndividual() {
return roleDesc, nil
}
roleDesc.IsPoster = issue.IsPoster(poster.ID) // check whether the comment's poster is the issue's poster
// Guess the role of the poster in the repo by permission
perm, hasPermCache := permsCache[poster.ID]
if !hasPermCache {
perm, err = access_model.GetIndividualUserRepoPermission(ctx, repo, poster)
if err != nil {
return roleDesc, err
}
}
if permsCache != nil {
permsCache[poster.ID] = perm
}
// Check if the poster is owner of the repo.
if perm.IsOwner() {
// If the poster isn't a site admin, then is must be the repo's owner
if !poster.IsAdmin {
roleDesc.RoleInRepo = issues_model.RoleRepoOwner
return roleDesc, nil
}
// Otherwise (poster is site admin), check if poster is the real repo admin.
isRealRepoAdmin, err := access_model.IsUserRealRepoAdmin(ctx, repo, poster)
if err != nil {
return roleDesc, err
}
if isRealRepoAdmin {
roleDesc.RoleInRepo = issues_model.RoleRepoOwner
return roleDesc, nil
}
}
// If repo is organization, check Member role
if err = repo.LoadOwner(ctx); err != nil {
return roleDesc, err
}
if repo.Owner.IsOrganization() {
if isMember, err := organization.IsOrganizationMember(ctx, repo.Owner.ID, poster.ID); err != nil {
return roleDesc, err
} else if isMember {
roleDesc.RoleInRepo = issues_model.RoleRepoMember
return roleDesc, nil
}
}
// If the poster is the collaborator of the repo
if isCollaborator, err := repo_model.IsCollaborator(ctx, repo.ID, poster.ID); err != nil {
return roleDesc, err
} else if isCollaborator {
roleDesc.RoleInRepo = issues_model.RoleRepoCollaborator
return roleDesc, nil
}
hasMergedPR, err := issues_model.HasMergedPullRequestInRepo(ctx, repo.ID, poster.ID)
if err != nil {
return roleDesc, err
} else if hasMergedPR {
roleDesc.RoleInRepo = issues_model.RoleRepoContributor
} else if issue.IsPull {
// only display first time contributor in the first opening pull request
roleDesc.RoleInRepo = issues_model.RoleRepoFirstTimeContributor
}
return roleDesc, nil
}
// checkBlockedByIssues return canRead and notPermitted
func checkBlockedByIssues(ctx *context.Context, blockers []*issues_model.DependencyInfo) (canRead, notPermitted []*issues_model.DependencyInfo) {
repoPerms := make(map[int64]access_model.Permission)
repoPerms[ctx.Repo.Repository.ID] = ctx.Repo.Permission
for _, blocker := range blockers {
// Get the permissions for this repository
// If the repo ID exists in the map, return the exist permissions
// else get the permission and add it to the map
var perm access_model.Permission
existPerm, ok := repoPerms[blocker.RepoID]
if ok {
perm = existPerm
} else {
var err error
perm, err = access_model.GetDoerRepoPermission(ctx, &blocker.Repository, ctx.Doer)
if err != nil {
ctx.ServerError("GetDoerRepoPermission", err)
return nil, nil
}
repoPerms[blocker.RepoID] = perm
}
if perm.CanReadIssuesOrPulls(blocker.Issue.IsPull) {
canRead = append(canRead, blocker)
} else {
notPermitted = append(notPermitted, blocker)
}
}
sortDependencyInfo(canRead)
sortDependencyInfo(notPermitted)
return canRead, notPermitted
}
func sortDependencyInfo(blockers []*issues_model.DependencyInfo) {
sort.Slice(blockers, func(i, j int) bool {
if blockers[i].RepoID == blockers[j].RepoID {
return blockers[i].Issue.CreatedUnix < blockers[j].Issue.CreatedUnix
}
return blockers[i].RepoID < blockers[j].RepoID
})
}
func addParticipant(poster *user_model.User, participants []*user_model.User) []*user_model.User {
for _, part := range participants {
if poster.ID == part.ID {
return participants
}
}
return append(participants, poster)
}
func filterXRefComments(ctx *context.Context, issue *issues_model.Issue) error {
// Remove comments that the user has no permissions to see
for i := 0; i < len(issue.Comments); {
c := issue.Comments[i]
if issues_model.CommentTypeIsRef(c.Type) && c.RefRepoID != issue.RepoID && c.RefRepoID != 0 {
var err error
// Set RefRepo for description in template
c.RefRepo, err = repo_model.GetRepositoryByID(ctx, c.RefRepoID)
if err != nil {
return err
}
perm, err := access_model.GetDoerRepoPermission(ctx, c.RefRepo, ctx.Doer)
if err != nil {
return err
}
if !perm.CanReadIssuesOrPulls(c.RefIsPull) {
issue.Comments = append(issue.Comments[:i], issue.Comments[i+1:]...)
continue
}
}
i++
}
return nil
}
// combineLabelComments combine the nearby label comments as one.
func combineLabelComments(issue *issues_model.Issue) {
var prev, cur *issues_model.Comment
for i := 0; i < len(issue.Comments); i++ {
cur = issue.Comments[i]
if i > 0 {
prev = issue.Comments[i-1]
}
if i == 0 || cur.Type != issues_model.CommentTypeLabel ||
(prev != nil && prev.PosterID != cur.PosterID) ||
(prev != nil && cur.CreatedUnix-prev.CreatedUnix >= 60) {
if cur.Type == issues_model.CommentTypeLabel && cur.Label != nil {
if cur.Content != "1" {
cur.RemovedLabels = append(cur.RemovedLabels, cur.Label)
} else {
cur.AddedLabels = append(cur.AddedLabels, cur.Label)
}
}
continue
}
if cur.Label != nil { // now cur MUST be label comment
if prev.Type == issues_model.CommentTypeLabel { // we can combine them only prev is a label comment
if cur.Content != "1" {
// remove labels from the AddedLabels list if the label that was removed is already
// in this list, and if it's not in this list, add the label to RemovedLabels
addedAndRemoved := false
for i, label := range prev.AddedLabels {
if cur.Label.ID == label.ID {
prev.AddedLabels = append(prev.AddedLabels[:i], prev.AddedLabels[i+1:]...)
addedAndRemoved = true
break
}
}
if !addedAndRemoved {
prev.RemovedLabels = append(prev.RemovedLabels, cur.Label)
}
} else {
// remove labels from the RemovedLabels list if the label that was added is already
// in this list, and if it's not in this list, add the label to AddedLabels
removedAndAdded := false
for i, label := range prev.RemovedLabels {
if cur.Label.ID == label.ID {
prev.RemovedLabels = append(prev.RemovedLabels[:i], prev.RemovedLabels[i+1:]...)
removedAndAdded = true
break
}
}
if !removedAndAdded {
prev.AddedLabels = append(prev.AddedLabels, cur.Label)
}
}
prev.CreatedUnix = cur.CreatedUnix
// remove the current comment since it has been combined to prev comment
issue.Comments = append(issue.Comments[:i], issue.Comments[i+1:]...)
i--
} else { // if prev is not a label comment, start a new group
if cur.Content != "1" {
cur.RemovedLabels = append(cur.RemovedLabels, cur.Label)
} else {
cur.AddedLabels = append(cur.AddedLabels, cur.Label)
}
}
}
}
}
func prepareIssueViewLoad(ctx *context.Context) *issues_model.Issue {
issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("index"))
if err != nil {
ctx.NotFoundOrServerError("GetIssueByIndex", issues_model.IsErrIssueNotExist, err)
return nil
}
issue.Repo = ctx.Repo.Repository
ctx.Data["Issue"] = issue
if err = issue.LoadPullRequest(ctx); err != nil {
ctx.ServerError("LoadPullRequest", err)
return nil
}
return issue
}
func handleViewIssueRedirectExternal(ctx *context.Context) {
if ctx.PathParam("type") == "issues" {
// If issue was requested we check if repo has external tracker and redirect
extIssueUnit, err := ctx.Repo.Repository.GetUnit(ctx, unit.TypeExternalTracker)
if err == nil && extIssueUnit != nil {
if extIssueUnit.ExternalTrackerConfig().ExternalTrackerStyle == markup.IssueNameStyleNumeric || extIssueUnit.ExternalTrackerConfig().ExternalTrackerStyle == "" {
metas := ctx.Repo.Repository.ComposeCommentMetas(ctx)
metas["index"] = ctx.PathParam("index")
res, err := vars.Expand(extIssueUnit.ExternalTrackerConfig().ExternalTrackerFormat, metas)
if err != nil {
log.Error("unable to expand template vars for issue url. issue: %s, err: %v", metas["index"], err)
ctx.ServerError("Expand", err)
return
}
ctx.Redirect(res)
return
}
} else if err != nil && !repo_model.IsErrUnitTypeNotExist(err) {
ctx.ServerError("GetUnit", err)
return
}
}
}
// ViewIssue render issue view page
func ViewIssue(ctx *context.Context) {
handleViewIssueRedirectExternal(ctx)
if ctx.Written() {
return
}
issue := prepareIssueViewLoad(ctx)
if ctx.Written() {
return
}
// Make sure type and URL matches.
if ctx.PathParam("type") == "issues" && issue.IsPull {
ctx.Redirect(issue.Link())
return
} else if ctx.PathParam("type") == "pulls" && !issue.IsPull {
ctx.Redirect(issue.Link())
return
}
if issue.IsPull {
MustAllowPulls(ctx)
if ctx.Written() {
return
}
ctx.Data["PageIsPullList"] = true
ctx.Data["PageIsPullConversation"] = true
} else {
MustEnableIssues(ctx)
if ctx.Written() {
return
}
ctx.Data["PageIsIssueList"] = true
ctx.Data["NewIssueChooseTemplate"] = issue_service.HasTemplatesOrContactLinks(ctx.Repo.Repository, ctx.Repo.GitRepo)
}
ctx.Data["IsProjectsEnabled"] = ctx.Repo.Permission.CanRead(unit.TypeProjects)
ctx.Data["IsAttachmentEnabled"] = setting.Attachment.Enabled
upload.AddUploadContext(ctx, "comment")
if err := issue.LoadAttributes(ctx); err != nil {
ctx.ServerError("LoadAttributes", err)
return
}
if err := filterXRefComments(ctx, issue); err != nil {
ctx.ServerError("filterXRefComments", err)
return
}
ctx.Data["Title"] = fmt.Sprintf("#%d - %s", issue.Index, emoji.ReplaceAliases(issue.Title))
if ctx.IsSigned {
// Update issue-user.
if err := activities_model.SetIssueReadBy(ctx, issue.ID, ctx.Doer.ID); err != nil {
ctx.ServerError("ReadBy", err)
return
}
}
pageMetaData := retrieveRepoIssueMetaData(ctx, ctx.Repo.Repository, issue, issue.IsPull)
if ctx.Written() {
return
}
pageMetaData.LabelsData.SetSelectedLabels(issue.Labels)
prViewInfo := newPullRequestViewInfo()
prepareFuncs := []func(*context.Context, *issues_model.Issue){
prepareIssueViewContent,
prepareIssueViewCommentsAndSidebarParticipants,
prepareIssueViewSidebarWatch,
prepareIssueViewSidebarTimeTracker,
prepareIssueViewSidebarDependency,
prepareIssueViewSidebarPin,
}
if issue.IsPull {
prepareFuncs = append(prepareFuncs,
prViewInfo.prepareViewInfo,
prViewInfo.prepareMergeBox,
)
}
for _, prepareFunc := range prepareFuncs {
prepareFunc(ctx, issue)
if ctx.Written() {
return
}
}
// Get more information if it's a pull request.
if issue.IsPull {
if issue.PullRequest.HasMerged {
ctx.Data["DisableStatusChange"] = issue.PullRequest.HasMerged
} else {
ctx.Data["DisableStatusChange"] = prViewInfo.IsPullRequestBroken && issue.IsClosed
}
}
ctx.Data["Reference"] = issue.Ref
ctx.Data["SignInLink"] = middleware.RedirectLinkUserLogin(ctx.Req)
ctx.Data["IsIssuePoster"] = ctx.IsSigned && issue.IsPoster(ctx.Doer.ID)
ctx.Data["HasIssuesOrPullsWritePermission"] = ctx.Repo.Permission.CanWriteIssuesOrPulls(issue.IsPull)
ctx.Data["HasProjectsWritePermission"] = ctx.Repo.Permission.CanWrite(unit.TypeProjects)
ctx.Data["IsRepoAdmin"] = ctx.IsSigned && (ctx.Repo.Permission.IsAdmin() || ctx.Doer.IsAdmin)
ctx.Data["LockReasons"] = setting.Repository.Issue.LockReasons
ctx.Data["RefEndName"] = git.RefName(issue.Ref).ShortName()
tags, err := repo_model.GetTagNamesByRepoID(ctx, ctx.Repo.Repository.ID)
if err != nil {
ctx.ServerError("GetTagNamesByRepoID", err)
return
}
ctx.Data["Tags"] = tags
ctx.Data["CanBlockUser"] = func(blocker, blockee *user_model.User) bool {
return user_service.CanBlockUser(ctx, ctx.Doer, blocker, blockee)
}
if !setting.IsProd && issue.PullRequest != nil && !issue.PullRequest.IsChecking() && prViewInfo.MergeBoxData != nil {
prViewInfo.MergeBoxData.ReloadingInterval = 1 // in dev env, force using the reloading logic to make sure it won't break
}
ctx.HTML(http.StatusOK, tplIssueView)
}
func ViewPullMergeBox(ctx *context.Context) {
issue := prepareIssueViewLoad(ctx)
if ctx.Written() {
return
}
if !issue.IsPull {
ctx.NotFound(nil)
return
}
prViewInfo := newPullRequestViewInfo()
prViewInfo.prepareViewInfo(ctx, issue)
if ctx.Written() {
return
}
prViewInfo.prepareMergeBox(ctx, issue)
if ctx.Written() {
return
}
ctx.Data["PullMergeBoxReloading"] = issue.PullRequest.IsChecking()
// TODO: it should use a dedicated struct to render the pull merge box, to make sure all data is prepared correctly
ctx.Data["IsIssuePoster"] = ctx.IsSigned && issue.IsPoster(ctx.Doer.ID)
ctx.Data["HasIssuesOrPullsWritePermission"] = ctx.Repo.Permission.CanWriteIssuesOrPulls(issue.IsPull)
ctx.HTML(http.StatusOK, tplPullMergeBox)
}
func prepareIssueViewSidebarDependency(ctx *context.Context, issue *issues_model.Issue) {
if issue.IsPull && !ctx.Repo.Permission.CanRead(unit.TypeIssues) {
ctx.Data["IssueDependencySearchType"] = "pulls"
} else if !issue.IsPull && !ctx.Repo.Permission.CanRead(unit.TypePullRequests) {
ctx.Data["IssueDependencySearchType"] = "issues"
} else {
ctx.Data["IssueDependencySearchType"] = "all"
}
// Check if the user can use the dependencies
ctx.Data["CanCreateIssueDependencies"] = ctx.Repo.CanCreateIssueDependencies(ctx, ctx.Doer, issue.IsPull)
// check if dependencies can be created across repositories
ctx.Data["AllowCrossRepositoryDependencies"] = setting.Service.AllowCrossRepositoryDependencies
// Get Dependencies
blockedBy, _, err := issue.BlockedByDependencies(ctx, db.ListOptions{})
if err != nil {
ctx.ServerError("BlockedByDependencies", err)
return
}
ctx.Data["BlockedByDependencies"], ctx.Data["BlockedByDependenciesNotPermitted"] = checkBlockedByIssues(ctx, blockedBy)
if ctx.Written() {
return
}
blocking, err := issue.BlockingDependencies(ctx)
if err != nil {
ctx.ServerError("BlockingDependencies", err)
return
}
ctx.Data["BlockingDependencies"], ctx.Data["BlockingDependenciesNotPermitted"] = checkBlockedByIssues(ctx, blocking)
}
func (prInfo *pullRequestViewInfo) prepareMergeBoxCommitSigning(ctx *context.Context) {
pull := prInfo.issue.PullRequest
data := prInfo.MergeBoxData
pb := prInfo.ProtectedBranchRule
data.requireSigned = pb != nil && pb.RequireSignedCommits
wontSignReason := ""
if ctx.Doer != nil {
sign, key, _, err := asymkey_service.SignMerge(ctx, pull, ctx.Doer, ctx.Repo.GitRepo)
data.willSign = sign
data.signingKeyMergeDisplay = asymkey_model.GetDisplaySigningKey(key)
if err != nil {
if asymkey_service.IsErrWontSign(err) {
wontSignReason = string(err.(*asymkey_service.ErrWontSign).Reason)
} else {
wontSignReason = "error"
if !errors.Is(err, util.ErrNotExist) {
log.Error("Error whilst checking if could sign pr %d in repo %s. Error: %v", pull.ID, pull.BaseRepo.FullName(), err)
}
}
}
}
if data.willSign {
prInfo.MergeBoxData.infoMergePrompts.AddInfoItem(
svg.RenderHTML("octicon-lock", 16, "tw-text-green"),
ctx.Locale.Tr("repo.signing.will_sign", data.signingKeyMergeDisplay),
)
}
if !data.requireSigned {
if wontSignReason != "" {
data.infoMergePrompts.AddInfoItem(
svg.RenderHTML("octicon-unlock"),
ctx.Locale.Tr("repo.signing.wont_sign."+wontSignReason),
)
}
return
}
if data.requireSigned && !data.willSign {
data.infoProtectionBlockers.AddErrorItem(ctx.Locale.Tr("repo.pulls.require_signed_wont_sign"))
if wontSignReason != "" {
data.infoProtectionBlockers.AddInfoItem(
svg.RenderHTML("octicon-unlock"),
ctx.Locale.Tr("repo.signing.wont_sign."+wontSignReason),
)
}
}
}
func prepareIssueViewSidebarWatch(ctx *context.Context, issue *issues_model.Issue) {
iw := new(issues_model.IssueWatch)
if ctx.Doer != nil {
iw.UserID = ctx.Doer.ID
iw.IssueID = issue.ID
var err error
iw.IsWatching, err = issues_model.CheckIssueWatch(ctx, ctx.Doer, issue)
if err != nil {
ctx.ServerError("CheckIssueWatch", err)
return
}
}
ctx.Data["IssueWatch"] = iw
}
func prepareIssueViewSidebarTimeTracker(ctx *context.Context, issue *issues_model.Issue) {
if !ctx.Repo.Repository.IsTimetrackerEnabled(ctx) {
return
}
if ctx.IsSigned {
// Deal with the stopwatch
ctx.Data["IsStopwatchRunning"] = issues_model.StopwatchExists(ctx, ctx.Doer.ID, issue.ID)
if !ctx.Data["IsStopwatchRunning"].(bool) {
exists, _, swIssue, err := issues_model.HasUserStopwatch(ctx, ctx.Doer.ID)
if err != nil {
ctx.ServerError("HasUserStopwatch", err)
return
}
ctx.Data["HasUserStopwatch"] = exists
if exists {
// Add warning if the user has already a stopwatch
// Add link to the issue of the already running stopwatch
ctx.Data["OtherStopwatchURL"] = swIssue.Link()
}
}
ctx.Data["CanUseTimetracker"] = ctx.Repo.CanUseTimetracker(ctx, issue, ctx.Doer)
} else {
ctx.Data["CanUseTimetracker"] = false
}
var err error
if ctx.Data["WorkingUsers"], err = issues_model.TotalTimesForEachUser(ctx, &issues_model.FindTrackedTimesOptions{IssueID: issue.ID}); err != nil {
ctx.ServerError("TotalTimesForEachUser", err)
return
}
}
func (prInfo *pullRequestViewInfo) prepareMergeBoxDeleteBranch(ctx *context.Context, canDelete bool) {
pull := prInfo.issue.PullRequest
isPullBranchDeletable := canDelete &&
pull.HeadRepo != nil &&
(!pull.HasMerged || prInfo.HeadBranchCommitID == prInfo.CompareInfo.HeadCommitID)
if isPullBranchDeletable {
isPullBranchDeletable, _ = git_model.IsBranchExist(ctx, pull.HeadRepo.ID, pull.HeadBranch)
}
if isPullBranchDeletable && pull.HasMerged {
exist, err := issues_model.HasUnmergedPullRequestsByHeadInfo(ctx, pull.HeadRepoID, pull.HeadBranch)
if err != nil {
ctx.ServerError("HasUnmergedPullRequestsByHeadInfo", err)
return
}
isPullBranchDeletable = !exist
}
prInfo.MergeBoxData.IsPullBranchDeletable = isPullBranchDeletable
}
func prepareIssueViewSidebarPin(ctx *context.Context, issue *issues_model.Issue) {
var pinAllowed bool
if err := issue.LoadPinOrder(ctx); err != nil {
ctx.ServerError("LoadPinOrder", err)
return
}
if issue.PinOrder == 0 {
var err error
pinAllowed, err = issues_model.IsNewPinAllowed(ctx, issue.RepoID, issue.IsPull)
if err != nil {
ctx.ServerError("IsNewPinAllowed", err)
return
}
} else {
pinAllowed = true
}
ctx.Data["NewPinAllowed"] = pinAllowed
ctx.Data["PinEnabled"] = setting.Repository.Issue.MaxPinned != 0
}
func prepareIssueViewCommentsAndSidebarParticipants(ctx *context.Context, issue *issues_model.Issue) {
var (
role issues_model.RoleDescriptor
ok bool
marked = make(map[int64]issues_model.RoleDescriptor)
comment *issues_model.Comment
participants = make([]*user_model.User, 1, 10)
latestCloseCommentID int64
err error
)
marked[issue.PosterID] = issue.ShowRole
// Render comments and fetch participants.
participants[0] = issue.Poster
if err := issue.Comments.LoadAttachmentsByIssue(ctx); err != nil {
ctx.ServerError("LoadAttachmentsByIssue", err)
return
}
if err := issue.Comments.LoadPosters(ctx); err != nil {
ctx.ServerError("LoadPosters", err)
return
}
permCache := make(map[int64]access_model.Permission)
for _, comment = range issue.Comments {
comment.Issue = issue
if comment.Type == issues_model.CommentTypeComment || comment.Type == issues_model.CommentTypeReview {
rctx := renderhelper.NewRenderContextRepoComment(ctx, issue.Repo, renderhelper.RepoCommentOptions{
FootnoteContextID: strconv.FormatInt(comment.ID, 10),
})
comment.RenderedContent, err = markdown.RenderString(rctx, comment.Content)
if err != nil {
ctx.ServerError("RenderString", err)
return
}
// Check tag.
role, ok = marked[comment.PosterID]
if ok {
comment.ShowRole = role
continue
}
comment.ShowRole, err = roleDescriptor(ctx, issue.Repo, comment.Poster, permCache, issue, comment.HasOriginalAuthor())
if err != nil {
ctx.ServerError("roleDescriptor", err)
return
}
marked[comment.PosterID] = comment.ShowRole
participants = addParticipant(comment.Poster, participants)
} else if comment.Type == issues_model.CommentTypeLabel {
if err = comment.LoadLabel(ctx); err != nil {
ctx.ServerError("LoadLabel", err)
return
}
} else if comment.Type == issues_model.CommentTypeMilestone {
if err = comment.LoadMilestone(ctx); err != nil {
ctx.ServerError("LoadMilestone", err)
return
}
ghostMilestone := &issues_model.Milestone{
ID: -1,
Name: ctx.Locale.TrString("repo.issues.deleted_milestone"),
}
if comment.OldMilestoneID > 0 && comment.OldMilestone == nil {
comment.OldMilestone = ghostMilestone
}
if comment.MilestoneID > 0 && comment.Milestone == nil {
comment.Milestone = ghostMilestone
}
} else if comment.Type == issues_model.CommentTypeProject {
if err = comment.LoadProject(ctx); err != nil {
ctx.ServerError("LoadProject", err)
return
}
ghostProject := &project_model.Project{
ID: project_model.GhostProjectID,
Title: ctx.Locale.TrString("repo.issues.deleted_project"),
}
if comment.OldProjectID > 0 && comment.OldProject == nil {
comment.OldProject = ghostProject
}
if comment.ProjectID > 0 && comment.Project == nil {
comment.Project = ghostProject
}
} else if comment.Type == issues_model.CommentTypeProjectColumn {
if err = comment.LoadProject(ctx); err != nil {
ctx.ServerError("LoadProject", err)
return
}
} else if comment.Type == issues_model.CommentTypeAssignees || comment.Type == issues_model.CommentTypeReviewRequest {
if err = comment.LoadAssigneeUserAndTeam(ctx); err != nil {
ctx.ServerError("LoadAssigneeUserAndTeam", err)
return
}
} else if comment.Type == issues_model.CommentTypeRemoveDependency || comment.Type == issues_model.CommentTypeAddDependency {
if err = comment.LoadDepIssueDetails(ctx); err != nil {
if !issues_model.IsErrIssueNotExist(err) {
ctx.ServerError("LoadDepIssueDetails", err)
return
}
}
} else if comment.Type.HasContentSupport() {
rctx := renderhelper.NewRenderContextRepoComment(ctx, issue.Repo, renderhelper.RepoCommentOptions{
FootnoteContextID: strconv.FormatInt(comment.ID, 10),
})
comment.RenderedContent, err = markdown.RenderString(rctx, comment.Content)
if err != nil {
ctx.ServerError("RenderString", err)
return
}
if err = comment.LoadReview(ctx); err != nil && !issues_model.IsErrReviewNotExist(err) {
ctx.ServerError("LoadReview", err)
return
}
participants = addParticipant(comment.Poster, participants)
if comment.Review == nil {
continue
}
if err = comment.Review.LoadAttributes(ctx); err != nil {
if !user_model.IsErrUserNotExist(err) {
ctx.ServerError("Review.LoadAttributes", err)
return
}
comment.Review.Reviewer = user_model.NewGhostUser()
}
if err = comment.Review.LoadCodeComments(ctx); err != nil {
ctx.ServerError("Review.LoadCodeComments", err)
return
}
for _, codeComments := range comment.Review.CodeComments {
for _, lineComments := range codeComments {
for _, c := range lineComments {
// Check tag.
role, ok = marked[c.PosterID]
if ok {
c.ShowRole = role
continue
}
c.ShowRole, err = roleDescriptor(ctx, issue.Repo, c.Poster, permCache, issue, c.HasOriginalAuthor())
if err != nil {
ctx.ServerError("roleDescriptor", err)
return
}
marked[c.PosterID] = c.ShowRole
participants = addParticipant(c.Poster, participants)
}
}
}
if err = comment.LoadResolveDoer(ctx); err != nil {
ctx.ServerError("LoadResolveDoer", err)
return
}
} else if comment.Type == issues_model.CommentTypePullRequestPush {
participants = addParticipant(comment.Poster, participants)
if err = issue_service.LoadCommentPushCommits(ctx, comment); err != nil {
ctx.ServerError("LoadCommentPushCommits", err)
return
}
if !ctx.Repo.Permission.CanRead(unit.TypeActions) {
for _, commit := range comment.Commits {
if commit.Status == nil {
continue
}
commit.Status.HideActionsURL(ctx)
git_model.CommitStatusesHideActionsURL(ctx, commit.Statuses)
}
}
} else if comment.Type == issues_model.CommentTypeAddTimeManual ||
comment.Type == issues_model.CommentTypeStopTracking ||
comment.Type == issues_model.CommentTypeDeleteTimeManual {
// drop error since times could be pruned from DB
_ = comment.LoadTime(ctx)
if comment.Content != "" {
// Content before v1.21 did store the formatted string instead of seconds,
// so "|" is used as delimiter to mark the new format
if comment.Content[0] != '|' {
// handle old time comments that have formatted text stored
comment.RenderedContent = markup.Sanitize(comment.Content)
comment.Content = ""
} else {
// else it's just a duration in seconds to pass on to the frontend
comment.Content = comment.Content[1:]
}
}
}
if comment.Type == issues_model.CommentTypeClose || comment.Type == issues_model.CommentTypeMergePull {
// record ID of the latest closed/merged comment.
// if PR is closed, the comments whose type is CommentTypePullRequestPush(29) after latestCloseCommentID won't be rendered.
latestCloseCommentID = comment.ID
}
}
ctx.Data["LatestCloseCommentID"] = latestCloseCommentID
// Combine multiple label assignments into a single comment
combineLabelComments(issue)
var hiddenCommentTypes *big.Int
if ctx.IsSigned {
val, err := user_model.GetUserSetting(ctx, ctx.Doer.ID, user_model.SettingsKeyHiddenCommentTypes)
if err != nil {
ctx.ServerError("GetUserSetting", err)
return
}
hiddenCommentTypes, _ = new(big.Int).SetString(val, 10) // we can safely ignore the failed conversion here
}
ctx.Data["ShouldShowCommentType"] = func(commentType issues_model.CommentType) bool {
return hiddenCommentTypes == nil || hiddenCommentTypes.Bit(int(commentType)) == 0
}
// prepare for sidebar participants
ctx.Data["Participants"] = participants
ctx.Data["NumParticipants"] = len(participants)
}
func (prInfo *pullRequestViewInfo) prepareMergeBox(ctx *context.Context, issue *issues_model.Issue) {
if prInfo.issue != issue {
panic("impossible, issue must be the same")
}
pull := issue.PullRequest
data := &pullMergeBoxData{}
prInfo.MergeBoxData = data
canDelete := false
canWriteToHeadRepo := false
pull_service.StartPullRequestCheckOnView(ctx, pull)
if !prInfo.IsPullRequestBroken {
data.ShowUpdatePullInfo = pull.CommitsBehind > 0 && !issue.IsClosed && !pull.IsChecking() && !pull.IsFilesConflicted() && !prInfo.IsPullRequestBroken
prInfo.preparePullUpdateActions(ctx)
if ctx.Written() {
return
}
}
if ctx.IsSigned {
if err := pull.LoadHeadRepo(ctx); err != nil {
log.Error("LoadHeadRepo: %v", err)
} else if pull.HeadRepo != nil {
perm, err := access_model.GetDoerRepoPermission(ctx, pull.HeadRepo, ctx.Doer)
if err != nil {
ctx.ServerError("GetDoerRepoPermission", err)
return
}
if perm.CanWrite(unit.TypeCode) {
// Check if branch is not protected
if pull.HeadBranch != pull.HeadRepo.DefaultBranch {
if protected, err := git_model.IsBranchProtected(ctx, pull.HeadRepo.ID, pull.HeadBranch); err != nil {
log.Error("IsProtectedBranch: %v", err)
} else if !protected {
canDelete = true
ctx.Data["DeleteBranchLink"] = issue.Link() + "/cleanup"
}
}
canWriteToHeadRepo = true
}
}
if err := pull.LoadBaseRepo(ctx); err != nil {
log.Error("LoadBaseRepo: %v", err)
}
perm, err := access_model.GetDoerRepoPermission(ctx, pull.BaseRepo, ctx.Doer)
if err != nil {
ctx.ServerError("GetDoerRepoPermission", err)
return
}
if !canWriteToHeadRepo { // maintainers maybe allowed to push to head repo even if they can't write to it
canWriteToHeadRepo = pull.AllowMaintainerEdit && perm.CanWrite(unit.TypeCode)
}
data.hasPermToMerge, err = pull_service.IsUserAllowedToMerge(ctx, pull, perm, ctx.Doer)
if err != nil {
ctx.ServerError("IsUserAllowedToMerge", err)
return
}
if ctx.Data["CanMarkConversation"], err = issues_model.CanMarkConversation(ctx, issue, ctx.Doer); err != nil {
ctx.ServerError("CanMarkConversation", err)
return
}
}
data.ReloadingInterval = util.Iif(pull.IsChecking(), 2000, 0)
data.ShowMergeInstructions = canWriteToHeadRepo
data.ShowPullCommands = pull.HeadRepo != nil && !pull.HasMerged && !issue.IsClosed
prInfo.prepareMergeBoxProtectionChecks(ctx)
if ctx.Written() {
return
}
prInfo.prepareMergeBoxCommitSigning(ctx)
if ctx.Written() {
return
}
prInfo.prepareMergeBoxDeleteBranch(ctx, canDelete)
if ctx.Written() {
return
}
prConfig := issue.Repo.MustGetUnit(ctx, unit.TypePullRequests).PullRequestsConfig()
data.AutodetectManualMerge = prConfig.AutodetectManualMerge
// Only show the merge box if the PR is not merged, or the branch is deletable.
// Otherwise, there is nothing to do, because the PR view page already contains enough information.
data.ShowMergeBox = !pull.HasMerged || data.IsPullBranchDeletable
isRepoAdmin := ctx.IsSigned && (ctx.Repo.Permission.IsAdmin() || ctx.Doer.IsAdmin)
// admin can merge without checks, writer can merge when checks succeed
// admin and writer both can make an auto merge schedule (not affected by overridable blockers)
// Required scoped workflow checks gate the merge even when the rule's own status check is disabled (see IsPullCommitStatusPass),
// so block on any required status context, not only when enableStatusCheck is on.
data.hasStatusCheckBlocker = (data.enableStatusCheck || data.hasRequiredStatusContexts) && !data.StatusCheckData.RequiredChecksState.IsSuccess()
// this logic is from:
// {{$notAllOverridableChecksOk := or .IsBlockedByApprovals .IsBlockedByRejection .IsBlockedByOfficialReviewRequests .IsBlockedByOutdatedBranch .IsBlockedByChangedProtectedFiles (and .EnableStatusCheck (not $requiredStatusCheckState.IsSuccess))}}
// HINT: if a PR's status is not mergeable, then it is a non-overridable blocker, such logic is handled separately (see IsStatusMergeable)
data.hasOverridableBlockers = data.isBlockedByApprovals || data.isBlockedByRejection ||
data.isBlockedByOfficialReviewRequests || data.isBlockedByOutdatedBranch || data.isBlockedByChangedProtectedFiles ||
data.hasStatusCheckBlocker
data.canBypassProtection = isRepoAdmin
data.canBypassProtectionAsAdmin = isRepoAdmin
if ctx.IsSigned && prInfo.ProtectedBranchRule != nil {
data.canBypassProtection = git_model.CanBypassBranchProtection(ctx, prInfo.ProtectedBranchRule, ctx.Doer, isRepoAdmin)
data.canBypassProtectionAsAdmin = isRepoAdmin && !prInfo.ProtectedBranchRule.BlockAdminMergeOverride
}
// CanMergeNow means: if the doer has write permission, whether the PR can be merged now
data.canMergeNow = (!data.hasOverridableBlockers || data.canBypassProtection) && // status checks are satisfied
(!data.requireSigned || data.willSign) // signing requirement is satisfied
prInfo.prepareMergeBoxFormProps(ctx)
prInfo.prepareMergeBoxInfoItems(ctx)
prInfo.prepareMergeBoxIconColor()
ctx.Data["PullMergeBoxData"] = prInfo.MergeBoxData
}
func (prInfo *pullRequestViewInfo) preparePullUpdateActions(ctx *context.Context) {
pull := prInfo.issue.PullRequest
data := prInfo.MergeBoxData
userUpdateStyles, err := pull_service.CheckUserAllowedToUpdate(ctx, pull, ctx.Doer)
if err != nil {
ctx.ServerError("IsUserAllowedToUpdate", err)
return
}
if !userUpdateStyles.MergeAllowed && !userUpdateStyles.RebaseAllowed {
return
}
issueLink := prInfo.issue.Link()
mergeAction := &pullUpdateAction{
URL: issueLink + "/update?style=merge",
Text: ctx.Tr("repo.pulls.update_branch"),
}
rebaseAction := &pullUpdateAction{
URL: issueLink + "/update?style=rebase",
Text: ctx.Tr("repo.pulls.update_branch_rebase"),
}
if userUpdateStyles.MergeAllowed {
data.UpdateStyleOptions = append(data.UpdateStyleOptions, mergeAction)
}
if userUpdateStyles.RebaseAllowed {
data.UpdateStyleOptions = append(data.UpdateStyleOptions, rebaseAction)
}
if userUpdateStyles.DefaultUpdateStyle == repo_model.UpdateStyleRebase && userUpdateStyles.RebaseAllowed {
data.UpdatePrimaryAction = rebaseAction
} else if userUpdateStyles.DefaultUpdateStyle == repo_model.UpdateStyleMerge && userUpdateStyles.MergeAllowed {
data.UpdatePrimaryAction = mergeAction
} else {
data.UpdatePrimaryAction = data.UpdateStyleOptions[0]
}
data.UpdatePrimaryAction.Selected = true
}
func (prInfo *pullRequestViewInfo) prepareMergeBoxProtectionChecks(ctx *context.Context) {
pb, err := git_model.GetFirstMatchProtectedBranchRule(ctx, ctx.Repo.Repository.ID, prInfo.issue.PullRequest.BaseBranch)
if err != nil {
ctx.ServerError("GetFirstMatchProtectedBranchRule", err)
return
}
if pb != nil {
pb.Repo = prInfo.issue.PullRequest.BaseRepo
prInfo.ProtectedBranchRule = pb
}
prInfo.prepareMergeBoxStatusCheckData(ctx)
if ctx.Written() {
return
}
prInfo.prepareMergeBoxProtectedRules(ctx)
if ctx.Written() {
return
}
}
func (prInfo *pullRequestViewInfo) prepareMergeBoxProtectedRules(ctx *context.Context) {
pb := prInfo.ProtectedBranchRule
if pb == nil {
return
}
pull := prInfo.issue.PullRequest
data := prInfo.MergeBoxData
data.isBlockedByApprovals = !issues_model.HasEnoughApprovals(ctx, pb, pull)
if data.isBlockedByApprovals {
grantedApprovals := issues_model.GetGrantedApprovalsCount(ctx, pb, pull)
blockerInfo := ctx.Locale.Tr("repo.pulls.blocked_by_approvals", grantedApprovals, pb.RequiredApprovals)
if pb.EnableApprovalsWhitelist {
blockerInfo = ctx.Locale.Tr("repo.pulls.blocked_by_approvals_whitelisted", grantedApprovals, pb.RequiredApprovals)
}
data.infoProtectionBlockers.AddErrorItem(blockerInfo)
}
data.isBlockedByRejection = issues_model.MergeBlockedByRejectedReview(ctx, pb, pull)
if data.isBlockedByRejection {
data.infoProtectionBlockers.AddErrorItem(ctx.Locale.Tr("repo.pulls.blocked_by_rejection"))
}
data.isBlockedByOfficialReviewRequests = issues_model.MergeBlockedByOfficialReviewRequests(ctx, pb, pull)
if data.isBlockedByOfficialReviewRequests {
data.infoProtectionBlockers.AddErrorItem(ctx.Locale.Tr("repo.pulls.blocked_by_official_review_requests"))
}
data.isBlockedByOutdatedBranch = issues_model.MergeBlockedByOutdatedBranch(pb, pull)
if data.isBlockedByOutdatedBranch {
data.infoProtectionBlockers.AddErrorItem(ctx.Locale.Tr("repo.pulls.blocked_by_outdated_branch"))
}
data.isBlockedByChangedProtectedFiles = len(pull.ChangedProtectedFiles) != 0
if data.isBlockedByChangedProtectedFiles {
detailItems := escapeStringSliceToHTML(pull.ChangedProtectedFiles)
data.infoProtectionBlockers.AddErrorItem(
ctx.Locale.TrN(len(pull.ChangedProtectedFiles), "repo.pulls.blocked_by_changed_protected_files_1", "repo.pulls.blocked_by_changed_protected_files_n"),
detailItems,
)
}
}
func prepareIssueViewContent(ctx *context.Context, issue *issues_model.Issue) {
var err error
rctx := renderhelper.NewRenderContextRepoComment(ctx, ctx.Repo.Repository, renderhelper.RepoCommentOptions{
FootnoteContextID: "0", // Set footnote context ID to 0 for the issue content
})
issue.RenderedContent, err = markdown.RenderString(rctx, issue.Content)
if err != nil {
ctx.ServerError("RenderString", err)
return
}
if issue.ShowRole, err = roleDescriptor(ctx, issue.Repo, issue.Poster, nil, issue, issue.HasOriginalAuthor()); err != nil {
ctx.ServerError("roleDescriptor", err)
return
}
}