diff --git a/docs/guidelines-backend.md b/docs/guidelines-backend.md index 9bef45da09..8fca9cb1a0 100644 --- a/docs/guidelines-backend.md +++ b/docs/guidelines-backend.md @@ -83,6 +83,24 @@ be touched by pull requests whose sole purpose is updating dependencies. Run Any `go.mod` / `go.sum` update must be justified in the PR description and must be verified by reviewers and the merger to reference an existing upstream commit. +## Golang HTML template + +Gitea uses Go's built-in HTML template engine which is dynamically & weakly typed. +To make template code maintainable: + +- Go code should take over complex logic and prepare template data as much as possible, templates only render the data. +- Prefer struct types provided by Go code instead of map types for template data. +- Avoid using single world names for non-local variables. +- Avoid passing `"root" $` or `"." .` to sub-templates, instead pass the specific data needed by the sub-template. +- Use explicit variable names instead of `.` to access data: ``{{range $item := $.TargetItems}}{{ $item.Name }}{{end}}`` +- Use Go code to implement render helpers if the render logic is too complex. + +Using a modern and statically & strongly typed template engine to replace Golang HTML template might be good, +but the challenge is huge, the requisites are: + +- A working and maintainable proof-of-concept for the most complex templates (like "PR View" and "PR Diff"). +- A firm commitment of sufficient engineering resources. + ## API v1 The API is documented with [Swagger](https://gitea.com/api/swagger) and is modelled @@ -120,7 +138,7 @@ In general, choose HTTP methods as follows: - **POST** creates a new object (e.g. a user) and returns **201 Created** with the created object. - **PUT** adds or assigns an existing object (e.g. a user to a team) and returns - **204 No Content** with no body. + **204 No Content** with no response body. - **PATCH** edits an existing object and returns the changed object with **200 OK**. - **DELETE** removes an object and returns **204 No Content** with no body. diff --git a/modelmigration/migrations.go b/modelmigration/migrations.go index d7eda29fb1..76cd1649f6 100644 --- a/modelmigration/migrations.go +++ b/modelmigration/migrations.go @@ -426,6 +426,7 @@ func prepareMigrationTasks() []*migration { newMigration(343, "Add max_parallel column to action_run_job", v1_28.AddMaxParallelToActionRunJob), newMigration(344, "Add deferred-matrix columns to ActionRunJob", v1_28.AddDeferredMatrixColumnsToActionRunJob), newMigration(345, "Add block on CODEOWNERS reviews branch protection", v1_28.AddBlockOnCodeownerReviews), + newMigration(346, "Add license_path column to repo_license and backfill", v1_28.AddLicensePathToRepoLicense), } return preparedMigrations } diff --git a/modelmigration/v1_28/v346.go b/modelmigration/v1_28/v346.go new file mode 100644 index 0000000000..c551ab4d56 --- /dev/null +++ b/modelmigration/v1_28/v346.go @@ -0,0 +1,52 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package v1_28 + +import ( + "context" + + "gitea.dev/modelmigration/base" + + "xorm.io/xorm" + "xorm.io/xorm/schemas" +) + +func AddLicensePathToRepoLicense(ctx context.Context, x base.EngineMigration) error { + // Drop the old 2-column UNIQUE(s) index on (repo_id, license) and, on + // re-runs, the index created under the new name. + // xorm Sync cannot reliably update an index when its column set changes. + indexes, err := x.Dialect().GetIndexes(x.DB(), ctx, "repo_license") + if err != nil { + return err + } + for _, idx := range indexes { + if idx.Name == "s" || idx.Name == "path" { + if _, err := x.Exec(x.Dialect().DropIndexSQL("repo_license", idx)); err != nil { + return err + } + } + } + + // Add license_path column. The DEFAULT backfills existing rows: all repos + // created before this migration used the single LICENSE file convention. + type RepoLicense struct { + LicensePath string `xorm:"VARCHAR(255) NOT NULL DEFAULT 'LICENSE'"` + } + if _, err := x.SyncWithOptions(xorm.SyncOptions{ + IgnoreDropIndices: true, + IgnoreConstrains: true, + }, new(RepoLicense)); err != nil { + return err + } + + // Create new 3-column UNIQUE(path) index on (repo_id, license, license_path); + // xorm prefixes the name to UQE_repo_license_path. + newIndex := schemas.NewIndex("path", schemas.UniqueType) + newIndex.AddColumn("repo_id", "license", "license_path") + if _, err := x.Exec(x.Dialect().CreateIndexSQL("repo_license", newIndex)); err != nil { + return err + } + + return nil +} diff --git a/modelmigration/v1_28/v346_test.go b/modelmigration/v1_28/v346_test.go new file mode 100644 index 0000000000..faa91c9286 --- /dev/null +++ b/modelmigration/v1_28/v346_test.go @@ -0,0 +1,80 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package v1_28 + +import ( + "context" + "testing" + + "gitea.dev/modelmigration/migrationtest" + "gitea.dev/modules/timeutil" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "xorm.io/xorm/schemas" +) + +// repoLicenseBeforeV345 mirrors the pre-migration repo_license table: no +// license_path column and a 2-column UNIQUE(s) index on (repo_id, license). +type repoLicenseBeforeV345 struct { + ID int64 `xorm:"pk autoincr"` + RepoID int64 `xorm:"UNIQUE(s) NOT NULL"` + CommitID string + License string `xorm:"VARCHAR(255) UNIQUE(s) NOT NULL"` + CreatedUnix timeutil.TimeStamp `xorm:"created"` + UpdatedUnix timeutil.TimeStamp `xorm:"updated"` +} + +func (repoLicenseBeforeV345) TableName() string { return "repo_license" } + +func Test_AddLicensePathToRepoLicense(t *testing.T) { + x, deferable := migrationtest.PrepareTestEnv(t, 0, new(repoLicenseBeforeV345)) + defer deferable() + + _, err := x.Insert(&repoLicenseBeforeV345{RepoID: 1, CommitID: "c1", License: "MIT"}) + require.NoError(t, err) + _, err = x.Insert(&repoLicenseBeforeV345{RepoID: 1, CommitID: "c1", License: "Apache-2.0"}) + require.NoError(t, err) + _, err = x.Insert(&repoLicenseBeforeV345{RepoID: 2, CommitID: "c2", License: "MIT"}) + require.NoError(t, err) + + indexes, err := x.Dialect().GetIndexes(x.DB(), context.Background(), "repo_license") + require.NoError(t, err) + oldIdx, ok := indexes["s"] // GetIndexes strips the UQE_repo_license_ prefix + require.True(t, ok, "old 2-column unique s index should exist before migration") + assert.Equal(t, schemas.UniqueType, oldIdx.Type) + assert.Equal(t, []string{"repo_id", "license"}, oldIdx.Cols) + + require.NoError(t, AddLicensePathToRepoLicense(t.Context(), x)) + require.NoError(t, AddLicensePathToRepoLicense(t.Context(), x)) // idempotent + + indexes, err = x.Dialect().GetIndexes(x.DB(), context.Background(), "repo_license") + require.NoError(t, err) + assert.NotContains(t, indexes, "s", "old 2-column unique s index should be gone after migration") + newIdx, ok := indexes["path"] // GetIndexes strips the UQE_repo_license_ prefix + require.True(t, ok, "new index must be named path (UQE_repo_license_path)") + assert.Equal(t, schemas.UniqueType, newIdx.Type) + assert.Equal(t, []string{"repo_id", "license", "license_path"}, newIdx.Cols) + + // pre-existing rows must default to the LICENSE path + type licenseRow struct { + RepoID int64 + License string + LicensePath string + } + var rows []licenseRow + require.NoError(t, x.SQL("SELECT repo_id, license, license_path FROM repo_license ORDER BY id").Find(&rows)) + require.Len(t, rows, 3) + for _, r := range rows { + assert.Equal(t, "LICENSE", r.LicensePath) + } + + // the new index is unique per (repo_id, license, license_path): the exact + // duplicate must be rejected, while a second path for the same license + // in the same repo is allowed + _, err = x.Exec("INSERT INTO repo_license (repo_id, commit_id, license, license_path) VALUES (1, 'c1', 'MIT', 'LICENSE')") + require.Error(t, err) + _, err = x.Exec("INSERT INTO repo_license (repo_id, commit_id, license, license_path) VALUES (1, 'c1', 'MIT', 'LICENSES/MIT.txt')") + require.NoError(t, err) +} diff --git a/models/repo/license.go b/models/repo/license.go index 3ab721a9de..781fd2a17d 100644 --- a/models/repo/license.go +++ b/models/repo/license.go @@ -16,9 +16,10 @@ func init() { type RepoLicense struct { //revive:disable-line:exported ID int64 `xorm:"pk autoincr"` - RepoID int64 `xorm:"UNIQUE(s) NOT NULL"` + RepoID int64 `xorm:"UNIQUE(path) NOT NULL"` CommitID string - License string `xorm:"VARCHAR(255) UNIQUE(s) NOT NULL"` + License string `xorm:"VARCHAR(255) UNIQUE(path) NOT NULL"` + LicensePath string `xorm:"VARCHAR(255) UNIQUE(path) NOT NULL DEFAULT 'LICENSE'"` CreatedUnix timeutil.TimeStamp `xorm:"INDEX CREATED"` UpdatedUnix timeutil.TimeStamp `xorm:"INDEX UPDATED"` } @@ -34,6 +35,13 @@ func (rll RepoLicenseList) StringList() []string { return licenses } +type DetectedLicense struct { + // SPDXID of the license + SPDXID string + // LicensePath is in repo path to the license + LicensePath string +} + // GetRepoLicenses returns the license statistics for a repository func GetRepoLicenses(ctx context.Context, repo *Repository) (RepoLicenseList, error) { licenses := make(RepoLicenseList, 0) @@ -43,8 +51,19 @@ func GetRepoLicenses(ctx context.Context, repo *Repository) (RepoLicenseList, er return licenses, nil } +func GetUniqueRepoLicenses(ctx context.Context, repo *Repository) (RepoLicenseList, error) { + licenses := make(RepoLicenseList, 0) + if err := db.GetEngine(ctx).Select("MAX(`id`) AS `id`, `license`, MAX(`license_path`) AS `license_path`, MAX(`commit_id`) AS `commit_id`"). + Where("`repo_id` = ?", repo.ID). + GroupBy("`license`"). + Find(&licenses); err != nil { + return nil, err + } + return licenses, nil +} + // UpdateRepoLicenses updates the license statistics for repository -func UpdateRepoLicenses(ctx context.Context, repo *Repository, commitID string, licenses []string) error { +func UpdateRepoLicenses(ctx context.Context, repo *Repository, commitID string, licenses []DetectedLicense) error { oldLicenses, err := GetRepoLicenses(ctx, repo) if err != nil { return err @@ -53,9 +72,10 @@ func UpdateRepoLicenses(ctx context.Context, repo *Repository, commitID string, upd := false for _, o := range oldLicenses { // Update already existing license - if o.License == license { + if o.License == license.SPDXID { o.CommitID = commitID - if _, err := db.GetEngine(ctx).ID(o.ID).Cols("`commit_id`").Update(o); err != nil { + o.LicensePath = license.LicensePath + if _, err := db.GetEngine(ctx).ID(o.ID).Cols("`commit_id`", "`license_path`").Update(o); err != nil { return err } upd = true @@ -65,9 +85,10 @@ func UpdateRepoLicenses(ctx context.Context, repo *Repository, commitID string, // Insert new license if !upd { if err := db.Insert(ctx, &RepoLicense{ - RepoID: repo.ID, - CommitID: commitID, - License: license, + RepoID: repo.ID, + CommitID: commitID, + License: license.SPDXID, + LicensePath: license.LicensePath, }); err != nil { return err } @@ -100,9 +121,10 @@ func CopyLicense(ctx context.Context, originalRepo, destRepo *Repository) error for _, rl := range repoLicenses { newRepoLicense := &RepoLicense{ - RepoID: destRepo.ID, - CommitID: rl.CommitID, - License: rl.License, + RepoID: destRepo.ID, + CommitID: rl.CommitID, + License: rl.License, + LicensePath: rl.LicensePath, } newRepoLicenses = append(newRepoLicenses, newRepoLicense) } diff --git a/routers/web/repo/view_home.go b/routers/web/repo/view_home.go index fb3e27294e..e829520ce6 100644 --- a/routers/web/repo/view_home.go +++ b/routers/web/repo/view_home.go @@ -123,14 +123,36 @@ func prepareHomeSidebarCitationFile(entry *git.TreeEntry) func(ctx *context.Cont } } +type licenseGroup struct { + LicensePath string + LicenseNames []string +} + func prepareHomeSidebarLicenses(ctx *context.Context) { - repoLicenses, err := repo_model.GetRepoLicenses(ctx, ctx.Repo.Repository) + repoLicenses, err := repo_model.GetUniqueRepoLicenses(ctx, ctx.Repo.Repository) if err != nil { ctx.ServerError("GetRepoLicenses", err) return } - ctx.Data["DetectedRepoLicenses"] = repoLicenses.StringList() - ctx.Data["LicenseFileName"] = repo_service.LicenseFileName + if len(repoLicenses) == 0 { + return + } + + order := make([]string, 0) + groups := make(map[string]*licenseGroup) + for _, rl := range repoLicenses { + if _, ok := groups[rl.LicensePath]; !ok { + groups[rl.LicensePath] = &licenseGroup{LicensePath: rl.LicensePath} + order = append(order, rl.LicensePath) + } + groups[rl.LicensePath].LicenseNames = append(groups[rl.LicensePath].LicenseNames, rl.License) + } + + result := make([]licenseGroup, 0, len(order)) + for _, path := range order { + result = append(result, *groups[path]) + } + ctx.Data["LicenseGroups"] = result } func prepareToRenderDirectory(ctx *context.Context) { diff --git a/services/repository/create.go b/services/repository/create.go index cefde90f05..b33c6047d0 100644 --- a/services/repository/create.go +++ b/services/repository/create.go @@ -308,16 +308,15 @@ func CreateRepositoryDirectly(ctx context.Context, doer, owner *user_model.User, } // 6 - update licenses - var licenses []string if len(opts.License) > 0 { - licenses = append(licenses, opts.License) - + licenses := make([]repo_model.DetectedLicense, 0, 1) var stdout string stdout, _, err = gitcmd.NewCommand("rev-parse", "HEAD").WithRepo(repo).RunStdString(ctx) if err != nil { log.Error("CreateRepository(git rev-parse HEAD) in %v: Stdout: %s\nError: %v", repo, stdout, err) return nil, fmt.Errorf("CreateRepository(git rev-parse HEAD): %w", err) } + licenses = append(licenses, repo_model.DetectedLicense{SPDXID: opts.License, LicensePath: LicenseLegacyFile}) if err = repo_model.UpdateRepoLicenses(ctx, repo, stdout, licenses); err != nil { return nil, err } diff --git a/services/repository/license.go b/services/repository/license.go index 3315cbbe30..428f6347ca 100644 --- a/services/repository/license.go +++ b/services/repository/license.go @@ -7,6 +7,8 @@ import ( "context" "fmt" "io" + "path" + "strings" "gitea.dev/models/db" repo_model "gitea.dev/models/repo" @@ -16,13 +18,20 @@ import ( "gitea.dev/modules/log" "gitea.dev/modules/options" "gitea.dev/modules/queue" + "gitea.dev/modules/util" licenseclassifier "github.com/google/licenseclassifier/v2" ) +const ( + LicenseLegacyFile = "LICENSE" + // LicenseReuseDir is for REUSE license spec - see https://reuse.software/spec-3.3/ + // TODO: Surface this version in repo creation + LicenseReuseDir = "LICENSES" +) + var ( - classifier *licenseclassifier.Classifier - LicenseFileName = "LICENSE" + classifier *licenseclassifier.Classifier // licenseUpdaterQueue represents a queue to handle update repo licenses licenseUpdaterQueue *queue.WorkerPoolQueue[*LicenseUpdaterOptions] @@ -71,21 +80,23 @@ func repoLicenseUpdater(items ...*LicenseUpdaterOptions) []*LicenseUpdaterOption continue } - gitRepo, err := git.OpenRepository(ctx, repo) - if err != nil { - log.Error("repoLicenseUpdater [%d] failed: OpenRepository: %v", opts.RepoID, err) - continue - } - defer gitRepo.Close() + func() { + gitRepo, err := git.OpenRepository(ctx, repo) + if err != nil { + log.Error("repoLicenseUpdater [%d] failed: OpenRepository: %v", opts.RepoID, err) + return + } + defer gitRepo.Close() - commit, err := gitRepo.GetBranchCommit(ctx, repo.DefaultBranch) - if err != nil { - log.Error("repoLicenseUpdater [%d] failed: GetBranchCommit: %v", opts.RepoID, err) - continue - } - if err = UpdateRepoLicenses(ctx, repo, gitRepo, commit); err != nil { - log.Error("repoLicenseUpdater [%d] failed: updateRepoLicenses: %v", opts.RepoID, err) - } + commit, err := gitRepo.GetBranchCommit(ctx, repo.DefaultBranch) + if err != nil { + log.Error("repoLicenseUpdater [%d] failed: GetBranchCommit: %v", opts.RepoID, err) + return + } + if err = UpdateRepoLicenses(ctx, repo, gitRepo, commit); err != nil { + log.Error("repoLicenseUpdater [%d] failed: updateRepoLicenses: %v", opts.RepoID, err) + } + }() } return nil } @@ -113,43 +124,94 @@ func SyncRepoLicenses(ctx context.Context) error { return nil } +// resolveReuseLicenses gathers all licenses in a subtree (assumed to be LicenseReuseDir as per REUSE specification) +func resolveReuseLicenses(ctx context.Context, gitrepo *git.Repository, parentPath string, tree *git.Tree) ([]repo_model.DetectedLicense, error) { + entries, err := tree.ListEntries(ctx, gitrepo) + if err != nil { + return nil, fmt.Errorf("ListEntries: %w", err) + } + licenses := make([]repo_model.DetectedLicense, 0) + for _, entry := range entries { + if entry.IsRegular() { + spdxID := util.PathBaseStem(entry.Name()) + licenses = append(licenses, repo_model.DetectedLicense{SPDXID: spdxID, LicensePath: path.Join(parentPath, entry.Name())}) + } + } + + return licenses, nil +} + +// isLicenseFile checks the prefix of the file and determines if it could plausibly be a license one +// it's checking well-known ones: license, licence and copying +// allowed extensions are: md, lesser and txt +func isLicenseFile(name string) bool { + lower := strings.ToLower(name) + stem := util.PathBaseStem(lower) + ext := path.Ext(lower) + // exact match (e.g. "LICENSE") or at most one allowed extension (e.g. "LICENSE.md") + return (stem == "license" || stem == "licence" || stem == "copying") && + (ext == "" || ext == ".md" || ext == ".lesser" || ext == ".txt") +} + +func resolveLicenses(ctx context.Context, gitRepo *git.Repository, commit *git.Commit) ([]repo_model.DetectedLicense, error) { + tree, err := commit.SubTree(ctx, gitRepo, LicenseReuseDir) + if err != nil && !git.IsErrNotExist(err) { + return nil, fmt.Errorf("SubTree: %w", err) + } + + // handle REUSE license spec first + if !git.IsErrNotExist(err) { + return resolveReuseLicenses(ctx, gitRepo, LicenseReuseDir, tree) + } + + tree, err = commit.SubTree(ctx, gitRepo, "") + if err != nil && !git.IsErrNotExist(err) { + return nil, fmt.Errorf("SubTree: %w", err) + } + + entries, err := tree.ListEntries(ctx, gitRepo) + if err != nil { + return nil, fmt.Errorf("ListEntries: %w", err) + } + licenses := make([]repo_model.DetectedLicense, 0) + for _, entry := range entries { + if !entry.IsRegular() { + continue + } + if !isLicenseFile(entry.Name()) { + continue + } + r, err := entry.Blob(gitRepo).DataAsync(ctx) + if err != nil { + continue + } + found, err := detectLicense(r) + _ = r.Close() + if err != nil { + continue + } + for _, license := range found { + licenses = append(licenses, repo_model.DetectedLicense{SPDXID: license, LicensePath: entry.Name()}) + } + } + return licenses, nil +} + // UpdateRepoLicenses will update repository licenses col if license file exists func UpdateRepoLicenses(ctx context.Context, repo *repo_model.Repository, gitRepo *git.Repository, commit *git.Commit) error { - if commit == nil { - return nil + licenses, err := resolveLicenses(ctx, gitRepo, commit) + if err != nil { + return err } - - b, err := commit.GetBlobByPath(ctx, gitRepo, LicenseFileName) - if err != nil && !git.IsErrNotExist(err) { - return fmt.Errorf("GetBlobByPath: %w", err) - } - - if git.IsErrNotExist(err) { + if len(licenses) == 0 { return repo_model.CleanRepoLicenses(ctx, repo) } - licenses := make([]string, 0) - if b != nil { - r, err := b.DataAsync(ctx) - if err != nil { - return err - } - defer r.Close() - - licenses, err = detectLicense(r) - if err != nil { - return fmt.Errorf("detectLicense: %w", err) - } - } return repo_model.UpdateRepoLicenses(ctx, repo, commit.ID.String(), licenses) } // detectLicense returns the licenses detected by the given content buff func detectLicense(r io.Reader) ([]string, error) { - if r == nil { - return nil, nil - } - matches, err := classifier.MatchFrom(r) if err != nil { return nil, err diff --git a/services/repository/license_test.go b/services/repository/license_test.go index a8a28cdf07..3cea2f7c53 100644 --- a/services/repository/license_test.go +++ b/services/repository/license_test.go @@ -4,9 +4,12 @@ package repository import ( + "path/filepath" "strings" "testing" + repo_model "gitea.dev/models/repo" + "gitea.dev/modules/git" repo_module "gitea.dev/modules/repository" "github.com/stretchr/testify/assert" @@ -41,7 +44,7 @@ func Test_detectLicense(t *testing.T) { Repo: "gitea", Year: "2024", }) - assert.NoError(t, err) + require.NoError(t, err) tests = append(tests, DetectLicenseTest{ name: "single license test: " + licenseName, @@ -59,12 +62,161 @@ func Test_detectLicense(t *testing.T) { }) } - result, err := detectLicense(strings.NewReader(tests[2].arg + tests[3].arg + tests[4].arg)) - assert.NoError(t, err) - t.Run("multiple licenses test", func(t *testing.T) { - assert.Len(t, result, 3) - assert.Contains(t, result, tests[2].want[0]) - assert.Contains(t, result, tests[3].want[0]) - assert.Contains(t, result, tests[4].want[0]) + // Build multi-license content from the first 3 real license entries. + require.GreaterOrEqual(t, len(repo_module.Licenses), 3, "need at least 3 licenses for multi-license test") + var multiContent strings.Builder + var multiWant []string + for _, name := range repo_module.Licenses[:3] { + lic, err := repo_module.GetLicense(name, &repo_module.LicenseValues{ + Owner: "Gitea", Email: "teabot@gitea.io", Repo: "gitea", Year: "2024", + }) + require.NoError(t, err) + multiContent.Write(lic) + multiWant = append(multiWant, name) + } + t.Run("multiple licenses", func(t *testing.T) { + result, err := detectLicense(strings.NewReader(multiContent.String())) + assert.NoError(t, err) + assert.ElementsMatch(t, multiWant, result) + }) +} + +func detectedLicenseIDs(licenses []repo_model.DetectedLicense) []string { + ids := make([]string, 0, len(licenses)) + for _, l := range licenses { + ids = append(ids, l.SPDXID) + } + return ids +} + +func Test_resolveLicenses(t *testing.T) { + require.NoError(t, repo_module.LoadRepoConfig()) + require.NoError(t, InitLicenseClassifier()) + + mitLicense, err := repo_module.GetLicense("MIT", &repo_module.LicenseValues{ + Owner: "Test", + Email: "test@test.com", + Repo: "test", + Year: "2024", + }) + require.NoError(t, err) + + repoDir := filepath.Join(t.TempDir(), "repo.git") + require.NoError(t, git.InitRepositoryLocal(t.Context(), repoDir, true, "sha1")) + gitRepo, err := git.OpenRepositoryLocal(t.Context(), repoDir) + require.NoError(t, err) + defer gitRepo.Close() + + // 1. repo with no license at all + require.NoError(t, git.ForceFastImport(t.Context(), gitRepo, []git.FastImportCommit{{ + Ref: "refs/heads/master", + Message: "empty", + }})) + commit, err := gitRepo.GetBranchCommit(t.Context(), "master") + require.NoError(t, err) + + licenses, err := resolveLicenses(t.Context(), gitRepo, commit) + assert.Empty(t, licenses) + assert.NoError(t, err) + + // 2. repo with a plain LICENSE file — classifier should detect MIT + require.NoError(t, git.ForceFastImport(t.Context(), gitRepo, []git.FastImportCommit{{ + Ref: "refs/heads/master", + Message: "add LICENSE", + Files: []git.FastImportFile{ + {Mode: git.EntryModeBlob, Path: "LICENSE", Content: string(mitLicense)}, + }, + }})) + commit, err = gitRepo.GetBranchCommit(t.Context(), "master") + require.NoError(t, err) + + licenses, err = resolveLicenses(t.Context(), gitRepo, commit) + assert.NoError(t, err) + assert.Len(t, licenses, 1) + assert.Equal(t, "MIT", licenses[0].SPDXID) + assert.Equal(t, "LICENSE", licenses[0].LicensePath) + + // 3. repo with REUSE LICENSES/ dir — should take priority over root LICENSE + require.NoError(t, git.ForceFastImport(t.Context(), gitRepo, []git.FastImportCommit{{ + Ref: "refs/heads/master", + Message: "add LICENSES dir", + Files: []git.FastImportFile{ + {Mode: git.EntryModeBlob, Path: "LICENSE", Content: string(mitLicense)}, + {Mode: git.EntryModeBlob, Path: "LICENSES/MIT.txt", Content: "MIT license text"}, + {Mode: git.EntryModeBlob, Path: "LICENSES/Apache-2.0.txt", Content: "Apache license text"}, + }, + }})) + commit, err = gitRepo.GetBranchCommit(t.Context(), "master") + require.NoError(t, err) + + licenses, err = resolveLicenses(t.Context(), gitRepo, commit) + assert.NoError(t, err) + assert.Len(t, licenses, 2) + + ids := detectedLicenseIDs(licenses) + assert.ElementsMatch(t, []string{"Apache-2.0", "MIT"}, ids) + // REUSE entries carry full path including LICENSES/ prefix + for _, l := range licenses { + assert.True(t, strings.HasPrefix(l.LicensePath, "LICENSES/")) + } + + // 4. remove all licenses — should return not exist + require.NoError(t, git.ForceFastImport(t.Context(), gitRepo, []git.FastImportCommit{{ + Ref: "refs/heads/master", + Message: "remove licenses", + }})) + commit, err = gitRepo.GetBranchCommit(t.Context(), "master") + require.NoError(t, err) + + licenses, err = resolveLicenses(t.Context(), gitRepo, commit) + assert.Empty(t, licenses) + assert.NoError(t, err) +} + +func Test_isLicenseFile(t *testing.T) { + shouldMatch := []string{ + "LICENSE", + "LICENCE", + "COPYING", + "License", + "Licence", + "copying", + "LICENSE.txt", + "LICENSE.md", + "LICENCE.txt", + "LICENCE.md", + "COPYING.txt", + "COPYING.md", + "LICENSE.TXT", + "LICENSE.MD", + "license.txt", + "licence.md", + "copying.TXT", + "COPYING.LESSER", + } + + shouldNotMatch := []string{ + "LICENSE.", + "README", + "README.md", + "NOTICE", + "AUTHORS", + "LICENSING", + "COPYLEFT", + "LICENSE.a.b", + "LICENSE.a.", + "LICENSE.a.md", + "LICENSE.md.a", + } + t.Run("match", func(t *testing.T) { + for _, name := range shouldMatch { + assert.True(t, isLicenseFile(name)) + } + }) + + t.Run("nomatch", func(t *testing.T) { + for _, name := range shouldNotMatch { + assert.False(t, isLicenseFile(name)) + } }) } diff --git a/templates/repo/home_sidebar_top.tmpl b/templates/repo/home_sidebar_top.tmpl index 690db11f80..431c507920 100644 --- a/templates/repo/home_sidebar_top.tmpl +++ b/templates/repo/home_sidebar_top.tmpl @@ -51,9 +51,10 @@ {{end}} - {{if .DetectedRepoLicenses}} - - {{svg "octicon-law"}} {{if eq (len .DetectedRepoLicenses) 1}}{{index .DetectedRepoLicenses 0}}{{else}}{{ctx.Locale.Tr "repo.multiple_licenses"}}{{end}} + {{range $licenseGroup := .LicenseGroups}} + {{$licenseDisplayName := StringUtils.Join $licenseGroup.LicenseNames ", "}} + + {{svg "octicon-law"}} {{$licenseDisplayName}} {{end}}