Skip to content

Commit

Permalink
Merge pull request #66 from fossas/feat/report-command
Browse files Browse the repository at this point in the history
Add `fossa report`
  • Loading branch information
elldritch committed Mar 6, 2018
2 parents 5635878 + 835c014 commit 9e3bf98
Show file tree
Hide file tree
Showing 7 changed files with 222 additions and 41 deletions.
2 changes: 1 addition & 1 deletion .fossa.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
version: 1
cli:
server: https://app.fossa.io
project: git@github.com:fossas/fossa-cli.git
project: github.com/fossas/fossa-cli
fetcher: git
analyze:
modules:
Expand Down
12 changes: 7 additions & 5 deletions cmd/fossa/common.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,17 +49,19 @@ func makeAPIRequest(method, endpoint, apiKey string, payload []byte) ([]byte, er
}
defer resp.Body.Close()

body, err := ioutil.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("could not read API HTTP response: %s", err.Error())
}

if resp.StatusCode == http.StatusForbidden {
commonLogger.Debugf("Response body: %s", string(body))
return nil, fmt.Errorf("invalid API key %#v (try setting $FOSSA_API_KEY); get one at https://fossa.io", apiKey)
} else if resp.StatusCode != http.StatusOK {
commonLogger.Debugf("Response body: %s", string(body))
return nil, fmt.Errorf("bad server response: %d", resp.StatusCode)
}

body, err := ioutil.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("could not read API HTTP response: %s", err.Error())
}
commonLogger.Debugf("Got API response: %#v", string(body))

return body, nil
}
14 changes: 14 additions & 0 deletions cmd/fossa/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,20 @@ func main() {
cli.BoolFlag{Name: "debug", Usage: debugUsage},
},
},
{
Name: "report",
Usage: "Generates a license report",
Action: reportCmd,
Flags: []cli.Flag{
cli.StringFlag{Name: "c, config", Usage: configUsage},
cli.StringFlag{Name: "fetcher", Usage: fetcherUsage},
cli.StringFlag{Name: "p, project", Usage: projectUsage},
cli.StringFlag{Name: "r, revision", Usage: revisionUsage},
cli.StringFlag{Name: "e, endpoint", Usage: endpointUsage},
cli.BoolFlag{Name: "debug", Usage: debugUsage},
cli.StringFlag{Name: "t, type", Usage: "the type of report to generate (either \"dependencies\" or \"licenses\""},
},
},
}

app.Run(os.Args)
Expand Down
167 changes: 167 additions & 0 deletions cmd/fossa/report.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
package main

import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"strings"
"time"

"github.com/briandowns/spinner"
logging "github.com/op/go-logging"
"github.com/urfave/cli"

"github.com/fossas/fossa-cli/config"
"github.com/fossas/fossa-cli/module"
)

var reportLogger = logging.MustGetLogger("report")

type dependencyResponse struct {
Loc struct {
Package string
Revision string
}
Licenses []licenseResponse
Project struct {
Title string
URL string
Authors []string
}
}

type licenseResponse struct {
ID string `json:"spdx_id"`
Title string
FullText string
}

func getRevisions(apiURL string, apiKey string, locators []string) ([]dependencyResponse, error) {
qs := strings.Join(locators, "&")

res, err := makeAPIRequest(http.MethodGet, apiURL+"?"+qs, apiKey, nil)
if err != nil {
return nil, fmt.Errorf("Could not get licenses from FOSSA API: %s", err.Error())
}
var deps []dependencyResponse
err = json.Unmarshal(res, &deps)
if err != nil {
return nil, fmt.Errorf("Could not parse API response: %s", err.Error())
}
return deps, nil
}

func reportLicenses(s *spinner.Spinner, endpoint, apiKey string, a analysis) {
server, err := url.Parse(endpoint)
if err != nil {
reportLogger.Fatalf("Invalid FOSSA endpoint: %s", err.Error())
}
api, err := server.Parse(fmt.Sprintf("/api/revisions"))
if err != nil {
reportLogger.Fatalf("Invalid API endpoint: %s", err.Error())
}

s.Suffix = " Loading licenses..."
s.Start()
total := 0
for _, deps := range a {
for _ = range deps {
total++
}
}
var locators []string
var responses []dependencyResponse
for _, deps := range a {
for _, dep := range deps {
if dep.Revision() != "" {
locator := string(module.MakeLocator(dep))
if dep.Fetcher() == "go" {
locator = config.MakeLocator("git", dep.Package(), dep.Revision())
}
locators = append(locators, fmt.Sprintf("locator[%d]=%s", len(locators), url.QueryEscape(locator)))

// We batch these in groups of 20 for pagination/performance purposes.
// TODO: do this in parallel.
if len(locators) == 20 {
responsePage, err := getRevisions(api.String(), apiKey, locators)
if err != nil {
s.Stop()
reportLogger.Fatalf("Could load licenses: %s", err.Error())
}
responses = append(responses, responsePage...)
locators = []string{}
s.Stop()
s.Suffix = fmt.Sprintf(" Loading licenses (%d/%d done)...", len(responses), total)
s.Restart()
}
}
}
}
if len(locators) > 0 {
responsePage, err := getRevisions(api.String(), apiKey, locators)
if err != nil {
s.Stop()
reportLogger.Fatalf("Could load licenses: %s", err.Error())
}
responses = append(responses, responsePage...)
}
s.Stop()

depsByLicense := make(map[licenseResponse][]dependencyResponse)
for _, dep := range responses {
for _, license := range dep.Licenses {
depsByLicense[license] = append(depsByLicense[license], dep)
}
}

fmt.Printf("This software includes the following software and licenses:\n\n")
for license, deps := range depsByLicense {
fmt.Printf(`
========================================================================
%s
========================================================================
The following software have components provided under the terms of this license:
`, license.Title)
for _, dep := range deps {
fmt.Printf("- %s (from %s)\n", dep.Project.Title, dep.Project.URL)
}
}
fmt.Println()
}

func reportCmd(c *cli.Context) {
conf, err := config.New(c)
if err != nil {
reportLogger.Fatalf("Could not load configuration: %s", err.Error())
}

s := spinner.New(spinner.CharSets[11], 100*time.Millisecond)

s.Suffix = " Analyzing modules..."
s.Start()
analysis, err := doAnalyze(conf.Modules, conf.AnalyzeCmd.AllowUnresolved)
s.Stop()
if err != nil {
reportLogger.Fatalf("Could not complete analysis (is the project built?): %s", err.Error())
}

switch conf.ReportCmd.Type {
case "licenses":
reportLicenses(s, conf.Endpoint, conf.APIKey, analysis)
case "dependencies":
outMap := make(map[string][]module.Dependency)
for key, deps := range analysis {
outMap[key.module.Name] = deps
}
out, err := json.Marshal(outMap)
if err != nil {
reportLogger.Fatalf("Could not marshal analysis: %s", err.Error())
}
fmt.Println(string(out))
default:
reportLogger.Fatalf("Report type is not recognized (supported types are \"dependencies\" or \"licenses\": %s", conf.ReportCmd.Type)
}
}
32 changes: 9 additions & 23 deletions cmd/fossa/upload.go
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
package main

import (
"bytes"
"encoding/json"
"fmt"
"io/ioutil"
"net/http"
"net/url"
"path/filepath"
Expand Down Expand Up @@ -170,33 +168,21 @@ func doUpload(conf config.CLIConfig, results []normalizedModule) (string, error)

analysisLogger.Debugf("Sending build data to <%#v>", postURL)

req, _ := http.NewRequest("POST", postURL, bytes.NewReader(buildData))
req.Close = true
req.Header.Set("Authorization", "token "+conf.APIKey)
req.Header.Set("Content-Type", "application/json")

resp, err := http.DefaultClient.Do(req)
res, err := makeAPIRequest(http.MethodPost, postURL, conf.APIKey, buildData)
if err != nil {
return "", fmt.Errorf("could not begin upload: %s", err.Error())
}
defer resp.Body.Close()
responseBytes, _ := ioutil.ReadAll(resp.Body)
responseStr := string(responseBytes)

if resp.StatusCode == http.StatusForbidden {
return "", fmt.Errorf("invalid API key (check the $FOSSA_API_KEY environment variable); get one at https://fossa.io")
} else if resp.StatusCode == http.StatusPreconditionRequired {
// TODO: handle "Managed Project" workflow
return "", fmt.Errorf("invalid project or revision; make sure this version is published and FOSSA has access to your repo. To submit a custom project, set Fetcher to `custom` in `.fossa.yml`")
} else if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("bad server response (%#v)", responseStr)
// HACK: really, we should be exporting this error and comparing against it
if err.Error() == "bad server response: 428" {
// TODO: handle "Managed Project" workflow
return "", fmt.Errorf("invalid project or revision; make sure this version is published and FOSSA has access to your repo (to submit a custom project, set Fetcher to `custom` in `.fossa.yml`)")
}
return "", fmt.Errorf("could not upload: %s", err.Error())
}

analysisLogger.Debugf("Upload succeeded")
analysisLogger.Debugf("Response: %#v", responseStr)
analysisLogger.Debugf("Response: %#v", string(res))

var jsonResponse map[string]interface{}
if err := json.Unmarshal(responseBytes, &jsonResponse); err != nil {
if err := json.Unmarshal(res, &jsonResponse); err != nil {
return "", fmt.Errorf("invalid response, but build was uploaded")
}
locParts := strings.Split(jsonResponse["locator"].(string), "$")
Expand Down
20 changes: 15 additions & 5 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,33 +12,38 @@ import (

var configLogger = logging.MustGetLogger("config")

// DefaultConfig specifies the config for the default cmd
// DefaultConfig specifies the config for the default command
type DefaultConfig struct {
Build bool
}

// AnalyzeConfig specifies the config for the analyze cmd
// AnalyzeConfig specifies the config for the analyze command
type AnalyzeConfig struct {
Output bool
AllowUnresolved bool
}

// BuildConfig specifies the config for the build cmd
// BuildConfig specifies the config for the build command
type BuildConfig struct {
Force bool
}

// TestConfig specifies the config for the test cmd
// TestConfig specifies the config for the test command
type TestConfig struct {
Timeout time.Duration
}

// UploadConfig specifies the config for the upload cmd
// UploadConfig specifies the config for the upload command
type UploadConfig struct {
Locators bool
Data string
}

// ReportConfig specifies the config for the report command
type ReportConfig struct {
Type string // Either "dependencies" or "licenses"
}

// CLIConfig specifies the config available to the cli
type CLIConfig struct {
APIKey string
Expand All @@ -54,6 +59,7 @@ type CLIConfig struct {
BuildCmd BuildConfig
TestCmd TestConfig
UploadCmd UploadConfig
ReportCmd ReportConfig

ConfigFilePath string
}
Expand Down Expand Up @@ -142,6 +148,10 @@ func New(c *cli.Context) (CLIConfig, error) {
Locators: c.Bool("locators"),
Data: c.String("data"),
},

ReportCmd: ReportConfig{
Type: c.String("type"),
},
}

// Load configuration file and set overrides.
Expand Down
16 changes: 9 additions & 7 deletions docs/user-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -342,19 +342,21 @@ Print debugging information to `stderr`.
##### `-h, --help`
Print a help message, then exit.
<!-- ### `fossa report`
### `fossa report`
Print the project's license report.
#### Flags
##### `-t, --type report_type`
Print a specific type of license report for automatically creating attribution files. Possible report types include:
- `NOTICE`: generate a `NOTICE` file for your dependencies
- `ATTRIBUTION`: generate an `ATTRIBUTION` file for your dependencies
##### `-j, --json`
<!-- ##### `-j, --json`
Print the report data in JSON format.
-->
##### `--debug`
Print debugging information to `stderr`.
##### `-h, --help`
Print a help message, then exit.
### `fossa test`
#### Example
Expand Down

0 comments on commit 9e3bf98

Please sign in to comment.