From e23fe79e5eb75fe639fea83f620664c6a7944832 Mon Sep 17 00:00:00 2001 From: TheFox0x7 Date: Mon, 3 Aug 2026 09:34:53 +0200 Subject: [PATCH] feat(licenses): support REUSE specification in licenses (#38720) extends the current license detection to support two modes: - legacy which is using classification and was expanded to handle more paths (extensions, different spelling or GNU copying file) - REUSE which avoids classification by relying on the spec dictating that license must be named as SPDX-ID.extension. Newly created repositories will default to REUSE based paths Use of styles at the same time is not allowed by design. Extends the UI to show all the detected licenses and paths to them, deduplicating them per SPDX-ID in database as is in github closes: https://github.com/go-gitea/gitea/issues/28672 --------- Assisted-By: omp:glm5.2 Assisted-By: omp:mimo-v2.5-pro Assisted-By: omp:mimimax-m3 Assisted-By: omp:kimi-k3 Assisted-By: omp:deepseek-v4-flash Co-authored-by: wxiaoguang --- docs/guidelines-backend.md | 20 +++- modelmigration/migrations.go | 1 + modelmigration/v1_28/v346.go | 52 +++++++++ modelmigration/v1_28/v346_test.go | 80 +++++++++++++ models/repo/license.go | 44 +++++-- routers/web/repo/view_home.go | 28 ++++- services/repository/create.go | 5 +- services/repository/license.go | 146 ++++++++++++++++------- services/repository/license_test.go | 168 +++++++++++++++++++++++++-- templates/repo/home_sidebar_top.tmpl | 7 +- 10 files changed, 480 insertions(+), 71 deletions(-) create mode 100644 modelmigration/v1_28/v346.go create mode 100644 modelmigration/v1_28/v346_test.go 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}}