Gitfed
bastien-mrq/gitfed/ Commits/ f3902d8

Add heuristic license-type detection

Repo page shows "MIT (LICENSE)", "Apache-2.0 (LICENSE)", etc. instead of a generic "License (LICENSE)" label, matched from a handful of distinctive strings per common license — not a full SPDX/licensee-style matcher, falls back to the generic label for anything unrecognized. LGPL/AGPL checked before plain GPL since their license bodies legitimately reference "GNU General Public License" as explanatory text and would otherwise be misclassified.

bastien-mrq 2026-07-30 12:24 commit f3902d81ba7d89dea4df8f15620381e9a5a57e48 parent 5d5ec9e1f0b5d08424272c9d84d50d08e018c9b7
5 files changed +194 −10
M CHANGELOG.md +4 −0
M ROADMAP.md +0 −6
M cmd/gitfed-web/handlers_repo.go +6 −4
A cmd/gitfed-web/license.go +72 −0
A cmd/gitfed-web/license_test.go +112 −0
CHANGELOG.md
diff --git a/CHANGELOG.md b/CHANGELOG.md index 8380a1a..eb942b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ A bullet starting with `**BREAKING:**` flags a change gitfed-ctl's update wizard makes you acknowledge individually before it will let you upgrade past that version. +## 1.2.17 + +- Heuristic license-type detection: the repo page now shows "MIT (LICENSE)", "Apache-2.0 (LICENSE)", etc. instead of a generic "License (LICENSE)", by matching a handful of distinctive strings per common license (MIT, Apache-2.0, GPL-2/3, LGPL, AGPL, MPL-2.0, BSD-2/3-Clause, ISC, Unlicense, CC0-1.0) — not a full SPDX/licensee-style matcher, falls back to the generic label for anything unrecognized. + ## 1.2.16 - Light/dark/system theme. Every CSS token gets a light-mode value, applied automatically via `prefers-color-scheme` or forced via a new nav switcher (Auto/Clair/Sombre) that persists across visits in a cookie — same server-rendered pattern as the existing EN/FR language switcher, no client-side JS. Found and fixed a real bug while wiring the new template field through: the CSP script hash was computed from a second, separate template execution that didn't get the new field, silently failing and pinning the hash of an *empty* string — would have broken every page's inline script under the CSP in production.
ROADMAP.md
diff --git a/ROADMAP.md b/ROADMAP.md index 84d123f..a60372b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -80,12 +80,6 @@ celui-là la prochaine fois qu'on rouvre ce document. - **Switch de langue directement dans la preview d'un README** quand un fichier sœur existe (`README.md` / `README.fr.md`) — aucune détection de ce type n'existe aujourd'hui, à construire. *(effort moyen)*. -- **Détection heuristique du type de licence** (MIT, Apache-2.0, GPL, - ...) par le contenu plutôt que se contenter d'afficher le fichier - brut — une heuristique simple (quelques chaînes caractéristiques par - licence courante) plutôt qu'un matching complet façon SPDX/licensee, - qui est un projet à part entière pour un gain marginal ici. *(effort - faible-moyen)*. ### Recherche & découverte
cmd/gitfed-web/handlers_repo.go
diff --git a/cmd/gitfed-web/handlers_repo.go b/cmd/gitfed-web/handlers_repo.go index b3b42a3..dede1a0 100644 --- a/cmd/gitfed-web/handlers_repo.go +++ b/cmd/gitfed-web/handlers_repo.go @@ -180,7 +180,7 @@ var repoTpl = newTpl("repo", ` {{if .LicenseFile}} <div class="gf-card gf-license-card"> - <span>{{icon "file"}} {{t .Lang "repo.license"}} ({{.LicenseFile}})</span> + <span>{{icon "file-doc"}} {{if .LicenseType}}{{.LicenseType}}{{else}}{{t .Lang "repo.license"}}{{end}} ({{.LicenseFile}})</span> <a class="gf-btn" href="/repo-blob/{{.Repo.Name}}?path={{.LicenseFile}}">{{t .Lang "repo.view_file"}}</a> </div> {{end}} @@ -232,7 +232,7 @@ func (s *server) handleRepoView(w http.ResponseWriter, r *http.Request) { commitCount, _, _ := s.ops.CountCommits(name) var readmeHTML template.HTML - var licenseFile string + var licenseFile, licenseType string var tags []string if path == "" { readmeContent, readmeFound, err := s.ops.GetRepoReadme(name) @@ -248,13 +248,14 @@ func (s *server) handleRepoView(w http.ResponseWriter, r *http.Request) { } } - _, foundLicenseFile, licenseFound, err := s.ops.GetRepoLicense(name) + licenseContent, foundLicenseFile, licenseFound, err := s.ops.GetRepoLicense(name) if err != nil { s.serverError(w, r, err) return } if licenseFound { licenseFile = foundLicenseFile + licenseType = detectLicenseType(licenseContent) } tags, err = s.ops.ListRepoTags(name) @@ -281,12 +282,13 @@ func (s *server) handleRepoView(w http.ResponseWriter, r *http.Request) { Tags []string ReadmeHTML template.HTML LicenseFile string + LicenseType string CanAdminister bool Flash template.HTML }{ repo, s.domain, "ssh://git@" + s.domain + ":2222/" + name + ".git", "https://" + s.domain + "/" + name + ".git", branch, commitCount, string(lang), breadcrumbs(path), views, path != "", parentPath(path), path == "" && !found, - tags, readmeHTML, licenseFile, canAdminister, flash(r), + tags, readmeHTML, licenseFile, licenseType, canAdminister, flash(r), }) title := name
cmd/gitfed-web/license.go
diff --git a/cmd/gitfed-web/license.go b/cmd/gitfed-web/license.go new file mode 100644 index 0000000..090bbc9 --- /dev/null +++ b/cmd/gitfed-web/license.go @@ -0,0 +1,72 @@ +package main + +import "strings" + +// detectLicenseType is a lightweight heuristic — a handful of distinctive +// substrings per common license, checked in order (most specific first, so +// e.g. LGPL/AGPL are recognized before they'd otherwise also match plain +// GPL's own marker text) — not a full SPDX/licensee-style matcher. Returns +// "" when nothing recognizable is found, which callers treat the same as +// "unknown" (still shows the raw file, just without a type label). +func detectLicenseType(content string) string { + c := strings.ToUpper(content) + + switch { + case strings.Contains(c, "GNU AFFERO GENERAL PUBLIC LICENSE"): + switch { + case strings.Contains(c, "VERSION 3"): + return "AGPL-3.0" + default: + return "AGPL" + } + + case strings.Contains(c, "GNU LESSER GENERAL PUBLIC LICENSE"): + switch { + case strings.Contains(c, "VERSION 3"): + return "LGPL-3.0" + case strings.Contains(c, "2.1"): + return "LGPL-2.1" + default: + return "LGPL" + } + + case strings.Contains(c, "GNU GENERAL PUBLIC LICENSE"): + switch { + case strings.Contains(c, "VERSION 3"): + return "GPL-3.0" + case strings.Contains(c, "VERSION 2"): + return "GPL-2.0" + default: + return "GPL" + } + + case strings.Contains(c, "APACHE LICENSE") && strings.Contains(c, "VERSION 2.0"): + return "Apache-2.0" + + case strings.Contains(c, "MOZILLA PUBLIC LICENSE") && strings.Contains(c, "2.0"): + return "MPL-2.0" + + case strings.Contains(c, "THIS IS FREE AND UNENCUMBERED SOFTWARE RELEASED INTO THE PUBLIC DOMAIN"): + return "Unlicense" + + case strings.Contains(c, "CC0") && (strings.Contains(c, "CREATIVE COMMONS") || strings.Contains(c, "PUBLIC DOMAIN")): + return "CC0-1.0" + + case strings.Contains(c, "PERMISSION TO USE, COPY, MODIFY, AND/OR DISTRIBUTE THIS SOFTWARE"): + return "ISC" + + case strings.Contains(c, "REDISTRIBUTION AND USE IN SOURCE AND BINARY FORMS"): + switch { + case strings.Contains(c, "MAY BE USED TO ENDORSE OR PROMOTE PRODUCTS"): + return "BSD-3-Clause" + default: + return "BSD-2-Clause" + } + + case strings.Contains(c, "PERMISSION IS HEREBY GRANTED, FREE OF CHARGE"): + return "MIT" + + default: + return "" + } +}
cmd/gitfed-web/license_test.go
diff --git a/cmd/gitfed-web/license_test.go b/cmd/gitfed-web/license_test.go new file mode 100644 index 0000000..3c570b2 --- /dev/null +++ b/cmd/gitfed-web/license_test.go @@ -0,0 +1,112 @@ +package main + +import "testing" + +func TestDetectLicenseType(t *testing.T) { + cases := []struct { + name string + text string + want string + }{ + {"MIT", `MIT License + +Copyright (c) 2024 Someone + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction...`, "MIT"}, + + {"Apache-2.0", ` Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION`, "Apache-2.0"}, + + {"GPL-3.0", ` GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>`, "GPL-3.0"}, + + {"GPL-2.0", ` GNU GENERAL PUBLIC LICENSE + Version 2, June 1991 + + Copyright (C) 1989, 1991 Free Software Foundation, Inc.`, "GPL-2.0"}, + + {"LGPL-3.0", ` GNU LESSER GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + This version of the GNU Lesser General Public License incorporates +the terms and conditions of version 3 of the GNU General Public +License, supplemented by the additional permissions listed below.`, "LGPL-3.0"}, + + {"AGPL-3.0", ` GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>`, "AGPL-3.0"}, + + {"MPL-2.0", `Mozilla Public License Version 2.0 +================================== + +1. Definitions +--------------`, "MPL-2.0"}, + + {"BSD-3-Clause", `Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission.`, "BSD-3-Clause"}, + + {"BSD-2-Clause", `Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer.`, "BSD-2-Clause"}, + + {"ISC", `Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies.`, "ISC"}, + + {"Unlicense", `This is free and unencumbered software released into the public domain. + +Anyone is free to copy, modify, publish, use, compile, sell, or +distribute this software, either in source code form or as a compiled +binary, for any purpose, commercial or non-commercial, and by any +means.`, "Unlicense"}, + + {"CC0-1.0", `Creative Commons Legal Code + +CC0 1.0 Universal + + CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM...`, "CC0-1.0"}, + + {"unrecognized", `All rights reserved. Contact legal@example.com for licensing terms.`, ""}, + + {"empty", "", ""}, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if got := detectLicenseType(c.text); got != c.want { + t.Errorf("detectLicenseType(%s) = %q, want %q", c.name, got, c.want) + } + }) + } +} + +// TestDetectLicenseTypeLGPLNotMisreadAsGPL reproduces the exact ordering +// hazard the switch's case order guards against: LGPL/AGPL license bodies +// legitimately reference "GNU General Public License" as explanatory text +// (LGPL incorporates GPL terms by reference), so checking plain GPL before +// LGPL/AGPL would misclassify them. +func TestDetectLicenseTypeLGPLNotMisreadAsGPL(t *testing.T) { + text := `GNU LESSER GENERAL PUBLIC LICENSE +Version 3, 29 June 2007 + +This version of the GNU Lesser General Public License incorporates +the terms and conditions of version 3 of the GNU General Public +License, supplemented by the additional permissions listed in section 3.` + if got := detectLicenseType(text); got != "LGPL-3.0" { + t.Errorf("detectLicenseType = %q, want %q (must not fall through to plain GPL-3.0)", got, "LGPL-3.0") + } +}