Allow multiple projects per issue and pull requests (#36784)

Add ability to add and remove multiple projects per issue
and pull request.

Resolve #12974

---------

Signed-off-by: Icy Avocado <avocado@ovacoda.com>
Co-authored-by: Tyrone Yeh <siryeh@gmail.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: OpenCode (gpt-5.2-codex) <opencode@openai.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
Co-authored-by: silverwind <me@silverwind.io>
Co-authored-by: Claude (Opus 4.7) <noreply@anthropic.com>
This commit is contained in:
Icy Avocado
2026-04-30 08:38:05 -06:00
committed by GitHub
parent 52d6baf5a8
commit 81692ceafa
58 changed files with 1597 additions and 430 deletions

View File

@@ -27,7 +27,7 @@ import (
const (
issueIndexerAnalyzer = "issueIndexer"
issueIndexerDocType = "issueIndexerDocType"
issueIndexerLatestVersion = 5
issueIndexerLatestVersion = 6
)
const unicodeNormalizeName = "unicodeNormalize"
@@ -83,8 +83,8 @@ func generateIssueIndexMapping() (mapping.IndexMapping, error) {
docMapping.AddFieldMappingsAt("label_ids", numberFieldMapping)
docMapping.AddFieldMappingsAt("no_label", boolFieldMapping)
docMapping.AddFieldMappingsAt("milestone_id", numberFieldMapping)
docMapping.AddFieldMappingsAt("project_id", numberFieldMapping)
docMapping.AddFieldMappingsAt("project_board_id", numberFieldMapping)
docMapping.AddFieldMappingsAt("project_ids", numberFieldMapping)
docMapping.AddFieldMappingsAt("no_project", boolFieldMapping)
docMapping.AddFieldMappingsAt("poster_id", numberFieldMapping)
docMapping.AddFieldMappingsAt("assignee_id", numberFieldMapping)
docMapping.AddFieldMappingsAt("mention_ids", numberFieldMapping)
@@ -241,11 +241,15 @@ func (b *Indexer) Search(ctx context.Context, options *internal.SearchOptions) (
queries = append(queries, bleve.NewDisjunctionQuery(milestoneQueries...))
}
if options.ProjectID.Has() {
queries = append(queries, inner_bleve.NumericEqualityQuery(options.ProjectID.Value(), "project_id"))
}
if options.ProjectColumnID.Has() {
queries = append(queries, inner_bleve.NumericEqualityQuery(options.ProjectColumnID.Value(), "project_board_id"))
if options.NoProjectOnly {
queries = append(queries, inner_bleve.BoolFieldQuery(true, "no_project"))
} else if len(options.ProjectIDs) > 0 {
var projectQueries []query.Query
for _, projectID := range options.ProjectIDs {
projectQueries = append(projectQueries, inner_bleve.NumericEqualityQuery(projectID, "project_ids"))
}
// FIXME: ISSUE-MULTIPLE-PROJECTS-FILTER: this logic is not right, it should use "AND" but not "OR"
queries = append(queries, bleve.NewDisjunctionQuery(projectQueries...))
}
if options.PosterID != "" {

View File

@@ -13,6 +13,7 @@ import (
"code.gitea.io/gitea/modules/container"
"code.gitea.io/gitea/modules/indexer/issues/internal"
"code.gitea.io/gitea/modules/optional"
"code.gitea.io/gitea/modules/util"
)
func ToDBOptions(ctx context.Context, options *internal.SearchOptions) (*issue_model.IssuesOptions, error) {
@@ -65,8 +66,7 @@ func ToDBOptions(ctx context.Context, options *internal.SearchOptions) (*issue_m
ReviewRequestedID: convertID(options.ReviewRequestedID),
ReviewedID: convertID(options.ReviewedID),
SubscriberID: convertID(options.SubscriberID),
ProjectID: convertID(options.ProjectID),
ProjectColumnID: convertID(options.ProjectColumnID),
ProjectIDs: util.Iif(options.NoProjectOnly, []int64{db.NoConditionID}, options.ProjectIDs),
IsClosed: options.IsClosed,
IsPull: options.IsPull,
IncludedLabelNames: nil,

View File

@@ -46,10 +46,10 @@ func ToSearchOptions(keyword string, opts *issues_model.IssuesOptions) *SearchOp
searchOpt.MilestoneIDs = opts.MilestoneIDs
}
if opts.ProjectID > 0 {
searchOpt.ProjectID = optional.Some(opts.ProjectID)
} else if opts.ProjectID == db.NoConditionID { // FIXME: this is inconsistent from other places
searchOpt.ProjectID = optional.Some[int64](0) // Those issues with no project(projectid==0)
if len(opts.ProjectIDs) == 1 && opts.ProjectIDs[0] == db.NoConditionID {
searchOpt.NoProjectOnly = true
} else {
searchOpt.ProjectIDs = opts.ProjectIDs
}
searchOpt.AssigneeID = opts.AssigneeID
@@ -65,7 +65,6 @@ func ToSearchOptions(keyword string, opts *issues_model.IssuesOptions) *SearchOp
return nil
}
searchOpt.ProjectColumnID = convertID(opts.ProjectColumnID)
searchOpt.PosterID = opts.PosterID
searchOpt.MentionID = convertID(opts.MentionedID)
searchOpt.ReviewedID = convertID(opts.ReviewedID)

View File

@@ -19,7 +19,7 @@ import (
)
const (
issueIndexerLatestVersion = 2
issueIndexerLatestVersion = 3
// multi-match-types, currently only 2 types are used
// Reference: https://www.elastic.co/guide/en/elasticsearch/reference/7.0/query-dsl-multi-match-query.html#multi-match-types
esMultiMatchTypeBestFields = "best_fields"
@@ -68,8 +68,8 @@ const (
"label_ids": { "type": "integer", "index": true },
"no_label": { "type": "boolean", "index": true },
"milestone_id": { "type": "integer", "index": true },
"project_id": { "type": "integer", "index": true },
"project_board_id": { "type": "integer", "index": true },
"project_ids": { "type": "integer", "index": true },
"no_project": { "type": "boolean", "index": true },
"poster_id": { "type": "integer", "index": true },
"assignee_id": { "type": "integer", "index": true },
"mention_ids": { "type": "integer", "index": true },
@@ -204,11 +204,11 @@ func (b *Indexer) Search(ctx context.Context, options *internal.SearchOptions) (
query.Must(elastic.NewTermsQuery("milestone_id", toAnySlice(options.MilestoneIDs)...))
}
if options.ProjectID.Has() {
query.Must(elastic.NewTermQuery("project_id", options.ProjectID.Value()))
}
if options.ProjectColumnID.Has() {
query.Must(elastic.NewTermQuery("project_board_id", options.ProjectColumnID.Value()))
if options.NoProjectOnly {
query.Must(elastic.NewTermQuery("no_project", true))
} else if len(options.ProjectIDs) > 0 {
// FIXME: ISSUE-MULTIPLE-PROJECTS-FILTER: this logic is not right, it should use "AND" but not "OR"
query.Must(elastic.NewTermsQuery("project_ids", toAnySlice(options.ProjectIDs)...))
}
if options.PosterID != "" {

View File

@@ -416,28 +416,42 @@ func searchIssueInProject(t *testing.T) {
}{
{
SearchOptions{
ProjectID: optional.Some(int64(1)),
ProjectIDs: []int64{1},
},
[]int64{5, 3, 2, 1},
},
{
SearchOptions{
ProjectColumnID: optional.Some(int64(1)),
},
[]int64{1},
},
{
SearchOptions{
ProjectColumnID: optional.Some(int64(0)), // issue with in default column
},
[]int64{2},
},
}
for _, test := range tests {
issueIDs, _, err := SearchIssues(t.Context(), &test.opts)
require.NoError(t, err)
assert.Equal(t, test.expectedIDs, issueIDs)
}
// Test filtering for issues with no project assigned using dynamic validation
t.Run("no project assigned", func(t *testing.T) {
issueIDs, total, err := SearchIssues(t.Context(), &SearchOptions{
ProjectIDs: []int64{db.NoConditionID},
})
require.NoError(t, err)
assert.NotEmpty(t, issueIDs)
assert.Equal(t, total, int64(len(issueIDs)))
// Verify each returned issue actually has no project
for _, issueID := range issueIDs {
issue, err := issues.GetIssueByID(t.Context(), issueID)
require.NoError(t, err)
err = issue.LoadProjects(t.Context())
require.NoError(t, err)
assert.Empty(t, issue.Projects, "Issue %d should have no projects", issueID)
}
// Count total issues with no project to verify we got them all
allIssues, err := issues.Issues(t.Context(), &issues.IssuesOptions{
ProjectIDs: []int64{db.NoConditionID},
})
require.NoError(t, err)
assert.Len(t, issueIDs, len(allIssues), "Should return all issues with no project")
})
}
func searchIssueWithPaginator(t *testing.T) {

View File

@@ -30,8 +30,9 @@ type IndexerData struct {
LabelIDs []int64 `json:"label_ids"`
NoLabel bool `json:"no_label"` // True if LabelIDs is empty
MilestoneID int64 `json:"milestone_id"`
ProjectID int64 `json:"project_id"`
ProjectColumnID int64 `json:"project_board_id"` // the key should be kept as project_board_id to keep compatible
ProjectIDs []int64 `json:"project_ids"`
NoProject bool `json:"no_project"` // True if ProjectIDs is empty
ProjectColumnMap map[int64]int64 `json:"project_column_map,omitempty"` // Maps project ID to column ID for each project the issue is in
PosterID int64 `json:"poster_id"`
AssigneeID int64 `json:"assignee_id"`
MentionIDs []int64 `json:"mention_ids"`
@@ -94,8 +95,8 @@ type SearchOptions struct {
MilestoneIDs []int64 // milestones the issues have
ProjectID optional.Option[int64] // project the issues belong to
ProjectColumnID optional.Option[int64] // project column the issues belong to
ProjectIDs []int64 // project the issues belong to. FIXME: ISSUE-MULTIPLE-PROJECTS-FILTER: no multiple project filter support yet. Search logic is wrong.
NoProjectOnly bool // if the issues have no project, if true, ProjectIDs will be ignored
PosterID string // poster of the issues, "(none)" or "(any)" or a user ID
AssigneeID string // assignee of the issues, "(none)" or "(any)" or a user ID

View File

@@ -301,75 +301,41 @@ var cases = []*testIndexerCase{
},
},
{
Name: "ProjectID",
Name: "ProjectIDs",
SearchOptions: &internal.SearchOptions{
Paginator: &db.ListOptions{
PageSize: 5,
},
ProjectID: optional.Some(int64(1)),
ProjectIDs: []int64{1},
},
Expected: func(t *testing.T, data map[int64]*internal.IndexerData, result *internal.SearchResult) {
assert.Len(t, result.Hits, 5)
for _, v := range result.Hits {
assert.Equal(t, int64(1), data[v.ID].ProjectID)
assert.Contains(t, data[v.ID].ProjectIDs, int64(1))
}
assert.Equal(t, countIndexerData(data, func(v *internal.IndexerData) bool {
return v.ProjectID == 1
return slices.Contains(v.ProjectIDs, int64(1))
}), result.Total)
},
},
{
Name: "no ProjectID",
Name: "no ProjectIDs (empty array)",
SearchOptions: &internal.SearchOptions{
Paginator: &db.ListOptions{
PageSize: 5,
PageSize: 50,
},
ProjectID: optional.Some(int64(0)),
NoProjectOnly: true,
},
Expected: func(t *testing.T, data map[int64]*internal.IndexerData, result *internal.SearchResult) {
assert.Len(t, result.Hits, 5)
// Verify only issues with no projects are returned
for _, v := range result.Hits {
assert.Equal(t, int64(0), data[v.ID].ProjectID)
assert.Empty(t, data[v.ID].ProjectIDs, "Issue %d should have no projects", v.ID)
}
assert.Equal(t, countIndexerData(data, func(v *internal.IndexerData) bool {
return v.ProjectID == 0
}), result.Total)
},
},
{
Name: "ProjectColumnID",
SearchOptions: &internal.SearchOptions{
Paginator: &db.ListOptions{
PageSize: 5,
},
ProjectColumnID: optional.Some(int64(1)),
},
Expected: func(t *testing.T, data map[int64]*internal.IndexerData, result *internal.SearchResult) {
assert.Len(t, result.Hits, 5)
for _, v := range result.Hits {
assert.Equal(t, int64(1), data[v.ID].ProjectColumnID)
}
assert.Equal(t, countIndexerData(data, func(v *internal.IndexerData) bool {
return v.ProjectColumnID == 1
}), result.Total)
},
},
{
Name: "no ProjectColumnID",
SearchOptions: &internal.SearchOptions{
Paginator: &db.ListOptions{
PageSize: 5,
},
ProjectColumnID: optional.Some(int64(0)),
},
Expected: func(t *testing.T, data map[int64]*internal.IndexerData, result *internal.SearchResult) {
assert.Len(t, result.Hits, 5)
for _, v := range result.Hits {
assert.Equal(t, int64(0), data[v.ID].ProjectColumnID)
}
assert.Equal(t, countIndexerData(data, func(v *internal.IndexerData) bool {
return v.ProjectColumnID == 0
}), result.Total)
// Verify we got ALL issues with no projects
expectedCount := countIndexerData(data, func(v *internal.IndexerData) bool {
return len(v.ProjectIDs) == 0
})
assert.Equal(t, expectedCount, result.Total, "Should return all %d issues with no project", expectedCount)
},
},
{
@@ -706,6 +672,10 @@ func generateDefaultIndexerData() []*internal.IndexerData {
for i := range subscriberIDs {
subscriberIDs[i] = int64(i) + 1 // SubscriberID should not be 0
}
projectIDs := make([]int64, id%5)
for i := range projectIDs {
projectIDs[i] = int64(i) + 1 // projectID should not be 0
}
data = append(data, &internal.IndexerData{
ID: id,
@@ -719,8 +689,8 @@ func generateDefaultIndexerData() []*internal.IndexerData {
LabelIDs: labelIDs,
NoLabel: len(labelIDs) == 0,
MilestoneID: issueIndex % 4,
ProjectID: issueIndex % 5,
ProjectColumnID: issueIndex % 6,
ProjectIDs: projectIDs,
NoProject: len(projectIDs) == 0,
PosterID: id%10 + 1, // PosterID should not be 0
AssigneeID: issueIndex % 10,
MentionIDs: mentionIDs,

View File

@@ -20,7 +20,7 @@ import (
)
const (
issueIndexerLatestVersion = 4
issueIndexerLatestVersion = 5
// TODO: make this configurable if necessary
maxTotalHits = 10000
@@ -71,8 +71,8 @@ func NewIndexer(url, apiKey, indexerName string) *Indexer {
"label_ids",
"no_label",
"milestone_id",
"project_id",
"project_board_id",
"project_ids",
"no_project",
"poster_id",
"assignee_id",
"mention_ids",
@@ -182,11 +182,11 @@ func (b *Indexer) Search(ctx context.Context, options *internal.SearchOptions) (
query.And(inner_meilisearch.NewFilterIn("milestone_id", options.MilestoneIDs...))
}
if options.ProjectID.Has() {
query.And(inner_meilisearch.NewFilterEq("project_id", options.ProjectID.Value()))
}
if options.ProjectColumnID.Has() {
query.And(inner_meilisearch.NewFilterEq("project_board_id", options.ProjectColumnID.Value()))
if options.NoProjectOnly {
query.And(inner_meilisearch.NewFilterEq("no_project", true))
} else if len(options.ProjectIDs) > 0 {
// FIXME: ISSUE-MULTIPLE-PROJECTS-FILTER: this logic is not right, it should use "AND" but not "OR"
query.And(inner_meilisearch.NewFilterIn("project_ids", options.ProjectIDs...))
}
if options.PosterID != "" {

View File

@@ -87,14 +87,9 @@ func getIssueIndexerData(ctx context.Context, issueID int64) (*internal.IndexerD
return nil, false, err
}
var projectID int64
if issue.Project != nil {
projectID = issue.Project.ID
}
projectColumnID, err := issue.ProjectColumnID(ctx)
if err != nil {
return nil, false, err
projectIDs := make([]int64, 0, len(issue.Projects))
for _, project := range issue.Projects {
projectIDs = append(projectIDs, project.ID)
}
if err := issue.Repo.LoadOwner(ctx); err != nil {
@@ -114,8 +109,8 @@ func getIssueIndexerData(ctx context.Context, issueID int64) (*internal.IndexerD
LabelIDs: labels,
NoLabel: len(labels) == 0,
MilestoneID: issue.MilestoneID,
ProjectID: projectID,
ProjectColumnID: projectColumnID,
ProjectIDs: projectIDs,
NoProject: len(projectIDs) == 0,
PosterID: issue.PosterID,
AssigneeID: issue.AssigneeID,
MentionIDs: mentionIDs,

View File

@@ -60,6 +60,7 @@ type Issue struct {
Attachments []*Attachment `json:"assets"`
Labels []*Label `json:"labels"`
Milestone *Milestone `json:"milestone"`
Projects []*Project `json:"projects"`
// deprecated
Assignee *User `json:"assignee"`
Assignees []*User `json:"assignees"`
@@ -100,7 +101,9 @@ type CreateIssueOption struct {
Milestone int64 `json:"milestone"`
// list of label ids
Labels []int64 `json:"labels"`
Closed bool `json:"closed"`
// list of project ids
Projects []int64 `json:"projects"`
Closed bool `json:"closed"`
}
// EditIssueOption options for editing an issue
@@ -112,7 +115,9 @@ type EditIssueOption struct {
Assignee *string `json:"assignee"`
Assignees []string `json:"assignees"`
Milestone *int64 `json:"milestone"`
State *string `json:"state"`
// list of project ids to set (replaces existing projects)
Projects *[]int64 `json:"projects"`
State *string `json:"state"`
// swagger:strfmt date-time
Deadline *time.Time `json:"due_date"`
RemoveDeadline *bool `json:"unset_due_date"`

View File

@@ -0,0 +1,33 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package structs
import (
"time"
)
// Project represents a project
// swagger:model
type Project struct {
// ID is the unique identifier for the project
ID int64 `json:"id"`
// Title is the title of the project
Title string `json:"title"`
// Description provides details about the project
Description string `json:"description"`
// OwnerID is the owner of the project (for org-level projects)
OwnerID int64 `json:"owner_id,omitempty"`
// RepoID is the repository this project belongs to (for repo-level projects)
RepoID int64 `json:"repo_id,omitempty"`
// CreatorID is the user who created the project
CreatorID int64 `json:"creator_id"`
// IsClosed indicates if the project is closed
IsClosed bool `json:"is_closed"`
// swagger:strfmt date-time
Created time.Time `json:"created_at"`
// swagger:strfmt date-time
Updated time.Time `json:"updated_at"`
// swagger:strfmt date-time
Closed *time.Time `json:"closed_at,omitempty"`
}

View File

@@ -5,6 +5,7 @@ package templates
import (
"context"
"fmt"
"html/template"
"io"
"net/http"
@@ -36,7 +37,11 @@ func (r *pageRenderer) funcMapDummy() template.FuncMap {
}
func (r *pageRenderer) TemplateLookup(tmpl string, templateCtx context.Context) (TemplateExecutor, error) { //nolint:revive // we don't use ctx, only pass it to the template executor
return r.tmplRenderer.Templates().Executor(tmpl, r.funcMap(templateCtx))
tmpls := r.tmplRenderer.Templates()
if tmpls == nil {
return nil, fmt.Errorf("no templates defined for %s", tmpl)
}
return tmpls.Executor(tmpl, r.funcMap(templateCtx))
}
func (r *pageRenderer) HTML(w io.Writer, status int, tplName TplName, data any, templateCtx context.Context) error { //nolint:revive // we don't use ctx, only pass it to the template executor

View File

@@ -6,6 +6,11 @@ package templates
import (
"fmt"
"reflect"
"slices"
"strconv"
"strings"
"code.gitea.io/gitea/modules/util"
)
type SliceUtils struct{}
@@ -33,3 +38,29 @@ func (su *SliceUtils) Contains(s, v any) bool {
}
return false
}
// JoinInt64 joins a slice of int64 values into a comma-separated string.
func (su *SliceUtils) JoinInt64(values []int64) string {
if len(values) == 0 {
return ""
}
strs := make([]string, len(values))
for i, v := range values {
strs[i] = strconv.FormatInt(v, 10)
}
return strings.Join(strs, ",")
}
func (su *SliceUtils) JoinToggleIDs(values []int64, target int64) (ret struct {
IsIncluded bool
ToggledIDs string
},
) {
ret.IsIncluded = slices.Contains(values, target)
if ret.IsIncluded {
ret.ToggledIDs = su.JoinInt64(util.SliceRemoveAll(slices.Clone(values), target))
} else {
ret.ToggledIDs = su.JoinInt64(append(values, target))
}
return ret
}

View File

@@ -70,6 +70,16 @@ func TestUtils(t *testing.T) {
actual = execTmpl("{{StringUtils.Contains .String .Value}}", map[string]any{"String": "abc", "Value": "x"})
assert.Equal(t, "false", actual)
// Test JoinInt64
actual = execTmpl("{{SliceUtils.JoinInt64 .Values}}", map[string]any{"Values": []int64{1, 2, 3}})
assert.Equal(t, "1,2,3", actual)
actual = execTmpl("{{SliceUtils.JoinInt64 .Values}}", map[string]any{"Values": []int64{}})
assert.Empty(t, actual)
actual = execTmpl("{{SliceUtils.JoinInt64 .Values}}", map[string]any{"Values": []int64{42}})
assert.Equal(t, "42", actual)
tmpl := template.New("test")
tmpl.Funcs(template.FuncMap{"SliceUtils": NewSliceUtils, "StringUtils": NewStringUtils})
template.Must(tmpl.Parse("{{SliceUtils.Contains .Slice .Value}}"))

View File

@@ -0,0 +1,74 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package util
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestDiffSliceBasic(t *testing.T) {
// Typical integer cases
t.Run("additions", func(t *testing.T) {
added, removed := DiffSlice([]int{1, 2}, []int{1, 2, 3})
assert.Equal(t, []int{3}, added)
assert.Empty(t, removed)
})
t.Run("removals", func(t *testing.T) {
added, removed := DiffSlice([]int{1, 2, 3}, []int{1, 2})
assert.Empty(t, added)
assert.Equal(t, []int{3}, removed)
})
t.Run("no changes", func(t *testing.T) {
added, removed := DiffSlice([]int{1, 2}, []int{1, 2})
assert.Empty(t, added)
assert.Empty(t, removed)
})
t.Run("empty slices", func(t *testing.T) {
added, removed := DiffSlice([]int{}, []int{})
assert.Empty(t, added)
assert.Empty(t, removed)
})
t.Run("overlapping elements", func(t *testing.T) {
added, removed := DiffSlice([]int{1, 2, 4}, []int{2, 3, 4})
assert.Equal(t, []int{3}, added)
assert.Equal(t, []int{1}, removed)
})
}
func TestDiffSliceOrderAndDuplicates(t *testing.T) {
oldSlice := []int{1, 2, 2, 3}
newSlice := []int{2, 4, 2, 5}
added, removed := DiffSlice(oldSlice, newSlice)
assert.Equal(t, []int{4, 5}, added)
assert.Equal(t, []int{1, 3}, removed)
}
func TestDiffSliceDeduplicatesOutput(t *testing.T) {
// Test case from issue: newSlice contains [4, 4, 5] and oldSlice is [1]
// added should return [4, 5], not [4, 4, 5]
t.Run("deduplicates added", func(t *testing.T) {
added, removed := DiffSlice([]int{1}, []int{4, 4, 5})
assert.Equal(t, []int{4, 5}, added)
assert.Equal(t, []int{1}, removed)
})
t.Run("deduplicates removed", func(t *testing.T) {
added, removed := DiffSlice([]int{1, 1, 2}, []int{3})
assert.Equal(t, []int{3}, added)
assert.Equal(t, []int{1, 2}, removed)
})
t.Run("deduplicates both", func(t *testing.T) {
added, removed := DiffSlice([]int{1, 1, 2, 2}, []int{3, 3, 4, 4})
assert.Equal(t, []int{3, 4}, added)
assert.Equal(t, []int{1, 2}, removed)
})
}

View File

@@ -15,6 +15,8 @@ import (
"strings"
"sync"
"code.gitea.io/gitea/modules/container"
"golang.org/x/text/cases"
"golang.org/x/text/language"
)
@@ -291,3 +293,21 @@ func NormalizeStringEOL(input string) string {
// Other than this, we should respect the original content, even leading or trailing spaces.
return UnsafeBytesToString(NormalizeEOL(UnsafeStringToBytes(input)))
}
func DiffSlice[T comparable](oldSlice, newSlice []T) (added, removed []T) {
oldSet := container.SetOf(oldSlice...)
newSet := container.SetOf(newSlice...)
addedSet, removedSet := container.Set[T]{}, container.Set[T]{}
for _, v := range newSlice {
if !oldSet.Contains(v) && addedSet.Add(v) {
added = append(added, v)
}
}
for _, v := range oldSlice {
if !newSet.Contains(v) && removedSet.Add(v) {
removed = append(removed, v)
}
}
return added, removed
}