diff --git a/modelmigration/migrations.go b/modelmigration/migrations.go index 96c2e26cf42..fc4bc6a8937 100644 --- a/modelmigration/migrations.go +++ b/modelmigration/migrations.go @@ -440,6 +440,7 @@ func prepareMigrationTasks() []*migration { newMigration(361, "Drop legacy commit_id column from comment", v29.DropCommentCommitID), newMigration(362, "Add reusable_workflow_ref column to action_run_job", v29.AddReusableWorkflowRefToActionRunJob), newMigration(363, "Add release_id to reaction table", v29.AddReleaseIDToReaction), + newMigration(364, "Add new table project_workflow", v29.AddProjectWorkflow), } return preparedMigrations } diff --git a/modelmigration/v29/v364.go b/modelmigration/v29/v364.go new file mode 100644 index 00000000000..ad3982759d3 --- /dev/null +++ b/modelmigration/v29/v364.go @@ -0,0 +1,26 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package v29 + +import ( + "context" + + "gitea.dev/modelmigration/base" + "gitea.dev/modules/timeutil" +) + +func AddProjectWorkflow(_ context.Context, x base.EngineMigration) error { + type ProjectWorkflow struct { + ID int64 `xorm:"pk autoincr"` + ProjectID int64 `xorm:"INDEX"` + WorkflowEvent string + WorkflowFilters string `xorm:"TEXT JSON"` + WorkflowActions string `xorm:"TEXT JSON"` + Enabled bool `xorm:"DEFAULT true NOT NULL"` + CreatedUnix timeutil.TimeStamp `xorm:"created"` + UpdatedUnix timeutil.TimeStamp `xorm:"updated"` + } + + return x.Sync(&ProjectWorkflow{}) +} diff --git a/models/audit/action.go b/models/audit/action.go index a3d81758e94..48d4b73eaa6 100644 --- a/models/audit/action.go +++ b/models/audit/action.go @@ -185,6 +185,10 @@ var ( ProjectUpdate = define("project:update", "Updated project {project} in {scope}.") ProjectDelete = define("project:delete", "Deleted project {project} from {scope}.") + ProjectWorkflowCreate = define("project:workflow:create", "Created workflow {workflow_id} for {event} in project {project} of {scope}.") + ProjectWorkflowUpdate = define("project:workflow:update", "Updated workflow {workflow_id} for {event} in project {project} of {scope}.") + ProjectWorkflowDelete = define("project:workflow:delete", "Deleted workflow {workflow_id} for {event} from project {project} of {scope}.") + WikiPageCreate = define("wiki:page:create", "Created wiki page {page} in repository {scope}.") WikiPageUpdate = define("wiki:page:update", "Updated wiki page {page} in repository {scope}.") WikiPageDelete = define("wiki:page:delete", "Deleted wiki page {page} from repository {scope}.") diff --git a/models/issues/comment.go b/models/issues/comment.go index c78bd2d8d25..9dd2ca23837 100644 --- a/models/issues/comment.go +++ b/models/issues/comment.go @@ -240,7 +240,10 @@ func (r RoleInRepo) LocaleHelper(lang translation.Locale) string { type SpecialDoerNameType string -const SpecialDoerNameCodeOwners SpecialDoerNameType = "CODEOWNERS" +const ( + SpecialDoerNameCodeOwners SpecialDoerNameType = "CODEOWNERS" + SpecialDoerNameProjectWorkflow SpecialDoerNameType = "ProjectWorkflow" +) // CommentMetaData stores metadata for a comment, these data will not be changed once inserted into database type CommentMetaData struct { @@ -248,9 +251,24 @@ type CommentMetaData struct { ProjectColumnTitle string `json:"project_column_title,omitempty"` ProjectTitle string `json:"project_title,omitempty"` + ProjectWorkflowEvent project_model.WorkflowEvent `json:"project_workflow_event,omitempty"` + SpecialDoerName SpecialDoerNameType `json:"special_doer_name,omitempty"` // e.g. "CODEOWNERS" for CODEOWNERS-triggered review requests } +type projectWorkflowContextKey struct{} + +func WithProjectWorkflow(ctx context.Context, event project_model.WorkflowEvent) context.Context { + return context.WithValue(ctx, projectWorkflowContextKey{}, &CommentMetaData{ + SpecialDoerName: SpecialDoerNameProjectWorkflow, + ProjectWorkflowEvent: event, + }) +} + +func IsProjectWorkflowContext(ctx context.Context) bool { + return ctx.Value(projectWorkflowContextKey{}) != nil +} + // Comment represents a comment in commit and issue page. type Comment struct { ID int64 `xorm:"pk autoincr"` @@ -789,10 +807,15 @@ func (c *Comment) MetaSpecialDoerTr(locale translation.Locale) template.HTML { if c.CommentMetaData == nil { return "" } - if c.CommentMetaData.SpecialDoerName == SpecialDoerNameCodeOwners { + switch c.CommentMetaData.SpecialDoerName { + case SpecialDoerNameCodeOwners: return locale.Tr("repo.issues.review.codeowners_rules") + case SpecialDoerNameProjectWorkflow: + return locale.Tr("repo.issues.project_workflow", htmlutil.HTMLFormat(`%s`, locale.Tr(c.CommentMetaData.ProjectWorkflowEvent.LangKey()))) + default: + // don't trust the content of SpecialDoerName, it might not be fully controlled by us + return htmlutil.HTMLFormat("%s", c.CommentMetaData.SpecialDoerName) } - return htmlutil.HTMLFormat("%s", c.CommentMetaData.SpecialDoerName) } func (c *Comment) TimelineRequestedReviewTr(locale translation.Locale, createdStr template.HTML) template.HTML { @@ -821,6 +844,25 @@ func (c *Comment) TimelineRequestedReviewTr(locale translation.Locale, createdSt return locale.Tr("repo.issues.review.add_review_request", assigneePrompt, createdStr) } +func buildCreateCommentMetaData(ctx context.Context, opts *CreateCommentOptions) *CommentMetaData { + var meta CommentMetaData + if workflowMeta, ok := ctx.Value(projectWorkflowContextKey{}).(*CommentMetaData); ok { + meta = *workflowMeta + } + if opts.ProjectColumnTitle != "" { + meta.ProjectColumnID = opts.ProjectColumnID + meta.ProjectColumnTitle = opts.ProjectColumnTitle + meta.ProjectTitle = opts.ProjectTitle + } + if opts.SpecialDoerName != "" { + meta.SpecialDoerName = opts.SpecialDoerName + } + if meta == (CommentMetaData{}) { + return nil + } + return &meta +} + // CreateComment creates comment with context func CreateComment(ctx context.Context, opts *CreateCommentOptions) (_ *Comment, err error) { return db.WithTx2(ctx, func(ctx context.Context) (*Comment, error) { @@ -829,20 +871,6 @@ func CreateComment(ctx context.Context, opts *CreateCommentOptions) (_ *Comment, LabelID = opts.Label.ID } - var commentMetaData *CommentMetaData - if opts.ProjectColumnTitle != "" { - commentMetaData = &CommentMetaData{ - ProjectColumnID: opts.ProjectColumnID, - ProjectColumnTitle: opts.ProjectColumnTitle, - ProjectTitle: opts.ProjectTitle, - } - } - if opts.SpecialDoerName != "" { - commentMetaData = &CommentMetaData{ - SpecialDoerName: opts.SpecialDoerName, - } - } - comment := &Comment{ Type: opts.Type, PosterID: opts.Doer.ID, @@ -875,7 +903,7 @@ func CreateComment(ctx context.Context, opts *CreateCommentOptions) (_ *Comment, RefIsPull: opts.RefIsPull, IsForcePush: opts.IsForcePush, Invalidated: opts.Invalidated, - CommentMetaData: commentMetaData, + CommentMetaData: buildCreateCommentMetaData(ctx, opts), } if err = db.Insert(ctx, comment); err != nil { return nil, err diff --git a/models/issues/comment_test.go b/models/issues/comment_test.go index 49408f67775..aa63956a011 100644 --- a/models/issues/comment_test.go +++ b/models/issues/comment_test.go @@ -9,6 +9,7 @@ import ( "gitea.dev/models/db" issues_model "gitea.dev/models/issues" + project_model "gitea.dev/models/project" repo_model "gitea.dev/models/repo" "gitea.dev/models/unittest" user_model "gitea.dev/models/user" @@ -44,6 +45,26 @@ func TestCreateComment(t *testing.T) { updatedIssue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: issue.ID}) unittest.AssertInt64InRange(t, now, then, int64(updatedIssue.UpdatedUnix)) + assert.Nil(t, comment.CommentMetaData) + + ctx := issues_model.WithProjectWorkflow(t.Context(), project_model.WorkflowEventItemClosed) + comment, err = issues_model.CreateComment(ctx, &issues_model.CreateCommentOptions{ + Type: issues_model.CommentTypeProjectColumn, + Doer: doer, + Repo: repo, + Issue: issue, + ProjectColumnID: 5, + ProjectColumnTitle: "Done", + ProjectTitle: "Board", + }) + require.NoError(t, err) + assert.Equal(t, &issues_model.CommentMetaData{ + ProjectColumnID: 5, + ProjectColumnTitle: "Done", + ProjectTitle: "Board", + ProjectWorkflowEvent: project_model.WorkflowEventItemClosed, + SpecialDoerName: issues_model.SpecialDoerNameProjectWorkflow, + }, unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ID: comment.ID}).CommentMetaData) } func TestLoadAssigneeUserAndTeam_DeletedTeamBecomesGhostTeam(t *testing.T) { diff --git a/models/issues/issue_project.go b/models/issues/issue_project.go index 59824f613b1..5a6908e91f6 100644 --- a/models/issues/issue_project.go +++ b/models/issues/issue_project.go @@ -67,8 +67,8 @@ func LoadProjectIssueColumnMap(ctx context.Context, projectID, defaultColumnID i // and removes projects that are currently assigned but not in newProjectIDs. // If newProjectIDs is empty, all projects are removed from the issue. // When adding an issue to a project, it is placed in the project's default column. -func IssueAssignOrRemoveProject(ctx context.Context, issue *Issue, doer *user_model.User, newProjectIDs []int64) error { - return db.WithTx(ctx, func(ctx context.Context) error { +func IssueAssignOrRemoveProject(ctx context.Context, issue *Issue, doer *user_model.User, newProjectIDs []int64) (added, removed []int64, err error) { + err = db.WithTx(ctx, func(ctx context.Context) error { if err := issue.LoadRepo(ctx); err != nil { return err } @@ -78,15 +78,15 @@ func IssueAssignOrRemoveProject(ctx context.Context, issue *Issue, doer *user_mo return err } - projectsToAdd, projectsToRemove := util.DiffSlice(oldProjectIDs, newProjectIDs) + added, removed = util.DiffSlice(oldProjectIDs, newProjectIDs) issue.isProjectsLoaded = false issue.Projects = nil - if len(projectsToRemove) > 0 { - if _, err := db.GetEngine(ctx).Where("issue_id=?", issue.ID).In("project_id", projectsToRemove).Delete(&project_model.ProjectIssue{}); err != nil { + if len(removed) > 0 { + if _, err := db.GetEngine(ctx).Where("issue_id=?", issue.ID).In("project_id", removed).Delete(&project_model.ProjectIssue{}); err != nil { return err } - for _, projectID := range projectsToRemove { + for _, projectID := range removed { if _, err := CreateComment(ctx, &CreateCommentOptions{ Type: CommentTypeProject, Doer: doer, @@ -100,13 +100,13 @@ func IssueAssignOrRemoveProject(ctx context.Context, issue *Issue, doer *user_mo } } - if len(projectsToAdd) > 0 { - projectMap, err := project_model.GetProjectsMapByIDs(ctx, projectsToAdd) + if len(added) > 0 { + projectMap, err := project_model.GetProjectsMapByIDs(ctx, added) if err != nil { return err } - for _, projectID := range projectsToAdd { + for _, projectID := range added { newProject, ok := projectMap[projectID] if !ok { return util.NewNotExistErrorf("project %d not found", projectID) @@ -149,4 +149,5 @@ func IssueAssignOrRemoveProject(ctx context.Context, issue *Issue, doer *user_mo } return nil }) + return added, removed, err } diff --git a/models/issues/issue_project_multi_test.go b/models/issues/issue_project_multi_test.go index 5d5aae3b9ce..8023ee67d63 100644 --- a/models/issues/issue_project_multi_test.go +++ b/models/issues/issue_project_multi_test.go @@ -37,21 +37,21 @@ func TestIssueMultipleProjects(t *testing.T) { _ = project_model.DeleteProjectByID(t.Context(), project2.ID) }() - err := issues_model.IssueAssignOrRemoveProject(t.Context(), issue1, user2, []int64{}) + _, _, err := issues_model.IssueAssignOrRemoveProject(t.Context(), issue1, user2, []int64{}) require.NoError(t, err) err = issue1.LoadProjects(t.Context()) require.NoError(t, err) require.Empty(t, issue1.Projects) // assign issue to both projects (each project uses its own default column) - err = issues_model.IssueAssignOrRemoveProject(t.Context(), issue1, user2, []int64{project1.ID}) + _, _, err = issues_model.IssueAssignOrRemoveProject(t.Context(), issue1, user2, []int64{project1.ID}) require.NoError(t, err) assert.Nilf(t, issue1.Projects, "Issue's Projects should be nil after IssueAssignOrRemoveProject to ensure it reloads fresh data") err = issue1.LoadProjects(t.Context()) require.NoError(t, err) require.Len(t, issue1.Projects, 1) - err = issues_model.IssueAssignOrRemoveProject(t.Context(), issue1, user2, []int64{project1.ID, project2.ID}) + _, _, err = issues_model.IssueAssignOrRemoveProject(t.Context(), issue1, user2, []int64{project1.ID, project2.ID}) require.NoError(t, err) assert.Nilf(t, issue1.Projects, "Issue's Projects should be nil after IssueAssignOrRemoveProject to ensure it reloads fresh data") err = issue1.LoadProjects(t.Context()) @@ -68,7 +68,7 @@ func TestIssueMultipleProjects(t *testing.T) { assert.Equal(t, p2Col.ID, projectColumnMap[project2.ID]) // only keep project2 - err = issues_model.IssueAssignOrRemoveProject(t.Context(), issue1, user2, []int64{project2.ID}) + _, _, err = issues_model.IssueAssignOrRemoveProject(t.Context(), issue1, user2, []int64{project2.ID}) require.NoError(t, err) err = issue1.LoadProjects(t.Context()) require.NoError(t, err) @@ -84,7 +84,7 @@ func TestIssueMultipleProjects(t *testing.T) { assert.Equal(t, project2.ID, issue1.Projects[0].ID) // remove issue's projects - err = issues_model.IssueAssignOrRemoveProject(t.Context(), issue1, user2, []int64{}) + _, _, err = issues_model.IssueAssignOrRemoveProject(t.Context(), issue1, user2, []int64{}) require.NoError(t, err) err = issue1.LoadProjects(t.Context()) require.NoError(t, err) @@ -114,11 +114,11 @@ func TestIssueMultipleProjects(t *testing.T) { } // Assign issue1 to projects 1 and 2 - err := issues_model.IssueAssignOrRemoveProject(t.Context(), issue1, user2, []int64{projects[0].ID, projects[1].ID}) + _, _, err := issues_model.IssueAssignOrRemoveProject(t.Context(), issue1, user2, []int64{projects[0].ID, projects[1].ID}) require.NoError(t, err) // Assign issue2 to project 3 - err = issues_model.IssueAssignOrRemoveProject(t.Context(), issue2, user2, []int64{projects[2].ID}) + _, _, err = issues_model.IssueAssignOrRemoveProject(t.Context(), issue2, user2, []int64{projects[2].ID}) require.NoError(t, err) // Query for issues in project 3 only (should find issue2) @@ -141,9 +141,9 @@ func TestIssueMultipleProjects(t *testing.T) { // FIXME: ISSUE-MULTIPLE-PROJECTS-FILTER: no multiple project filter support yet. Search logic is wrong. It should use "AND" but not "OR". // Clean up - err = issues_model.IssueAssignOrRemoveProject(t.Context(), issue1, user2, []int64{}) + _, _, err = issues_model.IssueAssignOrRemoveProject(t.Context(), issue1, user2, []int64{}) require.NoError(t, err) - err = issues_model.IssueAssignOrRemoveProject(t.Context(), issue2, user2, []int64{}) + _, _, err = issues_model.IssueAssignOrRemoveProject(t.Context(), issue2, user2, []int64{}) require.NoError(t, err) }) } diff --git a/models/project/column.go b/models/project/column.go index 37802f256fa..80432d54f2e 100644 --- a/models/project/column.go +++ b/models/project/column.go @@ -189,7 +189,7 @@ func deleteColumnByID(ctx context.Context, columnID int64) error { return errColumnIsDefault } - // move all issues to the default column + // move all issues to the default column, bulk moves intentionally don't trigger project workflows project, err := GetProjectByID(ctx, column.ProjectID) if err != nil { return err diff --git a/models/project/project.go b/models/project/project.go index 97c1bee27a7..d57df23dbbb 100644 --- a/models/project/project.go +++ b/models/project/project.go @@ -435,6 +435,10 @@ func DeleteProjectByID(ctx context.Context, id int64) error { return err } + if _, err := db.Delete[Workflow](ctx, FindWorkflowsOptions{ProjectIDs: []int64{id}}); err != nil { + return err + } + if _, err = db.GetEngine(ctx).ID(p.ID).Delete(new(Project)); err != nil { return err } @@ -444,6 +448,9 @@ func DeleteProjectByID(ctx context.Context, id int64) error { } func DeleteProjectByRepoID(ctx context.Context, repoID int64) error { + if _, err := db.GetEngine(ctx).In("project_id", builder.Select("id").From("project").Where(builder.Eq{"repo_id": repoID})).Delete(new(Workflow)); err != nil { + return err + } switch { case setting.Database.Type.IsSQLite3(): if _, err := db.GetEngine(ctx).Exec("DELETE FROM project_issue WHERE project_issue.id IN (SELECT project_issue.id FROM project_issue INNER JOIN project WHERE project.id = project_issue.project_id AND project.repo_id = ?)", repoID); err != nil { diff --git a/models/project/workflows.go b/models/project/workflows.go new file mode 100644 index 00000000000..968435ff7d8 --- /dev/null +++ b/models/project/workflows.go @@ -0,0 +1,256 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package project + +import ( + "context" + "slices" + + "gitea.dev/models/db" + repo_model "gitea.dev/models/repo" + "gitea.dev/modules/optional" + "gitea.dev/modules/timeutil" + + "xorm.io/builder" +) + +type WorkflowEvent string + +const ( + WorkflowEventItemAddedToProject WorkflowEvent = "item_added_to_project" + WorkflowEventItemRemovedFromProject WorkflowEvent = "item_removed_from_project" + WorkflowEventItemReopened WorkflowEvent = "item_reopened" + WorkflowEventItemClosed WorkflowEvent = "item_closed" + WorkflowEventItemColumnChanged WorkflowEvent = "item_column_changed" + WorkflowEventCodeChangesRequested WorkflowEvent = "code_changes_requested" + WorkflowEventCodeReviewApproved WorkflowEvent = "code_review_approved" + WorkflowEventPullRequestMerged WorkflowEvent = "pull_request_merged" +) + +type WorkflowFilterType string + +const ( + WorkflowFilterTypeIssueType WorkflowFilterType = "issue_type" + WorkflowFilterTypeSourceColumn WorkflowFilterType = "source_column" + WorkflowFilterTypeTargetColumn WorkflowFilterType = "target_column" + WorkflowFilterTypeLabels WorkflowFilterType = "labels" +) + +const ( + WorkflowIssueTypeIssue = "issue" + WorkflowIssueTypePullRequest = "pull_request" +) + +type WorkflowFilters struct { + IssueType string `json:"issue_type,omitempty"` + SourceColumnID int64 `json:"source_column_id,omitempty"` + TargetColumnID int64 `json:"target_column_id,omitempty"` + LabelIDs []int64 `json:"label_ids,omitempty"` +} + +func (f WorkflowFilters) Types() (types []WorkflowFilterType) { + if f.IssueType != "" { + types = append(types, WorkflowFilterTypeIssueType) + } + if f.SourceColumnID != 0 { + types = append(types, WorkflowFilterTypeSourceColumn) + } + if f.TargetColumnID != 0 { + types = append(types, WorkflowFilterTypeTargetColumn) + } + if len(f.LabelIDs) > 0 { + types = append(types, WorkflowFilterTypeLabels) + } + return types +} + +type WorkflowActionType string + +const ( + WorkflowActionTypeColumn WorkflowActionType = "column" + WorkflowActionTypeAddLabels WorkflowActionType = "add_labels" + WorkflowActionTypeRemoveLabels WorkflowActionType = "remove_labels" + WorkflowActionTypeIssueState WorkflowActionType = "issue_state" +) + +const ( + WorkflowIssueStateClose = "close" + WorkflowIssueStateReopen = "reopen" +) + +type WorkflowActions struct { + ColumnID int64 `json:"column_id,omitempty"` + AddLabelIDs []int64 `json:"add_label_ids,omitempty"` + RemoveLabelIDs []int64 `json:"remove_label_ids,omitempty"` + IssueState string `json:"issue_state,omitempty"` +} + +func (a WorkflowActions) Types() (types []WorkflowActionType) { + if a.ColumnID != 0 { + types = append(types, WorkflowActionTypeColumn) + } + if len(a.AddLabelIDs) > 0 { + types = append(types, WorkflowActionTypeAddLabels) + } + if len(a.RemoveLabelIDs) > 0 { + types = append(types, WorkflowActionTypeRemoveLabels) + } + if a.IssueState != "" { + types = append(types, WorkflowActionTypeIssueState) + } + return types +} + +type WorkflowEventCapabilities struct { + Filters []WorkflowFilterType + Actions []WorkflowActionType +} + +var ( + itemFilters = []WorkflowFilterType{WorkflowFilterTypeIssueType, WorkflowFilterTypeLabels} + pullFilters = []WorkflowFilterType{WorkflowFilterTypeLabels} + pullActions = []WorkflowActionType{WorkflowActionTypeColumn, WorkflowActionTypeAddLabels, WorkflowActionTypeRemoveLabels} +) + +var workflowEvents = []struct { + event WorkflowEvent + filters []WorkflowFilterType + actions []WorkflowActionType +}{ + {WorkflowEventItemAddedToProject, itemFilters, []WorkflowActionType{WorkflowActionTypeColumn, WorkflowActionTypeAddLabels, WorkflowActionTypeRemoveLabels, WorkflowActionTypeIssueState}}, + {WorkflowEventItemRemovedFromProject, itemFilters, []WorkflowActionType{WorkflowActionTypeAddLabels, WorkflowActionTypeRemoveLabels, WorkflowActionTypeIssueState}}, + {WorkflowEventItemReopened, itemFilters, pullActions}, + {WorkflowEventItemClosed, itemFilters, pullActions}, + { + WorkflowEventItemColumnChanged, + []WorkflowFilterType{WorkflowFilterTypeIssueType, WorkflowFilterTypeSourceColumn, WorkflowFilterTypeTargetColumn, WorkflowFilterTypeLabels}, + []WorkflowActionType{WorkflowActionTypeAddLabels, WorkflowActionTypeRemoveLabels, WorkflowActionTypeIssueState}, + }, + {WorkflowEventCodeChangesRequested, pullFilters, pullActions}, + {WorkflowEventCodeReviewApproved, pullFilters, pullActions}, + {WorkflowEventPullRequestMerged, pullFilters, pullActions}, +} + +func WorkflowEvents() []WorkflowEvent { + events := make([]WorkflowEvent, len(workflowEvents)) + for i, event := range workflowEvents { + events[i] = event.event + } + return events +} + +func (we WorkflowEvent) IsValid() bool { + return slices.Contains(WorkflowEvents(), we) +} + +func (we WorkflowEvent) Capabilities() WorkflowEventCapabilities { + for _, event := range workflowEvents { + if event.event == we { + return WorkflowEventCapabilities{Filters: event.filters, Actions: event.actions} + } + } + return WorkflowEventCapabilities{} +} + +func (we WorkflowEvent) LangKey() string { + return "projects.workflows.event." + string(we) +} + +type Workflow struct { + ID int64 `xorm:"pk autoincr"` + ProjectID int64 `xorm:"INDEX"` + WorkflowEvent WorkflowEvent + WorkflowFilters WorkflowFilters `xorm:"TEXT JSON"` + WorkflowActions WorkflowActions `xorm:"TEXT JSON"` + Enabled bool `xorm:"DEFAULT true NOT NULL"` + CreatedUnix timeutil.TimeStamp `xorm:"created"` + UpdatedUnix timeutil.TimeStamp `xorm:"updated"` +} + +func (wf *Workflow) UnsupportedRule() string { + capabilities := wf.WorkflowEvent.Capabilities() + for _, typ := range wf.WorkflowFilters.Types() { + if !slices.Contains(capabilities.Filters, typ) { + return string(typ) + } + } + for _, typ := range wf.WorkflowActions.Types() { + if !slices.Contains(capabilities.Actions, typ) { + return string(typ) + } + } + return "" +} + +func (Workflow) TableName() string { + return "project_workflow" +} + +func init() { + db.RegisterModel(new(Workflow)) +} + +type FindWorkflowsOptions struct { + db.ListOptions + ProjectIDs []int64 + Event WorkflowEvent + Enabled optional.Option[bool] +} + +func (opts FindWorkflowsOptions) ToConds() builder.Cond { + cond := builder.NewCond() + if len(opts.ProjectIDs) > 0 { + cond = cond.And(builder.In("project_id", opts.ProjectIDs)) + } + if opts.Event != "" { + cond = cond.And(builder.Eq{"workflow_event": opts.Event}) + } + if opts.Enabled.Has() { + cond = cond.And(builder.Eq{"enabled": opts.Enabled.Value()}) + } + return cond +} + +func (opts FindWorkflowsOptions) ToOrders() string { + return "project_id ASC, id ASC" // keep execution order stable across database engines +} + +// FindEnabledWorkflows returns the workflows of open projects among projectIDs that can hold items of repo, see CanBeAccessedByOwnerRepo +func FindEnabledWorkflows(ctx context.Context, repo *repo_model.Repository, projectIDs []int64, event WorkflowEvent) ([]*Workflow, error) { + if len(projectIDs) == 0 { + return nil, nil + } + opts := FindWorkflowsOptions{ProjectIDs: projectIDs, Event: event, Enabled: optional.Some(true)} + holdsRepoItems := builder.Eq{"repo_id": repo.ID}.Or(builder.Eq{"owner_id": repo.OwnerID, "repo_id": 0}) + workflows := make([]*Workflow, 0) + err := db.GetEngine(ctx).Where(opts.ToConds()). + In("project_id", builder.Select("id").From("project").Where(builder.Eq{"is_closed": false}.And(holdsRepoItems))). + OrderBy(opts.ToOrders()).Find(&workflows) + return workflows, err +} + +func GetWorkflowByProjectAndID(ctx context.Context, projectID, workflowID int64) (*Workflow, error) { + workflow, exist, err := db.Get[Workflow](ctx, builder.Eq{"project_id": projectID, "id": workflowID}) + if err != nil { + return nil, err + } + if !exist { + return nil, db.ErrNotExist{Resource: "ProjectWorkflow", ID: workflowID} + } + return workflow, nil +} + +func CreateWorkflow(ctx context.Context, wf *Workflow) error { + return db.Insert(ctx, wf) +} + +func UpdateWorkflow(ctx context.Context, wf *Workflow, cols ...string) error { + _, err := db.GetEngine(ctx).ID(wf.ID).Where("project_id=?", wf.ProjectID).Cols(cols...).Update(wf) + return err +} + +func DeleteWorkflow(ctx context.Context, projectID, id int64) error { + _, err := db.GetEngine(ctx).ID(id).Where("project_id=?", projectID).Delete(&Workflow{}) + return err +} diff --git a/models/project/workflows_test.go b/models/project/workflows_test.go new file mode 100644 index 00000000000..606df2370f7 --- /dev/null +++ b/models/project/workflows_test.go @@ -0,0 +1,60 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package project + +import ( + "testing" + + "gitea.dev/models/db" + repo_model "gitea.dev/models/repo" + "gitea.dev/models/unittest" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestWorkflowCRUD(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + ctx := t.Context() + + newWorkflow := func(event WorkflowEvent) *Workflow { + wf := &Workflow{ProjectID: 1, WorkflowEvent: event, Enabled: true} + require.NoError(t, CreateWorkflow(ctx, wf)) + return wf + } + closed := newWorkflow(WorkflowEventItemClosed) + added := newWorkflow(WorkflowEventItemAddedToProject) + closed2 := newWorkflow(WorkflowEventItemClosed) + + closed.Enabled = false + require.NoError(t, UpdateWorkflow(ctx, closed, "enabled")) + require.NoError(t, UpdateWorkflow(ctx, &Workflow{ID: added.ID, ProjectID: 2}, "enabled")) + require.NoError(t, DeleteWorkflow(ctx, 2, added.ID)) + assert.True(t, unittest.AssertExistsAndLoadBean(t, &Workflow{ID: added.ID}).Enabled, "other projects cannot modify it") + + workflows, err := db.Find[Workflow](ctx, FindWorkflowsOptions{ProjectIDs: []int64{1}}) + require.NoError(t, err) + require.Len(t, workflows, 3) + assert.Equal(t, []int64{closed.ID, added.ID, closed2.ID}, []int64{workflows[0].ID, workflows[1].ID, workflows[2].ID}) + + repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + enabled, err := FindEnabledWorkflows(ctx, repo1, []int64{1, 2}, WorkflowEventItemClosed) + require.NoError(t, err) + require.Len(t, enabled, 1) + assert.Equal(t, closed2.ID, enabled[0].ID) + enabled, err = FindEnabledWorkflows(ctx, unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2}), []int64{1}, WorkflowEventItemClosed) + require.NoError(t, err) + assert.Empty(t, enabled) + + require.NoError(t, ChangeProjectStatus(ctx, unittest.AssertExistsAndLoadBean(t, &Project{ID: 1}), true)) + enabled, err = FindEnabledWorkflows(ctx, repo1, []int64{1, 2}, WorkflowEventItemClosed) + require.NoError(t, err) + assert.Empty(t, enabled) + + require.NoError(t, DeleteWorkflow(ctx, 1, closed2.ID)) + unittest.AssertNotExistsBean(t, &Workflow{ID: closed2.ID}) + + require.NoError(t, DeleteProjectByID(ctx, 1)) + unittest.AssertNotExistsBean(t, &Workflow{ProjectID: 1}) +} diff --git a/modules/structs/project_workflow.go b/modules/structs/project_workflow.go new file mode 100644 index 00000000000..d3389eaf9e7 --- /dev/null +++ b/modules/structs/project_workflow.go @@ -0,0 +1,66 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package structs + +import ( + "time" +) + +// ProjectWorkflowFilters restricts which items a project workflow runs for +// swagger:model +type ProjectWorkflowFilters struct { + // "issue" or "pull_request", empty matches both + IssueType string `json:"issue_type,omitempty"` + // only for item_column_changed + SourceColumnID int64 `json:"source_column_id,omitempty"` + // only for item_column_changed + TargetColumnID int64 `json:"target_column_id,omitempty"` + // the item must have all of these labels + LabelIDs []int64 `json:"label_ids,omitempty"` +} + +// ProjectWorkflowActions describes what a project workflow does to a matching item +// swagger:model +type ProjectWorkflowActions struct { + ColumnID int64 `json:"column_id,omitempty"` + AddLabelIDs []int64 `json:"add_label_ids,omitempty"` + RemoveLabelIDs []int64 `json:"remove_label_ids,omitempty"` + // "close" or "reopen" + IssueState string `json:"issue_state,omitempty"` +} + +// ProjectWorkflow represents a project workflow +// swagger:model +type ProjectWorkflow struct { + ID int64 `json:"id"` + // one of item_added_to_project, item_removed_from_project, item_reopened, item_closed, item_column_changed, + // code_changes_requested, code_review_approved, pull_request_merged + Event string `json:"event"` + Enabled bool `json:"enabled"` + Filters ProjectWorkflowFilters `json:"filters"` + Actions ProjectWorkflowActions `json:"actions"` + // swagger:strfmt date-time + Created time.Time `json:"created_at"` + // swagger:strfmt date-time + Updated time.Time `json:"updated_at"` +} + +// CreateProjectWorkflowOption options for creating a project workflow +// swagger:model +type CreateProjectWorkflowOption struct { + // required: true + Event string `json:"event" binding:"Required"` + // defaults to true + Enabled *bool `json:"enabled,omitempty"` + Filters ProjectWorkflowFilters `json:"filters"` + Actions ProjectWorkflowActions `json:"actions"` +} + +// EditProjectWorkflowOption options for editing a project workflow, omitted fields are left unchanged +// swagger:model +type EditProjectWorkflowOption struct { + Enabled *bool `json:"enabled,omitempty"` + Filters *ProjectWorkflowFilters `json:"filters,omitempty"` + Actions *ProjectWorkflowActions `json:"actions,omitempty"` +} diff --git a/options/locale/locale_en-US.json b/options/locale/locale_en-US.json index 4c8c84286d3..3a91a95d71c 100644 --- a/options/locale/locale_en-US.json +++ b/options/locale/locale_en-US.json @@ -1538,6 +1538,7 @@ "repo.issues.context.edit": "Edit", "repo.issues.context.delete": "Delete", "repo.issues.no_content": "No description provided.", + "repo.issues.project_workflow": "Project workflow %s", "repo.issues.comment_no_content": "No comment provided.", "repo.issues.close": "Close Issue", "repo.issues.comment_pull_merged_at": "merged commit %[1]s into %[2]s %[3]s", @@ -3986,6 +3987,44 @@ "projects.type-3.display_name": "Organization Project", "projects.enter_fullscreen": "Fullscreen", "projects.exit_fullscreen": "Exit Fullscreen", + "projects.workflows": "Workflows", + "projects.workflows.event.item_added_to_project": "Item added to project", + "projects.workflows.event.item_removed_from_project": "Item removed from project", + "projects.workflows.event.item_reopened": "Item reopened", + "projects.workflows.event.item_closed": "Item closed", + "projects.workflows.event.item_column_changed": "Item column changed", + "projects.workflows.event.code_changes_requested": "Code changes requested", + "projects.workflows.event.code_review_approved": "Code review approved", + "projects.workflows.event.pull_request_merged": "Pull request merged", + "projects.workflows.filters": "Filters", + "projects.workflows.apply_to": "Apply to", + "projects.workflows.when_moved_from_column": "When moved from column", + "projects.workflows.when_moved_to_column": "When moved to column", + "projects.workflows.only_if_has_labels": "Only if it has labels", + "projects.workflows.default_workflows": "Default workflows", + "projects.workflows.runs_as_hint": "Workflows act on their items without further permission checks, so only users who can manage them may edit them. Deleting a column or transferring a repository does not trigger workflows.", + "projects.workflows.actions": "Actions", + "projects.workflows.move_to_column": "Move to column", + "projects.workflows.add_labels": "Add labels", + "projects.workflows.remove_labels": "Remove labels", + "projects.workflows.any_label": "Any label", + "projects.workflows.any_column": "Any column", + "projects.workflows.issue_state": "Issue state", + "projects.workflows.none": "None", + "projects.workflows.no_change": "No change", + "projects.workflows.clone": "Clone", + "projects.workflows.disable": "Disable", + "projects.workflows.enable": "Enable", + "projects.workflows.issues_and_pull_requests": "Issues and pull requests", + "projects.workflows.issues_only": "Issues only", + "projects.workflows.pull_requests_only": "Pull requests only", + "projects.workflows.summary.source": "Source: %[1]s", + "projects.workflows.summary.target": "Target: %[1]s", + "projects.workflows.summary.labels": "Labels: %[1]s", + "projects.workflows.close_issue": "Close issue", + "projects.workflows.reopen_issue": "Reopen issue", + "projects.workflows.at_least_one_action_required": "At least one action must be configured", + "projects.workflows.delete_confirm": "Are you sure you want to delete this workflow?", "git.filemode.changed_filemode": "%[1]s → %[2]s", "git.filemode.directory": "Directory", "git.filemode.normal_file": "Regular", diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index 8231dae185d..6cebb1d1842 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -775,6 +775,8 @@ func addProjectRoutes(m *web.Router, writeChecks ...any) { m.Get("", shared.GetProjectColumn) m.Get("/issues", shared.ListProjectColumnIssues) }) + m.Get("/workflows", shared.ListProjectWorkflows) + m.Get("/workflows/{workflow_id}", shared.GetProjectWorkflow) }) m.Group("", func() { m.Post("", bind(api.CreateProjectOption{}), shared.CreateProject) @@ -791,6 +793,11 @@ func addProjectRoutes(m *web.Router, writeChecks ...any) { m.Delete("/issues/{issue_id}", shared.RemoveIssueFromProjectColumn) }) m.Post("/issues/{issue_id}/move", bind(api.MoveProjectIssueOption{}), shared.MoveProjectIssue) + m.Post("/workflows", bind(api.CreateProjectWorkflowOption{}), shared.CreateProjectWorkflow) + m.Group("/workflows/{workflow_id}", func() { + m.Patch("", bind(api.EditProjectWorkflowOption{}), shared.EditProjectWorkflow) + m.Delete("", shared.DeleteProjectWorkflow) + }) }) }, writeChecks...) } diff --git a/routers/api/v1/repo/issue.go b/routers/api/v1/repo/issue.go index 4453b888c98..035d40cf8b9 100644 --- a/routers/api/v1/repo/issue.go +++ b/routers/api/v1/repo/issue.go @@ -616,8 +616,9 @@ func CreateIssue(ctx *context.APIContext) { return } } else { - // setting labels is not allowed if user is not a writer + // setting labels and projects is not allowed if user is not a writer form.Labels = make([]int64, 0) + form.Projects = nil } if err := issue_service.NewIssue(ctx, ctx.Repo.Repository, issue, form.Labels, nil, assigneeIDs, form.Projects); err != nil { @@ -631,7 +632,15 @@ func CreateIssue(ctx *context.APIContext) { return } - if form.Closed { + // Refetch from database to assign some automatic values and project workflow changes + var err error + issue, err = issues_model.GetIssueByID(ctx, issue.ID) + if err != nil { + ctx.APIErrorInternal(err) + return + } + + if form.Closed && !issue.IsClosed { if err := issue_service.CloseIssue(ctx, issue, ctx.Doer, ""); err != nil { if issues_model.IsErrDependenciesLeft(err) { ctx.APIError(http.StatusPreconditionFailed, "cannot close this issue because it still has open dependencies") @@ -641,14 +650,6 @@ func CreateIssue(ctx *context.APIContext) { return } } - - // Refetch from database to assign some automatic values - var err error - issue, err = issues_model.GetIssueByID(ctx, issue.ID) - if err != nil { - ctx.APIErrorInternal(err) - return - } ctx.JSON(http.StatusCreated, convert.ToAPIIssue(ctx, ctx.Doer, issue)) } @@ -824,7 +825,7 @@ func EditIssue(ctx *context.APIContext) { // Update projects if provided if canWrite && form.Projects != nil { - if err := issues_model.IssueAssignOrRemoveProject(ctx, issue, ctx.Doer, *form.Projects); err != nil { + if err := issue_service.AssignOrRemoveProjects(ctx, issue, ctx.Doer, *form.Projects); err != nil { if errors.Is(err, util.ErrPermissionDenied) || errors.Is(err, util.ErrNotExist) { ctx.APIError(http.StatusBadRequest, err.Error()) } else { diff --git a/routers/api/v1/shared/project.go b/routers/api/v1/shared/project.go index cc0c4e15d87..9fa975eb2e3 100644 --- a/routers/api/v1/shared/project.go +++ b/routers/api/v1/shared/project.go @@ -109,8 +109,8 @@ func (s projectScope) findColumnIssue(ctx *context.APIContext) (*project_model.C return column, s.findIssue(ctx) } -// findIssue resolves an issue addressable within this scope. Owner-level boards span -// repositories, so the issue is looked up globally and gated on the doer's read access. +// findIssue resolves an issue addressable within this scope, gated on the doer's read access. +// Owner-level boards span repositories, so the issue is looked up globally there. func (s projectScope) findIssue(ctx *context.APIContext) *issues_model.Issue { issueID := ctx.PathParamInt64("issue_id") var issue *issues_model.Issue @@ -124,26 +124,24 @@ func (s projectScope) findIssue(ctx *context.APIContext) *issues_model.Issue { ctx.APIErrorAuto(err) return nil } - if s.Repo == nil { - if err := issue.LoadRepo(ctx); err != nil { - ctx.APIErrorInternal(err) - return nil - } - perm, err := access_model.GetDoerRepoPermission(ctx, issue.Repo, ctx.Doer) - if err != nil { - ctx.APIErrorInternal(err) - return nil - } - // hide the issue's existence rather than reporting it as forbidden - if !perm.CanReadIssuesOrPulls(issue.IsPull) { - ctx.APIErrorNotFound() - return nil - } - // the repo route group has mustNotBeArchived, owner boards have to check per issue - if issue.Repo.IsArchived { - ctx.APIError(http.StatusLocked, "repo is archived") - return nil - } + if err := issue.LoadRepo(ctx); err != nil { + ctx.APIErrorInternal(err) + return nil + } + perm, err := access_model.GetDoerRepoPermission(ctx, issue.Repo, ctx.Doer) + if err != nil { + ctx.APIErrorInternal(err) + return nil + } + // hide the issue's existence rather than reporting it as forbidden + if !perm.CanReadIssuesOrPulls(issue.IsPull) { + ctx.APIErrorNotFound() + return nil + } + // the repo route group has mustNotBeArchived, owner boards have to check per issue + if issue.Repo.IsArchived { + ctx.APIError(http.StatusLocked, "repo is archived") + return nil } return issue } diff --git a/routers/api/v1/shared/project_workflow.go b/routers/api/v1/shared/project_workflow.go new file mode 100644 index 00000000000..2bdfcd0af42 --- /dev/null +++ b/routers/api/v1/shared/project_workflow.go @@ -0,0 +1,588 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package shared + +import ( + "net/http" + + "gitea.dev/models/db" + project_model "gitea.dev/models/project" + api "gitea.dev/modules/structs" + "gitea.dev/modules/web" + "gitea.dev/routers/api/v1/utils" + "gitea.dev/services/context" + "gitea.dev/services/convert" + project_service "gitea.dev/services/projects" +) + +func findWorkflow(ctx *context.APIContext, project *project_model.Project) *project_model.Workflow { + workflow, err := project_model.GetWorkflowByProjectAndID(ctx, project.ID, ctx.PathParamInt64("workflow_id")) + if err != nil { + ctx.APIErrorAuto(err) + return nil + } + return workflow +} + +func ListProjectWorkflows(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/projects/{id}/workflows repository repoListProjectWorkflows + // --- + // summary: List a project's workflows + // produces: + // - application/json + // parameters: + // - name: owner + // in: path + // description: owner of the repo + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repo + // type: string + // required: true + // - name: id + // in: path + // description: id of the project + // type: integer + // format: int64 + // required: true + // - name: page + // in: query + // description: page number of results to return (1-based) + // type: integer + // - name: limit + // in: query + // description: page size of results + // type: integer + // responses: + // "200": + // "$ref": "#/responses/ProjectWorkflowList" + // "404": + // "$ref": "#/responses/notFound" + + // swagger:operation GET /orgs/{org}/projects/{id}/workflows organization orgListProjectWorkflows + // --- + // summary: List a project's workflows + // produces: + // - application/json + // parameters: + // - name: org + // in: path + // description: name of the organization + // type: string + // required: true + // - name: id + // in: path + // description: id of the project + // type: integer + // format: int64 + // required: true + // - name: page + // in: query + // description: page number of results to return (1-based) + // type: integer + // - name: limit + // in: query + // description: page size of results + // type: integer + // responses: + // "200": + // "$ref": "#/responses/ProjectWorkflowList" + // "404": + // "$ref": "#/responses/notFound" + + // swagger:operation GET /user/projects/{id}/workflows user userCurrentListProjectWorkflows + // --- + // summary: List a project's workflows + // produces: + // - application/json + // parameters: + // - name: id + // in: path + // description: id of the project + // type: integer + // format: int64 + // required: true + // - name: page + // in: query + // description: page number of results to return (1-based) + // type: integer + // - name: limit + // in: query + // description: page size of results + // type: integer + // responses: + // "200": + // "$ref": "#/responses/ProjectWorkflowList" + // "404": + // "$ref": "#/responses/notFound" + + project := projectScopeFromContext(ctx).findProject(ctx) + if ctx.Written() { + return + } + listOptions := utils.GetListOptions(ctx) + workflows, total, err := db.FindAndCount[project_model.Workflow](ctx, project_model.FindWorkflowsOptions{ListOptions: listOptions, ProjectIDs: []int64{project.ID}}) + if err != nil { + ctx.APIErrorInternal(err) + return + } + ctx.SetLinkHeader(total, listOptions.PageSize) + ctx.SetTotalCountHeader(total) + ctx.JSON(http.StatusOK, convert.ToProjectWorkflowList(workflows)) +} + +func GetProjectWorkflow(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/projects/{id}/workflows/{workflow_id} repository repoGetProjectWorkflow + // --- + // summary: Get a project workflow + // produces: + // - application/json + // parameters: + // - name: owner + // in: path + // description: owner of the repo + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repo + // type: string + // required: true + // - name: id + // in: path + // description: id of the project + // type: integer + // format: int64 + // required: true + // - name: workflow_id + // in: path + // description: id of the workflow + // type: integer + // format: int64 + // required: true + // responses: + // "200": + // "$ref": "#/responses/ProjectWorkflow" + // "404": + // "$ref": "#/responses/notFound" + + // swagger:operation GET /orgs/{org}/projects/{id}/workflows/{workflow_id} organization orgGetProjectWorkflow + // --- + // summary: Get a project workflow + // produces: + // - application/json + // parameters: + // - name: org + // in: path + // description: name of the organization + // type: string + // required: true + // - name: id + // in: path + // description: id of the project + // type: integer + // format: int64 + // required: true + // - name: workflow_id + // in: path + // description: id of the workflow + // type: integer + // format: int64 + // required: true + // responses: + // "200": + // "$ref": "#/responses/ProjectWorkflow" + // "404": + // "$ref": "#/responses/notFound" + + // swagger:operation GET /user/projects/{id}/workflows/{workflow_id} user userCurrentGetProjectWorkflow + // --- + // summary: Get a project workflow + // produces: + // - application/json + // parameters: + // - name: id + // in: path + // description: id of the project + // type: integer + // format: int64 + // required: true + // - name: workflow_id + // in: path + // description: id of the workflow + // type: integer + // format: int64 + // required: true + // responses: + // "200": + // "$ref": "#/responses/ProjectWorkflow" + // "404": + // "$ref": "#/responses/notFound" + + project := projectScopeFromContext(ctx).findProject(ctx) + if ctx.Written() { + return + } + workflow := findWorkflow(ctx, project) + if ctx.Written() { + return + } + ctx.JSON(http.StatusOK, convert.ToProjectWorkflow(workflow)) +} + +func CreateProjectWorkflow(ctx *context.APIContext) { + // swagger:operation POST /repos/{owner}/{repo}/projects/{id}/workflows repository repoCreateProjectWorkflow + // --- + // summary: Create a project workflow + // consumes: + // - application/json + // produces: + // - application/json + // parameters: + // - name: owner + // in: path + // description: owner of the repo + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repo + // type: string + // required: true + // - name: id + // in: path + // description: id of the project + // type: integer + // format: int64 + // required: true + // - name: body + // in: body + // schema: + // "$ref": "#/definitions/CreateProjectWorkflowOption" + // responses: + // "201": + // "$ref": "#/responses/ProjectWorkflow" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + // "422": + // "$ref": "#/responses/validationError" + // "423": + // "$ref": "#/responses/repoArchivedError" + + // swagger:operation POST /orgs/{org}/projects/{id}/workflows organization orgCreateProjectWorkflow + // --- + // summary: Create a project workflow + // consumes: + // - application/json + // produces: + // - application/json + // parameters: + // - name: org + // in: path + // description: name of the organization + // type: string + // required: true + // - name: id + // in: path + // description: id of the project + // type: integer + // format: int64 + // required: true + // - name: body + // in: body + // schema: + // "$ref": "#/definitions/CreateProjectWorkflowOption" + // responses: + // "201": + // "$ref": "#/responses/ProjectWorkflow" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + // "422": + // "$ref": "#/responses/validationError" + + // swagger:operation POST /user/projects/{id}/workflows user userCurrentCreateProjectWorkflow + // --- + // summary: Create a project workflow + // consumes: + // - application/json + // produces: + // - application/json + // parameters: + // - name: id + // in: path + // description: id of the project + // type: integer + // format: int64 + // required: true + // - name: body + // in: body + // schema: + // "$ref": "#/definitions/CreateProjectWorkflowOption" + // responses: + // "201": + // "$ref": "#/responses/ProjectWorkflow" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + // "422": + // "$ref": "#/responses/validationError" + + project := projectScopeFromContext(ctx).findOpenProject(ctx) + if ctx.Written() { + return + } + workflow, err := project_service.CreateWorkflow(ctx, ctx.Doer, project, web.GetForm[*api.CreateProjectWorkflowOption](ctx)) + if err != nil { + ctx.APIErrorAuto(err) + return + } + ctx.JSON(http.StatusCreated, convert.ToProjectWorkflow(workflow)) +} + +func EditProjectWorkflow(ctx *context.APIContext) { + // swagger:operation PATCH /repos/{owner}/{repo}/projects/{id}/workflows/{workflow_id} repository repoEditProjectWorkflow + // --- + // summary: Edit a project workflow + // consumes: + // - application/json + // produces: + // - application/json + // parameters: + // - name: owner + // in: path + // description: owner of the repo + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repo + // type: string + // required: true + // - name: id + // in: path + // description: id of the project + // type: integer + // format: int64 + // required: true + // - name: workflow_id + // in: path + // description: id of the workflow + // type: integer + // format: int64 + // required: true + // - name: body + // in: body + // schema: + // "$ref": "#/definitions/EditProjectWorkflowOption" + // responses: + // "200": + // "$ref": "#/responses/ProjectWorkflow" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + // "422": + // "$ref": "#/responses/validationError" + // "423": + // "$ref": "#/responses/repoArchivedError" + + // swagger:operation PATCH /orgs/{org}/projects/{id}/workflows/{workflow_id} organization orgEditProjectWorkflow + // --- + // summary: Edit a project workflow + // consumes: + // - application/json + // produces: + // - application/json + // parameters: + // - name: org + // in: path + // description: name of the organization + // type: string + // required: true + // - name: id + // in: path + // description: id of the project + // type: integer + // format: int64 + // required: true + // - name: workflow_id + // in: path + // description: id of the workflow + // type: integer + // format: int64 + // required: true + // - name: body + // in: body + // schema: + // "$ref": "#/definitions/EditProjectWorkflowOption" + // responses: + // "200": + // "$ref": "#/responses/ProjectWorkflow" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + // "422": + // "$ref": "#/responses/validationError" + + // swagger:operation PATCH /user/projects/{id}/workflows/{workflow_id} user userCurrentEditProjectWorkflow + // --- + // summary: Edit a project workflow + // consumes: + // - application/json + // produces: + // - application/json + // parameters: + // - name: id + // in: path + // description: id of the project + // type: integer + // format: int64 + // required: true + // - name: workflow_id + // in: path + // description: id of the workflow + // type: integer + // format: int64 + // required: true + // - name: body + // in: body + // schema: + // "$ref": "#/definitions/EditProjectWorkflowOption" + // responses: + // "200": + // "$ref": "#/responses/ProjectWorkflow" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + // "422": + // "$ref": "#/responses/validationError" + + project := projectScopeFromContext(ctx).findOpenProject(ctx) + if ctx.Written() { + return + } + workflow := findWorkflow(ctx, project) + if ctx.Written() { + return + } + if err := project_service.UpdateWorkflow(ctx, ctx.Doer, project, workflow, web.GetForm[*api.EditProjectWorkflowOption](ctx)); err != nil { + ctx.APIErrorAuto(err) + return + } + ctx.JSON(http.StatusOK, convert.ToProjectWorkflow(workflow)) +} + +func DeleteProjectWorkflow(ctx *context.APIContext) { + // swagger:operation DELETE /repos/{owner}/{repo}/projects/{id}/workflows/{workflow_id} repository repoDeleteProjectWorkflow + // --- + // summary: Delete a project workflow + // parameters: + // - name: owner + // in: path + // description: owner of the repo + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repo + // type: string + // required: true + // - name: id + // in: path + // description: id of the project + // type: integer + // format: int64 + // required: true + // - name: workflow_id + // in: path + // description: id of the workflow + // type: integer + // format: int64 + // required: true + // responses: + // "204": + // "$ref": "#/responses/empty" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + // "423": + // "$ref": "#/responses/repoArchivedError" + + // swagger:operation DELETE /orgs/{org}/projects/{id}/workflows/{workflow_id} organization orgDeleteProjectWorkflow + // --- + // summary: Delete a project workflow + // parameters: + // - name: org + // in: path + // description: name of the organization + // type: string + // required: true + // - name: id + // in: path + // description: id of the project + // type: integer + // format: int64 + // required: true + // - name: workflow_id + // in: path + // description: id of the workflow + // type: integer + // format: int64 + // required: true + // responses: + // "204": + // "$ref": "#/responses/empty" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + + // swagger:operation DELETE /user/projects/{id}/workflows/{workflow_id} user userCurrentDeleteProjectWorkflow + // --- + // summary: Delete a project workflow + // parameters: + // - name: id + // in: path + // description: id of the project + // type: integer + // format: int64 + // required: true + // - name: workflow_id + // in: path + // description: id of the workflow + // type: integer + // format: int64 + // required: true + // responses: + // "204": + // "$ref": "#/responses/empty" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + + project := projectScopeFromContext(ctx).findOpenProject(ctx) + if ctx.Written() { + return + } + workflow := findWorkflow(ctx, project) + if ctx.Written() { + return + } + if err := project_service.DeleteWorkflow(ctx, ctx.Doer, project, workflow); err != nil { + ctx.APIErrorAuto(err) + return + } + ctx.Status(http.StatusNoContent) +} diff --git a/routers/api/v1/swagger/options.go b/routers/api/v1/swagger/options.go index 0522fcec680..59100d42e5f 100644 --- a/routers/api/v1/swagger/options.go +++ b/routers/api/v1/swagger/options.go @@ -256,6 +256,12 @@ type swaggerParameterBodies struct { // in:body MoveProjectIssueOption api.MoveProjectIssueOption + // in:body + CreateProjectWorkflowOption api.CreateProjectWorkflowOption + + // in:body + EditProjectWorkflowOption api.EditProjectWorkflowOption + // in:body MergeUpstreamRequest api.MergeUpstreamRequest } diff --git a/routers/api/v1/swagger/project.go b/routers/api/v1/swagger/project.go index 6374c850c16..ed08f996221 100644 --- a/routers/api/v1/swagger/project.go +++ b/routers/api/v1/swagger/project.go @@ -34,3 +34,17 @@ type swaggerResponseProjectColumnList struct { // in:body Body []api.ProjectColumn `json:"body"` } + +// ProjectWorkflow +// swagger:response ProjectWorkflow +type swaggerResponseProjectWorkflow struct { + // in:body + Body api.ProjectWorkflow `json:"body"` +} + +// ProjectWorkflowList +// swagger:response ProjectWorkflowList +type swaggerResponseProjectWorkflowList struct { + // in:body + Body []api.ProjectWorkflow `json:"body"` +} diff --git a/routers/web/org/projects.go b/routers/web/org/projects.go index 33719637a2a..183ac9075ce 100644 --- a/routers/web/org/projects.go +++ b/routers/web/org/projects.go @@ -19,6 +19,7 @@ import ( "gitea.dev/modules/templates" "gitea.dev/modules/web" "gitea.dev/routers/web/shared/issue" + project_shared "gitea.dev/routers/web/shared/project" shared_user "gitea.dev/routers/web/shared/user" "gitea.dev/services/audit" "gitea.dev/services/context" @@ -29,9 +30,10 @@ import ( ) const ( - tplProjects templates.TplName = "org/projects/list" - tplProjectsNew templates.TplName = "org/projects/new" - tplProjectsView templates.TplName = "org/projects/view" + tplProjects templates.TplName = "org/projects/list" + tplProjectsNew templates.TplName = "org/projects/new" + tplProjectsView templates.TplName = "org/projects/view" + tplProjectWorkflows templates.TplName = "org/projects/workflows" ) // Projects renders the home page of projects @@ -476,3 +478,13 @@ func ViewProject(ctx *context.Context) { ctx.HTML(http.StatusOK, tplProjectsView) } + +// ProjectWorkflows renders the workflows page of an owner-level project +func ProjectWorkflows(ctx *context.Context) { + if _, err := shared_user.RenderUserOrgHeader(ctx); err != nil { + ctx.ServerError("RenderUserOrgHeader", err) + return + } + ctx.Data["PageIsViewProjects"] = true + project_shared.RenderWorkflows(ctx, canWriteProjects(ctx), tplProjectWorkflows) +} diff --git a/routers/web/repo/issue_new.go b/routers/web/repo/issue_new.go index 19cf71c5448..6d82bda3f2e 100644 --- a/routers/web/repo/issue_new.go +++ b/routers/web/repo/issue_new.go @@ -263,6 +263,12 @@ func ValidateRepoMetasForCreateIssue(ctx *context.Context, form forms.CreateIssu pageMetaData.MilestonesData.SelectedMilestoneID = form.MilestoneID inputProjectIDs := ctx.FormStringInt64s("project_ids") + allProjects := slices.Concat(pageMetaData.ProjectsData.OpenProjects, pageMetaData.ProjectsData.ClosedProjects) + candidateProjects := toSet(allProjects, func(project *project_model.Project) int64 { return project.ID }) + if len(inputProjectIDs) > 0 && !candidateProjects.Contains(inputProjectIDs...) { + ctx.NotFound(nil) + return ret + } pageMetaData.SetSelectedProjectIDs(inputProjectIDs) // prepare assignees diff --git a/routers/web/repo/issue_test.go b/routers/web/repo/issue_test.go index 9db2a73ca78..ad351079f96 100644 --- a/routers/web/repo/issue_test.go +++ b/routers/web/repo/issue_test.go @@ -385,6 +385,17 @@ func TestCombineLabelComments(t *testing.T) { }, }, }, + { + name: "project workflow", + beforeCombined: []*issues_model.Comment{ + {Type: issues_model.CommentTypeLabel, PosterID: 1, Content: "1", Label: &issues_model.Label{Name: "kind/bug"}}, + {Type: issues_model.CommentTypeLabel, PosterID: 1, Content: "1", Label: &issues_model.Label{Name: "status/done"}, CommentMetaData: &issues_model.CommentMetaData{SpecialDoerName: issues_model.SpecialDoerNameProjectWorkflow}}, + }, + afterCombined: []*issues_model.Comment{ + {Type: issues_model.CommentTypeLabel, PosterID: 1, Content: "1", Label: &issues_model.Label{Name: "kind/bug"}, AddedLabels: []*issues_model.Label{{Name: "kind/bug"}}}, + {Type: issues_model.CommentTypeLabel, PosterID: 1, Content: "1", Label: &issues_model.Label{Name: "status/done"}, AddedLabels: []*issues_model.Label{{Name: "status/done"}}, CommentMetaData: &issues_model.CommentMetaData{SpecialDoerName: issues_model.SpecialDoerNameProjectWorkflow}}, + }, + }, } for _, kase := range kases { diff --git a/routers/web/repo/issue_view.go b/routers/web/repo/issue_view.go index 788b2baab7f..a195d91c951 100644 --- a/routers/web/repo/issue_view.go +++ b/routers/web/repo/issue_view.go @@ -217,6 +217,11 @@ func combineXRefComments(issue *issues_model.Issue) { } } +// project workflows post as the user who triggered them, the metadata keeps their changes apart +func sameCommentMetaData(a, b *issues_model.CommentMetaData) bool { + return a == b || (a != nil && b != nil && *a == *b) +} + // combineLabelComments combine the nearby label comments as one. func combineLabelComments(issue *issues_model.Issue) { var prev, cur *issues_model.Comment @@ -227,7 +232,8 @@ func combineLabelComments(issue *issues_model.Issue) { } if i == 0 || cur.Type != issues_model.CommentTypeLabel || (prev != nil && prev.PosterID != cur.PosterID) || - (prev != nil && cur.CreatedUnix-prev.CreatedUnix >= 60) { + (prev != nil && cur.CreatedUnix-prev.CreatedUnix >= 60) || + (prev != nil && !sameCommentMetaData(prev.CommentMetaData, cur.CommentMetaData)) { if cur.Type == issues_model.CommentTypeLabel && cur.Label != nil { if cur.Content != "1" { cur.RemovedLabels = append(cur.RemovedLabels, cur.Label) diff --git a/routers/web/repo/projects.go b/routers/web/repo/projects.go index 872a78c930b..8a8a4fc1c65 100644 --- a/routers/web/repo/projects.go +++ b/routers/web/repo/projects.go @@ -24,17 +24,20 @@ import ( "gitea.dev/modules/util" "gitea.dev/modules/web" "gitea.dev/routers/web/shared/issue" + project_shared "gitea.dev/routers/web/shared/project" shared_user "gitea.dev/routers/web/shared/user" "gitea.dev/services/audit" "gitea.dev/services/context" "gitea.dev/services/forms" + issue_service "gitea.dev/services/issue" project_service "gitea.dev/services/projects" ) const ( - tplProjects templates.TplName = "repo/projects/list" - tplProjectsNew templates.TplName = "repo/projects/new" - tplProjectsView templates.TplName = "repo/projects/view" + tplProjects templates.TplName = "repo/projects/list" + tplProjectsNew templates.TplName = "repo/projects/new" + tplProjectsView templates.TplName = "repo/projects/view" + tplProjectWorkflows templates.TplName = "repo/projects/workflows" ) // MustEnableRepoProjects check if repo projects are enabled in settings @@ -435,6 +438,12 @@ func ViewProject(ctx *context.Context) { ctx.HTML(http.StatusOK, tplProjectsView) } +// ProjectWorkflows renders the workflows page of a repository project +func ProjectWorkflows(ctx *context.Context) { + ctx.Data["IsProjectsPage"] = true + project_shared.RenderWorkflows(ctx, ctx.Repo.Permission.CanWrite(unit.TypeProjects) && !ctx.Repo.Repository.IsArchived, tplProjectWorkflows) +} + // UpdateIssueProject change an issue's project func UpdateIssueProject(ctx *context.Context) { issues := getActionIssues(ctx) @@ -454,7 +463,7 @@ func UpdateIssueProject(ctx *context.Context) { projectIDs := ctx.FormStringInt64s("id") var failedIssues []int64 for _, issue := range issues { - if err := issues_model.IssueAssignOrRemoveProject(ctx, issue, ctx.Doer, projectIDs); err != nil { + if err := issue_service.AssignOrRemoveProjects(ctx, issue, ctx.Doer, projectIDs); err != nil { if errors.Is(err, util.ErrPermissionDenied) { failedIssues = append(failedIssues, issue.ID) continue @@ -478,6 +487,10 @@ func UpdateIssueProjectColumn(ctx *context.Context) { ctx.ServerError("GetIssueByID", err) return } + if !ctx.Repo.Permission.CanReadIssuesOrPulls(issue.IsPull) { + ctx.NotFound(nil) + return + } column, err := project_model.GetColumn(ctx, ctx.FormInt64("id")) if err != nil { ctx.ServerError("GetColumn", err) diff --git a/routers/web/repo/projects_test.go b/routers/web/repo/projects_test.go new file mode 100644 index 00000000000..7fff8a47724 --- /dev/null +++ b/routers/web/repo/projects_test.go @@ -0,0 +1,43 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "fmt" + "net/http" + "testing" + + "gitea.dev/models/db" + issues_model "gitea.dev/models/issues" + "gitea.dev/models/organization" + "gitea.dev/models/perm" + project_model "gitea.dev/models/project" + "gitea.dev/models/unit" + "gitea.dev/models/unittest" + user_model "gitea.dev/models/user" + "gitea.dev/services/contexttest" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestUpdateIssueProjectColumnNeedsItemRead(t *testing.T) { + unittest.PrepareTestEnv(t) + _, err := db.Exec(t.Context(), "UPDATE team SET authorize = ? WHERE id = ?", perm.AccessModeNone, 2) + require.NoError(t, err) + _, err = db.DeleteByBean(t.Context(), &organization.TeamUnit{TeamID: 2, Type: unit.TypePullRequests}) + require.NoError(t, err) + pull := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 12}) + _, _, err = issues_model.IssueAssignOrRemoveProject(t.Context(), pull, unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}), []int64{2}) + require.NoError(t, err) + column := &project_model.Column{Title: "Done", ProjectID: 2} + require.NoError(t, project_model.NewColumn(t.Context(), column)) + + ctx, _ := contexttest.MockContext(t, fmt.Sprintf("POST /org3/repo3/issues/projects/column?issue_id=%d&id=%d", pull.ID, column.ID)) + contexttest.LoadUser(t, ctx, 4) + contexttest.LoadRepo(t, ctx, 3) + UpdateIssueProjectColumn(ctx) + assert.Equal(t, http.StatusNotFound, ctx.Resp.WrittenStatus()) + assert.NotEqual(t, column.ID, unittest.AssertExistsAndLoadBean(t, &project_model.ProjectIssue{IssueID: pull.ID, ProjectID: 2}).ProjectColumnID) +} diff --git a/routers/web/shared/project/project.go b/routers/web/shared/project/project.go index 599544f427e..0dc31db00e6 100644 --- a/routers/web/shared/project/project.go +++ b/routers/web/shared/project/project.go @@ -5,8 +5,10 @@ package project import ( "errors" + "net/http" issues_model "gitea.dev/models/issues" + access_model "gitea.dev/models/perm/access" project_model "gitea.dev/models/project" "gitea.dev/modules/json" "gitea.dev/modules/web" @@ -189,11 +191,28 @@ func MoveIssues(ctx *context.Context) { return } + repoPerms := map[int64]access_model.Permission{} for _, issue := range movedIssues { if !project.CanBeAccessedByOwnerRepo(issue.Repo.OwnerID, issue.Repo) { ctx.ServerError("Some issue's repoID is not equal to project's repoID", errors.New("Some issue's repoID is not equal to project's repoID")) return } + perm, ok := repoPerms[issue.RepoID] + if !ok { + if perm, err = access_model.GetDoerRepoPermission(ctx, issue.Repo, ctx.Doer); err != nil { + ctx.ServerError("GetDoerRepoPermission", err) + return + } + repoPerms[issue.RepoID] = perm + } + if !perm.CanReadIssuesOrPulls(issue.IsPull) { + ctx.NotFound(nil) + return + } + if issue.Repo.IsArchived { + ctx.HTTPError(http.StatusLocked, "repo is archived") + return + } } if err = project_service.MoveIssuesOnProjectColumn(ctx, ctx.Doer, column, sortedIssueIDs); err != nil { diff --git a/routers/web/shared/project/project_test.go b/routers/web/shared/project/project_test.go index 64017c226ff..c977a0af95a 100644 --- a/routers/web/shared/project/project_test.go +++ b/routers/web/shared/project/project_test.go @@ -4,13 +4,26 @@ package project import ( + "fmt" + "io" "net/http" + "strconv" + "strings" "testing" + "gitea.dev/models/db" + issues_model "gitea.dev/models/issues" + "gitea.dev/models/organization" + "gitea.dev/models/perm" + project_model "gitea.dev/models/project" + repo_model "gitea.dev/models/repo" + "gitea.dev/models/unit" "gitea.dev/models/unittest" + user_model "gitea.dev/models/user" "gitea.dev/services/contexttest" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestMain(m *testing.M) { @@ -58,3 +71,58 @@ func TestFindColumn(t *testing.T) { }) } } + +func TestMoveIssuesNeedsIssueRead(t *testing.T) { + stripTeamIssues := func(t *testing.T) { + _, err := db.Exec(t.Context(), "UPDATE team SET authorize = ? WHERE id = ?", perm.AccessModeNone, 2) + require.NoError(t, err) + _, err = db.DeleteByBean(t.Context(), &organization.TeamUnit{TeamID: 2, Type: unit.TypeIssues}) + require.NoError(t, err) + } + archiveRepo3 := func(t *testing.T) { + require.NoError(t, repo_model.UpdateRepositoryColsNoAutoTime(t.Context(), &repo_model.Repository{ID: 3, IsArchived: true}, "is_archived")) + } + for _, tc := range []struct { + name string + doerID int64 + repoBoard bool + prepare func(*testing.T) + status int + }{ + {"OwnerBoardReader", 2, false, nil, http.StatusOK}, + {"OwnerBoardWithoutIssueAccess", 15, false, nil, http.StatusNotFound}, + {"OwnerBoardArchivedRepo", 2, false, archiveRepo3, http.StatusLocked}, + {"RepoBoardProjectsWriterWithoutIssueRead", 4, true, stripTeamIssues, http.StatusNotFound}, + } { + t.Run(tc.name, func(t *testing.T) { + unittest.PrepareTestEnv(t) + if tc.prepare != nil { + tc.prepare(t) + } + ctx, _ := contexttest.MockContext(t, "POST /org3/-/projects") + contexttest.LoadUser(t, ctx, tc.doerID) + project := unittest.AssertExistsAndLoadBean(t, &project_model.Project{ID: 2}) + if tc.repoBoard { + contexttest.LoadRepo(t, ctx, 3) + } else { + ctx.ContextUser = unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 3}) + project = &project_model.Project{Title: "board", OwnerID: 3, CreatorID: 2, Type: project_model.TypeOrganization} + require.NoError(t, project_model.NewProject(ctx, project)) + require.NoError(t, project_model.NewColumn(ctx, &project_model.Column{Title: "Backlog", ProjectID: project.ID, Default: true})) + } + column := &project_model.Column{Title: "Done", ProjectID: project.ID} + require.NoError(t, project_model.NewColumn(ctx, column)) + issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 6}) + _, _, err := issues_model.IssueAssignOrRemoveProject(ctx, issue, unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}), []int64{project.ID}) + require.NoError(t, err) + ctx.SetPathParam("id", strconv.FormatInt(project.ID, 10)) + ctx.SetPathParam("columnID", strconv.FormatInt(column.ID, 10)) + ctx.Req.Body = io.NopCloser(strings.NewReader(fmt.Sprintf(`{"issues":[{"issueID":%d,"sorting":0}]}`, issue.ID))) + + MoveIssues(ctx) + assert.Equal(t, tc.status, ctx.Resp.WrittenStatus()) + card := unittest.AssertExistsAndLoadBean(t, &project_model.ProjectIssue{IssueID: issue.ID, ProjectID: project.ID}) + assert.Equal(t, tc.status == http.StatusOK, card.ProjectColumnID == column.ID) + }) + } +} diff --git a/routers/web/shared/project/workflow.go b/routers/web/shared/project/workflow.go new file mode 100644 index 00000000000..045d71c5bf9 --- /dev/null +++ b/routers/web/shared/project/workflow.go @@ -0,0 +1,169 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package project + +import ( + "html/template" + "net/http" + "slices" + "strconv" + + "gitea.dev/models/db" + project_model "gitea.dev/models/project" + "gitea.dev/modules/json" + api "gitea.dev/modules/structs" + "gitea.dev/modules/templates" + "gitea.dev/services/context" + "gitea.dev/services/convert" + project_service "gitea.dev/services/projects" +) + +func RenderWorkflows(ctx *context.Context, canWrite bool, tpl templates.TplName) { + project := findProject(ctx) + if ctx.Written() { + return + } + if canWrite { + var err error + if canWrite, err = project_service.CanManageWorkflows(ctx, ctx.Doer, project); err != nil { + ctx.ServerError("CanManageWorkflows", err) + return + } + } + workflows, err := db.Find[project_model.Workflow](ctx, project_model.FindWorkflowsOptions{ListOptions: db.ListOptionsAll, ProjectIDs: []int64{project.ID}}) + if err != nil { + ctx.ServerError("FindWorkflows", err) + return + } + var workflowID int64 + if param := ctx.PathParam("workflow_id"); param != "" { + id, err := strconv.ParseInt(param, 10, 64) + if err != nil || !slices.ContainsFunc(workflows, func(workflow *project_model.Workflow) bool { return workflow.ID == id }) { + ctx.NotFound(nil) + return + } + workflowID = id + } + event := ctx.FormString("event") + if event != "" && !project_model.WorkflowEvent(event).IsValid() { + ctx.NotFound(nil) + return + } + columns, err := project_model.GetColumns(ctx, project.ID, db.ListOptionsAll) + if err != nil { + ctx.ServerError("GetColumns", err) + return + } + labels, err := project_service.GetProjectLabels(ctx, project) + if err != nil { + ctx.ServerError("GetProjectLabels", err) + return + } + + type workflowEvent struct { + Event project_model.WorkflowEvent `json:"event"` + DisplayName string `json:"display_name"` + Filters []project_model.WorkflowFilterType `json:"supported_filters"` + Actions []project_model.WorkflowActionType `json:"supported_actions"` + } + events := make([]workflowEvent, 0, len(project_model.WorkflowEvents())) + for _, event := range project_model.WorkflowEvents() { + capabilities := event.Capabilities() + events = append(events, workflowEvent{event, ctx.Locale.TrString(event.LangKey()), capabilities.Filters, capabilities.Actions}) + } + + type workflowColumn struct { + ID int64 `json:"id"` + Title string `json:"title"` + } + workflowColumns := make([]workflowColumn, 0, len(columns)) + for _, column := range columns { + workflowColumns = append(workflowColumns, workflowColumn{column.ID, column.Title}) + } + + type workflowLabel struct { + ID int64 `json:"id"` + Name string `json:"name"` + HTML template.HTML `json:"html"` + } + renderUtils := templates.NewRenderUtils(ctx) + workflowLabels := make([]workflowLabel, 0, len(labels)) + for _, label := range labels { + workflowLabels = append(workflowLabels, workflowLabel{label.ID, label.Name, renderUtils.RenderLabel(label)}) + } + + ctx.PageData["projectWorkflows"] = map[string]any{ + "events": events, + "workflows": convert.ToProjectWorkflowList(workflows), + "columns": workflowColumns, + "labels": workflowLabels, + } + ctx.Data["Title"] = ctx.Tr("projects.workflows") + ctx.Data["Project"] = project + ctx.Data["ProjectLink"] = project.Link(ctx) + ctx.Data["WorkflowID"] = workflowID + ctx.Data["WorkflowEvent"] = event + ctx.Data["CanWrite"] = canWrite + ctx.HTML(http.StatusOK, tpl) +} + +func CreateWorkflow(ctx *context.Context) { + project := findProject(ctx) + if ctx.Written() { + return + } + var form api.CreateProjectWorkflowOption + if err := json.NewDecoder(ctx.Req.Body).Decode(&form); err != nil { + ctx.JSONError("invalid request body") + return + } + workflow, err := project_service.CreateWorkflow(ctx, ctx.Doer, project, &form) + if err != nil { + ctx.JSONErrorAuto(err) + return + } + ctx.JSON(http.StatusOK, convert.ToProjectWorkflow(workflow)) +} + +func UpdateWorkflow(ctx *context.Context) { + project, workflow := findWorkflow(ctx) + if ctx.Written() { + return + } + var form api.EditProjectWorkflowOption + if err := json.NewDecoder(ctx.Req.Body).Decode(&form); err != nil { + ctx.JSONError("invalid request body") + return + } + if err := project_service.UpdateWorkflow(ctx, ctx.Doer, project, workflow, &form); err != nil { + ctx.JSONErrorAuto(err) + return + } + ctx.JSON(http.StatusOK, convert.ToProjectWorkflow(workflow)) +} + +func DeleteWorkflow(ctx *context.Context) { + project, workflow := findWorkflow(ctx) + if ctx.Written() { + return + } + if err := project_service.DeleteWorkflow(ctx, ctx.Doer, project, workflow); err != nil { + ctx.JSONErrorAuto(err) + return + } + ctx.JSONOK() +} + +func findWorkflow(ctx *context.Context) (*project_model.Project, *project_model.Workflow) { + project := findProject(ctx) + if ctx.Written() { + return nil, nil + } + workflow, err := project_model.GetWorkflowByProjectAndID(ctx, project.ID, ctx.PathParamInt64("workflow_id")) + if err != nil { + ctx.JSONErrorAuto(err) + return nil, nil + } + return project, workflow +} diff --git a/routers/web/web.go b/routers/web/web.go index 329d12fb6ff..0fa4c8b7a9e 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -324,6 +324,12 @@ func Routes() *web.Router { // Such requests are not cross-origin requests, so disable CrossOriginProtection. var optSignInFromAnyOrigin = verifyAuthWithOptionsWeb(&common.VerifyOptions{DisableCrossOriginProtection: true}) +func addProjectWorkflowRoutes(m *web.Router) { + m.Post("/workflows", project.CreateWorkflow) + m.Post("/workflows/{workflow_id}", project.UpdateWorkflow) + m.Post("/workflows/{workflow_id}/delete", project.DeleteWorkflow) +} + // addProjectBoardRoutes registers a board's column and card routes, shared by the // repository and owner mount points. func addProjectBoardRoutes(m *web.Router) { @@ -1132,6 +1138,8 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) { m.Group("", func() { m.Get("", org.Projects) m.Get("/{id}", org.ViewProject) + m.Get("/{id}/workflows", org.ProjectWorkflows) + m.Get("/{id}/workflows/{workflow_id}", org.ProjectWorkflows) }, reqAnyRepoUnitAccess(unit.TypeProjects, perm.AccessModeRead, true)) m.Group("", func() { m.Get("/new", org.RenderNewProject) @@ -1144,6 +1152,7 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) { m.Post("/{action:open|close}", org.ChangeProjectStatus) addProjectBoardRoutes(m) + addProjectWorkflowRoutes(m) }) }, reqSignIn, reqAnyRepoUnitAccess(unit.TypeProjects, perm.AccessModeWrite, true), func(ctx *context.Context) { if ctx.ContextUser.IsIndividual() && ctx.ContextUser.ID != ctx.Doer.ID { @@ -1535,6 +1544,8 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) { m.Group("/{username}/{reponame}/projects", func() { m.Get("", repo.Projects) m.Get("/{id}", repo.ViewProject) + m.Get("/{id}/workflows", repo.ProjectWorkflows) + m.Get("/{id}/workflows/{workflow_id}", repo.ProjectWorkflows) m.Group("", func() { m.Get("/new", repo.RenderNewProject) m.Post("/new", web.Bind[*forms.CreateProjectForm](), repo.NewProjectPost) @@ -1546,6 +1557,7 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) { m.Post("/{action:open|close}", repo.ChangeProjectStatus) addProjectBoardRoutes(m) + addProjectWorkflowRoutes(m) }) }, reqRepoProjectsWriter, context.RepoMustNotBeArchived()) }, optSignIn, context.RepoAssignment, reqRepoProjectsReader, repo.MustEnableRepoProjects) diff --git a/services/convert/project.go b/services/convert/project.go index 3ea77a33f1a..39141fbba97 100644 --- a/services/convert/project.go +++ b/services/convert/project.go @@ -195,3 +195,23 @@ func ToProjectColumnList(ctx context.Context, columns []*project_model.Column, d } return result } + +func ToProjectWorkflow(wf *project_model.Workflow) *api.ProjectWorkflow { + return &api.ProjectWorkflow{ + ID: wf.ID, + Event: string(wf.WorkflowEvent), + Enabled: wf.Enabled, + Filters: api.ProjectWorkflowFilters(wf.WorkflowFilters), + Actions: api.ProjectWorkflowActions(wf.WorkflowActions), + Created: wf.CreatedUnix.AsTime(), + Updated: wf.UpdatedUnix.AsTime(), + } +} + +func ToProjectWorkflowList(workflows []*project_model.Workflow) []*api.ProjectWorkflow { + result := make([]*api.ProjectWorkflow, len(workflows)) + for i, wf := range workflows { + result[i] = ToProjectWorkflow(wf) + } + return result +} diff --git a/services/issue/issue.go b/services/issue/issue.go index d039c406095..0621b65a995 100644 --- a/services/issue/issue.go +++ b/services/issue/issue.go @@ -36,6 +36,7 @@ func NewIssue(ctx context.Context, repo *repo_model.Repository, issue *issues_mo assigneeCommentMap := make(map[int64]*issues_model.Comment) assignees := make(map[int64]*user_model.User) + var addedProjectIDs []int64 if err := db.WithTx(ctx, func(ctx context.Context) error { if err := issues_model.NewIssue(ctx, repo, issue, labelIDs, uuids); err != nil { return err @@ -54,8 +55,8 @@ func NewIssue(ctx context.Context, repo *repo_model.Repository, issue *issues_mo assigneeCommentMap[assigneeID] = comment } if len(projectIDs) > 0 { - err := issues_model.IssueAssignOrRemoveProject(ctx, issue, issue.Poster, projectIDs) - if err != nil { + var err error + if addedProjectIDs, _, err = issues_model.IssueAssignOrRemoveProject(ctx, issue, issue.Poster, projectIDs); err != nil { return err } } @@ -82,6 +83,7 @@ func NewIssue(ctx context.Context, repo *repo_model.Repository, issue *issues_mo notify_service.IssueChangeAssignee(ctx, issue.Poster, issue, assignee, false, assigneeCommentMap[assignee.ID]) } } + notify_service.IssueChangeProjects(ctx, issue.Poster, issue, addedProjectIDs, nil) return nil } diff --git a/services/issue/label.go b/services/issue/label.go index 2e788553a13..79bd5f50726 100644 --- a/services/issue/label.go +++ b/services/issue/label.go @@ -5,6 +5,7 @@ package issue import ( "context" + "slices" "gitea.dev/models/db" issues_model "gitea.dev/models/issues" @@ -85,3 +86,40 @@ func ReplaceLabels(ctx context.Context, issue *issues_model.Issue, doer *user_mo notify_service.IssueChangeLabels(ctx, doer, issue, labels, old) return nil } + +func AddRemoveLabels(ctx context.Context, issue *issues_model.Issue, doer *user_model.User, toAdd, toRemove []*issues_model.Label) error { + oldLabels, err := issues_model.GetLabelsByIssueID(ctx, issue.ID) + if err != nil { + return err + } + + if err := db.WithTx(ctx, func(ctx context.Context) error { + if err := issues_model.NewIssueLabels(ctx, issue, toAdd, doer); err != nil { + return err + } + for _, label := range toRemove { + if err := issues_model.DeleteIssueLabel(ctx, issue, label, doer); err != nil { + return err + } + } + return nil + }); err != nil { + return err + } + + var added, removed []*issues_model.Label + for _, label := range issue.Labels { + if !slices.ContainsFunc(oldLabels, func(old *issues_model.Label) bool { return old.ID == label.ID }) { + added = append(added, label) + } + } + for _, label := range oldLabels { + if !slices.ContainsFunc(issue.Labels, func(current *issues_model.Label) bool { return current.ID == label.ID }) { + removed = append(removed, label) + } + } + if len(added) > 0 || len(removed) > 0 { + notify_service.IssueChangeLabels(ctx, doer, issue, added, removed) + } + return nil +} diff --git a/services/issue/label_test.go b/services/issue/label_test.go index 04079c85d43..666837a54f0 100644 --- a/services/issue/label_test.go +++ b/services/issue/label_test.go @@ -4,13 +4,16 @@ package issue import ( + "context" "testing" issues_model "gitea.dev/models/issues" "gitea.dev/models/unittest" user_model "gitea.dev/models/user" + notify_service "gitea.dev/services/notify" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestIssue_AddLabels(t *testing.T) { @@ -59,3 +62,49 @@ func TestIssue_AddLabel(t *testing.T) { unittest.AssertExistsAndLoadBean(t, &issues_model.IssueLabel{IssueID: test.issueID, LabelID: test.labelID}) } } + +type labelChangeRecorder struct { + notify_service.NullNotifier + calls int + added, removed []int64 +} + +func (r *labelChangeRecorder) IssueChangeLabels(_ctx context.Context, _doer *user_model.User, _issue *issues_model.Issue, added, removed []*issues_model.Label) { + r.calls++ + r.added, r.removed = labelIDs(added), labelIDs(removed) +} + +func labelIDs(labels []*issues_model.Label) (ids []int64) { + for _, label := range labels { + ids = append(ids, label.ID) + } + return ids +} + +func TestIssue_AddRemoveLabels(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + ctx := t.Context() + doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + labels := func(ids ...int64) []*issues_model.Label { + labels, err := issues_model.GetLabelsByIDs(ctx, ids) + require.NoError(t, err) + return labels + } + *labelChanges = labelChangeRecorder{} + + issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 1}) + require.NoError(t, AddRemoveLabels(ctx, issue, doer, labels(1, 3), labels(2))) + assert.Equal(t, []int64{1}, labelIDs(issue.Labels)) + assert.Equal(t, labelChangeRecorder{}, *labelChanges) + + require.NoError(t, AddRemoveLabels(ctx, issue, doer, labels(2), labels(1))) + assert.Equal(t, []int64{2}, labelIDs(issue.Labels)) + assert.Equal(t, labelChangeRecorder{calls: 1, added: []int64{2}, removed: []int64{1}}, *labelChanges) + + exclusive := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 18}) + require.NoError(t, AddRemoveLabels(ctx, exclusive, doer, labels(7), nil)) + *labelChanges = labelChangeRecorder{} + require.NoError(t, AddRemoveLabels(ctx, exclusive, doer, labels(8), nil)) + assert.Equal(t, []int64{8}, labelIDs(exclusive.Labels)) + assert.Equal(t, labelChangeRecorder{calls: 1, added: []int64{8}, removed: []int64{7}}, *labelChanges) +} diff --git a/services/issue/main_test.go b/services/issue/main_test.go index 7c126d19521..8bf411ee184 100644 --- a/services/issue/main_test.go +++ b/services/issue/main_test.go @@ -7,11 +7,15 @@ import ( "testing" "gitea.dev/models/unittest" + notify_service "gitea.dev/services/notify" _ "gitea.dev/models" _ "gitea.dev/models/actions" ) +var labelChanges = &labelChangeRecorder{} + func TestMain(m *testing.M) { + notify_service.RegisterNotifier(labelChanges) unittest.MainTest(m) } diff --git a/services/issue/project.go b/services/issue/project.go new file mode 100644 index 00000000000..00a6dae22ba --- /dev/null +++ b/services/issue/project.go @@ -0,0 +1,21 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package issue + +import ( + "context" + + issues_model "gitea.dev/models/issues" + user_model "gitea.dev/models/user" + notify_service "gitea.dev/services/notify" +) + +func AssignOrRemoveProjects(ctx context.Context, issue *issues_model.Issue, doer *user_model.User, newProjectIDs []int64) error { + added, removed, err := issues_model.IssueAssignOrRemoveProject(ctx, issue, doer, newProjectIDs) + if err != nil { + return err + } + notify_service.IssueChangeProjects(ctx, doer, issue, added, removed) + return nil +} diff --git a/services/notify/notifier.go b/services/notify/notifier.go index 9f572163a15..58e8f805dfb 100644 --- a/services/notify/notifier.go +++ b/services/notify/notifier.go @@ -10,6 +10,7 @@ import ( git_model "gitea.dev/models/git" issues_model "gitea.dev/models/issues" packages_model "gitea.dev/models/packages" + project_model "gitea.dev/models/project" repo_model "gitea.dev/models/repo" user_model "gitea.dev/models/user" "gitea.dev/modules/git" @@ -41,6 +42,8 @@ type Notifier interface { IssueChangeRef(ctx context.Context, doer *user_model.User, issue *issues_model.Issue, oldRef string) IssueChangeLabels(ctx context.Context, doer *user_model.User, issue *issues_model.Issue, addedLabels, removedLabels []*issues_model.Label) + IssueChangeProjects(ctx context.Context, doer *user_model.User, issue *issues_model.Issue, addedProjectIDs, removedProjectIDs []int64) + IssueChangeProjectColumn(ctx context.Context, doer *user_model.User, issue *issues_model.Issue, oldColumnID int64, newColumn *project_model.Column) NewPullRequest(ctx context.Context, pr *issues_model.PullRequest, mentions []*user_model.User) MergePullRequest(ctx context.Context, doer *user_model.User, pr *issues_model.PullRequest) diff --git a/services/notify/notify.go b/services/notify/notify.go index 02c8c01a459..a58c83f6ddb 100644 --- a/services/notify/notify.go +++ b/services/notify/notify.go @@ -10,6 +10,7 @@ import ( git_model "gitea.dev/models/git" issues_model "gitea.dev/models/issues" packages_model "gitea.dev/models/packages" + project_model "gitea.dev/models/project" repo_model "gitea.dev/models/repo" user_model "gitea.dev/models/user" "gitea.dev/modules/git" @@ -338,6 +339,22 @@ func IssueChangeLabels(ctx context.Context, doer *user_model.User, issue *issues } } +func IssueChangeProjects(ctx context.Context, doer *user_model.User, issue *issues_model.Issue, addedProjectIDs, removedProjectIDs []int64) { + defer recoverPanic() + + for _, notifier := range notifiers { + notifier.IssueChangeProjects(ctx, doer, issue, addedProjectIDs, removedProjectIDs) + } +} + +func IssueChangeProjectColumn(ctx context.Context, doer *user_model.User, issue *issues_model.Issue, oldColumnID int64, newColumn *project_model.Column) { + defer recoverPanic() + + for _, notifier := range notifiers { + notifier.IssueChangeProjectColumn(ctx, doer, issue, oldColumnID, newColumn) + } +} + // CreateRepository notifies create repository to notifiers func CreateRepository(ctx context.Context, doer, u *user_model.User, repo *repo_model.Repository) { defer recoverPanic() diff --git a/services/notify/null.go b/services/notify/null.go index 71d8a71af39..299905cc26e 100644 --- a/services/notify/null.go +++ b/services/notify/null.go @@ -10,6 +10,7 @@ import ( git_model "gitea.dev/models/git" issues_model "gitea.dev/models/issues" packages_model "gitea.dev/models/packages" + project_model "gitea.dev/models/project" repo_model "gitea.dev/models/repo" user_model "gitea.dev/models/user" "gitea.dev/modules/git" @@ -143,6 +144,12 @@ func (*NullNotifier) IssueChangeLabels(ctx context.Context, doer *user_model.Use addedLabels, removedLabels []*issues_model.Label) { } +func (*NullNotifier) IssueChangeProjects(ctx context.Context, doer *user_model.User, issue *issues_model.Issue, addedProjectIDs, removedProjectIDs []int64) { +} + +func (*NullNotifier) IssueChangeProjectColumn(ctx context.Context, doer *user_model.User, issue *issues_model.Issue, oldColumnID int64, newColumn *project_model.Column) { +} + // CreateRepository places a place holder function func (*NullNotifier) CreateRepository(ctx context.Context, doer, u *user_model.User, repo *repo_model.Repository) { } diff --git a/services/projects/issue.go b/services/projects/issue.go index d8119460c8d..bcb5a2ff966 100644 --- a/services/projects/issue.go +++ b/services/projects/issue.go @@ -14,6 +14,7 @@ import ( user_model "gitea.dev/models/user" "gitea.dev/modules/optional" "gitea.dev/modules/util" + notify_service "gitea.dev/services/notify" "xorm.io/builder" ) @@ -25,134 +26,183 @@ var ErrIssueNotInProject = util.ErrorWrap(util.ErrUnprocessableContent, "all iss // AddIssueToColumn assigns the issue to the column's project if needed, then places it in // the column. One transaction, so a failure cannot strand it in the default column. func AddIssueToColumn(ctx context.Context, doer *user_model.User, issue *issues_model.Issue, column *project_model.Column) error { - return db.WithTx(ctx, func(ctx context.Context) error { + var added []int64 + moves, err := db.WithTx2(ctx, func(ctx context.Context) ([]columnMove, error) { projectIDs, err := issue.ProjectIDs(ctx) if err != nil { - return err + return nil, err } if !slices.Contains(projectIDs, column.ProjectID) { // lands in the default column, the move below puts it in the requested one - if err := issues_model.IssueAssignOrRemoveProject(ctx, issue, doer, append(projectIDs, column.ProjectID)); err != nil { - return err + if added, _, err = issues_model.IssueAssignOrRemoveProject(ctx, issue, doer, append(projectIDs, column.ProjectID)); err != nil { + return nil, err } } - return MoveIssueToColumn(ctx, doer, issue, column, optional.None[int64]()) + return moveIssueToColumn(ctx, doer, issue, column, optional.None[int64]()) }) + if err != nil { + return err + } + notify_service.IssueChangeProjects(ctx, doer, issue, added, nil) + notifyColumnMoves(ctx, doer, column, moves) + return nil } // MoveIssueToColumn places an issue already in the project into a column, appending it // when sorting is absent. func MoveIssueToColumn(ctx context.Context, doer *user_model.User, issue *issues_model.Issue, column *project_model.Column, sorting optional.Option[int64]) error { - return db.WithTx(ctx, func(ctx context.Context) error { - position := sorting.Value() - if !sorting.Has() { - next, err := project_model.GetColumnIssueNextSorting(ctx, column) - if err != nil { - return err - } - position = next - } - return MoveIssuesOnProjectColumn(ctx, doer, column, map[int64]int64{position: issue.ID}) + moves, err := db.WithTx2(ctx, func(ctx context.Context) ([]columnMove, error) { + return moveIssueToColumn(ctx, doer, issue, column, sorting) }) + if err != nil { + return err + } + notifyColumnMoves(ctx, doer, column, moves) + return nil +} + +func moveIssueToColumn(ctx context.Context, doer *user_model.User, issue *issues_model.Issue, column *project_model.Column, sorting optional.Option[int64]) ([]columnMove, error) { + position := sorting.Value() + if !sorting.Has() { + next, err := project_model.GetColumnIssueNextSorting(ctx, column) + if err != nil { + return nil, err + } + position = next + } + return moveIssuesOnProjectColumn(ctx, doer, column, map[int64]int64{position: issue.ID}) } // RemoveIssueFromColumn detaches the issue from the column's project, reporting a // not-exist error when it is not in that column. func RemoveIssueFromColumn(ctx context.Context, doer *user_model.User, issue *issues_model.Issue, column *project_model.Column) error { - return db.WithTx(ctx, func(ctx context.Context) error { + removed, err := db.WithTx2(ctx, func(ctx context.Context) ([]int64, error) { exists, err := project_model.IsIssueInColumn(ctx, issue.ID, column) if err != nil { - return err + return nil, err } if !exists { - return util.NewNotExistErrorf("issue %d is not in column %d", issue.ID, column.ID) + return nil, util.NewNotExistErrorf("issue %d is not in column %d", issue.ID, column.ID) } projectIDs, err := issue.ProjectIDs(ctx) if err != nil { - return err + return nil, err } - remaining := util.SliceRemoveAll(projectIDs, column.ProjectID) - return issues_model.IssueAssignOrRemoveProject(ctx, issue, doer, remaining) + _, removed, err := issues_model.IssueAssignOrRemoveProject(ctx, issue, doer, util.SliceRemoveAll(projectIDs, column.ProjectID)) + return removed, err }) + if err != nil { + return err + } + notify_service.IssueChangeProjects(ctx, doer, issue, nil, removed) + return nil } // MoveIssuesOnProjectColumn moves or keeps issues in a column and sorts them inside that column func MoveIssuesOnProjectColumn(ctx context.Context, doer *user_model.User, column *project_model.Column, sortedIssueIDs map[int64]int64) error { - return db.WithTx(ctx, func(ctx context.Context) error { - issueIDs := make([]int64, 0, len(sortedIssueIDs)) - for _, issueID := range sortedIssueIDs { - issueIDs = append(issueIDs, issueID) + moves, err := db.WithTx2(ctx, func(ctx context.Context) ([]columnMove, error) { + return moveIssuesOnProjectColumn(ctx, doer, column, sortedIssueIDs) + }) + if err != nil { + return err + } + notifyColumnMoves(ctx, doer, column, moves) + return nil +} + +type columnMove struct { + issue *issues_model.Issue + oldColumnID int64 +} + +func notifyColumnMoves(ctx context.Context, doer *user_model.User, column *project_model.Column, moves []columnMove) { + for _, move := range moves { + notify_service.IssueChangeProjectColumn(ctx, doer, move.issue, move.oldColumnID, column) + } +} + +func moveIssuesOnProjectColumn(ctx context.Context, doer *user_model.User, column *project_model.Column, sortedIssueIDs map[int64]int64) ([]columnMove, error) { + issueIDs := util.ValuesOfMap(sortedIssueIDs) + count, err := db.GetEngine(ctx). + Where("project_id=?", column.ProjectID). + In("issue_id", issueIDs). + Count(new(project_model.ProjectIssue)) + if err != nil { + return nil, err + } + if int(count) != len(sortedIssueIDs) { + return nil, ErrIssueNotInProject + } + + issues, err := issues_model.GetIssuesByIDs(ctx, issueIDs) + if err != nil { + return nil, err + } + if _, err := issues.LoadRepositories(ctx); err != nil { + return nil, err + } + + project, err := project_model.GetProjectByID(ctx, column.ProjectID) + if err != nil { + return nil, err + } + + issuesMap := make(map[int64]*issues_model.Issue, len(issues)) + for _, issue := range issues { + issuesMap[issue.ID] = issue + } + + var moves []columnMove + var defaultColumn *project_model.Column + for sorting, issueID := range sortedIssueIDs { + curIssue := issuesMap[issueID] + if curIssue == nil { + continue } - count, err := db.GetEngine(ctx). - Where("project_id=?", column.ProjectID). - In("issue_id", issueIDs). - Count(new(project_model.ProjectIssue)) + + projectColumnMap, err := curIssue.ProjectColumnMap(ctx) if err != nil { - return err - } - if int(count) != len(sortedIssueIDs) { - return ErrIssueNotInProject + return nil, err } - issues, err := issues_model.GetIssuesByIDs(ctx, issueIDs) - if err != nil { - return err - } - if _, err := issues.LoadRepositories(ctx); err != nil { - return err - } - - project, err := project_model.GetProjectByID(ctx, column.ProjectID) - if err != nil { - return err - } - - issuesMap := make(map[int64]*issues_model.Issue, len(issues)) - for _, issue := range issues { - issuesMap[issue.ID] = issue - } - - for sorting, issueID := range sortedIssueIDs { - curIssue := issuesMap[issueID] - if curIssue == nil { - continue - } - - projectColumnMap, err := curIssue.ProjectColumnMap(ctx) - if err != nil { - return err - } - - projectColumnID := projectColumnMap[column.ProjectID] - - if projectColumnID != column.ID { - // add timeline to issue - if _, err := issues_model.CreateComment(ctx, &issues_model.CreateCommentOptions{ - Type: issues_model.CommentTypeProjectColumn, - Doer: doer, - Repo: curIssue.Repo, - Issue: curIssue, - ProjectID: column.ProjectID, - ProjectTitle: project.Title, - ProjectColumnID: column.ID, - ProjectColumnTitle: column.Title, - }); err != nil { - return err + oldColumnID := projectColumnMap[column.ProjectID] + if oldColumnID == 0 { // legacy encoding of the default column + if defaultColumn == nil { + if defaultColumn, err = project.MustDefaultColumn(ctx); err != nil { + return nil, err } } - - // Update the column and sorting for this specific issue in this specific project. - // IMPORTANT: The WHERE clause must include both issue_id AND project_id to ensure - // that moving an issue's column in one project doesn't affect its column in other - // projects when the issue is assigned to multiple projects. - _, err = db.Exec(ctx, "UPDATE `project_issue` SET project_board_id=?, sorting=? WHERE issue_id=? AND project_id=?", - column.ID, sorting, issueID, column.ProjectID) - if err != nil { - return err - } + oldColumnID = defaultColumn.ID } - return nil - }) + + if oldColumnID != column.ID { + // add timeline to issue + if _, err := issues_model.CreateComment(ctx, &issues_model.CreateCommentOptions{ + Type: issues_model.CommentTypeProjectColumn, + Doer: doer, + Repo: curIssue.Repo, + Issue: curIssue, + ProjectID: column.ProjectID, + ProjectTitle: project.Title, + ProjectColumnID: column.ID, + ProjectColumnTitle: column.Title, + }); err != nil { + return nil, err + } + moves = append(moves, columnMove{issue: curIssue, oldColumnID: oldColumnID}) + } + + // Update the column and sorting for this specific issue in this specific project. + // IMPORTANT: The WHERE clause must include both issue_id AND project_id to ensure + // that moving an issue's column in one project doesn't affect its column in other + // projects when the issue is assigned to multiple projects. + _, err = db.Exec(ctx, "UPDATE `project_issue` SET project_board_id=?, sorting=? WHERE issue_id=? AND project_id=?", + column.ID, sorting, issueID, column.ProjectID) + if err != nil { + return nil, err + } + } + return moves, nil } func LoadIssuesAssigneesForProject(ctx context.Context, projectID int64) (users []*user_model.User, _ error) { diff --git a/services/projects/issue_test.go b/services/projects/issue_test.go index df9f9c1c244..f8f071524a7 100644 --- a/services/projects/issue_test.go +++ b/services/projects/issue_test.go @@ -111,12 +111,12 @@ func Test_Projects(t *testing.T) { // issue 6 belongs to private repo 3 under org 3 issue6 := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 6}) - err = issues_model.IssueAssignOrRemoveProject(t.Context(), issue6, user2, []int64{project1.ID}) + _, _, err = issues_model.IssueAssignOrRemoveProject(t.Context(), issue6, user2, []int64{project1.ID}) assert.NoError(t, err) // issue 16 belongs to public repo 16 under org 3 issue16 := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 16}) - err = issues_model.IssueAssignOrRemoveProject(t.Context(), issue16, user2, []int64{project1.ID}) + _, _, err = issues_model.IssueAssignOrRemoveProject(t.Context(), issue16, user2, []int64{project1.ID}) assert.NoError(t, err) projects, err := db.Find[project_model.Project](t.Context(), project_model.SearchOptions{ @@ -189,7 +189,8 @@ func Test_Projects(t *testing.T) { }() } - assert.NoError(t, issues_model.IssueAssignOrRemoveProject(t.Context(), issue11, user2, []int64{projects[0].ID, projects[1].ID})) + _, _, err := issues_model.IssueAssignOrRemoveProject(t.Context(), issue11, user2, []int64{projects[0].ID, projects[1].ID}) + assert.NoError(t, err) // the column the issue must stay in for the second project otherColumn, err := projects[1].MustDefaultColumn(t.Context()) diff --git a/services/projects/label.go b/services/projects/label.go new file mode 100644 index 00000000000..74db72f868f --- /dev/null +++ b/services/projects/label.go @@ -0,0 +1,34 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package project + +import ( + "context" + + "gitea.dev/models/db" + issues_model "gitea.dev/models/issues" + project_model "gitea.dev/models/project" +) + +func GetProjectLabels(ctx context.Context, project *project_model.Project) ([]*issues_model.Label, error) { + if project.Type != project_model.TypeRepository { + labels, err := issues_model.GetLabelsByOrgID(ctx, project.OwnerID, "", db.ListOptionsAll) + issues_model.SortLabelsForDisplay(labels) + return labels, err + } + labels, err := issues_model.GetLabelsByRepoID(ctx, project.RepoID, "", db.ListOptionsAll) + if err != nil { + return nil, err + } + issues_model.SortLabelsForDisplay(labels) + if err := project.LoadRepo(ctx); err != nil { + return nil, err + } + orgLabels, err := issues_model.GetLabelsByOrgID(ctx, project.Repo.OwnerID, "", db.ListOptionsAll) + if err != nil { + return nil, err + } + issues_model.SortLabelsForDisplay(orgLabels) + return append(labels, orgLabels...), nil +} diff --git a/services/projects/main_test.go b/services/projects/main_test.go index c30b34f46d5..610ab995a42 100644 --- a/services/projects/main_test.go +++ b/services/projects/main_test.go @@ -7,11 +7,15 @@ import ( "testing" "gitea.dev/models/unittest" + notify_service "gitea.dev/services/notify" _ "gitea.dev/models/actions" _ "gitea.dev/models/activities" ) +var labelChanges = &labelChangeRecorder{} + func TestMain(m *testing.M) { + notify_service.RegisterNotifier(labelChanges) unittest.MainTest(m) } diff --git a/services/projects/workflow.go b/services/projects/workflow.go new file mode 100644 index 00000000000..500ae64bf54 --- /dev/null +++ b/services/projects/workflow.go @@ -0,0 +1,196 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package project + +import ( + "context" + "slices" + + audit_model "gitea.dev/models/audit" + 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/unit" + user_model "gitea.dev/models/user" + "gitea.dev/modules/log" + "gitea.dev/modules/optional" + api "gitea.dev/modules/structs" + "gitea.dev/modules/util" + "gitea.dev/services/audit" +) + +// CanManageWorkflows checks the authority needed besides project write to manage a project's workflows. They act on +// the project's items without further permission checks, so this needs write access to both issues and pull requests +// of a repository project's repository, owning an organization project's organization, or being a user project's user. +func CanManageWorkflows(ctx context.Context, doer *user_model.User, project *project_model.Project) (bool, error) { + if project.Type == project_model.TypeRepository { + if err := project.LoadRepo(ctx); err != nil { + return false, err + } + perm, err := access_model.GetDoerRepoPermission(ctx, project.Repo, doer) + if err != nil { + return false, err + } + return perm.CanWrite(unit.TypeIssues) && perm.CanWrite(unit.TypePullRequests), nil + } + if !user_model.DoerCanViewPrivate(doer) { // owner-level projects hold items of the owner's private repositories too + return false, nil + } + if project.Type == project_model.TypeOrganization { + if user_model.DoerIsSiteAdminFullAccess(doer) { + return true, nil + } + return organization.IsOrganizationOwner(ctx, project.OwnerID, doer.ID) + } + return doer.ID == project.OwnerID, nil +} + +func checkCanManageWorkflows(ctx context.Context, doer *user_model.User, project *project_model.Project) error { + allowed, err := CanManageWorkflows(ctx, doer, project) + if err != nil { + return err + } + if !allowed { + return util.NewPermissionDeniedErrorf("not allowed to manage the workflows of this project") + } + return nil +} + +func CreateWorkflow(ctx context.Context, doer *user_model.User, project *project_model.Project, opts *api.CreateProjectWorkflowOption) (*project_model.Workflow, error) { + if err := checkCanManageWorkflows(ctx, doer, project); err != nil { + return nil, err + } + wf := &project_model.Workflow{ + ProjectID: project.ID, + WorkflowEvent: project_model.WorkflowEvent(opts.Event), + Enabled: optional.FromPtr(opts.Enabled).ValueOrDefault(true), + } + if !wf.WorkflowEvent.IsValid() { + return nil, util.ErrorWrap(util.ErrUnprocessableContent, "invalid event: %s", opts.Event) + } + if err := setWorkflowRules(ctx, project, wf, &opts.Filters, &opts.Actions); err != nil { + return nil, err + } + if err := project_model.CreateWorkflow(ctx, wf); err != nil { + return nil, err + } + recordWorkflowAudit(ctx, doer, audit_model.ProjectWorkflowCreate, project, wf) + return wf, nil +} + +func UpdateWorkflow(ctx context.Context, doer *user_model.User, project *project_model.Project, wf *project_model.Workflow, opts *api.EditProjectWorkflowOption) error { + if err := checkCanManageWorkflows(ctx, doer, project); err != nil { + return err + } + var cols []string + if opts.Filters != nil || opts.Actions != nil { + if err := setWorkflowRules(ctx, project, wf, opts.Filters, opts.Actions); err != nil { + return err + } + } + if opts.Filters != nil { + cols = append(cols, "workflow_filters") + } + if opts.Actions != nil { + cols = append(cols, "workflow_actions") + } + if opts.Enabled != nil { + wf.Enabled = *opts.Enabled + cols = append(cols, "enabled") + } + if len(cols) == 0 { + return nil + } + if err := project_model.UpdateWorkflow(ctx, wf, cols...); err != nil { + return err + } + recordWorkflowAudit(ctx, doer, audit_model.ProjectWorkflowUpdate, project, wf) + return nil +} + +func DeleteWorkflow(ctx context.Context, doer *user_model.User, project *project_model.Project, wf *project_model.Workflow) error { + if err := checkCanManageWorkflows(ctx, doer, project); err != nil { + return err + } + if err := project_model.DeleteWorkflow(ctx, project.ID, wf.ID); err != nil { + return err + } + recordWorkflowAudit(ctx, doer, audit_model.ProjectWorkflowDelete, project, wf) + return nil +} + +func recordWorkflowAudit(ctx context.Context, doer *user_model.User, action audit_model.Action, project *project_model.Project, wf *project_model.Workflow) { + var scope audit_model.EntityRef + if project.Type == project_model.TypeRepository { + if err := project.LoadRepo(ctx); err != nil { + log.Error("recordWorkflowAudit: LoadRepo: %v", err) + scope = audit_model.EntityRef{Type: audit_model.ScopeRepository, ID: project.RepoID} + } else { + scope = audit.ScopeFromRepository(project.Repo) + } + } else { + scope = audit.ScopeFromUserID(ctx, project.OwnerID) + } + audit.RecordAs(ctx, doer, action, scope, "project", project.Title, "project_id", project.ID, "workflow_id", wf.ID, "event", string(wf.WorkflowEvent)) +} + +func setWorkflowRules(ctx context.Context, project *project_model.Project, wf *project_model.Workflow, newFilters *api.ProjectWorkflowFilters, newActions *api.ProjectWorkflowActions) error { + if newFilters != nil { + wf.WorkflowFilters = project_model.WorkflowFilters(*newFilters) + wf.WorkflowFilters.LabelIDs = uniqueIDs(newFilters.LabelIDs) + } + if newActions != nil { + wf.WorkflowActions = project_model.WorkflowActions(*newActions) + wf.WorkflowActions.AddLabelIDs = uniqueIDs(newActions.AddLabelIDs) + wf.WorkflowActions.RemoveLabelIDs = uniqueIDs(newActions.RemoveLabelIDs) + } + filters, actions := wf.WorkflowFilters, wf.WorkflowActions + + if typ := wf.UnsupportedRule(); typ != "" { + return util.ErrorWrap(util.ErrUnprocessableContent, "%s is not supported by this event", typ) + } + if len(actions.Types()) == 0 { + return util.ErrorWrapTranslatable(util.ErrorWrap(util.ErrUnprocessableContent, "at least one action must be configured"), "projects.workflows.at_least_one_action_required") + } + if !slices.Contains([]string{"", project_model.WorkflowIssueTypeIssue, project_model.WorkflowIssueTypePullRequest}, filters.IssueType) { + return util.ErrorWrap(util.ErrUnprocessableContent, "invalid issue_type: %s", filters.IssueType) + } + if !slices.Contains([]string{"", project_model.WorkflowIssueStateClose, project_model.WorkflowIssueStateReopen}, actions.IssueState) { + return util.ErrorWrap(util.ErrUnprocessableContent, "invalid issue_state: %s", actions.IssueState) + } + if actions.IssueState == project_model.WorkflowIssueStateReopen && filters.IssueType == project_model.WorkflowIssueTypePullRequest { + return util.ErrorWrap(util.ErrUnprocessableContent, "pull requests cannot be reopened by a workflow") + } + + for _, columnID := range []int64{filters.SourceColumnID, filters.TargetColumnID, actions.ColumnID} { + if columnID == 0 { + continue + } + if _, err := project_model.GetColumnByIDAndProjectID(ctx, columnID, project.ID); project_model.IsErrProjectColumnNotExist(err) { + return util.ErrorWrap(util.ErrUnprocessableContent, "invalid column: %d", columnID) + } else if err != nil { + return err + } + } + + labelIDs := slices.Concat(filters.LabelIDs, actions.AddLabelIDs, actions.RemoveLabelIDs) + if len(labelIDs) == 0 { + return nil + } + labels, err := GetProjectLabels(ctx, project) + if err != nil { + return err + } + for _, labelID := range labelIDs { + if !slices.ContainsFunc(labels, func(l *issues_model.Label) bool { return l.ID == labelID }) { + return util.ErrorWrap(util.ErrUnprocessableContent, "invalid label: %d", labelID) + } + } + return nil +} + +func uniqueIDs(ids []int64) []int64 { + return slices.Compact(slices.Sorted(slices.Values(ids))) +} diff --git a/services/projects/workflow_notifier.go b/services/projects/workflow_notifier.go new file mode 100644 index 00000000000..90bc1af62ac --- /dev/null +++ b/services/projects/workflow_notifier.go @@ -0,0 +1,197 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package project + +import ( + "context" + "slices" + + issues_model "gitea.dev/models/issues" + project_model "gitea.dev/models/project" + user_model "gitea.dev/models/user" + "gitea.dev/modules/log" + "gitea.dev/modules/optional" + "gitea.dev/modules/util" + issue_service "gitea.dev/services/issue" + notify_service "gitea.dev/services/notify" +) + +func init() { + notify_service.RegisterNotifier(&workflowNotifier{}) +} + +type workflowNotifier struct { + notify_service.NullNotifier +} + +func (*workflowNotifier) IssueChangeStatus(ctx context.Context, doer *user_model.User, _ string, issue *issues_model.Issue, _ *issues_model.Comment, isClosed bool) { + runIssueProjectsWorkflows(ctx, doer, issue, util.Iif(isClosed, project_model.WorkflowEventItemClosed, project_model.WorkflowEventItemReopened)) +} + +func (*workflowNotifier) IssueChangeProjects(ctx context.Context, doer *user_model.User, issue *issues_model.Issue, addedProjectIDs, removedProjectIDs []int64) { + runWorkflows(ctx, doer, issue, removedProjectIDs, project_model.WorkflowEventItemRemovedFromProject, workflowTrigger{}) + runWorkflows(ctx, doer, issue, addedProjectIDs, project_model.WorkflowEventItemAddedToProject, workflowTrigger{}) +} + +func (*workflowNotifier) IssueChangeProjectColumn(ctx context.Context, doer *user_model.User, issue *issues_model.Issue, oldColumnID int64, newColumn *project_model.Column) { + runWorkflows(ctx, doer, issue, []int64{newColumn.ProjectID}, project_model.WorkflowEventItemColumnChanged, workflowTrigger{ + sourceColumnID: oldColumnID, + targetColumnID: newColumn.ID, + }) +} + +func (*workflowNotifier) MergePullRequest(ctx context.Context, doer *user_model.User, pr *issues_model.PullRequest) { + if err := pr.LoadIssue(ctx); err != nil { + log.Error("LoadIssue: %v", err) + return + } + runIssueProjectsWorkflows(ctx, doer, pr.Issue, project_model.WorkflowEventPullRequestMerged) +} + +func (m *workflowNotifier) AutoMergePullRequest(ctx context.Context, doer *user_model.User, pr *issues_model.PullRequest) { + m.MergePullRequest(ctx, doer, pr) +} + +func (*workflowNotifier) PullRequestReview(ctx context.Context, pr *issues_model.PullRequest, review *issues_model.Review, _ *issues_model.Comment, _ []*user_model.User) { + if !review.Official || (review.Type != issues_model.ReviewTypeApprove && review.Type != issues_model.ReviewTypeReject) { + return + } + if err := pr.LoadIssue(ctx); err != nil { + log.Error("LoadIssue: %v", err) + return + } + runIssueProjectsWorkflows(ctx, review.Reviewer, pr.Issue, util.Iif(review.Type == issues_model.ReviewTypeApprove, project_model.WorkflowEventCodeReviewApproved, project_model.WorkflowEventCodeChangesRequested)) +} + +type workflowTrigger struct { + sourceColumnID int64 + targetColumnID int64 +} + +func runIssueProjectsWorkflows(ctx context.Context, doer *user_model.User, issue *issues_model.Issue, event project_model.WorkflowEvent) { + if issues_model.IsProjectWorkflowContext(ctx) { + return + } + projectIDs, err := issue.ProjectIDs(ctx) + if err != nil { + log.Error("ProjectIDs: %v", err) + return + } + runWorkflows(ctx, doer, issue, projectIDs, event, workflowTrigger{}) +} + +func runWorkflows(ctx context.Context, doer *user_model.User, issue *issues_model.Issue, projectIDs []int64, event project_model.WorkflowEvent, trigger workflowTrigger) { + if len(projectIDs) == 0 || issues_model.IsProjectWorkflowContext(ctx) { + return + } + if err := issue.LoadRepo(ctx); err != nil { + log.Error("LoadRepo: %v", err) + return + } + if issue.Repo.IsArchived { + return + } + workflows, err := project_model.FindEnabledWorkflows(ctx, issue.Repo, projectIDs, event) + if err != nil { + log.Error("FindEnabledWorkflows: %v", err) + return + } + if len(workflows) == 0 { + return + } + repo := issue.Repo + // the caller and the notifiers after this one keep using their issue, don't change it under them + issue, err = issues_model.GetIssueByID(ctx, issue.ID) + if err != nil { + log.Error("GetIssueByID: %v", err) + return + } + issue.Repo = repo + if err := issue.LoadLabels(ctx); err != nil { + log.Error("LoadLabels: %v", err) + return + } + // filters see the item as the event left it, not as earlier workflows changed it + matched := slices.DeleteFunc(workflows, func(wf *project_model.Workflow) bool { return !matchWorkflowFilters(wf, issue, trigger) }) + for _, wf := range matched { + executeWorkflowActions(ctx, wf, doer, issue) + } +} + +func matchWorkflowFilters(wf *project_model.Workflow, issue *issues_model.Issue, trigger workflowTrigger) bool { + if wf.UnsupportedRule() != "" { + return false + } + filters := wf.WorkflowFilters + if filters.IssueType != "" && filters.IssueType != util.Iif(issue.IsPull, project_model.WorkflowIssueTypePullRequest, project_model.WorkflowIssueTypeIssue) { + return false + } + if filters.SourceColumnID != 0 && filters.SourceColumnID != trigger.sourceColumnID { + return false + } + if filters.TargetColumnID != 0 && filters.TargetColumnID != trigger.targetColumnID { + return false + } + for _, labelID := range filters.LabelIDs { + if !slices.ContainsFunc(issue.Labels, func(l *issues_model.Label) bool { return l.ID == labelID }) { + return false + } + } + return true +} + +func executeWorkflowActions(ctx context.Context, wf *project_model.Workflow, doer *user_model.User, issue *issues_model.Issue) { + ctx = issues_model.WithProjectWorkflow(ctx, wf.WorkflowEvent) + actions := wf.WorkflowActions + + if actions.ColumnID != 0 { + if err := moveIssueByWorkflow(ctx, wf, doer, issue); err != nil { + log.Error("Project workflow %d: move issue %d: %v", wf.ID, issue.ID, err) + } + } + + if len(actions.AddLabelIDs) > 0 || len(actions.RemoveLabelIDs) > 0 { + if err := changeLabelsByWorkflow(ctx, doer, issue, actions.AddLabelIDs, actions.RemoveLabelIDs); err != nil { + log.Error("Project workflow %d: change labels of issue %d: %v", wf.ID, issue.ID, err) + } + } + + var err error + if actions.IssueState == project_model.WorkflowIssueStateClose && !issue.IsClosed { + err = issue_service.CloseIssue(ctx, issue, doer, "") + } else if actions.IssueState == project_model.WorkflowIssueStateReopen && issue.IsClosed && !issue.IsPull { // pull requests need the reopen checks of the pull request handlers + err = issue_service.ReopenIssue(ctx, issue, doer, "") + } + if err != nil { + log.Error("Project workflow %d: change state of issue %d: %v", wf.ID, issue.ID, err) + } +} + +func moveIssueByWorkflow(ctx context.Context, wf *project_model.Workflow, doer *user_model.User, issue *issues_model.Issue) error { + column, err := project_model.GetColumnByIDAndProjectID(ctx, wf.WorkflowActions.ColumnID, wf.ProjectID) + if project_model.IsErrProjectColumnNotExist(err) { + log.Warn("Project workflow %d: column %d no longer exists", wf.ID, wf.WorkflowActions.ColumnID) + return nil + } else if err != nil { + return err + } + columnIDs, err := issue.ProjectColumnMap(ctx) + if err != nil { + return err + } + if columnIDs[wf.ProjectID] == column.ID { + return nil + } + return MoveIssueToColumn(ctx, doer, issue, column, optional.None[int64]()) +} + +func changeLabelsByWorkflow(ctx context.Context, doer *user_model.User, issue *issues_model.Issue, addLabelIDs, removeLabelIDs []int64) error { + labels, err := issues_model.GetLabelsByIDs(ctx, slices.Concat(addLabelIDs, removeLabelIDs)) + if err != nil { + return err + } + addLabels := slices.DeleteFunc(slices.Clone(labels), func(l *issues_model.Label) bool { return !slices.Contains(addLabelIDs, l.ID) }) + removeLabels := slices.DeleteFunc(labels, func(l *issues_model.Label) bool { return !slices.Contains(removeLabelIDs, l.ID) }) + return issue_service.AddRemoveLabels(ctx, issue, doer, addLabels, removeLabels) +} diff --git a/services/projects/workflow_test.go b/services/projects/workflow_test.go new file mode 100644 index 00000000000..7f45ce83249 --- /dev/null +++ b/services/projects/workflow_test.go @@ -0,0 +1,290 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package project + +import ( + "context" + "testing" + + audit_model "gitea.dev/models/audit" + "gitea.dev/models/db" + issues_model "gitea.dev/models/issues" + "gitea.dev/models/organization" + "gitea.dev/models/perm" + project_model "gitea.dev/models/project" + repo_model "gitea.dev/models/repo" + "gitea.dev/models/unit" + "gitea.dev/models/unittest" + user_model "gitea.dev/models/user" + "gitea.dev/modules/container" + "gitea.dev/modules/optional" + "gitea.dev/modules/setting" + api "gitea.dev/modules/structs" + "gitea.dev/modules/test" + "gitea.dev/modules/util" + issue_service "gitea.dev/services/issue" + notify_service "gitea.dev/services/notify" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestMatchWorkflowFilters(t *testing.T) { + issue := &issues_model.Issue{Labels: []*issues_model.Label{{ID: 1}}} + pull := &issues_model.Issue{IsPull: true} + moved := workflowTrigger{sourceColumnID: 1, targetColumnID: 2} + added, columnChanged := project_model.WorkflowEventItemAddedToProject, project_model.WorkflowEventItemColumnChanged + tests := []struct { + name string + event project_model.WorkflowEvent + filters project_model.WorkflowFilters + issue *issues_model.Issue + trigger workflowTrigger + want bool + }{ + {"no filters", added, project_model.WorkflowFilters{}, issue, workflowTrigger{}, true}, + {"issue type", added, project_model.WorkflowFilters{IssueType: "issue"}, issue, workflowTrigger{}, true}, + {"other issue type", added, project_model.WorkflowFilters{IssueType: "issue"}, pull, workflowTrigger{}, false}, + {"label", added, project_model.WorkflowFilters{LabelIDs: []int64{1}}, issue, workflowTrigger{}, true}, + {"missing label", added, project_model.WorkflowFilters{LabelIDs: []int64{1, 2}}, issue, workflowTrigger{}, false}, + {"columns", columnChanged, project_model.WorkflowFilters{SourceColumnID: 1, TargetColumnID: 2}, issue, moved, true}, + {"other target", columnChanged, project_model.WorkflowFilters{TargetColumnID: 3}, issue, moved, false}, + {"no column supplied", columnChanged, project_model.WorkflowFilters{SourceColumnID: 1}, issue, workflowTrigger{}, false}, + {"unsupported by event", added, project_model.WorkflowFilters{TargetColumnID: 2}, issue, moved, false}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + wf := &project_model.Workflow{WorkflowEvent: test.event, WorkflowFilters: test.filters} + assert.Equal(t, test.want, matchWorkflowFilters(wf, test.issue, test.trigger)) + }) + } +} + +func TestCanManageWorkflows(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + user := func(id int64) *user_model.User { return unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: id}) } + publicOnly := func(id int64) *user_model.User { + doer := *user(id) + doer.ExtDoerData = user_model.NewTokenExtDoerData(user_model.CredentialAccessToken, 1, "public-only,write:issue") + return &doer + } + repoProject := unittest.AssertExistsAndLoadBean(t, &project_model.Project{ID: 2}) + orgProject := &project_model.Project{Title: "org", OwnerID: 3, Type: project_model.TypeOrganization} + userProject := &project_model.Project{Title: "user", OwnerID: 2, Type: project_model.TypeIndividual} + for _, tc := range []struct { + project *project_model.Project + doer *user_model.User + want bool + }{ + {repoProject, user(4), true}, + {repoProject, user(5), false}, + {orgProject, user(2), true}, + {orgProject, user(1), true}, + {orgProject, user(4), false}, + {orgProject, publicOnly(2), false}, + {userProject, user(2), true}, + {userProject, user(1), false}, + {userProject, publicOnly(2), false}, + } { + allowed, err := CanManageWorkflows(t.Context(), tc.doer, tc.project) + require.NoError(t, err) + assert.Equal(t, tc.want, allowed, "project %q, user %d", tc.project.Title, tc.doer.ID) + } + for _, removedUnit := range []unit.Type{unit.TypeIssues, unit.TypePullRequests} { + require.NoError(t, unittest.PrepareTestDatabase()) + _, err := db.Exec(t.Context(), "UPDATE team SET authorize = ? WHERE id = ?", perm.AccessModeNone, 2) + require.NoError(t, err) + _, err = db.DeleteByBean(t.Context(), &organization.TeamUnit{TeamID: 2, Type: removedUnit}) + require.NoError(t, err) + allowed, err := CanManageWorkflows(t.Context(), user(4), repoProject) + require.NoError(t, err) + assert.False(t, allowed, removedUnit.LogString()) + } +} + +func TestCreateUpdateWorkflow(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + defer test.MockVariableValue(&setting.Audit.RecordOutput, setting.AuditRecordOutputDatabase)() + ctx := t.Context() + owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + reader := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 5}) + project := unittest.AssertExistsAndLoadBean(t, &project_model.Project{ID: 1}) + + t.Run("Rejects", func(t *testing.T) { + tests := []struct { + name string + doer *user_model.User + opts api.CreateProjectWorkflowOption + code int + }{ + {"unknown event", owner, api.CreateProjectWorkflowOption{Event: "bogus", Actions: api.ProjectWorkflowActions{ColumnID: 2}}, 422}, + {"no actions", owner, api.CreateProjectWorkflowOption{Event: "item_added_to_project"}, 422}, + {"other project column", owner, api.CreateProjectWorkflowOption{Event: "item_added_to_project", Actions: api.ProjectWorkflowActions{ColumnID: 4}}, 422}, + {"other owner label", owner, api.CreateProjectWorkflowOption{Event: "item_added_to_project", Actions: api.ProjectWorkflowActions{AddLabelIDs: []int64{3}}}, 422}, + {"unsupported filter", owner, api.CreateProjectWorkflowOption{Event: "item_added_to_project", Filters: api.ProjectWorkflowFilters{SourceColumnID: 1}, Actions: api.ProjectWorkflowActions{ColumnID: 2}}, 422}, + {"unsupported action", owner, api.CreateProjectWorkflowOption{Event: "item_removed_from_project", Actions: api.ProjectWorkflowActions{ColumnID: 2}}, 422}, + {"unknown issue state", owner, api.CreateProjectWorkflowOption{Event: "item_added_to_project", Actions: api.ProjectWorkflowActions{IssueState: "bogus"}}, 422}, + {"reopen pull requests", owner, api.CreateProjectWorkflowOption{Event: "item_added_to_project", Filters: api.ProjectWorkflowFilters{IssueType: "pull_request"}, Actions: api.ProjectWorkflowActions{IssueState: "reopen"}}, 422}, + {"unknown issue type", owner, api.CreateProjectWorkflowOption{Event: "item_added_to_project", Filters: api.ProjectWorkflowFilters{IssueType: "bogus"}, Actions: api.ProjectWorkflowActions{ColumnID: 2}}, 422}, + {"without issue and pull request write", reader, api.CreateProjectWorkflowOption{Event: "item_added_to_project", Actions: api.ProjectWorkflowActions{ColumnID: 2}}, 403}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + _, err := CreateWorkflow(ctx, test.doer, project, &test.opts) + _, code := util.ErrorUnwrapForUser(err) + assert.Equal(t, test.code, code) + }) + } + }) + + t.Run("PartialUpdate", func(t *testing.T) { + wf, err := CreateWorkflow(ctx, owner, project, &api.CreateProjectWorkflowOption{ + Event: "item_added_to_project", + Filters: api.ProjectWorkflowFilters{IssueType: "issue", LabelIDs: []int64{2, 1, 2}}, + Actions: api.ProjectWorkflowActions{ColumnID: 2}, + }) + require.NoError(t, err) + + require.NoError(t, UpdateWorkflow(ctx, owner, project, wf, &api.EditProjectWorkflowOption{Enabled: new(false)})) + require.NoError(t, UpdateWorkflow(ctx, owner, project, wf, &api.EditProjectWorkflowOption{Actions: &api.ProjectWorkflowActions{AddLabelIDs: []int64{1}}})) + + unittest.AssertExistsAndLoadBean(t, &audit_model.Event{Action: audit_model.ProjectWorkflowCreate, ActorID: owner.ID, ScopeType: audit_model.ScopeRepository, ScopeID: project.RepoID}) + + stored := unittest.AssertExistsAndLoadBean(t, &project_model.Workflow{ID: wf.ID}) + assert.False(t, stored.Enabled) + assert.Equal(t, project_model.WorkflowFilters{IssueType: "issue", LabelIDs: []int64{1, 2}}, stored.WorkflowFilters) + assert.Equal(t, project_model.WorkflowActions{AddLabelIDs: []int64{1}}, stored.WorkflowActions) + + assert.ErrorIs(t, UpdateWorkflow(ctx, reader, project, stored, &api.EditProjectWorkflowOption{Enabled: new(true)}), util.ErrPermissionDenied) + assert.ErrorIs(t, DeleteWorkflow(ctx, reader, project, stored), util.ErrPermissionDenied) + require.NoError(t, DeleteWorkflow(ctx, owner, project, stored)) + unittest.AssertNotExistsBean(t, &project_model.Workflow{ID: wf.ID}) + for _, action := range []audit_model.Action{audit_model.ProjectWorkflowUpdate, audit_model.ProjectWorkflowDelete} { + unittest.AssertExistsAndLoadBean(t, &audit_model.Event{Action: action, ActorID: owner.ID, ScopeType: audit_model.ScopeRepository, ScopeID: project.RepoID}) + } + }) + + userProject := &project_model.Project{Title: "workflows", OwnerID: owner.ID, CreatorID: owner.ID, Type: project_model.TypeIndividual} + require.NoError(t, project_model.NewProject(ctx, userProject)) + _, err := CreateWorkflow(ctx, owner, userProject, &api.CreateProjectWorkflowOption{Event: "item_added_to_project", Actions: api.ProjectWorkflowActions{IssueState: "close"}}) + require.NoError(t, err) + event := unittest.AssertExistsAndLoadBean(t, &audit_model.Event{Action: audit_model.ProjectWorkflowCreate, ScopeType: audit_model.ScopeUser, ScopeID: userProject.OwnerID}) + assert.Contains(t, event.Message, "item_added_to_project") +} + +type labelChangeRecorder struct { + notify_service.NullNotifier + added [][]int64 +} + +func (r *labelChangeRecorder) IssueChangeLabels(_ctx context.Context, _doer *user_model.User, _issue *issues_model.Issue, added, _removed []*issues_model.Label) { + r.added = append(r.added, container.FilterSlice(added, func(l *issues_model.Label) (int64, bool) { return l.ID, true })) +} + +func TestWorkflowExecution(t *testing.T) { + const labelID = 2 + require.NoError(t, unittest.PrepareTestDatabase()) + doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + + addLabel := project_model.WorkflowActions{AddLabelIDs: []int64{labelID}} + addWorkflow := func(t *testing.T, wf project_model.Workflow) { + wf.ProjectID, wf.Enabled = 1, true + require.NoError(t, project_model.CreateWorkflow(t.Context(), &wf)) + } + setup := func(t *testing.T, wf project_model.Workflow) { + require.NoError(t, unittest.PrepareTestDatabase()) + addWorkflow(t, wf) + } + hasLabel := func(t *testing.T, issueID int64) bool { + return unittest.GetCount(t, &issues_model.IssueLabel{IssueID: issueID, LabelID: labelID}) == 1 + } + moveIssue := func(t *testing.T, ctx context.Context, issueID, columnID int64) { + issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: issueID}) + column := unittest.AssertExistsAndLoadBean(t, &project_model.Column{ID: columnID}) + require.NoError(t, MoveIssueToColumn(ctx, doer, issue, column, optional.None[int64]())) + } + + t.Run("ColumnChangedFromDefaultColumn", func(t *testing.T) { + setup(t, project_model.Workflow{WorkflowEvent: project_model.WorkflowEventItemColumnChanged, WorkflowFilters: project_model.WorkflowFilters{SourceColumnID: 1, TargetColumnID: 2}, WorkflowActions: addLabel}) + moveIssue(t, t.Context(), 2, 2) + assert.True(t, hasLabel(t, 2)) + }) + + t.Run("NoOpMoveWithinDefaultColumn", func(t *testing.T) { + setup(t, project_model.Workflow{WorkflowEvent: project_model.WorkflowEventItemColumnChanged, WorkflowActions: addLabel}) + moveIssue(t, t.Context(), 2, 1) + assert.False(t, hasLabel(t, 2)) + unittest.AssertNotExistsBean(t, &issues_model.Comment{IssueID: 2, Type: issues_model.CommentTypeProjectColumn}) + }) + + t.Run("NoCascadeFromWorkflows", func(t *testing.T) { + setup(t, project_model.Workflow{WorkflowEvent: project_model.WorkflowEventItemColumnChanged, WorkflowActions: addLabel}) + moveIssue(t, issues_model.WithProjectWorkflow(t.Context(), project_model.WorkflowEventItemAddedToProject), 1, 2) + assert.False(t, hasLabel(t, 1)) + }) + + t.Run("LabelsAndClosesPullRequestAsTriggeringReader", func(t *testing.T) { + setup(t, project_model.Workflow{WorkflowEvent: project_model.WorkflowEventItemColumnChanged, WorkflowActions: project_model.WorkflowActions{AddLabelIDs: []int64{labelID}, IssueState: project_model.WorkflowIssueStateClose}}) + reader := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 5}) + require.NoError(t, MoveIssueToColumn(t.Context(), reader, unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 3}), unittest.AssertExistsAndLoadBean(t, &project_model.Column{ID: 3}), optional.None[int64]())) + assert.True(t, hasLabel(t, 3)) + assert.True(t, unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 3}).IsClosed) + for _, commentType := range []issues_model.CommentType{issues_model.CommentTypeLabel, issues_model.CommentTypeClose} { + comment := unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{IssueID: 3, Type: commentType}) + assert.Equal(t, reader.ID, comment.PosterID) + assert.Equal(t, &issues_model.CommentMetaData{ + ProjectWorkflowEvent: project_model.WorkflowEventItemColumnChanged, + SpecialDoerName: issues_model.SpecialDoerNameProjectWorkflow, + }, comment.CommentMetaData) + } + }) + + t.Run("ArchivedRepository", func(t *testing.T) { + setup(t, project_model.Workflow{WorkflowEvent: project_model.WorkflowEventItemClosed, WorkflowActions: addLabel}) + require.NoError(t, repo_model.UpdateRepositoryColsNoAutoTime(t.Context(), &repo_model.Repository{ID: 1, IsArchived: true}, "is_archived")) + (&workflowNotifier{}).IssueChangeStatus(t.Context(), doer, "", unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 1}), nil, true) + assert.False(t, hasLabel(t, 1)) + }) + + t.Run("ReopenSkipsPullRequests", func(t *testing.T) { + setup(t, project_model.Workflow{WorkflowEvent: project_model.WorkflowEventItemColumnChanged, WorkflowActions: project_model.WorkflowActions{IssueState: project_model.WorkflowIssueStateReopen}}) + require.NoError(t, issues_model.UpdateIssueCols(t.Context(), &issues_model.Issue{ID: 3, IsClosed: true}, "is_closed")) + moveIssue(t, t.Context(), 3, 1) + moveIssue(t, t.Context(), 5, 1) + assert.True(t, unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 3}).IsClosed) + assert.False(t, unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 5}).IsClosed) + }) + + t.Run("OfficialReviewsOnly", func(t *testing.T) { + setup(t, project_model.Workflow{WorkflowEvent: project_model.WorkflowEventCodeReviewApproved, WorkflowActions: addLabel}) + pr := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{IssueID: 3}) + review := &issues_model.Review{Type: issues_model.ReviewTypeApprove, Reviewer: doer} + (&workflowNotifier{}).PullRequestReview(t.Context(), pr, review, nil, nil) + assert.False(t, hasLabel(t, 3)) + review.Official = true + (&workflowNotifier{}).PullRequestReview(t.Context(), pr, review, nil, nil) + assert.True(t, hasLabel(t, 3)) + }) + + t.Run("AddedToAndRemovedFromProject", func(t *testing.T) { + setup(t, project_model.Workflow{WorkflowEvent: project_model.WorkflowEventItemAddedToProject, WorkflowActions: addLabel}) + addWorkflow(t, project_model.Workflow{WorkflowEvent: project_model.WorkflowEventItemRemovedFromProject, WorkflowActions: project_model.WorkflowActions{RemoveLabelIDs: []int64{labelID}}}) + issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 11}) + column := unittest.AssertExistsAndLoadBean(t, &project_model.Column{ID: 2}) + require.NoError(t, AddIssueToColumn(t.Context(), doer, issue, column)) + assert.True(t, hasLabel(t, 11)) + require.NoError(t, RemoveIssueFromColumn(t.Context(), doer, issue, column)) + assert.False(t, hasLabel(t, 11)) + }) + + t.Run("NewItemInProject", func(t *testing.T) { + setup(t, project_model.Workflow{WorkflowEvent: project_model.WorkflowEventItemAddedToProject, WorkflowActions: project_model.WorkflowActions{ColumnID: 3, AddLabelIDs: []int64{labelID}}}) + *labelChanges = labelChangeRecorder{} + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + issue := &issues_model.Issue{RepoID: repo.ID, PosterID: doer.ID, Title: "new item"} + require.NoError(t, issue_service.NewIssue(t.Context(), repo, issue, []int64{1}, nil, nil, []int64{1})) + assert.Equal(t, [][]int64{{1}, {labelID}}, labelChanges.added) + assert.EqualValues(t, 3, unittest.AssertExistsAndLoadBean(t, &project_model.ProjectIssue{IssueID: issue.ID}).ProjectColumnID) + }) +} diff --git a/services/pull/pull.go b/services/pull/pull.go index 58a3e8ab25d..3789d7f0b1c 100644 --- a/services/pull/pull.go +++ b/services/pull/pull.go @@ -98,6 +98,7 @@ func NewPullRequest(ctx context.Context, opts *NewPullRequestOptions) error { assigneeCommentMap := make(map[int64]*issues_model.Comment) assignees := make(map[int64]*user_model.User) var reviewNotifiers []*issue_service.ReviewRequestNotifier + var addedProjectIDs []int64 err := db.WithTx(ctx, func(ctx context.Context) error { err := issues_model.NewPullRequest(ctx, repo, issue, labelIDs, uuids, pr) if err != nil { @@ -119,7 +120,7 @@ func NewPullRequest(ctx context.Context, opts *NewPullRequestOptions) error { } if len(opts.ProjectIDs) > 0 && canAssignProject { - if err := issues_model.IssueAssignOrRemoveProject(ctx, issue, issue.Poster, opts.ProjectIDs); err != nil { + if addedProjectIDs, _, err = issues_model.IssueAssignOrRemoveProject(ctx, issue, issue.Poster, opts.ProjectIDs); err != nil { return err } } @@ -194,6 +195,7 @@ func NewPullRequest(ctx context.Context, opts *NewPullRequestOptions) error { for _, assignee := range assignees { notify_service.IssueChangeAssignee(ctx, issue.Poster, issue, assignee, false, assigneeCommentMap[assignee.ID]) } + notify_service.IssueChangeProjects(ctx, issue.Poster, issue, addedProjectIDs, nil) return nil } diff --git a/services/repository/transfer.go b/services/repository/transfer.go index 5c58d326e7e..ae493fb6b8d 100644 --- a/services/repository/transfer.go +++ b/services/repository/transfer.go @@ -217,7 +217,7 @@ func transferOwnership(ctx context.Context, doer *user_model.User, newOwnerName return fmt.Errorf("removeOrgRepo: %w", err) } - // Remove project's issues that belong to old organization's projects + // Remove project's issues that belong to old organization's projects, this intentionally doesn't trigger project workflows projects, err := project_model.GetAllProjectsIDsByOwnerIDAndType(ctx, oldOwner.ID, project_model.TypeOrganization) if err != nil { return fmt.Errorf("Unable to find old org projects: %w", err) diff --git a/templates/org/projects/workflows.tmpl b/templates/org/projects/workflows.tmpl new file mode 100644 index 00000000000..d954dab8c8b --- /dev/null +++ b/templates/org/projects/workflows.tmpl @@ -0,0 +1,13 @@ +{{template "base/head" .}} +
{{ctx.Locale.Tr "projects.workflows.runs_as_hint"}}
+ +