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" .}} +
+ {{if .ContextUser.IsOrganization}} + {{template "org/header" .}} + {{else}} + {{template "shared/user/org_profile_avatar" .}} +
+ {{template "user/overview/header" .}} +
+ {{end}} + {{template "projects/workflows" .}} +
+{{template "base/footer" .}} diff --git a/templates/projects/view.tmpl b/templates/projects/view.tmpl index bc38c8a4b40..8e527c260a4 100644 --- a/templates/projects/view.tmpl +++ b/templates/projects/view.tmpl @@ -23,8 +23,12 @@ "ClosedMilestones" .ClosedMilestones }} - {{if $canWriteProject}} -
diff --git a/templates/projects/workflows.tmpl b/templates/projects/workflows.tmpl new file mode 100644 index 00000000000..feb18836b5e --- /dev/null +++ b/templates/projects/workflows.tmpl @@ -0,0 +1,47 @@ +
+ {{svg "octicon-arrow-left"}} {{.Project.Title}} +

{{ctx.Locale.Tr "projects.workflows.runs_as_hint"}}

+
+
diff --git a/templates/repo/issue/view_content/comments.tmpl b/templates/repo/issue/view_content/comments.tmpl index 4ce549f6116..0d184e07135 100644 --- a/templates/repo/issue/view_content/comments.tmpl +++ b/templates/repo/issue/view_content/comments.tmpl @@ -171,7 +171,7 @@ {{svg "octicon-tag"}} {{template "shared/user/avatarlink" dict "user" .Poster}} - {{template "shared/user/authorlink" .Poster}} + {{template "repo/issue/view_content/comments_authorlink" dict "comment" .}} {{if and .AddedLabels (not .RemovedLabels)}} {{ctx.Locale.TrN (len .AddedLabels) "repo.issues.add_label" "repo.issues.add_labels" (ctx.RenderUtils.RenderLabels .AddedLabels $.RepoLink .Issue) $createdStr}} {{else if and (not .AddedLabels) .RemovedLabels}} @@ -590,7 +590,7 @@ {{svg "octicon-project"}} {{template "shared/user/avatarlink" dict "user" .Poster}} - {{template "shared/user/authorlink" .Poster}} + {{template "repo/issue/view_content/comments_authorlink" dict "comment" .}} {{$newProjectDisplay := .CommentMetaData.ProjectTitle}} {{if .Project}} {{$trKey := printf "projects.type-%d.display_name" .Project.Type}} diff --git a/templates/repo/issue/view_content/comments_authorlink.tmpl b/templates/repo/issue/view_content/comments_authorlink.tmpl index 8e0645c983e..ffeccc1b38e 100644 --- a/templates/repo/issue/view_content/comments_authorlink.tmpl +++ b/templates/repo/issue/view_content/comments_authorlink.tmpl @@ -7,5 +7,10 @@ ({{ctx.Locale.Tr "repo.migrated_from" ctx.RootData.Repository.OriginalURL ctx.RootData.Repository.GetOriginalURLHostname}}) {{end}} {{else}} - {{template "shared/user/authorlink" .comment.Poster}} + {{$specialDoerHtml := .comment.MetaSpecialDoerTr ctx.Locale}} + {{if $specialDoerHtml}} + {{$specialDoerHtml}} + {{else}} + {{template "shared/user/authorlink" .comment.Poster}} + {{end}} {{end}} diff --git a/templates/repo/projects/workflows.tmpl b/templates/repo/projects/workflows.tmpl new file mode 100644 index 00000000000..b29d9af19a6 --- /dev/null +++ b/templates/repo/projects/workflows.tmpl @@ -0,0 +1,6 @@ +{{template "base/head" .}} +
+ {{template "repo/header" .}} + {{template "projects/workflows" .}} +
+{{template "base/footer" .}} diff --git a/templates/swagger/v1-openapi3.generated.json b/templates/swagger/v1-openapi3.generated.json index 8e4aaee7c8f..09edc792d8b 100644 --- a/templates/swagger/v1-openapi3.generated.json +++ b/templates/swagger/v1-openapi3.generated.json @@ -1016,6 +1016,29 @@ }, "description": "ProjectList" }, + "ProjectWorkflow": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectWorkflow" + } + } + }, + "description": "ProjectWorkflow" + }, + "ProjectWorkflowList": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/ProjectWorkflow" + }, + "type": "array" + } + } + }, + "description": "ProjectWorkflowList" + }, "PublicKey": { "content": { "application/json": { @@ -4534,6 +4557,31 @@ "type": "object", "x-go-package": "gitea.dev/modules/structs" }, + "CreateProjectWorkflowOption": { + "description": "CreateProjectWorkflowOption options for creating a project workflow", + "properties": { + "actions": { + "$ref": "#/components/schemas/ProjectWorkflowActions" + }, + "enabled": { + "description": "defaults to true", + "type": "boolean", + "x-go-name": "Enabled" + }, + "event": { + "type": "string", + "x-go-name": "Event" + }, + "filters": { + "$ref": "#/components/schemas/ProjectWorkflowFilters" + } + }, + "required": [ + "event" + ], + "type": "object", + "x-go-package": "gitea.dev/modules/structs" + }, "CreatePullRequestOption": { "description": "CreatePullRequestOption options when creating a pull request", "properties": { @@ -5863,6 +5911,23 @@ "type": "object", "x-go-package": "gitea.dev/modules/structs" }, + "EditProjectWorkflowOption": { + "description": "EditProjectWorkflowOption options for editing a project workflow, omitted fields are left unchanged", + "properties": { + "actions": { + "$ref": "#/components/schemas/ProjectWorkflowActions" + }, + "enabled": { + "type": "boolean", + "x-go-name": "Enabled" + }, + "filters": { + "$ref": "#/components/schemas/ProjectWorkflowFilters" + } + }, + "type": "object", + "x-go-package": "gitea.dev/modules/structs" + }, "EditPullRequestOption": { "description": "EditPullRequestOption options when modify pull request", "properties": { @@ -8761,6 +8826,109 @@ "type": "object", "x-go-package": "gitea.dev/modules/structs" }, + "ProjectWorkflow": { + "description": "ProjectWorkflow represents a project workflow", + "properties": { + "actions": { + "$ref": "#/components/schemas/ProjectWorkflowActions" + }, + "created_at": { + "format": "date-time", + "type": "string", + "x-go-name": "Created" + }, + "enabled": { + "type": "boolean", + "x-go-name": "Enabled" + }, + "event": { + "description": "one of item_added_to_project, item_removed_from_project, item_reopened, item_closed, item_column_changed,\ncode_changes_requested, code_review_approved, pull_request_merged", + "type": "string", + "x-go-name": "Event" + }, + "filters": { + "$ref": "#/components/schemas/ProjectWorkflowFilters" + }, + "id": { + "format": "int64", + "type": "integer", + "x-go-name": "ID" + }, + "updated_at": { + "format": "date-time", + "type": "string", + "x-go-name": "Updated" + } + }, + "type": "object", + "x-go-package": "gitea.dev/modules/structs" + }, + "ProjectWorkflowActions": { + "description": "ProjectWorkflowActions describes what a project workflow does to a matching item", + "properties": { + "add_label_ids": { + "items": { + "format": "int64", + "type": "integer" + }, + "type": "array", + "x-go-name": "AddLabelIDs" + }, + "column_id": { + "format": "int64", + "type": "integer", + "x-go-name": "ColumnID" + }, + "issue_state": { + "description": "\"close\" or \"reopen\"", + "type": "string", + "x-go-name": "IssueState" + }, + "remove_label_ids": { + "items": { + "format": "int64", + "type": "integer" + }, + "type": "array", + "x-go-name": "RemoveLabelIDs" + } + }, + "type": "object", + "x-go-package": "gitea.dev/modules/structs" + }, + "ProjectWorkflowFilters": { + "description": "ProjectWorkflowFilters restricts which items a project workflow runs for", + "properties": { + "issue_type": { + "description": "\"issue\" or \"pull_request\", empty matches both", + "type": "string", + "x-go-name": "IssueType" + }, + "label_ids": { + "description": "the item must have all of these labels", + "items": { + "format": "int64", + "type": "integer" + }, + "type": "array", + "x-go-name": "LabelIDs" + }, + "source_column_id": { + "description": "only for item_column_changed", + "format": "int64", + "type": "integer", + "x-go-name": "SourceColumnID" + }, + "target_column_id": { + "description": "only for item_column_changed", + "format": "int64", + "type": "integer", + "x-go-name": "TargetColumnID" + } + }, + "type": "object", + "x-go-package": "gitea.dev/modules/structs" + }, "PublicKey": { "description": "PublicKey publickey is a user key to push code to repository", "properties": { @@ -15573,6 +15741,271 @@ ] } }, + "/orgs/{org}/projects/{id}/workflows": { + "get": { + "operationId": "orgListProjectWorkflows", + "parameters": [ + { + "description": "name of the organization", + "in": "path", + "name": "org", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "id of the project", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + }, + { + "description": "page number of results to return (1-based)", + "in": "query", + "name": "page", + "schema": { + "type": "integer" + } + }, + { + "description": "page size of results", + "in": "query", + "name": "limit", + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "$ref": "#/components/responses/ProjectWorkflowList" + }, + "404": { + "$ref": "#/components/responses/notFound" + } + }, + "summary": "List a project's workflows", + "tags": [ + "organization" + ] + }, + "post": { + "operationId": "orgCreateProjectWorkflow", + "parameters": [ + { + "description": "name of the organization", + "in": "path", + "name": "org", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "id of the project", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateProjectWorkflowOption" + } + } + }, + "x-originalParamName": "body" + }, + "responses": { + "201": { + "$ref": "#/components/responses/ProjectWorkflow" + }, + "403": { + "$ref": "#/components/responses/forbidden" + }, + "404": { + "$ref": "#/components/responses/notFound" + }, + "422": { + "$ref": "#/components/responses/validationError" + } + }, + "summary": "Create a project workflow", + "tags": [ + "organization" + ] + } + }, + "/orgs/{org}/projects/{id}/workflows/{workflow_id}": { + "delete": { + "operationId": "orgDeleteProjectWorkflow", + "parameters": [ + { + "description": "name of the organization", + "in": "path", + "name": "org", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "id of the project", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + }, + { + "description": "id of the workflow", + "in": "path", + "name": "workflow_id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + } + ], + "responses": { + "204": { + "$ref": "#/components/responses/empty" + }, + "403": { + "$ref": "#/components/responses/forbidden" + }, + "404": { + "$ref": "#/components/responses/notFound" + } + }, + "summary": "Delete a project workflow", + "tags": [ + "organization" + ] + }, + "get": { + "operationId": "orgGetProjectWorkflow", + "parameters": [ + { + "description": "name of the organization", + "in": "path", + "name": "org", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "id of the project", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + }, + { + "description": "id of the workflow", + "in": "path", + "name": "workflow_id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + } + ], + "responses": { + "200": { + "$ref": "#/components/responses/ProjectWorkflow" + }, + "404": { + "$ref": "#/components/responses/notFound" + } + }, + "summary": "Get a project workflow", + "tags": [ + "organization" + ] + }, + "patch": { + "operationId": "orgEditProjectWorkflow", + "parameters": [ + { + "description": "name of the organization", + "in": "path", + "name": "org", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "id of the project", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + }, + { + "description": "id of the workflow", + "in": "path", + "name": "workflow_id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EditProjectWorkflowOption" + } + } + }, + "x-originalParamName": "body" + }, + "responses": { + "200": { + "$ref": "#/components/responses/ProjectWorkflow" + }, + "403": { + "$ref": "#/components/responses/forbidden" + }, + "404": { + "$ref": "#/components/responses/notFound" + }, + "422": { + "$ref": "#/components/responses/validationError" + } + }, + "summary": "Edit a project workflow", + "tags": [ + "organization" + ] + } + }, "/orgs/{org}/public_members": { "get": { "operationId": "orgListPublicMembers", @@ -28845,6 +29278,325 @@ ] } }, + "/repos/{owner}/{repo}/projects/{id}/workflows": { + "get": { + "operationId": "repoListProjectWorkflows", + "parameters": [ + { + "description": "owner of the repo", + "in": "path", + "name": "owner", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "name of the repo", + "in": "path", + "name": "repo", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "id of the project", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + }, + { + "description": "page number of results to return (1-based)", + "in": "query", + "name": "page", + "schema": { + "type": "integer" + } + }, + { + "description": "page size of results", + "in": "query", + "name": "limit", + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "$ref": "#/components/responses/ProjectWorkflowList" + }, + "404": { + "$ref": "#/components/responses/notFound" + } + }, + "summary": "List a project's workflows", + "tags": [ + "repository" + ] + }, + "post": { + "operationId": "repoCreateProjectWorkflow", + "parameters": [ + { + "description": "owner of the repo", + "in": "path", + "name": "owner", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "name of the repo", + "in": "path", + "name": "repo", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "id of the project", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateProjectWorkflowOption" + } + } + }, + "x-originalParamName": "body" + }, + "responses": { + "201": { + "$ref": "#/components/responses/ProjectWorkflow" + }, + "403": { + "$ref": "#/components/responses/forbidden" + }, + "404": { + "$ref": "#/components/responses/notFound" + }, + "422": { + "$ref": "#/components/responses/validationError" + }, + "423": { + "$ref": "#/components/responses/repoArchivedError" + } + }, + "summary": "Create a project workflow", + "tags": [ + "repository" + ] + } + }, + "/repos/{owner}/{repo}/projects/{id}/workflows/{workflow_id}": { + "delete": { + "operationId": "repoDeleteProjectWorkflow", + "parameters": [ + { + "description": "owner of the repo", + "in": "path", + "name": "owner", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "name of the repo", + "in": "path", + "name": "repo", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "id of the project", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + }, + { + "description": "id of the workflow", + "in": "path", + "name": "workflow_id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + } + ], + "responses": { + "204": { + "$ref": "#/components/responses/empty" + }, + "403": { + "$ref": "#/components/responses/forbidden" + }, + "404": { + "$ref": "#/components/responses/notFound" + }, + "423": { + "$ref": "#/components/responses/repoArchivedError" + } + }, + "summary": "Delete a project workflow", + "tags": [ + "repository" + ] + }, + "get": { + "operationId": "repoGetProjectWorkflow", + "parameters": [ + { + "description": "owner of the repo", + "in": "path", + "name": "owner", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "name of the repo", + "in": "path", + "name": "repo", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "id of the project", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + }, + { + "description": "id of the workflow", + "in": "path", + "name": "workflow_id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + } + ], + "responses": { + "200": { + "$ref": "#/components/responses/ProjectWorkflow" + }, + "404": { + "$ref": "#/components/responses/notFound" + } + }, + "summary": "Get a project workflow", + "tags": [ + "repository" + ] + }, + "patch": { + "operationId": "repoEditProjectWorkflow", + "parameters": [ + { + "description": "owner of the repo", + "in": "path", + "name": "owner", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "name of the repo", + "in": "path", + "name": "repo", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "id of the project", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + }, + { + "description": "id of the workflow", + "in": "path", + "name": "workflow_id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EditProjectWorkflowOption" + } + } + }, + "x-originalParamName": "body" + }, + "responses": { + "200": { + "$ref": "#/components/responses/ProjectWorkflow" + }, + "403": { + "$ref": "#/components/responses/forbidden" + }, + "404": { + "$ref": "#/components/responses/notFound" + }, + "422": { + "$ref": "#/components/responses/validationError" + }, + "423": { + "$ref": "#/components/responses/repoArchivedError" + } + }, + "summary": "Edit a project workflow", + "tags": [ + "repository" + ] + } + }, "/repos/{owner}/{repo}/pulls": { "get": { "operationId": "repoListPullRequests", @@ -36536,6 +37288,226 @@ ] } }, + "/user/projects/{id}/workflows": { + "get": { + "operationId": "userCurrentListProjectWorkflows", + "parameters": [ + { + "description": "id of the project", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + }, + { + "description": "page number of results to return (1-based)", + "in": "query", + "name": "page", + "schema": { + "type": "integer" + } + }, + { + "description": "page size of results", + "in": "query", + "name": "limit", + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "$ref": "#/components/responses/ProjectWorkflowList" + }, + "404": { + "$ref": "#/components/responses/notFound" + } + }, + "summary": "List a project's workflows", + "tags": [ + "user" + ] + }, + "post": { + "operationId": "userCurrentCreateProjectWorkflow", + "parameters": [ + { + "description": "id of the project", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateProjectWorkflowOption" + } + } + }, + "x-originalParamName": "body" + }, + "responses": { + "201": { + "$ref": "#/components/responses/ProjectWorkflow" + }, + "403": { + "$ref": "#/components/responses/forbidden" + }, + "404": { + "$ref": "#/components/responses/notFound" + }, + "422": { + "$ref": "#/components/responses/validationError" + } + }, + "summary": "Create a project workflow", + "tags": [ + "user" + ] + } + }, + "/user/projects/{id}/workflows/{workflow_id}": { + "delete": { + "operationId": "userCurrentDeleteProjectWorkflow", + "parameters": [ + { + "description": "id of the project", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + }, + { + "description": "id of the workflow", + "in": "path", + "name": "workflow_id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + } + ], + "responses": { + "204": { + "$ref": "#/components/responses/empty" + }, + "403": { + "$ref": "#/components/responses/forbidden" + }, + "404": { + "$ref": "#/components/responses/notFound" + } + }, + "summary": "Delete a project workflow", + "tags": [ + "user" + ] + }, + "get": { + "operationId": "userCurrentGetProjectWorkflow", + "parameters": [ + { + "description": "id of the project", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + }, + { + "description": "id of the workflow", + "in": "path", + "name": "workflow_id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + } + ], + "responses": { + "200": { + "$ref": "#/components/responses/ProjectWorkflow" + }, + "404": { + "$ref": "#/components/responses/notFound" + } + }, + "summary": "Get a project workflow", + "tags": [ + "user" + ] + }, + "patch": { + "operationId": "userCurrentEditProjectWorkflow", + "parameters": [ + { + "description": "id of the project", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + }, + { + "description": "id of the workflow", + "in": "path", + "name": "workflow_id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EditProjectWorkflowOption" + } + } + }, + "x-originalParamName": "body" + }, + "responses": { + "200": { + "$ref": "#/components/responses/ProjectWorkflow" + }, + "403": { + "$ref": "#/components/responses/forbidden" + }, + "404": { + "$ref": "#/components/responses/notFound" + }, + "422": { + "$ref": "#/components/responses/validationError" + } + }, + "summary": "Edit a project workflow", + "tags": [ + "user" + ] + } + }, "/user/repos": { "get": { "operationId": "userCurrentListRepos", diff --git a/templates/swagger/v1-swagger.generated.json b/templates/swagger/v1-swagger.generated.json index 3abc587963a..8de92daf950 100644 --- a/templates/swagger/v1-swagger.generated.json +++ b/templates/swagger/v1-swagger.generated.json @@ -4244,6 +4244,253 @@ } } }, + "/orgs/{org}/projects/{id}/workflows": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "organization" + ], + "summary": "List a project's workflows", + "operationId": "orgListProjectWorkflows", + "parameters": [ + { + "type": "string", + "description": "name of the organization", + "name": "org", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "id of the project", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "page number of results to return (1-based)", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "page size of results", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "$ref": "#/responses/ProjectWorkflowList" + }, + "404": { + "$ref": "#/responses/notFound" + } + } + }, + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "organization" + ], + "summary": "Create a project workflow", + "operationId": "orgCreateProjectWorkflow", + "parameters": [ + { + "type": "string", + "description": "name of the organization", + "name": "org", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "id of the project", + "name": "id", + "in": "path", + "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" + } + } + } + }, + "/orgs/{org}/projects/{id}/workflows/{workflow_id}": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "organization" + ], + "summary": "Get a project workflow", + "operationId": "orgGetProjectWorkflow", + "parameters": [ + { + "type": "string", + "description": "name of the organization", + "name": "org", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "id of the project", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "id of the workflow", + "name": "workflow_id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "$ref": "#/responses/ProjectWorkflow" + }, + "404": { + "$ref": "#/responses/notFound" + } + } + }, + "delete": { + "tags": [ + "organization" + ], + "summary": "Delete a project workflow", + "operationId": "orgDeleteProjectWorkflow", + "parameters": [ + { + "type": "string", + "description": "name of the organization", + "name": "org", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "id of the project", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "id of the workflow", + "name": "workflow_id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "$ref": "#/responses/empty" + }, + "403": { + "$ref": "#/responses/forbidden" + }, + "404": { + "$ref": "#/responses/notFound" + } + } + }, + "patch": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "organization" + ], + "summary": "Edit a project workflow", + "operationId": "orgEditProjectWorkflow", + "parameters": [ + { + "type": "string", + "description": "name of the organization", + "name": "org", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "id of the project", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "id of the workflow", + "name": "workflow_id", + "in": "path", + "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" + } + } + } + }, "/orgs/{org}/public_members": { "get": { "produces": [ @@ -16428,6 +16675,297 @@ } } }, + "/repos/{owner}/{repo}/projects/{id}/workflows": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "repository" + ], + "summary": "List a project's workflows", + "operationId": "repoListProjectWorkflows", + "parameters": [ + { + "type": "string", + "description": "owner of the repo", + "name": "owner", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the repo", + "name": "repo", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "id of the project", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "page number of results to return (1-based)", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "page size of results", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "$ref": "#/responses/ProjectWorkflowList" + }, + "404": { + "$ref": "#/responses/notFound" + } + } + }, + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "repository" + ], + "summary": "Create a project workflow", + "operationId": "repoCreateProjectWorkflow", + "parameters": [ + { + "type": "string", + "description": "owner of the repo", + "name": "owner", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the repo", + "name": "repo", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "id of the project", + "name": "id", + "in": "path", + "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" + } + } + } + }, + "/repos/{owner}/{repo}/projects/{id}/workflows/{workflow_id}": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "repository" + ], + "summary": "Get a project workflow", + "operationId": "repoGetProjectWorkflow", + "parameters": [ + { + "type": "string", + "description": "owner of the repo", + "name": "owner", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the repo", + "name": "repo", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "id of the project", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "id of the workflow", + "name": "workflow_id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "$ref": "#/responses/ProjectWorkflow" + }, + "404": { + "$ref": "#/responses/notFound" + } + } + }, + "delete": { + "tags": [ + "repository" + ], + "summary": "Delete a project workflow", + "operationId": "repoDeleteProjectWorkflow", + "parameters": [ + { + "type": "string", + "description": "owner of the repo", + "name": "owner", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the repo", + "name": "repo", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "id of the project", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "id of the workflow", + "name": "workflow_id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "$ref": "#/responses/empty" + }, + "403": { + "$ref": "#/responses/forbidden" + }, + "404": { + "$ref": "#/responses/notFound" + }, + "423": { + "$ref": "#/responses/repoArchivedError" + } + } + }, + "patch": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "repository" + ], + "summary": "Edit a project workflow", + "operationId": "repoEditProjectWorkflow", + "parameters": [ + { + "type": "string", + "description": "owner of the repo", + "name": "owner", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the repo", + "name": "repo", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "id of the project", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "id of the workflow", + "name": "workflow_id", + "in": "path", + "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" + } + } + } + }, "/repos/{owner}/{repo}/pulls": { "get": { "produces": [ @@ -23698,6 +24236,218 @@ } } }, + "/user/projects/{id}/workflows": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "List a project's workflows", + "operationId": "userCurrentListProjectWorkflows", + "parameters": [ + { + "type": "integer", + "format": "int64", + "description": "id of the project", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "page number of results to return (1-based)", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "page size of results", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "$ref": "#/responses/ProjectWorkflowList" + }, + "404": { + "$ref": "#/responses/notFound" + } + } + }, + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "Create a project workflow", + "operationId": "userCurrentCreateProjectWorkflow", + "parameters": [ + { + "type": "integer", + "format": "int64", + "description": "id of the project", + "name": "id", + "in": "path", + "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" + } + } + } + }, + "/user/projects/{id}/workflows/{workflow_id}": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "Get a project workflow", + "operationId": "userCurrentGetProjectWorkflow", + "parameters": [ + { + "type": "integer", + "format": "int64", + "description": "id of the project", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "id of the workflow", + "name": "workflow_id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "$ref": "#/responses/ProjectWorkflow" + }, + "404": { + "$ref": "#/responses/notFound" + } + } + }, + "delete": { + "tags": [ + "user" + ], + "summary": "Delete a project workflow", + "operationId": "userCurrentDeleteProjectWorkflow", + "parameters": [ + { + "type": "integer", + "format": "int64", + "description": "id of the project", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "id of the workflow", + "name": "workflow_id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "$ref": "#/responses/empty" + }, + "403": { + "$ref": "#/responses/forbidden" + }, + "404": { + "$ref": "#/responses/notFound" + } + } + }, + "patch": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "Edit a project workflow", + "operationId": "userCurrentEditProjectWorkflow", + "parameters": [ + { + "type": "integer", + "format": "int64", + "description": "id of the project", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "id of the workflow", + "name": "workflow_id", + "in": "path", + "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" + } + } + } + }, "/user/repos": { "get": { "produces": [ @@ -27607,6 +28357,31 @@ }, "x-go-package": "gitea.dev/modules/structs" }, + "CreateProjectWorkflowOption": { + "description": "CreateProjectWorkflowOption options for creating a project workflow", + "type": "object", + "required": [ + "event" + ], + "properties": { + "actions": { + "$ref": "#/definitions/ProjectWorkflowActions" + }, + "enabled": { + "description": "defaults to true", + "type": "boolean", + "x-go-name": "Enabled" + }, + "event": { + "type": "string", + "x-go-name": "Event" + }, + "filters": { + "$ref": "#/definitions/ProjectWorkflowFilters" + } + }, + "x-go-package": "gitea.dev/modules/structs" + }, "CreatePullRequestOption": { "description": "CreatePullRequestOption options when creating a pull request", "type": "object", @@ -28968,6 +29743,23 @@ }, "x-go-package": "gitea.dev/modules/structs" }, + "EditProjectWorkflowOption": { + "description": "EditProjectWorkflowOption options for editing a project workflow, omitted fields are left unchanged", + "type": "object", + "properties": { + "actions": { + "$ref": "#/definitions/ProjectWorkflowActions" + }, + "enabled": { + "type": "boolean", + "x-go-name": "Enabled" + }, + "filters": { + "$ref": "#/definitions/ProjectWorkflowFilters" + } + }, + "x-go-package": "gitea.dev/modules/structs" + }, "EditPullRequestOption": { "description": "EditPullRequestOption options when modify pull request", "type": "object", @@ -31860,6 +32652,109 @@ }, "x-go-package": "gitea.dev/modules/structs" }, + "ProjectWorkflow": { + "description": "ProjectWorkflow represents a project workflow", + "type": "object", + "properties": { + "actions": { + "$ref": "#/definitions/ProjectWorkflowActions" + }, + "created_at": { + "type": "string", + "format": "date-time", + "x-go-name": "Created" + }, + "enabled": { + "type": "boolean", + "x-go-name": "Enabled" + }, + "event": { + "description": "one of item_added_to_project, item_removed_from_project, item_reopened, item_closed, item_column_changed,\ncode_changes_requested, code_review_approved, pull_request_merged", + "type": "string", + "x-go-name": "Event" + }, + "filters": { + "$ref": "#/definitions/ProjectWorkflowFilters" + }, + "id": { + "type": "integer", + "format": "int64", + "x-go-name": "ID" + }, + "updated_at": { + "type": "string", + "format": "date-time", + "x-go-name": "Updated" + } + }, + "x-go-package": "gitea.dev/modules/structs" + }, + "ProjectWorkflowActions": { + "description": "ProjectWorkflowActions describes what a project workflow does to a matching item", + "type": "object", + "properties": { + "add_label_ids": { + "type": "array", + "items": { + "type": "integer", + "format": "int64" + }, + "x-go-name": "AddLabelIDs" + }, + "column_id": { + "type": "integer", + "format": "int64", + "x-go-name": "ColumnID" + }, + "issue_state": { + "description": "\"close\" or \"reopen\"", + "type": "string", + "x-go-name": "IssueState" + }, + "remove_label_ids": { + "type": "array", + "items": { + "type": "integer", + "format": "int64" + }, + "x-go-name": "RemoveLabelIDs" + } + }, + "x-go-package": "gitea.dev/modules/structs" + }, + "ProjectWorkflowFilters": { + "description": "ProjectWorkflowFilters restricts which items a project workflow runs for", + "type": "object", + "properties": { + "issue_type": { + "description": "\"issue\" or \"pull_request\", empty matches both", + "type": "string", + "x-go-name": "IssueType" + }, + "label_ids": { + "description": "the item must have all of these labels", + "type": "array", + "items": { + "type": "integer", + "format": "int64" + }, + "x-go-name": "LabelIDs" + }, + "source_column_id": { + "description": "only for item_column_changed", + "type": "integer", + "format": "int64", + "x-go-name": "SourceColumnID" + }, + "target_column_id": { + "description": "only for item_column_changed", + "type": "integer", + "format": "int64", + "x-go-name": "TargetColumnID" + } + }, + "x-go-package": "gitea.dev/modules/structs" + }, "PublicKey": { "description": "PublicKey publickey is a user key to push code to repository", "type": "object", @@ -34855,6 +35750,21 @@ } } }, + "ProjectWorkflow": { + "description": "ProjectWorkflow", + "schema": { + "$ref": "#/definitions/ProjectWorkflow" + } + }, + "ProjectWorkflowList": { + "description": "ProjectWorkflowList", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/ProjectWorkflow" + } + } + }, "PublicKey": { "description": "PublicKey", "schema": { diff --git a/tests/e2e/project-workflow.test.ts b/tests/e2e/project-workflow.test.ts new file mode 100644 index 00000000000..c2585545079 --- /dev/null +++ b/tests/e2e/project-workflow.test.ts @@ -0,0 +1,91 @@ +import {env} from 'node:process'; +import {test, expect} from '@playwright/test'; +import { + apiCreateLabel, apiCreateProject, apiCreateRepo, apiDeleteRepo, createProjectColumn, login, logout, randomString, +} from './utils.ts'; +import type {Page} from '@playwright/test'; + +const owner = env.GITEA_TEST_E2E_USER; + +const sidebar = (page: Page) => page.getByRole('navigation', {name: 'Default workflows'}); +const workflowLink = (page: Page, name: string) => sidebar(page).getByRole('link', {name}); +const editor = (page: Page) => page.getByRole('form', {name: /^Item added to project/}); +const editorButton = (page: Page, name: string) => editor(page).getByRole('button', {name, exact: true}); +const editorField = (page: Page, name: string) => editor(page).getByRole('button', {name}); + +async function choose(page: Page, field: string, option: string) { + await editorField(page, field).click(); + await page.getByRole('menuitemradio', {name: option}).click(); +} + +async function createWorkflowProject(page: Page) { + const repo = `e2e-project-workflow-${randomString(8)}`; + await Promise.all([login(page), apiCreateRepo(page.request, {name: repo, autoInit: false})]); + const id = await apiCreateProject(page.request, owner, repo, 'Workflows'); + await Promise.all(['Backlog', 'Done'].map((title) => createProjectColumn(page.request, owner, repo, String(id), title))); + return {repo, url: `/${owner}/${repo}/projects/${id}/workflows`}; +} + +async function configureItemAdded(page: Page, column: string) { + await workflowLink(page, 'Item added to project').click(); + await choose(page, 'Move to column', column); + await editorButton(page, 'Save').click(); +} + +test('project workflow: configure, toggle, edit and view read-only', async ({page}) => { + const {repo, url} = await createWorkflowProject(page); + try { + await page.goto(url); + await expect(sidebar(page).getByRole('link')).toHaveCount(8); + await choose(page, 'Apply to', 'Issues only'); + await configureItemAdded(page, 'Backlog'); + await expect(page).toHaveURL(/\/workflows\/\d+$/); + await expect(editor(page).getByLabel('Apply to')).toHaveText('Issues only'); + await expect(workflowLink(page, 'Item added to project')).toContainText('(Issues only)'); + + await editorButton(page, 'Disable').click(); + await expect(workflowLink(page, 'Item added to project').getByRole('img', {name: 'Disabled'})).toBeVisible(); + await editorButton(page, 'Enable').click(); + await expect(workflowLink(page, 'Item added to project').getByRole('img', {name: 'Enabled'})).toBeVisible(); + + await page.goto(`${url}?event=item_added_to_project`); + await expect(editor(page).getByLabel('Move to column')).toHaveText('Backlog'); + + await editorButton(page, 'Edit').click(); + await expect(editorField(page, 'Apply to')).toHaveText('Issues only'); + await choose(page, 'Move to column', 'Done'); + await editorButton(page, 'Save').click(); + await expect(editorButton(page, 'Edit')).toBeVisible(); + + await logout(page); + await page.goto(url); + await expect(editor(page).getByLabel('Move to column')).toHaveText('Done'); + await expect(editor(page).getByRole('button')).toHaveCount(0); + } finally { + await apiDeleteRepo(page.request, owner, repo); + } +}); + +test('project workflow: clone with labels and delete', async ({page}) => { + const {repo, url} = await createWorkflowProject(page); + try { + await apiCreateLabel(page.request, owner, repo, 'bug', 'ee0701'); + await page.goto(url); + await configureItemAdded(page, 'Backlog'); + + await editorButton(page, 'Clone').click(); + await editorField(page, 'Add labels').click(); + await page.getByRole('menuitemcheckbox', {name: 'bug'}).click(); + await editorButton(page, 'Save').click(); + await expect(editor(page).getByRole('group', {name: 'Add labels'})).toHaveText('bug'); + await expect(workflowLink(page, 'Item added to project #2')).toHaveAttribute('aria-current', 'page'); + + await editorButton(page, 'Edit').click(); + await editorButton(page, 'Remove').click(); + await page.getByRole('button', {name: 'Confirm', exact: true}).click(); + await expect(workflowLink(page, 'Item added to project')).toHaveCount(1); + await expect(editor(page).getByLabel('Move to column')).toHaveText('Backlog'); + } finally { + await apiDeleteRepo(page.request, owner, repo); + } +}); diff --git a/tests/e2e/utils.ts b/tests/e2e/utils.ts index bf24603295d..26201363042 100644 --- a/tests/e2e/utils.ts +++ b/tests/e2e/utils.ts @@ -130,6 +130,21 @@ export async function createProjectColumn(requestContext: APIRequestContext, own }), 'createProjectColumn'); } +export async function apiCreateProject(requestContext: APIRequestContext, owner: string, repo: string, title: string): Promise { + const response = await apiRetry(() => requestContext.post(`${baseUrl()}/api/v1/repos/${owner}/${repo}/projects`, { + headers: apiHeaders(), + data: {title}, + }), 'apiCreateProject'); + return (await response.json()).id; +} + +export async function apiCreateLabel(requestContext: APIRequestContext, owner: string, repo: string, name: string, color: string) { + await apiRetry(() => requestContext.post(`${baseUrl()}/api/v1/repos/${owner}/${repo}/labels`, { + headers: apiHeaders(), + data: {name, color}, + }), 'apiCreateLabel'); +} + export async function apiDeleteRepo(requestContext: APIRequestContext, owner: string, name: string) { await apiRetry(() => requestContext.delete(`${baseUrl()}/api/v1/repos/${owner}/${name}`, { headers: apiHeaders(), diff --git a/tests/integration/api_issue_test.go b/tests/integration/api_issue_test.go index ed361a93305..225d8104e78 100644 --- a/tests/integration/api_issue_test.go +++ b/tests/integration/api_issue_test.go @@ -650,4 +650,8 @@ func testAPIIssueProjects(t *testing.T) { Projects: []int64{2}, }).AddTokenAuth(token) MakeRequest(t, req, http.StatusBadRequest) + + readerToken := getUserToken(t, "user4", auth_model.AccessTokenScopeWriteIssue) + req = NewRequestWithJSON(t, "POST", urlStr, &api.CreateIssueOption{Title: "issue by a reader", Projects: []int64{1}}).AddTokenAuth(readerToken) + assert.Empty(t, DecodeJSON(t, MakeRequest(t, req, http.StatusCreated), &api.Issue{}).Projects) } diff --git a/tests/integration/api_project_test.go b/tests/integration/api_project_test.go index 08547b67755..37ccb9ae96e 100644 --- a/tests/integration/api_project_test.go +++ b/tests/integration/api_project_test.go @@ -11,8 +11,12 @@ import ( "testing" auth_model "gitea.dev/models/auth" + "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" api "gitea.dev/modules/structs" "gitea.dev/tests" @@ -88,6 +92,14 @@ func TestAPIProjects(t *testing.T) { t.Run("Permissions", func(t *testing.T) { testAPIProjectPermissions(t, token, outsider) }) t.Run("Visibility", func(t *testing.T) { testAPIProjectVisibility(t, outsider) }) t.Run("RepoProjectsModeOwner", func(t *testing.T) { testAPIRepoProjectsModeOwner(t, token) }) + t.Run("RepoBoardNeedsIssueRead", 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) + projectsWriter := getUserToken(t, "user4", auth_model.AccessTokenScopeWriteIssue, auth_model.AccessTokenScopeWriteRepository) + MakeRequest(t, NewRequest(t, "POST", "/api/v1/repos/org3/repo3/projects/2/columns/5/issues/6").AddTokenAuth(projectsWriter), http.StatusNotFound) + }) } func testProjectLifecycle(t *testing.T, scope projectScope, token string) { diff --git a/tests/integration/api_project_workflow_test.go b/tests/integration/api_project_workflow_test.go new file mode 100644 index 00000000000..665cdac9a76 --- /dev/null +++ b/tests/integration/api_project_workflow_test.go @@ -0,0 +1,86 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "fmt" + "net/http" + "testing" + + auth_model "gitea.dev/models/auth" + project_model "gitea.dev/models/project" + api "gitea.dev/modules/structs" + "gitea.dev/tests" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAPIProjectWorkflows(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + token := getUserToken(t, "user2", auth_model.AccessTokenScopeWriteIssue, auth_model.AccessTokenScopeWriteOrganization) + listURL := "/api/v1/repos/user2/repo1/projects/1/workflows" + + req := NewRequestWithJSON(t, "POST", listURL, &api.CreateProjectWorkflowOption{ + Event: "item_column_changed", + Filters: api.ProjectWorkflowFilters{IssueType: "issue", TargetColumnID: 3}, + Actions: api.ProjectWorkflowActions{AddLabelIDs: []int64{1}, IssueState: "close"}, + }).AddTokenAuth(token) + created := DecodeJSON(t, MakeRequest(t, req, http.StatusCreated), &api.ProjectWorkflow{}) + assert.Equal(t, "item_column_changed", created.Event) + assert.True(t, created.Enabled) + assert.Equal(t, api.ProjectWorkflowFilters{IssueType: "issue", TargetColumnID: 3}, created.Filters) + assert.Equal(t, api.ProjectWorkflowActions{AddLabelIDs: []int64{1}, IssueState: "close"}, created.Actions) + workflowURL := fmt.Sprintf("%s/%d", listURL, created.ID) + + fetched := DecodeJSON(t, MakeRequest(t, NewRequest(t, "GET", workflowURL).AddTokenAuth(token), http.StatusOK), &api.ProjectWorkflow{}) + assert.Equal(t, created.Filters, fetched.Filters) + assert.Equal(t, created.Actions, fetched.Actions) + + resp := MakeRequest(t, NewRequest(t, "GET", listURL).AddTokenAuth(token), http.StatusOK) + assert.Equal(t, "1", resp.Header().Get("X-Total-Count")) + listed := DecodeJSON(t, resp, []*api.ProjectWorkflow{}) + require.Len(t, listed, 1) + assert.Equal(t, created.ID, listed[0].ID) + + req = NewRequestWithJSON(t, "PATCH", workflowURL, &api.EditProjectWorkflowOption{Filters: &api.ProjectWorkflowFilters{SourceColumnID: 1}}).AddTokenAuth(token) + updated := DecodeJSON(t, MakeRequest(t, req, http.StatusOK), &api.ProjectWorkflow{}) + assert.Equal(t, api.ProjectWorkflowFilters{SourceColumnID: 1}, updated.Filters) + assert.Equal(t, created.Actions, updated.Actions) + + req = NewRequestWithJSON(t, "PATCH", workflowURL, &api.EditProjectWorkflowOption{Filters: &api.ProjectWorkflowFilters{LabelIDs: []int64{999999}}}).AddTokenAuth(token) + assert.Contains(t, MakeRequest(t, req, http.StatusUnprocessableEntity).Body.String(), "invalid label") + + outsider := getUserToken(t, "user4", auth_model.AccessTokenScopeWriteIssue) + req = NewRequestWithJSON(t, "POST", listURL, &api.CreateProjectWorkflowOption{Event: "item_added_to_project", Actions: api.ProjectWorkflowActions{ColumnID: 3}}).AddTokenAuth(outsider) + MakeRequest(t, req, http.StatusForbidden) + MakeRequest(t, NewRequest(t, "DELETE", workflowURL).AddTokenAuth(outsider), http.StatusForbidden) + + MakeRequest(t, NewRequest(t, "DELETE", workflowURL).AddTokenAuth(token), http.StatusNoContent) + MakeRequest(t, NewRequest(t, "GET", workflowURL).AddTokenAuth(token), http.StatusNotFound) + + t.Run("CreateClosedIssueThatWorkflowCloses", func(t *testing.T) { + req := NewRequestWithJSON(t, "POST", listURL, &api.CreateProjectWorkflowOption{Event: "item_added_to_project", Actions: api.ProjectWorkflowActions{IssueState: "close"}}).AddTokenAuth(token) + MakeRequest(t, req, http.StatusCreated) + req = NewRequestWithJSON(t, "POST", "/api/v1/repos/user2/repo1/issues", &api.CreateIssueOption{Title: "closed", Projects: []int64{1}, Closed: true}).AddTokenAuth(token) + assert.Equal(t, api.StateClosed, DecodeJSON(t, MakeRequest(t, req, http.StatusCreated), &api.Issue{}).State) + }) + + t.Run("Organization", func(t *testing.T) { + orgProject := &project_model.Project{Title: "org workflows", OwnerID: 3, CreatorID: 2, Type: project_model.TypeOrganization} + require.NoError(t, project_model.NewProject(t.Context(), orgProject)) + column := &project_model.Column{Title: "Done", ProjectID: orgProject.ID} + require.NoError(t, project_model.NewColumn(t.Context(), column)) + orgListURL := fmt.Sprintf("/api/v1/orgs/org3/projects/%d/workflows", orgProject.ID) + req := NewRequestWithJSON(t, "POST", orgListURL, &api.CreateProjectWorkflowOption{ + Event: "item_closed", + Actions: api.ProjectWorkflowActions{ColumnID: column.ID}, + }).AddTokenAuth(token) + created := DecodeJSON(t, MakeRequest(t, req, http.StatusCreated), &api.ProjectWorkflow{}) + listed := DecodeJSON(t, MakeRequest(t, NewRequest(t, "GET", orgListURL).AddTokenAuth(token), http.StatusOK), []*api.ProjectWorkflow{}) + require.Len(t, listed, 1) + assert.Equal(t, created.ID, listed[0].ID) + }) +} diff --git a/tests/integration/project_test.go b/tests/integration/project_test.go index 0ace73ea4ac..df9b50acd29 100644 --- a/tests/integration/project_test.go +++ b/tests/integration/project_test.go @@ -110,6 +110,18 @@ func TestUpdateIssueProject(t *testing.T) { sess.MakeRequest(t, req, http.StatusOK) unittest.AssertNotExistsBean(t, &project_model.ProjectIssue{IssueID: 2, ProjectID: 1}) }) + + t.Run("AssignOnCreationNeedsWriter", func(t *testing.T) { + newIssue := func(session *TestSession, title string, status int) { + req := NewRequestWithValues(t, "POST", "/user2/repo1/issues/new", map[string]string{"title": title, "project_ids": "1"}) + session.MakeRequest(t, req, status) + } + newIssue(sess, "writer issue", http.StatusOK) + issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{RepoID: 1, Title: "writer issue"}) + unittest.AssertExistsAndLoadBean(t, &project_model.ProjectIssue{IssueID: issue.ID, ProjectID: 1}) + newIssue(loginUser(t, "user4"), "reader issue", http.StatusNotFound) + unittest.AssertNotExistsBean(t, &issues_model.Issue{RepoID: 1, Title: "reader issue"}) + }) } func TestUpdateIssueProjectColumn(t *testing.T) { @@ -314,8 +326,10 @@ func TestOrgProjectFilterByMilestone(t *testing.T) { require.NoError(t, project_model.NewProject(t.Context(), &project)) // Add issues to the project - require.NoError(t, issues_model.IssueAssignOrRemoveProject(t.Context(), issue16, user1, []int64{project.ID})) - require.NoError(t, issues_model.IssueAssignOrRemoveProject(t.Context(), issue17, user1, []int64{project.ID})) + for _, issue := range []*issues_model.Issue{issue16, issue17} { + _, _, err := issues_model.IssueAssignOrRemoveProject(t.Context(), issue, user1, []int64{project.ID}) + require.NoError(t, err) + } sess := loginUser(t, "user1") projectURL := fmt.Sprintf("/org3/-/projects/%d", project.ID) diff --git a/tests/integration/project_workflow_test.go b/tests/integration/project_workflow_test.go new file mode 100644 index 00000000000..bf013a5c775 --- /dev/null +++ b/tests/integration/project_workflow_test.go @@ -0,0 +1,98 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "fmt" + "net/http" + "testing" + + auth_model "gitea.dev/models/auth" + issues_model "gitea.dev/models/issues" + project_model "gitea.dev/models/project" + "gitea.dev/models/unittest" + api "gitea.dev/modules/structs" + "gitea.dev/tests" + + "github.com/stretchr/testify/assert" +) + +func TestProjectWorkflowsWeb(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + link := "/user2/repo1/projects/1/workflows" + session := loginUser(t, "user2") + + canWrite := func(session *TestSession) string { + resp := session.MakeRequest(t, NewRequest(t, "GET", link), http.StatusOK) + return NewHTMLParser(t, resp.Body).Find("#project-workflows").AttrOr("data-can-write", "") + } + assert.Equal(t, "true", canWrite(session)) + assert.Equal(t, "false", canWrite(nil)) + session.MakeRequest(t, NewRequest(t, "GET", link+"?event=item_closed"), http.StatusOK) + session.MakeRequest(t, NewRequest(t, "GET", link+"?event=no_such_event"), http.StatusNotFound) + session.MakeRequest(t, NewRequest(t, "GET", link+"/item_closed"), http.StatusNotFound) + session.MakeRequest(t, NewRequest(t, "GET", link+"/999999"), http.StatusNotFound) + + req := NewRequestWithJSON(t, "POST", link, &api.CreateProjectWorkflowOption{ + Event: "item_closed", + Actions: api.ProjectWorkflowActions{ColumnID: 3}, + }) + workflow := DecodeJSON(t, session.MakeRequest(t, req, http.StatusOK), &api.ProjectWorkflow{}) + assert.Equal(t, api.ProjectWorkflowActions{ColumnID: 3}, workflow.Actions) + workflowLink := fmt.Sprintf("%s/%d", link, workflow.ID) + session.MakeRequest(t, NewRequest(t, "GET", workflowLink), http.StatusOK) + + req = NewRequestWithJSON(t, "POST", workflowLink, &api.EditProjectWorkflowOption{Enabled: new(false)}) + updated := DecodeJSON(t, session.MakeRequest(t, req, http.StatusOK), &api.ProjectWorkflow{}) + assert.False(t, updated.Enabled) + assert.Equal(t, workflow.Actions, updated.Actions) + + req = NewRequestWithJSON(t, "POST", link, &api.CreateProjectWorkflowOption{Event: "item_closed"}) + resp := session.MakeRequest(t, req, http.StatusBadRequest) + assert.Equal(t, "At least one action must be configured", DecodeJSON(t, resp, map[string]any{})["errorMessage"]) + + loginUser(t, "user4").MakeRequest(t, NewRequest(t, "POST", workflowLink+"/delete"), http.StatusNotFound) + + session.MakeRequest(t, NewRequest(t, "POST", workflowLink+"/delete"), http.StatusOK) + session.MakeRequest(t, NewRequest(t, "GET", workflowLink), http.StatusNotFound) +} + +func TestProjectWorkflowExecution(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + link := "/user2/repo1/projects/1" + session := loginUser(t, "user2") + + for _, opts := range []*api.CreateProjectWorkflowOption{{ + Event: "item_added_to_project", + Actions: api.ProjectWorkflowActions{ColumnID: 2, AddLabelIDs: []int64{1}}, + }, { + Event: "item_column_changed", + Actions: api.ProjectWorkflowActions{IssueState: "close"}, + }} { + session.MakeRequest(t, NewRequestWithJSON(t, "POST", link+"/workflows", opts), http.StatusOK) + } + + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteIssue) + req := NewRequestWithJSON(t, "POST", "/api/v1/repos/user2/repo1/issues", &api.CreateIssueOption{ + Title: "workflow item", + Projects: []int64{1}, + }).AddTokenAuth(token) + created := DecodeJSON(t, MakeRequest(t, req, http.StatusCreated), &api.Issue{}) + + assert.EqualValues(t, 2, unittest.AssertExistsAndLoadBean(t, &project_model.ProjectIssue{IssueID: created.ID, ProjectID: 1}).ProjectColumnID) + unittest.AssertExistsAndLoadBean(t, &issues_model.IssueLabel{IssueID: created.ID, LabelID: 1}) + assert.False(t, unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: created.ID}).IsClosed) + resp := session.MakeRequest(t, NewRequestf(t, "GET", "/user2/repo1/issues/%d", created.Index), http.StatusOK) + assert.Contains(t, NewHTMLParser(t, resp.Body).Find(".timeline-item.event .comment-text-line").Text(), "Project workflow Item added to project") + + req = NewRequestWithJSON(t, "POST", link+"/3/move", map[string]any{ + "issues": []map[string]any{{"issueID": created.ID, "sorting": 0}}, + }) + session.MakeRequest(t, req, http.StatusOK) + + assert.EqualValues(t, 3, unittest.AssertExistsAndLoadBean(t, &project_model.ProjectIssue{IssueID: created.ID, ProjectID: 1}).ProjectColumnID) + assert.True(t, unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: created.ID}).IsClosed) +} diff --git a/web_src/js/components/ProjectWorkflow.vue b/web_src/js/components/ProjectWorkflow.vue new file mode 100644 index 00000000000..1e68c3262ca --- /dev/null +++ b/web_src/js/components/ProjectWorkflow.vue @@ -0,0 +1,77 @@ + + + diff --git a/web_src/js/components/ProjectWorkflowDropdown.test.ts b/web_src/js/components/ProjectWorkflowDropdown.test.ts new file mode 100644 index 00000000000..140b37da522 --- /dev/null +++ b/web_src/js/components/ProjectWorkflowDropdown.test.ts @@ -0,0 +1,21 @@ +import {createApp, nextTick} from 'vue'; +import {userEvent} from 'vitest/browser'; +import ProjectWorkflowDropdown from './ProjectWorkflowDropdown.vue'; + +test('label filter gets focus on open and keeps space, enter and left/right inside the menu', async () => { + const root = document.createElement('div'); + document.body.append(root); + const app = createApp(ProjectWorkflowDropdown, {label: 'Labels', options: [{value: 1, text: 'bug', html: 'bug'}], readonly: false, modelValue: []}); + app.mount(root); + await nextTick(); + const menu = root.querySelector('button')!._tippy; + menu.show(); + const filter = menu.popper.querySelector('input')!; + await expect.poll(() => document.activeElement).toBe(filter); + await userEvent.keyboard('a b{ArrowLeft}c{Enter}'); + expect(filter.value).toEqual('a cb'); + expect(menu.state.isVisible).toBe(true); + + app.unmount(); + root.remove(); +}); diff --git a/web_src/js/components/ProjectWorkflowDropdown.vue b/web_src/js/components/ProjectWorkflowDropdown.vue new file mode 100644 index 00000000000..e7c4aa382d0 --- /dev/null +++ b/web_src/js/components/ProjectWorkflowDropdown.vue @@ -0,0 +1,116 @@ + + + diff --git a/web_src/js/components/ProjectWorkflowEditor.vue b/web_src/js/components/ProjectWorkflowEditor.vue new file mode 100644 index 00000000000..b2e178b0170 --- /dev/null +++ b/web_src/js/components/ProjectWorkflowEditor.vue @@ -0,0 +1,107 @@ + + + diff --git a/web_src/js/components/ProjectWorkflowStore.test.ts b/web_src/js/components/ProjectWorkflowStore.test.ts new file mode 100644 index 00000000000..2c76e7ddea5 --- /dev/null +++ b/web_src/js/components/ProjectWorkflowStore.test.ts @@ -0,0 +1,166 @@ +import {performFetchActionRequest} from '../modules/fetch-action.ts'; +import {createProjectWorkflowStore} from './ProjectWorkflowStore.ts'; +import type {ProjectWorkflow, ProjectWorkflowLocale} from './ProjectWorkflowStore.ts'; + +vi.mock('../modules/fetch-action.ts', () => ({performFetchActionRequest: vi.fn()})); + +const locale = { + issuesOnly: 'Issues only', + pullRequestsOnly: 'Pull requests only', + summarySource: 'Source: %s', + summaryTarget: 'Target: %s', + summaryLabels: 'Labels: %s', +} as ProjectWorkflowLocale; + +const workflow = (id: number, event: string, extra: Partial = {}): ProjectWorkflow => ({ + id, event, enabled: true, filters: {}, actions: {column_id: 1}, ...extra, +}); + +function setup(workflows: ProjectWorkflow[]) { + window.config.pageData.projectWorkflows = { + events: [ + {event: 'item_added_to_project', display_name: 'Item added to project', supported_filters: ['issue_type', 'labels'], supported_actions: ['column']}, + {event: 'item_closed', display_name: 'Item closed', supported_filters: [], supported_actions: ['column']}, + ], + workflows, + columns: [{id: 1, title: 'Backlog'}, {id: 2, title: 'Done'}], + labels: [{id: 7, name: 'bug', html: ''}, {id: 8, name: 'docs', html: ''}], + }; + return createProjectWorkflowStore({el: document.createElement('div'), projectLink: '/o/r/projects/1', canWrite: true, locale}); +} + +beforeEach(() => { + vi.resetAllMocks(); + vi.spyOn(window.history, 'pushState').mockImplementation(() => {}); + vi.spyOn(window.history, 'replaceState').mockImplementation(() => {}); +}); + +test('rows, display names, summaries and deep link resolution', () => { + const store = setup([ + workflow(3, 'item_added_to_project', {filters: {issue_type: 'issue', source_column_id: 2, target_column_id: 1, label_ids: [8, 7]}}), + workflow(5, 'item_added_to_project'), + ]); + expect(store.rows.map((r) => [r.key, r.displayName])).toEqual([ + ['3', 'Item added to project #1'], + ['5', 'Item added to project #2'], + ['item_closed', 'Item closed'], + ]); + expect(store.resolve('5')!.key).toBe('5'); + expect(store.resolve('item_added_to_project')!.key).toBe('3'); + expect(store.resolve('item_closed')!.workflow).toBeUndefined(); + expect(store.resolve('42')).toBeUndefined(); + expect(store.urlFor(store.rows[2])).toBe('/o/r/projects/1/workflows?event=item_closed'); + expect(store.summary(store.rows[0])).toBe('(Issues only) (Source: Done) (Target: Backlog) (Labels: bug, docs)'); + expect(store.summary(store.rows[1])).toBe(''); + expect(store.summary(store.rows[2])).toBe(''); +}); + +test('edits to a saved workflow are dropped on switching, unsaved drafts are kept', () => { + const store = setup([workflow(3, 'item_added_to_project')]); + store.select(store.resolve('3')!); + store.editMode = true; + store.form.actions.column_id = 2; + store.cancel(); + expect(store.editing).toBe(false); + expect(store.form.actions.column_id).toBe(1); + store.editMode = true; + store.form.actions.column_id = 2; + store.select(store.resolve('item_closed')!); + expect(store.editing).toBe(true); + store.form.actions.column_id = 2; + store.select(store.resolve('3')!); + expect(store.editing).toBe(false); + expect(store.form.actions.column_id).toBe(1); + store.select(store.resolve('item_closed')!); + expect(store.form.actions.column_id).toBe(2); +}); + +test('the form drops columns and labels deleted since saving', () => { + const store = setup([workflow(3, 'item_added_to_project', {filters: {label_ids: [7, 9]}, actions: {column_id: 4, add_label_ids: [9]}})]); + store.select(store.resolve('3')!); + expect(store.form.filters.label_ids).toEqual([7]); + expect(store.form.actions.column_id).toBe(0); + expect(store.form.actions.add_label_ids).toEqual([]); +}); + +test('clone is numbered after its source, cancel returns to the source, saving adds a history entry', async () => { + const store = setup([workflow(3, 'item_added_to_project'), workflow(5, 'item_added_to_project', {actions: {column_id: 2}})]); + store.select(store.resolve('5')!); + store.clone(); + expect(store.selected!.displayName).toBe('Item added to project #3'); + expect(store.editing).toBe(true); + expect(store.form.actions.column_id).toBe(2); + expect(store.canClone(store.resolve('5')!)).toBe(false); + store.cancel(); + expect(store.selectedKey).toBe('5'); + expect(store.rows).toHaveLength(3); + expect(window.history.replaceState).toHaveBeenCalledWith({key: '5', event: 'item_added_to_project'}, '', '/o/r/projects/1/workflows/5'); + + store.clone(); + vi.mocked(performFetchActionRequest).mockResolvedValueOnce(Response.json(workflow(9, 'item_added_to_project', {actions: {column_id: 2}}))); + await store.save(); + expect(store.selected!.displayName).toBe('Item added to project #3'); + expect(window.history.pushState).toHaveBeenCalledWith({key: '9', event: 'item_added_to_project'}, '', '/o/r/projects/1/workflows/9'); +}); + +test('saving a placeholder retains the draft on failure and creates the workflow on success', async () => { + const store = setup([]); + store.select(store.resolve('item_closed')!); + store.form.actions.column_id = 2; + vi.mocked(performFetchActionRequest).mockResolvedValueOnce(null); + await store.save(); + expect(store.selectedKey).toBe('item_closed'); + expect(store.editing).toBe(true); + expect(store.form.actions.column_id).toBe(2); + + vi.mocked(performFetchActionRequest).mockResolvedValueOnce(Response.json(workflow(9, 'item_closed', {actions: {column_id: 2}}))); + await store.save(); + + expect(performFetchActionRequest).toHaveBeenCalledWith(expect.any(HTMLElement), {url: '/o/r/projects/1/workflows', method: 'POST', data: { + event: 'item_closed', + filters: {issue_type: '', source_column_id: 0, target_column_id: 0, label_ids: []}, + actions: {column_id: 2, add_label_ids: [], remove_label_ids: [], issue_state: ''}, + }}); + expect(store.selectedKey).toBe('9'); + expect(window.history.replaceState).toHaveBeenCalledWith({key: '9', event: 'item_closed'}, '', '/o/r/projects/1/workflows/9'); + expect(store.drafts.find((d) => d.key === 'item_closed')!.draft.actions.column_id).toBe(0); +}); + +test.each(['save', 'setEnabled'] as const)('%s preserves selection changed through history during the request', async (operation) => { + const store = setup([workflow(3, 'item_added_to_project'), workflow(5, 'item_added_to_project', {actions: {column_id: 2}})]); + store.select(store.resolve('3')!); + const response = Promise.withResolvers(); + vi.mocked(performFetchActionRequest).mockReturnValueOnce(response.promise); + const request = operation === 'save' ? store.save() : store.setEnabled(false); + store.select(store.resolve('5')!); + response.resolve(Response.json(workflow(3, 'item_added_to_project', {enabled: false}))); + await request; + expect(store.workflows[0].enabled).toBe(false); + expect(store.selectedKey).toBe('5'); + expect(store.form.actions.column_id).toBe(2); + expect(window.history.pushState).not.toHaveBeenCalled(); + expect(window.history.replaceState).not.toHaveBeenCalled(); +}); + +test('delete keeps its target after selection changes and selects the next row only when still selected', async () => { + const store = setup([workflow(3, 'item_added_to_project'), workflow(5, 'item_added_to_project')]); + store.select(store.resolve('3')!); + vi.mocked(performFetchActionRequest).mockResolvedValueOnce(null); + await store.remove(store.selected!.workflow!); + expect(store.rows).toHaveLength(3); + expect(store.selectedKey).toBe('3'); + + const workflowToDelete = store.selected!.workflow!; + store.select(store.resolve('5')!); + vi.mocked(performFetchActionRequest).mockResolvedValueOnce(Response.json({})); + await store.remove(workflowToDelete); + expect(performFetchActionRequest).toHaveBeenLastCalledWith(expect.any(HTMLElement), {url: '/o/r/projects/1/workflows/3/delete', method: 'POST', data: {}}); + expect(store.rows.map((r) => r.key)).toEqual(['5', 'item_closed']); + expect(store.selectedKey).toBe('5'); + expect(window.history.replaceState).not.toHaveBeenCalled(); + + vi.mocked(performFetchActionRequest).mockResolvedValueOnce(Response.json({})); + await store.remove(store.selected!.workflow!); + expect(store.selectedKey).toBe('item_added_to_project'); + expect(window.history.replaceState).toHaveBeenCalledWith({key: 'item_added_to_project', event: 'item_added_to_project'}, '', '/o/r/projects/1/workflows?event=item_added_to_project'); +}); diff --git a/web_src/js/components/ProjectWorkflowStore.ts b/web_src/js/components/ProjectWorkflowStore.ts new file mode 100644 index 00000000000..52aa05e5d39 --- /dev/null +++ b/web_src/js/components/ProjectWorkflowStore.ts @@ -0,0 +1,281 @@ +import {computed, reactive} from 'vue'; +import {performFetchActionRequest} from '../modules/fetch-action.ts'; +import {trString} from '../modules/i18n.ts'; + +type ProjectWorkflowFilters = { + issue_type?: '' | 'issue' | 'pull_request'; + source_column_id?: number; + target_column_id?: number; + label_ids?: number[]; +}; + +type ProjectWorkflowActions = { + column_id?: number; + add_label_ids?: number[]; + remove_label_ids?: number[]; + issue_state?: '' | 'close' | 'reopen'; +}; + +export type ProjectWorkflow = { + id: number; + event: string; + enabled: boolean; + filters: ProjectWorkflowFilters; + actions: ProjectWorkflowActions; +}; + +export type ProjectWorkflowFilterType = 'issue_type' | 'source_column' | 'target_column' | 'labels'; +export type ProjectWorkflowActionType = 'column' | 'add_labels' | 'remove_labels' | 'issue_state'; + +type ProjectWorkflowEvent = { + event: string; + display_name: string; + supported_filters: ProjectWorkflowFilterType[]; + supported_actions: ProjectWorkflowActionType[]; +}; + +type ProjectColumn = {id: number; title: string}; + +type ProjectLabel = {id: number; name: string; html: string}; + +export type ProjectWorkflowData = { + events: ProjectWorkflowEvent[]; + workflows: ProjectWorkflow[]; + columns: ProjectColumn[]; + labels: ProjectLabel[]; +}; + +type WorkflowForm = { + filters: Required; + actions: Required; +}; + +export type WorkflowRow = { + key: string; + event: ProjectWorkflowEvent; + displayName: string; +} & ({workflow: ProjectWorkflow, draft?: never} | {workflow?: never, draft: WorkflowForm}); + +type WorkflowDraft = {key: string; event: string; draft: WorkflowForm; sourceKey?: string}; + +export type ProjectWorkflowLocale = { + defaultWorkflows: string; + enable: string; + disable: string; + clone: string; + deleteConfirm: string; + filters: string; + applyTo: string; + issuesAndPullRequests: string; + issuesOnly: string; + pullRequestsOnly: string; + whenMovedFromColumn: string; + whenMovedToColumn: string; + anyColumn: string; + onlyIfHasLabels: string; + anyLabel: string; + filterLabels: string; + clearLabels: string; + actions: string; + moveToColumn: string; + addLabels: string; + removeLabels: string; + none: string; + issueState: string; + noChange: string; + closeIssue: string; + reopenIssue: string; + summarySource: string; + summaryTarget: string; + summaryLabels: string; + edit: string; + save: string; + cancel: string; + remove: string; + enabled: string; + disabled: string; +}; + +function toWorkflowForm({filters, actions}: Pick = {filters: {}, actions: {}}): WorkflowForm { + return { + filters: { + issue_type: filters.issue_type ?? '', + source_column_id: filters.source_column_id ?? 0, + target_column_id: filters.target_column_id ?? 0, + label_ids: [...filters.label_ids ?? []], + }, + actions: { + column_id: actions.column_id ?? 0, + add_label_ids: [...actions.add_label_ids ?? []], + remove_label_ids: [...actions.remove_label_ids ?? []], + issue_state: actions.issue_state ?? '', + }, + }; +} + +function buildWorkflowRows(events: ProjectWorkflowEvent[], workflows: ProjectWorkflow[], drafts: WorkflowDraft[]): WorkflowRow[] { + return events.flatMap((event) => { + const eventDrafts = drafts.filter((d) => d.event === event.event); + const group: Array> = [ + ...workflows.filter((wf) => wf.event === event.event).map((workflow) => ({key: String(workflow.id), workflow})), + ...eventDrafts.filter((d) => d.sourceKey).map(({key, draft}) => ({key, draft})), + ]; + if (!group.length) group.push(...eventDrafts.map(({key, draft}) => ({key, draft}))); + return group.map((row, idx) => ({ + ...row, + event, + displayName: group.length > 1 ? `${event.display_name} #${idx + 1}` : event.display_name, + } as WorkflowRow)); + }); +} + +let cloneSeq = 0; + +export function createProjectWorkflowStore(props: {el: HTMLElement, projectLink: string, canWrite: boolean, locale: ProjectWorkflowLocale}) { + const baseUrl = `${props.projectLink}/workflows`; + const data = window.config.pageData.projectWorkflows!; + + const store = reactive({ + canWrite: props.canWrite, + ...data, + drafts: data.events.map((event): WorkflowDraft => ({key: event.event, event: event.event, draft: toWorkflowForm()})), + + selectedKey: '', + editMode: false, + saving: false, + form: toWorkflowForm(), + + rows: computed((): WorkflowRow[] => buildWorkflowRows(store.events, store.workflows, store.drafts)), + + get selected(): WorkflowRow | undefined { + return store.rows.find((row) => row.key === store.selectedKey); + }, + + get editing(): boolean { + const row = store.selected; + if (!store.canWrite || !row) return false; + return store.editMode || !row.workflow; + }, + + resolve: (key: string): WorkflowRow | undefined => store.rows.find((row) => row.key === key) ?? store.rows.find((row) => row.event.event === key), + + urlFor: (row: WorkflowRow): string => row.workflow ? `${baseUrl}/${row.workflow.id}` : `${baseUrl}?event=${encodeURIComponent(row.event.event)}`, + + summary(row: WorkflowRow): string { + if (!row.workflow) return ''; + const {issue_type, source_column_id, target_column_id, label_ids} = row.workflow.filters; + const sourceTitle = source_column_id && columnTitle(source_column_id); + const targetTitle = target_column_id && columnTitle(target_column_id); + const labelNames = store.labels.filter((l) => label_ids?.includes(l.id)).map((l) => l.name); + const parts = [ + issue_type === 'issue' && props.locale.issuesOnly, + issue_type === 'pull_request' && props.locale.pullRequestsOnly, + sourceTitle && trString(props.locale.summarySource, sourceTitle), + targetTitle && trString(props.locale.summaryTarget, targetTitle), + labelNames.length && trString(props.locale.summaryLabels, labelNames.join(', ')), + ]; + return parts.filter(Boolean).map((part) => `(${part})`).join(' '); + }, + + select(row: WorkflowRow, history?: 'push' | 'replace') { + store.selectedKey = row.key; + store.editMode = false; + store.form = row.workflow ? formFor(row.workflow) : row.draft; + const state = {key: row.key, event: row.event.event}; + if (history === 'push') window.history.pushState(state, '', store.urlFor(row)); + else if (history === 'replace') window.history.replaceState(state, '', store.urlFor(row)); + }, + + cancel() { + const row = store.selected!; + const clone = store.drafts.find((d) => d.key === row.key && d.sourceKey); + if (clone) { + store.drafts = store.drafts.filter((d) => d !== clone); + store.select((store.resolve(clone.sourceKey!) ?? store.resolve(clone.event))!, 'replace'); + return; + } + store.editMode = false; + store.form = formFor(row.workflow!); + }, + + clone() { + const source = store.selected!; + const key = `clone-${++cloneSeq}`; + store.drafts.push({key, event: source.event.event, sourceKey: source.key, draft: formFor(source.workflow!)}); + store.select(store.resolve(key)!); + }, + + canClone: (row: WorkflowRow): boolean => store.drafts.every((d) => d.sourceKey !== row.key), + + async save() { + const row = store.selected!; + const saved = row.workflow ? + await post(`${baseUrl}/${row.workflow.id}`, store.form) : + await post(baseUrl, {event: row.event.event, ...store.form}); + if (!saved) return; + let history: 'push' | 'replace' | undefined; + if (row.workflow) { + replaceWorkflow(saved); + } else { + store.workflows.push(saved); + const draft = store.drafts.find((d) => d.key === row.key)!; + if (draft.sourceKey) { + store.drafts = store.drafts.filter((d) => d !== draft); + history = 'push'; // a clone has no URL of its own, keep its source's entry + } else { + draft.draft = toWorkflowForm(); + history = 'replace'; + } + } + if (store.selectedKey === row.key) store.select(store.resolve(String(saved.id))!, history); + }, + + async setEnabled(enabled: boolean) { + const workflow = store.selected!.workflow!; + const saved = await post(`${baseUrl}/${workflow.id}`, {enabled}); + if (saved) replaceWorkflow(saved); + }, + + async remove(workflow: ProjectWorkflow) { + if (!await post(`${baseUrl}/${workflow.id}/delete`, {})) return; + store.workflows = store.workflows.filter((wf) => wf.id !== workflow.id); + if (store.selectedKey === String(workflow.id)) store.select(store.resolve(workflow.event)!, 'replace'); + }, + }); + + function columnTitle(id: number): string | undefined { + return store.columns.find((c) => c.id === id)?.title; + } + + // drops columns and labels deleted since the workflow was saved, the server would reject them + function formFor(workflow: ProjectWorkflow): WorkflowForm { + const form = toWorkflowForm(workflow); + const hasColumn = (id: number) => store.columns.some((c) => c.id === id); + const hasLabel = (id: number) => store.labels.some((l) => l.id === id); + if (!hasColumn(form.filters.source_column_id)) form.filters.source_column_id = 0; + if (!hasColumn(form.filters.target_column_id)) form.filters.target_column_id = 0; + if (!hasColumn(form.actions.column_id)) form.actions.column_id = 0; + form.filters.label_ids = form.filters.label_ids.filter(hasLabel); + form.actions.add_label_ids = form.actions.add_label_ids.filter(hasLabel); + form.actions.remove_label_ids = form.actions.remove_label_ids.filter(hasLabel); + return form; + } + + async function post(url: string, data: Record): Promise { + store.saving = true; + try { + const resp = await performFetchActionRequest(props.el, {url, method: 'POST', data}); + return resp ? await resp.json() : null; + } finally { + store.saving = false; + } + } + + function replaceWorkflow(saved: ProjectWorkflow) { + store.workflows = store.workflows.map((wf) => wf.id === saved.id ? saved : wf); + } + + return store; +} + +export type ProjectWorkflowStore = ReturnType; diff --git a/web_src/js/features/project-workflow.ts b/web_src/js/features/project-workflow.ts new file mode 100644 index 00000000000..6bbd99715ff --- /dev/null +++ b/web_src/js/features/project-workflow.ts @@ -0,0 +1,19 @@ +import {createApp} from 'vue'; +import {registerGlobalInitFunc} from '../modules/observer.ts'; +import type {ProjectWorkflowLocale} from '../components/ProjectWorkflowStore.ts'; + +async function mountProjectWorkflow(el: HTMLElement) { + const {default: ProjectWorkflow} = await import('../components/ProjectWorkflow.vue'); + createApp(ProjectWorkflow, { + el, + projectLink: el.getAttribute('data-project-link')!, + workflowId: Number(el.getAttribute('data-workflow-id')), + workflowEvent: el.getAttribute('data-workflow-event')!, + canWrite: el.getAttribute('data-can-write') === 'true', + locale: JSON.parse(el.getAttribute('data-locale')!) as ProjectWorkflowLocale, + }).mount(el); +} + +export function initProjectWorkflow() { + registerGlobalInitFunc('initProjectWorkflow', mountProjectWorkflow); +} diff --git a/web_src/js/globals.d.ts b/web_src/js/globals.d.ts index e9e357d7507..5c9949794be 100644 --- a/web_src/js/globals.d.ts +++ b/web_src/js/globals.d.ts @@ -44,6 +44,7 @@ interface Window { name: string, }>, dashboardRepoList?: Record, + projectWorkflows?: import('./components/ProjectWorkflowStore.ts').ProjectWorkflowData, }, notificationSettings: { MinTimeout: number, diff --git a/web_src/js/index.ts b/web_src/js/index.ts index ca896ea7641..bc75b7b1633 100644 --- a/web_src/js/index.ts +++ b/web_src/js/index.ts @@ -65,6 +65,7 @@ import {initRepoViewFileTree} from './features/repo-view-file-tree.ts'; import {initActionsPermissionsForm} from './features/common-actions-permissions.ts'; import {initRefIssueContextPopup} from './features/ref-issue.ts'; import {initGlobalShortcut} from './modules/shortcut.ts'; +import {initProjectWorkflow} from './features/project-workflow.ts'; import {initDevtest} from './modules/devtest.ts'; import {initRepoWatch} from './features/repo-watch.ts'; import {initPackagesView} from './features/packages.ts'; @@ -166,6 +167,7 @@ const initPerformanceTracer = callInitFunctions([ initRepoFileView, initActionsPermissionsForm, + initProjectWorkflow, initDevtest, ]);