Repository: bpicode/fritzctl
Pull request #143 (2018-03-24 15:41:08)

Write debian copyright file by parsing NOTICE and vendor tree
Issue: #124

codecov[bot]: # [Codecov](https://codecov.io/gh/bpicode/fritzctl/pull/143?src=pr&el=h1) Report
> Merging [#143](https://codecov.io/gh/bpicode/fritzctl/pull/143?src=pr&el=desc) into [master](https://codecov.io/gh/bpicode/fritzctl/commit/34dc85a3158292c89f2cfffdede19c013b171719?src=pr&el=desc) will **not change** coverage.
> The diff coverage is `100%`.

[![Impacted file tree graph](https://codecov.io/gh/bpicode/fritzctl/pull/143/graphs/tree.svg?token=BE6YV2nOnc&width=650&src=pr&height=150)](https://codecov.io/gh/bpicode/fritzctl/pull/143?src=pr&el=tree)

```diff
@@          Coverage Diff          @@
##           master   #143   +/-   ##
=====================================
  Coverage     100%   100%           
=====================================
  Files          81     82    +1     
  Lines        2093   2188   +95     
=====================================
+ Hits         2093   2188   +95
```


| [Impacted Files](https://codecov.io/gh/bpicode/fritzctl/pull/143?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [tools/notice2copyright/main.go](https://codecov.io/gh/bpicode/fritzctl/pull/143/diff?src=pr&el=tree#diff-dG9vbHMvbm90aWNlMmNvcHlyaWdodC9tYWluLmdv) | `100% <100%> (ø)` | |

------

[Continue to review full report at Codecov](https://codecov.io/gh/bpicode/fritzctl/pull/143?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/bpicode/fritzctl/pull/143?src=pr&el=footer). Last update [34dc85a...8309a66](https://codecov.io/gh/bpicode/fritzctl/pull/143?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).


Patch:
diff --git a/.gitignore b/.gitignore
index b82aee9..9e29826 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,6 +5,7 @@
 *.out
 coverage-all.html
 /fritzctl
+/notice2copyright
 
 # Folders
 _obj
diff --git a/Makefile b/Makefile
index 1deaff9..ac514cb 100644
--- a/Makefile
+++ b/Makefile
@@ -6,13 +6,14 @@ FRITZCTL_OUTPUT           ?= fritzctl
 FRITZCTL_REVISION         := $(shell git rev-parse HEAD)
 BASH_COMPLETION_OUTPUT    ?= "os/completion/fritzctl"
 MAN_PAGE_OUTPUT           ?= "os/man/fritzctl.1"
+COPYRIGHT_OUTPUT          ?= "os/doc/copyright"
 DEPENDENCIES_GRAPH_OUTPUT ?= "dependencies.png"
 BUILDFLAGS                := -ldflags="-s -w -X github.com/bpicode/fritzctl/config.Version=$(FRITZCTL_VERSION) -X github.com/bpicode/fritzctl/config.Revision=$(FRITZCTL_REVISION)" -gcflags="-trimpath=$(GOPATH)" -asmflags="-trimpath=$(GOPATH)"
 TESTFLAGS                 ?=
 
-all: sysinfo build install test codequality completion_bash man
+all: sysinfo build install test codequality completion_bash man copyright
 
-.PHONY: clean build man
+.PHONY: clean build man copyright
 
 define ok
 	@tput setaf 6 2>/dev/null || echo -n ""
@@ -36,6 +37,7 @@ clean:
 	@go clean -i
 	@rm -f ./os/completion/fritzctl
 	@rm -f ./os/man/*.gz
+	@rm -f ./os/doc/copyright
 	@rm -f ./coverage-all.html
 	@rm -f ./coverage-all.out
 	@rm -f ./coverage.out
@@ -96,6 +98,12 @@ man:
 	@gzip --force $(MAN_PAGE_OUTPUT)
 	@$(call ok)
 
+copyright:
+	@echo -n ">> COPYRIGHT, output = $(COPYRIGHT_OUTPUT)"
+	@go build github.com/bpicode/fritzctl/tools/notice2copyright
+	@./notice2copyright ./ "MIT License (Expat)"> $(COPYRIGHT_OUTPUT)
+	@$(call ok)
+
 codequality:
 	@echo ">> CODE QUALITY"
 	@echo -n "     FMT"
@@ -179,15 +187,17 @@ pkg_darwin: dist_darwin
 	@zip -q build/distributions/fritzctl-$(FRITZCTL_VERSION)-darwin-amd64.zip build/distributions/darwin_amd64/fritzctl
 	@$(call ok)
 
-pkg_linux: dist_linux man completion_bash
+pkg_linux: dist_linux man completion_bash copyright
 	@mkdir -p build/distributions/linux_amd64/usr/bin
 	@mkdir -p build/distributions/linux_amd64/etc/fritzctl
 	@mkdir -p build/distributions/linux_amd64/etc/bash_completion.d
 	@mkdir -p build/distributions/linux_amd64/usr/share/man/man1
+	@mkdir -p build/distributions/linux_amd64/usr/share/doc/fritzctl
 	@cp os/completion/fritzctl build/distributions/linux_amd64/etc/bash_completion.d/
 	@cp os/config/fritzctl.json build/distributions/linux_amd64/etc/fritzctl/
 	@cp os/config/fritz.pem build/distributions/linux_amd64/etc/fritzctl/
 	@cp os/man/*.1.gz build/distributions/linux_amd64/usr/share/man/man1/
+	@cp os/doc/copyright build/distributions/linux_amd64/usr/share/doc/fritzctl/
 
 	@echo ">> PACKAGE, linux/amd64/deb"
 	@echo -n "     "
@@ -200,10 +210,12 @@ pkg_linux: dist_linux man completion_bash
 	@mkdir -p build/distributions/linux_arm/etc/fritzctl
 	@mkdir -p build/distributions/linux_arm/etc/bash_completion.d
 	@mkdir -p build/distributions/linux_arm/usr/share/man/man1
+	@mkdir -p build/distributions/linux_arm/usr/share/doc/fritzctl
 	@cp os/completion/fritzctl build/distributions/linux_arm/etc/bash_completion.d/
 	@cp os/config/fritzctl.json build/distributions/linux_arm/etc/fritzctl/
 	@cp os/config/fritz.pem build/distributions/linux_arm/etc/fritzctl/
 	@cp os/man/*.1.gz build/distributions/linux_arm/usr/share/man/man1/
+	@cp os/doc/copyright build/distributions/linux_arm/usr/share/doc/fritzctl/
 
 	@echo ">> PACKAGE, linux/armhf/deb"
 	@echo -n "     "
diff --git a/NOTICE b/NOTICE
index 0b021ad..c6fb55d 100644
--- a/NOTICE
+++ b/NOTICE
@@ -10,7 +10,7 @@ The following software have components provided under the terms of this license:
 
 - cobra (from https://github.com/spf13/cobra)
 - mousetrap (from https://github.com/inconshreveable/mousetrap)
-- yaml (from https://github.com/go-yaml/yaml)
+- yaml (from https://gopkg.in/yaml.v2)
 
 ========================================================================
 MIT License (Expat)
@@ -38,11 +38,11 @@ BSD 3-Clause License (Revised)
 
 The following software have components provided under the terms of this license:
 
-- crypto (from https://github.com/golang/crypto)
+- crypto (from https://golang.org/x/crypto)
 - httprouter (from https://github.com/julienschmidt/httprouter)
 - go-difflib (from https://github.com/pmezard/go-difflib)
 - pflag (from https://github.com/spf13/pflag)
-- sys (from https://github.com/golang/sys)
+- sys (from https://golang.org/x/sys)
 
 ========================================================================
 BSD 2-Clause License (FreeBSD/Simplified)
diff --git a/os/doc/.gitignore b/os/doc/.gitignore
new file mode 100644
index 0000000..e65ac3a
--- /dev/null
+++ b/os/doc/.gitignore
@@ -0,0 +1,1 @@
+copyright
\ No newline at end of file
diff --git a/tools/README.md b/tools/README.md
new file mode 100644
index 0000000..7c9cb11
--- /dev/null
+++ b/tools/README.md
@@ -0,0 +1,1 @@
+# Tools of our trade
diff --git a/tools/notice2copyright/README.md b/tools/notice2copyright/README.md
new file mode 100644
index 0000000..3591971
--- /dev/null
+++ b/tools/notice2copyright/README.md
@@ -0,0 +1,47 @@
+# notice2copyright
+
+## About
+
+Parse a `NOTICE` file to generate the [debian copyright file](https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/).
+
+## Build
+
+```sh
+go build
+```
+
+## Usage
+
+```sh
+notice2copyright path/to/github.com/user/project "MIT License (Expat)"
+```
+
+## Assumptions
+
+* Every dependency is licensed under one license known to this tool.
+* The project has a `NOTICE` file in the following format:
+  ```text
+  ...
+  
+  ========================================================================
+  Apache License 2.0 (Apache-2.0)
+  ========================================================================
+  
+  The following software have components provided under the terms of this license:
+  
+  - cobra (from https://github.com/spf13/cobra)
+
+  ========================================================================
+  MIT License (Expat)
+  ========================================================================
+  
+  The following software have components provided under the terms of this license:
+  
+  - color (from https://github.com/fatih/color)
+
+  ...
+  ```
+  The file needs to reside at the top-level directory of the project.
+* Vendoring. All dependencies need to be in the vendor folder including their `LICENSE` files.
+  A dependency referenced via `- cobra (from https://github.com/spf13/cobra)` in the `NOTICE`
+  file has to be present in the vendor folder as `vendor/github.com/spf13/cobra)`.
diff --git a/tools/notice2copyright/assert.go b/tools/notice2copyright/assert.go
new file mode 100644
index 0000000..440895b
--- /dev/null
+++ b/tools/notice2copyright/assert.go
@@ -0,0 +1,16 @@
+package main
+
+import (
+	"fmt"
+	"log"
+)
+
+func assertOrFatal(val bool, f string, v ...interface{}) {
+	assertTrue(log.Fatalln, val, fmt.Sprintf(f, v...))
+}
+
+func assertTrue(fat func(v ...interface{}), val bool, f string) {
+	if !val {
+		fat(f)
+	}
+}
diff --git a/tools/notice2copyright/copyright_holder.go b/tools/notice2copyright/copyright_holder.go
new file mode 100644
index 0000000..eba116b
--- /dev/null
+++ b/tools/notice2copyright/copyright_holder.go
@@ -0,0 +1,67 @@
+package main
+
+import (
+	"bufio"
+	"fmt"
+	"io"
+	"os"
+	"path/filepath"
+	"regexp"
+	"strings"
+)
+
+var copyrightRegex = regexp.MustCompile(`^>*\s*[Cc]opyright\s(\([Cc]\)|©)*(?P<CLINE>.*)`)
+
+var copyrightNoAuthorRegex = regexp.MustCompile(`([Cc]opyright notice|[Cc]opyright license to reproduce)`)
+
+func findCopyrightHolders(projects []project, dir string) []project {
+	withCpHolders := make([]project, len(projects))
+	for i, p := range projects {
+		withCpHolders[i] = findCopyrightHoldersOf(p, dir)
+	}
+	return withCpHolders
+}
+
+func findCopyrightHoldersOf(p project, dir string) project {
+	srcDir := filepath.Join(dir, p.dir())
+	lf, err := openFirst(srcDir, "LICENSE", "LICENSE.md", "LICENSE.txt", "LICENSE.rst")
+	assertOrFatal(err == nil, "unable to open license file for project '%s' (%s): %v", p.name, p.url, err)
+	defer lf.Close()
+	p.copyrightHolders = readCopyrightHolders(lf)
+	return p
+}
+
+func openFirst(dir string, names ...string) (*os.File, error) {
+	for _, name := range names {
+		f, err := os.Open(filepath.Join(dir, name))
+		if err == nil {
+			return f, nil
+		}
+	}
+	return nil, fmt.Errorf("no license file [%s] found in directory '%s'", names, dir)
+}
+
+func readCopyrightHolders(r io.Reader) []string {
+	var holders []string
+	s := bufio.NewScanner(r)
+	for s.Scan() {
+		line := s.Text()
+		holders = addCopyRightHolder(line, holders)
+	}
+	if len(holders) == 0 {
+		holders = append(holders, "unknown")
+	}
+	return holders
+}
+
+func addCopyRightHolder(text string, holders []string) []string {
+	if copyrightRegex.MatchString(text) && !copyrightNoAuthorRegex.MatchString(text) {
+		holders = append(holders, parseCopyrightHolder(text))
+	}
+	return holders
+}
+
+func parseCopyrightHolder(text string) string {
+	matches := copyrightRegex.FindStringSubmatch(text)
+	return strings.TrimSpace(matches[len(matches)-1])
+}
diff --git a/tools/notice2copyright/copyright_writer.go b/tools/notice2copyright/copyright_writer.go
new file mode 100644
index 0000000..1e5282d
--- /dev/null
+++ b/tools/notice2copyright/copyright_writer.go
@@ -0,0 +1,94 @@
+package main
+
+import (
+	"bufio"
+	"bytes"
+	"fmt"
+	"io"
+	"sort"
+	"strings"
+)
+
+type copyrightWriter struct {
+	root project
+	deps []project
+}
+
+func (c *copyrightWriter) writeTo(w io.Writer) {
+	c.writeHead(w)
+	c.writeProject(w, c.root)
+	c.writeDeps(w)
+	c.writeLics(w)
+}
+
+func (c *copyrightWriter) writeHead(w io.Writer) {
+	fmt.Fprintln(w, "Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/")
+	fmt.Fprintf(w, "Upstream-Name: %s\n", c.root.name)
+	fmt.Fprintf(w, "Source: %s\n", c.root.url)
+	fmt.Fprintln(w, "")
+}
+
+func (c *copyrightWriter) writeProject(w io.Writer, p project) {
+	if p.isRoot {
+		fmt.Fprintln(w, "Files: *")
+	} else {
+		fmt.Fprintf(w, "Files: %s/*\n", p.dir())
+	}
+
+	fmt.Fprintf(w, "Copyright: %s\n", p.copyrightHolders[0])
+	for _, other := range p.copyrightHolders[1:] {
+		fmt.Fprintf(w, "           %s\n", other)
+	}
+	fmt.Fprintf(w, "License: %s\n\n", p.license.shortName)
+}
+
+func (c *copyrightWriter) writeDeps(w io.Writer) {
+	sort.Slice(c.deps, func(i, j int) bool {
+		return c.deps[i].name < c.deps[j].name
+	})
+	for _, d := range c.deps {
+		c.writeProject(w, d)
+	}
+}
+
+func (c *copyrightWriter) writeLics(w io.Writer) {
+	m := c.licSet()
+	allLics := c.sortByShortName(m)
+	for _, l := range allLics {
+		c.writeLic(w, l)
+	}
+}
+
+func (c *copyrightWriter) licSet() map[license]interface{} {
+	m := make(map[license]interface{})
+	m[c.root.license] = nil
+	for _, p := range c.deps {
+		m[p.license] = nil
+	}
+	return m
+}
+
+func (c *copyrightWriter) sortByShortName(m map[license]interface{}) []license {
+	allLics := make([]license, 0)
+	for l := range m {
+		allLics = append(allLics, l)
+	}
+	sort.Slice(allLics, func(i, j int) bool {
+		return allLics[i].shortName < allLics[j].shortName
+	})
+	return allLics
+}
+
+func (c *copyrightWriter) writeLic(w io.Writer, l license) {
+	fmt.Printf("License: %s\n", l.shortName)
+	s := bufio.NewScanner(bytes.NewBufferString(l.text))
+	for s.Scan() {
+		line := s.Text()
+		if strings.TrimSpace(line) == "" {
+			fmt.Fprintln(w, " .")
+		} else {
+			fmt.Fprintf(w, " %s\n", line)
+		}
+	}
+	fmt.Fprintln(w, "")
+}
diff --git a/tools/notice2copyright/license.go b/tools/notice2copyright/license.go
new file mode 100644
index 0000000..e16cebb
--- /dev/null
+++ b/tools/notice2copyright/license.go
@@ -0,0 +1,126 @@
+package main
+
+type license struct {
+	name      string
+	shortName string
+	text      string
+}
+
+var (
+	mit = license{
+		shortName: "Expat",
+		name:      "MIT License (Expat)",
+		text: `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, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+`}
+	apache2 = license{
+		shortName: "Apache-2.0",
+		name:      "Apache License 2.0 (Apache-2.0)",
+		text: `Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+     http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+
+On Debian systems, the complete text of the Apache 2.0 License
+can be found in /usr/share/common-licenses/Apache-2.0 file.
+`}
+
+	isc = license{
+		shortName: "ISC",
+		name:      "ISC License",
+		text: `Permission to use, copy, modify, and 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.
+
+THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+`}
+
+	bsd3 = license{
+		shortName: "BSD-3-clause",
+		name:      "BSD 3-Clause License (Revised)",
+		text: `Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+* Redistributions of source code must retain the above copyright
+  notice, this list of conditions and the following disclaimer.
+* Redistributions in binary form must reproduce the above
+  copyright notice, this list of conditions and the following disclaimer
+  in the documentation and/or other materials provided with the
+  distribution.
+* Neither the name of Google Inc. nor the names of its
+  contributors may be used to endorse or promote products derived from
+  this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+`}
+
+	bsd2 = license{
+		shortName: "BSD-2-clause",
+		name:      "BSD 2-Clause License (FreeBSD/Simplified)",
+		text: `Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+* Redistributions of source code must retain the above copyright notice, this
+  list of conditions and the following disclaimer.
+
+* Redistributions in binary form must reproduce the above copyright notice,
+  this list of conditions and the following disclaimer in the documentation
+  and/or other materials provided with the distribution.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+`}
+)
+
+var licenses = map[string]license{
+	"Apache License 2.0 (Apache-2.0)":           apache2,
+	"MIT License (Expat)":                       mit,
+	"ISC License":                               isc,
+	"BSD 3-Clause License (Revised)":            bsd3,
+	"BSD 2-Clause License (FreeBSD/Simplified)": bsd2,
+}
diff --git a/tools/notice2copyright/main.go b/tools/notice2copyright/main.go
new file mode 100644
index 0000000..31803c9
--- /dev/null
+++ b/tools/notice2copyright/main.go
@@ -0,0 +1,38 @@
+package main
+
+import (
+	"fmt"
+	"os"
+	"path/filepath"
+	"strings"
+)
+
+func main() {
+	assertOrFatal(len(os.Args) >= 3, "usage notice2copyright <root project directory> <root project license>")
+	root := parseRoot(os.Args[1])
+	deps := parseNotice(filepath.Join(os.Args[1], "NOTICE"))
+	deps = findCopyrightHolders(deps, filepath.Dir(os.Args[1]))
+	writer := copyrightWriter{root: root, deps: deps}
+	writer.writeTo(os.Stdout)
+}
+
+func parseRoot(dir string) project {
+	abs, err := filepath.Abs(dir)
+	assertOrFatal(err == nil, "cannot determine rot project dir: %v", err)
+	dirs := strings.Split(abs, string(filepath.Separator))
+	root := parseRootFromDirHierarchy(dirs)
+	return findCopyrightHoldersOf(root, dir)
+}
+
+func parseRootFromDirHierarchy(dirs []string) project {
+	assertOrFatal(len(dirs) >= 3, "cannot determine root project: project should reside in a folder like '.../github.com/user/project', got instead %s", dirs)
+	name, user, host := dirs[len(dirs)-1], dirs[len(dirs)-2], dirs[len(dirs)-3]
+	url := fmt.Sprintf("https://%s/%s/%s", host, user, name)
+	p := project{
+		url:     url,
+		name:    name,
+		license: licenses[os.Args[2]],
+		isRoot:  true,
+	}
+	return p
+}
diff --git a/tools/notice2copyright/notice.go b/tools/notice2copyright/notice.go
new file mode 100644
index 0000000..20c3b16
--- /dev/null
+++ b/tools/notice2copyright/notice.go
@@ -0,0 +1,52 @@
+package main
+
+import (
+	"bufio"
+	"bytes"
+	"io"
+	"io/ioutil"
+	"os"
+	"regexp"
+)
+
+var projectRegex = regexp.MustCompile(`\s*-*\s*(?P<NAME>\S+)\s*\(\s*from\s*(?P<URL>\S+)\).*`)
+
+func parseNotice(name string) []project {
+	s := readNotice(name)
+	projects := collectProjects(bytes.NewBuffer(s))
+	return projects
+}
+
+func readNotice(name string) []byte {
+	f, err := os.Open(name)
+	assertOrFatal(err == nil, "failed to open '%s': %v", name, err)
+	defer f.Close()
+	s, err := ioutil.ReadAll(f)
+	assertOrFatal(err == nil, "unable to read '%s: %v", name, err)
+	return s
+}
+
+func collectProjects(r io.Reader) []project {
+	s := bufio.NewScanner(r)
+	var projects []project
+	var curLic license
+	for s.Scan() {
+		text := s.Text()
+		updateCurrentLicense(text, &curLic)
+		projects = addProject(projects, text, curLic)
+	}
+	return projects
+}
+
+func addProject(projects []project, text string, lic license) []project {
+	if tokens := projectRegex.FindStringSubmatch(text); len(tokens) == 3 {
+		projects = append(projects, project{license: lic, name: tokens[1], url: tokens[2]})
+	}
+	return projects
+}
+
+func updateCurrentLicense(text string, current *license) {
+	if lic, ok := licenses[text]; ok {
+		*current = lic
+	}
+}
diff --git a/tools/notice2copyright/project.go b/tools/notice2copyright/project.go
new file mode 100644
index 0000000..a19078d
--- /dev/null
+++ b/tools/notice2copyright/project.go
@@ -0,0 +1,27 @@
+package main
+
+import (
+	"net/url"
+	"path/filepath"
+)
+
+// codebeat:disable[TOO_MANY_IVARS]
+
+type project struct {
+	name             string
+	url              string
+	license          license
+	copyrightHolders []string
+	isRoot           bool
+}
+
+// codebeat:enable[TOO_MANY_IVARS]
+
+func (p *project) dir() string {
+	if p.isRoot {
+		return "."
+	}
+	u, err := url.Parse(p.url)
+	assertOrFatal(err == nil, "unable to parse URL for project '%s' (%s): %v", p.name, p.url, err)
+	return filepath.Join("vendor", u.Host, filepath.FromSlash(u.Path))
+}


Test patch:
diff --git a/cmd/jsonapi/converter_test.go b/cmd/jsonapi/converter_test.go
index 7ce31c0..72c0d2d 100644
--- a/cmd/jsonapi/converter_test.go
+++ b/cmd/jsonapi/converter_test.go
@@ -39,7 +39,7 @@ func problematicPlungerHkr() fritz.Device {
 	return fritz.Device{
 		Name:            "myhkrwitherr",
 		Functionbitmask: "320",
-		Thermostat:      fritz.Thermostat{ErrorCode: "2", DeviceLock: "1", BatteryLow: "1"},
+		Thermostat:      fritz.Thermostat{ErrorCode: "2", DeviceLock: "1"},
 	}
 }
 
diff --git a/tools/notice2copyright/assert_test.go b/tools/notice2copyright/assert_test.go
new file mode 100644
index 0000000..9d0ba13
--- /dev/null
+++ b/tools/notice2copyright/assert_test.go
@@ -0,0 +1,19 @@
+package main
+
+import (
+	"log"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func Test_assertTrue(t *testing.T) {
+	assert.NotPanics(t, func() {
+		assertTrue(log.Fatalln, true, "err")
+	})
+	assert.Panics(t, func() {
+		assertTrue(func(v ...interface{}) {
+			panic("panic")
+		}, false, "err")
+	})
+}
diff --git a/tools/notice2copyright/copyright_holder_test.go b/tools/notice2copyright/copyright_holder_test.go
new file mode 100644
index 0000000..fd3348d
--- /dev/null
+++ b/tools/notice2copyright/copyright_holder_test.go
@@ -0,0 +1,19 @@
+package main
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func Test_openFirst(t *testing.T) {
+	_, err := openFirst("/that/should/not/work/ever", "asdfagag.txt", "asasgdsg.md")
+	assert.Error(t, err)
+}
+
+func Test_parseCopyrightHolder(t *testing.T) {
+	assert.Equal(t, "2016 bpicode", parseCopyrightHolder("Copyright (c) 2016 bpicode"))
+	assert.Equal(t, "John Doe <john.d@example.com>", parseCopyrightHolder("Copyright (c) John Doe <john.d@example.com>"))
+	assert.Equal(t, "2009 The Go Authors. All rights reserved.", parseCopyrightHolder("Copyright (c) 2009 The Go Authors. All rights reserved."))
+	assert.Equal(t, "2011 John Doe <john.d@example.com>", parseCopyrightHolder("Copyright © 2011 John Doe <john.d@example.com>"))
+}
diff --git a/tools/notice2copyright/main_test.go b/tools/notice2copyright/main_test.go
new file mode 100644
index 0000000..440c683
--- /dev/null
+++ b/tools/notice2copyright/main_test.go
@@ -0,0 +1,15 @@
+package main
+
+import (
+	"os"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func Test_main(t *testing.T) {
+	argsOrig := os.Args[:]
+	defer func() { os.Args = argsOrig }()
+	os.Args = []string{"notice2copyright", "../../", "MIT License (Expat)"}
+	assert.NotPanics(t, main)
+}
diff --git a/tools/notice2copyright/notice_test.go b/tools/notice2copyright/notice_test.go
new file mode 100644
index 0000000..3b0333e
--- /dev/null
+++ b/tools/notice2copyright/notice_test.go
@@ -0,0 +1,13 @@
+package main
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func Test_projectRegex(t *testing.T) {
+	s := "- cobra (from https://github.com/spf13/cobra)"
+	matches := projectRegex.FindStringSubmatch(s)
+	assert.Len(t, matches, 3)
+}