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.
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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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")
+ }
+}