0
0
mirror of https://github.com/go-gitea/gitea.git synced 2025-07-19 15:00:52 +02:00

Merge a4f84081907afc9779c928c2a4b8cb451cf580df into 6599efb3b1400ac06d06e1c8b68ae6037fbb7952

This commit is contained in:
Lunny Xiao 2025-07-12 12:57:04 +02:00 committed by GitHub
commit 113f5ced8d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 484 additions and 4 deletions

View File

@ -213,6 +213,18 @@ func GetColumn(ctx context.Context, columnID int64) (*Column, error) {
return column, nil
}
func GetColumnByProjectIDAndColumnName(ctx context.Context, projectID int64, columnName string) (*Column, error) {
board := new(Column)
has, err := db.GetEngine(ctx).Where("project_id=? AND title=?", projectID, columnName).Get(board)
if err != nil {
return nil, err
} else if !has {
return nil, ErrProjectColumnNotExist{ProjectID: projectID, Name: columnName}
}
return board, nil
}
// UpdateColumn updates a project column
func UpdateColumn(ctx context.Context, column *Column) error {
var fieldToUpdate []string

View File

@ -33,6 +33,19 @@ func deleteProjectIssuesByProjectID(ctx context.Context, projectID int64) error
return err
}
func AddIssueToColumn(ctx context.Context, issueID int64, newColumn *Column) error {
return db.Insert(ctx, &ProjectIssue{
IssueID: issueID,
ProjectID: newColumn.ProjectID,
ProjectColumnID: newColumn.ID,
})
}
func MoveIssueToAnotherColumn(ctx context.Context, issueID int64, newColumn *Column) error {
_, err := db.GetEngine(ctx).Exec("UPDATE `project_issue` SET project_board_id=? WHERE issue_id=?", newColumn.ID, issueID)
return err
}
func (c *Column) moveIssuesToAnotherColumn(ctx context.Context, newColumn *Column) error {
if c.ProjectID != newColumn.ProjectID {
return errors.New("columns have to be in the same project")

View File

@ -46,6 +46,7 @@ const (
type ErrProjectNotExist struct {
ID int64
RepoID int64
Name string
}
// IsErrProjectNotExist checks if an error is a ErrProjectNotExist
@ -55,6 +56,9 @@ func IsErrProjectNotExist(err error) bool {
}
func (err ErrProjectNotExist) Error() string {
if err.RepoID > 0 && len(err.Name) > 0 {
return fmt.Sprintf("projects does not exist [repo_id: %d, name: %s]", err.RepoID, err.Name)
}
return fmt.Sprintf("projects does not exist [id: %d]", err.ID)
}
@ -65,6 +69,8 @@ func (err ErrProjectNotExist) Unwrap() error {
// ErrProjectColumnNotExist represents a "ErrProjectColumnNotExist" kind of error.
type ErrProjectColumnNotExist struct {
ColumnID int64
ProjectID int64
Name string
}
// IsErrProjectColumnNotExist checks if an error is a ErrProjectColumnNotExist
@ -74,6 +80,9 @@ func IsErrProjectColumnNotExist(err error) bool {
}
func (err ErrProjectColumnNotExist) Error() string {
if err.ProjectID > 0 && len(err.Name) > 0 {
return fmt.Sprintf("project column does not exist [project_id: %d, name: %s]", err.ProjectID, err.Name)
}
return fmt.Sprintf("project column does not exist [id: %d]", err.ColumnID)
}
@ -302,6 +311,19 @@ func GetProjectByID(ctx context.Context, id int64) (*Project, error) {
return p, nil
}
// GetProjectByName returns the projects in a repository
func GetProjectByName(ctx context.Context, repoID int64, name string) (*Project, error) {
p := new(Project)
has, err := db.GetEngine(ctx).Where("repo_id=? AND title=?", repoID, name).Get(p)
if err != nil {
return nil, err
} else if !has {
return nil, ErrProjectNotExist{RepoID: repoID, Name: name}
}
return p, nil
}
// GetProjectForRepoByID returns the projects in a repository
func GetProjectForRepoByID(ctx context.Context, repoID, id int64) (*Project, error) {
p := new(Project)

181
models/project/workflows.go Normal file
View File

@ -0,0 +1,181 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package project
import (
"context"
"fmt"
"code.gitea.io/gitea/models/db"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/timeutil"
"code.gitea.io/gitea/modules/util"
)
type WorkflowEvent string
const (
WorkflowEventItemAddedToProject WorkflowEvent = "item_added_to_project"
WorkflowEventItemReopened WorkflowEvent = "item_reopened"
WorkflowEventItemClosed WorkflowEvent = "item_closed"
WorkflowEventCodeChangesRequested WorkflowEvent = "code_changes_requested"
WorkflowEventCodeReviewApproved WorkflowEvent = "code_review_approved"
WorkflowEventPullRequestMerged WorkflowEvent = "pull_request_merged"
WorkflowEventAutoArchiveItems WorkflowEvent = "auto_archive_items"
WorkflowEventAutoAddToProject WorkflowEvent = "auto_add_to_project"
WorkflowEventAutoCloseIssue WorkflowEvent = "auto_close_issue"
)
var workflowEvents = []WorkflowEvent{
WorkflowEventItemAddedToProject,
WorkflowEventItemReopened,
WorkflowEventItemClosed,
WorkflowEventCodeChangesRequested,
WorkflowEventCodeReviewApproved,
WorkflowEventPullRequestMerged,
WorkflowEventAutoArchiveItems,
WorkflowEventAutoAddToProject,
WorkflowEventAutoCloseIssue,
}
func GetWorkflowEvents() []WorkflowEvent {
return workflowEvents
}
func (we WorkflowEvent) ToString() string {
switch we {
case WorkflowEventItemAddedToProject:
return "Item added to project"
case WorkflowEventItemReopened:
return "Item reopened"
case WorkflowEventItemClosed:
return "Item closed"
case WorkflowEventCodeChangesRequested:
return "Code changes requested"
case WorkflowEventCodeReviewApproved:
return "Code review approved"
case WorkflowEventPullRequestMerged:
return "Pull request merged"
case WorkflowEventAutoArchiveItems:
return "Auto archive items"
case WorkflowEventAutoAddToProject:
return "Auto add to project"
case WorkflowEventAutoCloseIssue:
return "Auto close issue"
default:
return string(we)
}
}
func (we WorkflowEvent) UUID() string {
switch we {
case WorkflowEventItemAddedToProject:
return "item_added_to_project"
case WorkflowEventItemReopened:
return "item_reopened"
case WorkflowEventItemClosed:
return "item_closed"
case WorkflowEventCodeChangesRequested:
return "code_changes_requested"
case WorkflowEventCodeReviewApproved:
return "code_review_approved"
case WorkflowEventPullRequestMerged:
return "pull_request_merged"
case WorkflowEventAutoArchiveItems:
return "auto_archive_items"
case WorkflowEventAutoAddToProject:
return "auto_add_to_project"
case WorkflowEventAutoCloseIssue:
return "auto_close_issue"
default:
return string(we)
}
}
type WorkflowFilterType string
const (
WorkflowFilterTypeScope WorkflowFilterType = "scope" // issue, pull_request, etc.
)
type WorkflowFilter struct {
Type WorkflowFilterType
Value string // e.g., "issue", "pull_request", etc.
}
type WorkflowActionType string
const (
WorkflowActionTypeColumn WorkflowActionType = "column" // add the item to the project's column
WorkflowActionTypeLabel WorkflowActionType = "label" // choose one or more labels
WorkflowActionTypeClose WorkflowActionType = "close" // close the issue
)
type WorkflowAction struct {
ActionType WorkflowActionType
ActionValue string
}
type Workflow struct {
ID int64
ProjectID int64 `xorm:"unique(s)"`
Project *Project `xorm:"-"`
WorkflowEvent WorkflowEvent `xorm:"unique(s)"`
WorkflowFilters []WorkflowFilter `xorm:"TEXT json"`
WorkflowActions []WorkflowAction `xorm:"TEXT json"`
CreatedUnix timeutil.TimeStamp `xorm:"created"`
UpdatedUnix timeutil.TimeStamp `xorm:"updated"`
}
// TableName overrides the table name used by ProjectWorkflow to `project_workflow`
func (Workflow) TableName() string {
return "project_workflow"
}
func (p *Workflow) LoadProject(ctx context.Context) error {
if p.Project != nil || p.ProjectID <= 0 {
return nil
}
project, err := GetProjectByID(ctx, p.ProjectID)
if err != nil {
return err
}
p.Project = project
return nil
}
func (p *Workflow) Link(ctx context.Context) string {
if err := p.LoadProject(ctx); err != nil {
log.Error("ProjectWorkflow Link: %v", err)
return ""
}
return p.Project.Link(ctx) + fmt.Sprintf("/workflows/%d", p.ID)
}
func init() {
db.RegisterModel(new(Workflow))
}
func FindWorkflowEvents(ctx context.Context, projectID int64) (map[WorkflowEvent]*Workflow, error) {
events := make(map[WorkflowEvent]*Workflow)
if err := db.GetEngine(ctx).Where("project_id=?", projectID).Find(&events); err != nil {
return nil, err
}
res := make(map[WorkflowEvent]*Workflow, len(events))
for _, event := range events {
res[event.WorkflowEvent] = event
}
return res, nil
}
func GetWorkflowByID(ctx context.Context, id int64) (*Workflow, error) {
p, exist, err := db.GetByID[Workflow](ctx, id)
if err != nil {
return nil, err
}
if !exist {
return nil, util.ErrNotExist
}
return p, nil
}

View File

@ -0,0 +1,85 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package projects
import (
"strconv"
project_model "code.gitea.io/gitea/models/project"
"code.gitea.io/gitea/modules/templates"
"code.gitea.io/gitea/services/context"
)
var (
tmplRepoWorkflows = templates.TplName("repo/projects/workflows")
tmplOrgWorkflows = templates.TplName("org/projects/workflows")
)
func Workflows(ctx *context.Context) {
ctx.Data["WorkflowEvents"] = project_model.GetWorkflowEvents()
projectID := ctx.PathParamInt64("id")
p, err := project_model.GetProjectByID(ctx, projectID)
if err != nil {
if project_model.IsErrProjectNotExist(err) {
ctx.NotFound(nil)
} else {
ctx.ServerError("GetProjectByID", err)
}
return
}
if p.Type == project_model.TypeRepository && p.RepoID != ctx.Repo.Repository.ID {
ctx.NotFound(nil)
return
}
if (p.Type == project_model.TypeOrganization || p.Type == project_model.TypeIndividual) && p.OwnerID != ctx.ContextUser.ID {
ctx.NotFound(nil)
return
}
ctx.Data["Title"] = ctx.Tr("projects.workflows")
ctx.Data["PageIsWorkflows"] = true
ctx.Data["PageIsProjects"] = true
ctx.Data["PageIsProjectsWorkflows"] = true
ctx.Data["Project"] = p
workflows, err := project_model.FindWorkflowEvents(ctx, projectID)
if err != nil {
ctx.ServerError("GetWorkflows", err)
return
}
for _, wf := range workflows {
wf.Project = p
}
ctx.Data["Workflows"] = workflows
workflowIDStr := ctx.PathParam("workflow_id")
ctx.Data["workflowIDStr"] = workflowIDStr
var curWorkflow *project_model.Workflow
if workflowIDStr == "" { // get first value workflow or the first workflow
for _, wf := range workflows {
if wf.ID > 0 {
curWorkflow = wf
break
}
}
} else {
workflowID, _ := strconv.ParseInt(workflowIDStr, 10, 64)
if workflowID > 0 {
for _, wf := range workflows {
if wf.ID == workflowID {
curWorkflow = wf
break
}
}
}
}
ctx.Data["CurWorkflow"] = curWorkflow
if p.Type == project_model.TypeRepository {
ctx.HTML(200, tmplRepoWorkflows)
} else {
ctx.HTML(200, tmplOrgWorkflows)
}
}

View File

@ -34,6 +34,7 @@ import (
"code.gitea.io/gitea/routers/web/misc"
"code.gitea.io/gitea/routers/web/org"
org_setting "code.gitea.io/gitea/routers/web/org/setting"
"code.gitea.io/gitea/routers/web/projects"
"code.gitea.io/gitea/routers/web/repo"
"code.gitea.io/gitea/routers/web/repo/actions"
repo_setting "code.gitea.io/gitea/routers/web/repo/setting"
@ -1035,6 +1036,10 @@ func registerWebRoutes(m *web.Router) {
m.Get("", org.Projects)
m.Get("/{id}", org.ViewProject)
}, reqUnitAccess(unit.TypeProjects, perm.AccessModeRead, true))
m.Group("/{id}/workflows", func() {
m.Get("", projects.Workflows)
m.Get("/{workflow_id}", projects.Workflows)
})
m.Group("", func() { //nolint:dupl // duplicates lines 1421-1441
m.Get("/new", org.RenderNewProject)
m.Post("/new", web.Bind(forms.CreateProjectForm{}), org.NewProjectPost)
@ -1422,6 +1427,10 @@ func registerWebRoutes(m *web.Router) {
m.Group("/{username}/{reponame}/projects", func() {
m.Get("", repo.Projects)
m.Get("/{id}", repo.ViewProject)
m.Group("/{id}/workflows", func() {
m.Get("", projects.Workflows)
m.Get("/{workflow_id}", projects.Workflows)
})
m.Group("", func() { //nolint:dupl // duplicates lines 1034-1054
m.Get("/new", repo.RenderNewProject)
m.Post("/new", web.Bind(forms.CreateProjectForm{}), repo.NewProjectPost)

View File

@ -1,7 +1,7 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package project
package projects
import (
"context"

View File

@ -1,7 +1,7 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package project
package projects
import (
"testing"

View File

@ -1,7 +1,7 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package project
package projects
import (
"testing"

View File

@ -0,0 +1,119 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package projects
import (
"context"
"slices"
"strings"
issues_model "code.gitea.io/gitea/models/issues"
project_model "code.gitea.io/gitea/models/project"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/util"
notify_service "code.gitea.io/gitea/services/notify"
)
func init() {
notify_service.RegisterNotifier(&workflowNotifier{})
}
type workflowNotifier struct {
notify_service.NullNotifier
}
var _ notify_service.Notifier = &workflowNotifier{}
// NewNotifier create a new workflowNotifier notifier
func NewNotifier() notify_service.Notifier {
return &workflowNotifier{}
}
func (m *workflowNotifier) NewIssue(ctx context.Context, issue *issues_model.Issue, mentions []*user_model.User) {
if err := issue.LoadRepo(ctx); err != nil {
log.Error("NewIssue: LoadRepo: %v", err)
return
}
if err := issue.LoadProject(ctx); err != nil {
log.Error("NewIssue: LoadProject: %v", err)
return
}
if issue.Project == nil {
return
}
eventWorkflows, err := project_model.FindWorkflowEvents(ctx, issue.Project.ID)
if err != nil {
log.Error("NewIssue: FindWorkflowEvents: %v", err)
return
}
workflow := eventWorkflows[project_model.WorkflowEventItemAddedToProject]
if workflow == nil {
return
}
fireIssueWorkflow(ctx, workflow, issue)
}
func (m *workflowNotifier) IssueChangeStatus(ctx context.Context, doer *user_model.User, commitID string, issue *issues_model.Issue, actionComment *issues_model.Comment, isClosed bool) {
if err := issue.LoadRepo(ctx); err != nil {
log.Error("IssueChangeStatus: LoadRepo: %v", err)
return
}
if err := issue.LoadProject(ctx); err != nil {
log.Error("NewIssue: LoadProject: %v", err)
return
}
if issue.Project == nil {
return
}
eventWorkflows, err := project_model.FindWorkflowEvents(ctx, issue.Project.ID)
if err != nil {
log.Error("NewIssue: FindWorkflowEvents: %v", err)
return
}
workflowEvent := util.Iif(isClosed, project_model.WorkflowEventItemClosed, project_model.WorkflowEventItemReopened)
workflow := eventWorkflows[workflowEvent]
if workflow == nil {
return
}
fireIssueWorkflow(ctx, workflow, issue)
}
func fireIssueWorkflow(ctx context.Context, workflow *project_model.Workflow, issue *issues_model.Issue) {
for _, filter := range workflow.WorkflowFilters {
switch filter.Type {
case project_model.WorkflowFilterTypeScope:
values := strings.Split(filter.Value, ",")
if !(slices.Contains(values, "issue") && !issue.IsPull) || (slices.Contains(values, "pull") && issue.IsPull) {
return
}
default:
log.Error("NewIssue: Unsupported filter type: %s", filter.Type)
return
}
}
for _, action := range workflow.WorkflowActions {
switch action.ActionType {
case project_model.WorkflowActionTypeColumn:
column, err := project_model.GetColumnByProjectIDAndColumnName(ctx, issue.Project.ID, action.ActionValue)
if err != nil {
log.Error("NewIssue: GetColumnByProjectIDAndColumnName: %v", err)
continue
}
if err := project_model.AddIssueToColumn(ctx, issue.ID, column); err != nil {
log.Error("NewIssue: AddIssueToColumn: %v", err)
continue
}
default:
log.Error("NewIssue: Unsupported action type: %s", action.ActionType)
}
}
}

View File

@ -0,0 +1,13 @@
{{template "base/head" .}}
<div role="main" aria-label="{{.Title}}" class="page-content organization repository projects view-project">
{{if .ContextUser.IsOrganization}}
{{template "org/header" .}}
{{else}}
{{template "shared/user/org_profile_avatar" .}}
<div class="ui container tw-mb-4">
{{template "user/overview/header" .}}
</div>
{{end}}
{{template "projects/workflows" .}}
</div>
{{template "base/footer" .}}

View File

@ -0,0 +1,12 @@
<div class="ui container fluid padded projects-view">
<div class="ui container flex-text-block project-header">
<div class="four wide column">
{{range .WorkflowEvents}}
<div class="ui fluid vertical menu">
{{$workflow := (index $.Workflows .)}}
<a class="item{{if or (and $workflow $.CurWorkflow (eq $workflow.ID $.CurWorkflow.ID)) (eq $.workflowIDStr .UUID)}} active{{end}}" href="{{$.Project.Link ctx}}/workflows/{{if $workflow}}{{$workflow.ID}}{{else}}{{.UUID}}{{end}}">{{.ToString}}</a>
</div>
{{end}}
</div>
</div>
</div>

View File

@ -0,0 +1,14 @@
{{template "base/head" .}}
<div role="main" aria-label="{{.Title}}" class="page-content repository projects view-project">
{{template "repo/header" .}}
<div class="ui container padded">
<div class="flex-text-block tw-justify-end tw-mb-4">
<a class="ui small button" href="{{.RepoLink}}/labels">{{ctx.Locale.Tr "repo.labels"}}</a>
<a class="ui small button" href="{{.RepoLink}}/milestones">{{ctx.Locale.Tr "repo.milestones"}}</a>
<a class="ui small primary button" href="{{.RepoLink}}/issues/new/choose?project={{.Project.ID}}">{{ctx.Locale.Tr "repo.issues.new"}}</a>
</div>
</div>
{{template "projects/workflows" .}}
</div>
{{template "base/footer" .}}