Repository: mmcloughlin/avo
Pull request #223 (2021-11-07 23:56:11)

tests/thirdparty: package metadata
Fetches third-party package metadata from Github.

codecov-commenter: # [Codecov](https://codecov.io/gh/mmcloughlin/avo/pull/223?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Michael+McLoughlin) Report
> Merging [#223](https://codecov.io/gh/mmcloughlin/avo/pull/223?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Michael+McLoughlin) (29424a9) into [master](https://codecov.io/gh/mmcloughlin/avo/commit/afe2d539b865c6145b2cffb2b40066bfd3948767?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Michael+McLoughlin) (afe2d53) will **decrease** coverage by `0.12%`.
> The diff coverage is `25.30%`.

[![Impacted file tree graph](https://codecov.io/gh/mmcloughlin/avo/pull/223/graphs/tree.svg?width=650&height=150&src=pr&token=DaOLKxzdQS&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Michael+McLoughlin)](https://codecov.io/gh/mmcloughlin/avo/pull/223?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Michael+McLoughlin)

```diff
@@            Coverage Diff             @@
##           master     #223      +/-   ##
==========================================
- Coverage   75.64%   75.52%   -0.13%     
==========================================
  Files          57       58       +1     
  Lines       35132    35209      +77     
==========================================
+ Hits        26577    26592      +15     
- Misses       8402     8464      +62     
  Partials      153      153              
```

| Flag | Coverage Δ | |
|---|---|---|
| integration | `8.81% <ø> (ø)` | |
| unittests | `73.18% <25.30%> (-0.12%)` | :arrow_down: |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Michael+McLoughlin#carryforward-flags-in-the-pull-request-comment) to find out more.

| [Impacted Files](https://codecov.io/gh/mmcloughlin/avo/pull/223?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Michael+McLoughlin) | Coverage Δ | |
|---|---|---|
| [internal/github/client.go](https://codecov.io/gh/mmcloughlin/avo/pull/223/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Michael+McLoughlin#diff-aW50ZXJuYWwvZ2l0aHViL2NsaWVudC5nbw==) | `0.00% <0.00%> (ø)` | |
| [tests/thirdparty/config.go](https://codecov.io/gh/mmcloughlin/avo/pull/223/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Michael+McLoughlin#diff-dGVzdHMvdGhpcmRwYXJ0eS9jb25maWcuZ28=) | `91.48% <75.00%> (-4.31%)` | :arrow_down: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/mmcloughlin/avo/pull/223?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Michael+McLoughlin).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Michael+McLoughlin)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/mmcloughlin/avo/pull/223?src=pr&el=footer&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Michael+McLoughlin). Last update [afe2d53...29424a9](https://codecov.io/gh/mmcloughlin/avo/pull/223?src=pr&el=lastupdated&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Michael+McLoughlin). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Michael+McLoughlin).


Patch:
diff --git a/.github/workflows/thirdparty.yml b/.github/workflows/thirdparty.yml
index 2e773c3..6e90f91 100644
--- a/.github/workflows/thirdparty.yml
+++ b/.github/workflows/thirdparty.yml
@@ -31,4 +31,4 @@ jobs:
           persist-credentials: false
       - name: Run Third-Party Tests
         working-directory: tests/thirdparty
-        run: go test -pkgs packages.json
+        run: go test -net -pkgs packages.json
diff --git a/internal/github/client.go b/internal/github/client.go
new file mode 100644
index 0000000..84fb2fa
--- /dev/null
+++ b/internal/github/client.go
@@ -0,0 +1,107 @@
+// Package github provides a client for the Github REST API.
+package github
+
+import (
+	"context"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"net/http"
+	"os"
+)
+
+// Client for the Github REST API.
+type Client struct {
+	client *http.Client
+	base   string
+	token  string
+}
+
+// Option configures a Github client.
+type Option func(*Client)
+
+// WithHTTPClient configures the HTTP client that should be used for Github API
+// requests.
+func WithHTTPClient(h *http.Client) Option {
+	return func(c *Client) { c.client = h }
+}
+
+// WithToken configures a Client with an authentication token for Github API
+// requests.
+func WithToken(token string) Option {
+	return func(c *Client) { c.token = token }
+}
+
+// WithTokenFromEnvironment configures a Client using the GITHUB_TOKEN
+// environment variable.
+func WithTokenFromEnvironment() Option {
+	return WithToken(os.Getenv("GITHUB_TOKEN"))
+}
+
+// NewClient initializes a client using the given HTTP client.
+func NewClient(opts ...Option) *Client {
+	c := &Client{
+		client: http.DefaultClient,
+		base:   "https://api.github.com",
+	}
+	for _, opt := range opts {
+		opt(c)
+	}
+	return c
+}
+
+// Repository gets information about the given Github repository.
+func (c *Client) Repository(ctx context.Context, owner, name string) (*Repository, error) {
+	// Build request.
+	u := c.base + "/repos/" + owner + "/" + name
+	req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
+	if err != nil {
+		return nil, err
+	}
+
+	// Execute.
+	repo := &Repository{}
+	if err := c.request(req, repo); err != nil {
+		return nil, err
+	}
+
+	return repo, nil
+}
+
+func (c *Client) request(req *http.Request, payload interface{}) (err error) {
+	// Add common headers.
+	if c.token != "" {
+		req.Header.Set("Authorization", "Bearer "+c.token)
+	}
+	req.Header.Set("Accept", "application/vnd.github.v3+json")
+
+	// Execute the request.
+	res, err := c.client.Do(req)
+	if err != nil {
+		return err
+	}
+	defer func() {
+		if errc := res.Body.Close(); errc != nil && err == nil {
+			err = errc
+		}
+	}()
+
+	// Check status.
+	if res.StatusCode != http.StatusOK {
+		return fmt.Errorf("http status %d: %s", res.StatusCode, http.StatusText(res.StatusCode))
+	}
+
+	// Parse response body.
+	d := json.NewDecoder(res.Body)
+
+	if err := d.Decode(payload); err != nil {
+		return err
+	}
+
+	// Should not have trailing data.
+	if d.More() {
+		return errors.New("unexpected extra data after JSON")
+	}
+
+	return nil
+}
diff --git a/internal/github/models.go b/internal/github/models.go
new file mode 100644
index 0000000..15553c8
--- /dev/null
+++ b/internal/github/models.go
@@ -0,0 +1,140 @@
+package github
+
+import "time"
+
+// Repository is a github repository.
+type Repository struct {
+	ID               int           `json:"id"`
+	NodeID           string        `json:"node_id"`
+	Name             string        `json:"name"`
+	FullName         string        `json:"full_name"`
+	Private          bool          `json:"private"`
+	Owner            *User         `json:"owner"`
+	HTMLURL          string        `json:"html_url"`
+	Description      string        `json:"description"`
+	Fork             bool          `json:"fork"`
+	URL              string        `json:"url"`
+	ForksURL         string        `json:"forks_url"`
+	KeysURL          string        `json:"keys_url"`
+	CollaboratorsURL string        `json:"collaborators_url"`
+	TeamsURL         string        `json:"teams_url"`
+	HooksURL         string        `json:"hooks_url"`
+	IssueEventsURL   string        `json:"issue_events_url"`
+	EventsURL        string        `json:"events_url"`
+	AssigneesURL     string        `json:"assignees_url"`
+	BranchesURL      string        `json:"branches_url"`
+	TagsURL          string        `json:"tags_url"`
+	BlobsURL         string        `json:"blobs_url"`
+	GitTagsURL       string        `json:"git_tags_url"`
+	GitRefsURL       string        `json:"git_refs_url"`
+	TreesURL         string        `json:"trees_url"`
+	StatusesURL      string        `json:"statuses_url"`
+	LanguagesURL     string        `json:"languages_url"`
+	StargazersURL    string        `json:"stargazers_url"`
+	ContributorsURL  string        `json:"contributors_url"`
+	SubscribersURL   string        `json:"subscribers_url"`
+	SubscriptionURL  string        `json:"subscription_url"`
+	CommitsURL       string        `json:"commits_url"`
+	GitCommitsURL    string        `json:"git_commits_url"`
+	CommentsURL      string        `json:"comments_url"`
+	IssueCommentURL  string        `json:"issue_comment_url"`
+	ContentsURL      string        `json:"contents_url"`
+	CompareURL       string        `json:"compare_url"`
+	MergesURL        string        `json:"merges_url"`
+	ArchiveURL       string        `json:"archive_url"`
+	DownloadsURL     string        `json:"downloads_url"`
+	IssuesURL        string        `json:"issues_url"`
+	PullsURL         string        `json:"pulls_url"`
+	MilestonesURL    string        `json:"milestones_url"`
+	NotificationsURL string        `json:"notifications_url"`
+	LabelsURL        string        `json:"labels_url"`
+	ReleasesURL      string        `json:"releases_url"`
+	DeploymentsURL   string        `json:"deployments_url"`
+	CreatedAt        time.Time     `json:"created_at"`
+	UpdatedAt        time.Time     `json:"updated_at"`
+	PushedAt         time.Time     `json:"pushed_at"`
+	GitURL           string        `json:"git_url"`
+	SSHURL           string        `json:"ssh_url"`
+	CloneURL         string        `json:"clone_url"`
+	SvnURL           string        `json:"svn_url"`
+	Homepage         string        `json:"homepage"`
+	Size             int           `json:"size"`
+	StargazersCount  int           `json:"stargazers_count"`
+	WatchersCount    int           `json:"watchers_count"`
+	Language         string        `json:"language"`
+	HasIssues        bool          `json:"has_issues"`
+	HasProjects      bool          `json:"has_projects"`
+	HasDownloads     bool          `json:"has_downloads"`
+	HasWiki          bool          `json:"has_wiki"`
+	HasPages         bool          `json:"has_pages"`
+	ForksCount       int           `json:"forks_count"`
+	MirrorURL        string        `json:"mirror_url"`
+	Archived         bool          `json:"archived"`
+	Disabled         bool          `json:"disabled"`
+	OpenIssuesCount  int           `json:"open_issues_count"`
+	License          *License      `json:"license"`
+	AllowForking     bool          `json:"allow_forking"`
+	IsTemplate       bool          `json:"is_template"`
+	Topics           []string      `json:"topics"`
+	Visibility       string        `json:"visibility"`
+	Forks            int           `json:"forks"`
+	OpenIssues       int           `json:"open_issues"`
+	Watchers         int           `json:"watchers"`
+	DefaultBranch    string        `json:"default_branch"`
+	Organization     *Organization `json:"organization"`
+	NetworkCount     int           `json:"network_count"`
+	SubscribersCount int           `json:"subscribers_count"`
+}
+
+// User is a Github user.
+type User struct {
+	Login             string `json:"login"`
+	ID                int    `json:"id"`
+	NodeID            string `json:"node_id"`
+	AvatarURL         string `json:"avatar_url"`
+	GravatarID        string `json:"gravatar_id"`
+	URL               string `json:"url"`
+	HTMLURL           string `json:"html_url"`
+	FollowersURL      string `json:"followers_url"`
+	FollowingURL      string `json:"following_url"`
+	GistsURL          string `json:"gists_url"`
+	StarredURL        string `json:"starred_url"`
+	SubscriptionsURL  string `json:"subscriptions_url"`
+	OrganizationsURL  string `json:"organizations_url"`
+	ReposURL          string `json:"repos_url"`
+	EventsURL         string `json:"events_url"`
+	ReceivedEventsURL string `json:"received_events_url"`
+	Type              string `json:"type"`
+	SiteAdmin         bool   `json:"site_admin"`
+}
+
+// Organization is a Github organization.
+type Organization struct {
+	Login             string `json:"login"`
+	ID                int    `json:"id"`
+	NodeID            string `json:"node_id"`
+	AvatarURL         string `json:"avatar_url"`
+	GravatarID        string `json:"gravatar_id"`
+	URL               string `json:"url"`
+	HTMLURL           string `json:"html_url"`
+	FollowersURL      string `json:"followers_url"`
+	FollowingURL      string `json:"following_url"`
+	GistsURL          string `json:"gists_url"`
+	StarredURL        string `json:"starred_url"`
+	SubscriptionsURL  string `json:"subscriptions_url"`
+	OrganizationsURL  string `json:"organizations_url"`
+	ReposURL          string `json:"repos_url"`
+	EventsURL         string `json:"events_url"`
+	ReceivedEventsURL string `json:"received_events_url"`
+	Type              string `json:"type"`
+	SiteAdmin         bool   `json:"site_admin"`
+}
+
+// License is an open source license.
+type License struct {
+	Key    string `json:"key"`
+	Name   string `json:"name"`
+	SPDXID string `json:"spdx_id"`
+	URL    string `json:"url"`
+	NodeID string `json:"node_id"`
+}


Test patch:
diff --git a/internal/github/client_test.go b/internal/github/client_test.go
new file mode 100644
index 0000000..2bb54b7
--- /dev/null
+++ b/internal/github/client_test.go
@@ -0,0 +1,27 @@
+package github
+
+import (
+	"context"
+	"encoding/json"
+	"net/http"
+	"testing"
+
+	"github.com/mmcloughlin/avo/internal/test"
+)
+
+func TestClientRepository(t *testing.T) {
+	test.RequiresNetwork(t)
+
+	ctx := context.Background()
+	g := NewClient(WithHTTPClient(http.DefaultClient), WithTokenFromEnvironment())
+	r, err := g.Repository(ctx, "golang", "go")
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	j, err := json.MarshalIndent(r, "", "\t")
+	if err != nil {
+		t.Fatal(err)
+	}
+	t.Logf("repository = %s", j)
+}
diff --git a/internal/test/utils.go b/internal/test/utils.go
index 8b8cc42..1e0d929 100644
--- a/internal/test/utils.go
+++ b/internal/test/utils.go
@@ -2,6 +2,7 @@
 package test
 
 import (
+	"flag"
 	"io"
 	"io/ioutil"
 	"log"
@@ -12,6 +13,17 @@ import (
 	"testing"
 )
 
+var network = flag.Bool("net", false, "allow network access")
+
+// RequiresNetwork declares that a test requires network access. The test is
+// skipped if network access isn't enabled with the -net flag.
+func RequiresNetwork(t *testing.T) {
+	t.Helper()
+	if !*network {
+		t.Skip("requires network: enable with -net flag")
+	}
+}
+
 // Assembles asserts that the given assembly code passes the go assembler.
 func Assembles(t *testing.T, asm []byte) {
 	t.Helper()
diff --git a/tests/thirdparty/config.go b/tests/thirdparty/config.go
index 1e7d0a5..751dc5d 100644
--- a/tests/thirdparty/config.go
+++ b/tests/thirdparty/config.go
@@ -27,11 +27,23 @@ func (r GithubRepository) CloneURL() string {
 	return fmt.Sprintf("https://github.com/%s.git", r)
 }
 
+// Metadata about the repository.
+type Metadata struct {
+	// Repository description.
+	Description string `json:"description,omitempty"`
+
+	// Homepage URL. Not the same as the Github page.
+	Homepage string `json:"homepage,omitempty"`
+
+	// Number of Github stars.
+	Stars int `json:"stars,omitempty"`
+}
+
 // Step represents a set of commands to run as part of the testing plan for a
 // third-party package.
 type Step struct {
-	Name             string   `json:"name"`
-	WorkingDirectory string   `json:"dir"`
+	Name             string   `json:"name,omitempty"`
+	WorkingDirectory string   `json:"dir,omitempty"`
 	Commands         []string `json:"commands"`
 }
 
@@ -52,13 +64,19 @@ type Package struct {
 	// available on github.
 	Repository GithubRepository `json:"repository"`
 
+	// Repository metadata.
+	Metadata Metadata `json:"metadata"`
+
+	// Default git branch. This is used when testing against the latest version.
+	DefaultBranch string `json:"default_branch,omitempty"`
+
 	// Version as a git sha, tag or branch.
 	Version string `json:"version"`
 
 	// Sub-package within the repository under test. All file path references
 	// will be relative to this directory. If empty the root of the repository
 	// is used.
-	SubPackage string `json:"pkg"`
+	SubPackage string `json:"pkg,omitempty"`
 
 	// Path to the module file for the avo generator package. This is necessary
 	// so the integration test can insert replace directives to point at the avo
@@ -68,13 +86,13 @@ type Package struct {
 	// Setup steps. These run prior to the insertion of avo replace directives,
 	// therefore should be used if it's necessary to initialize new go modules
 	// within the repository.
-	Setup []*Step `json:"setup"`
+	Setup []*Step `json:"setup,omitempty"`
 
 	// Steps to run the avo code generator.
 	Generate []*Step `json:"generate"` // generate commands to run
 
 	// Test steps. If empty, defaults to "go test ./...".
-	Test []*Step `json:"test"`
+	Test []*Step `json:"test,omitempty"`
 }
 
 // ID returns an identifier for the package.
@@ -83,9 +101,8 @@ func (p *Package) ID() string {
 	return strings.ReplaceAll(pkgpath, "/", "-")
 }
 
-// setdefaults fills in missing parameters to help make the input package
-// descriptions less verbose.
-func (p *Package) setdefaults() {
+// defaults sets or removes default field values.
+func (p *Package) defaults(set bool) {
 	for _, stage := range []struct {
 		Steps       []*Step
 		DefaultName string
@@ -94,14 +111,28 @@ func (p *Package) setdefaults() {
 		{p.Generate, "Generate"},
 		{p.Test, "Test"},
 	} {
-		if len(stage.Steps) == 1 && stage.Steps[0].Name == "" {
-			stage.Steps[0].Name = stage.DefaultName
+		if len(stage.Steps) == 1 {
+			stage.Steps[0].Name = applydefault(set, stage.Steps[0].Name, stage.DefaultName)
 		}
 	}
 }
 
+func applydefault(set bool, s, def string) string {
+	switch {
+	case set && s == "":
+		return def
+	case !set && s == def:
+		return ""
+	default:
+		return s
+	}
+}
+
 // Validate package definition.
 func (p *Package) Validate() error {
+	if p.DefaultBranch == "" {
+		return errors.New("missing default branch")
+	}
 	if p.Version == "" {
 		return errors.New("missing version")
 	}
@@ -193,9 +224,9 @@ func (p *Package) Steps(c *Context) []*Step {
 // Packages is a collection of third-party integration tests.
 type Packages []*Package
 
-func (p Packages) setdefaults() {
+func (p Packages) defaults(set bool) {
 	for _, pkg := range p {
-		pkg.setdefaults()
+		pkg.defaults(set)
 	}
 }
 
@@ -217,7 +248,7 @@ func LoadPackages(r io.Reader) (Packages, error) {
 	if err := d.Decode(&pkgs); err != nil {
 		return nil, err
 	}
-	pkgs.setdefaults()
+	pkgs.defaults(true)
 	return pkgs, nil
 }
 
@@ -230,3 +261,23 @@ func LoadPackagesFile(filename string) (Packages, error) {
 	defer f.Close()
 	return LoadPackages(f)
 }
+
+// StorePackages writes a list of package configurations in JSON format.
+func StorePackages(w io.Writer, pkgs Packages) error {
+	e := json.NewEncoder(w)
+	e.SetIndent("", "    ")
+	pkgs.defaults(false)
+	err := e.Encode(pkgs)
+	pkgs.defaults(true)
+	return err
+}
+
+// StorePackagesFile writes a list of package configurations to a JSON file.
+func StorePackagesFile(filename string, pkgs Packages) error {
+	f, err := os.Create(filename)
+	if err != nil {
+		return err
+	}
+	defer f.Close()
+	return StorePackages(f, pkgs)
+}
diff --git a/tests/thirdparty/config_test.go b/tests/thirdparty/config_test.go
index 81cc399..6954b65 100644
--- a/tests/thirdparty/config_test.go
+++ b/tests/thirdparty/config_test.go
@@ -1,6 +1,8 @@
 package thirdparty
 
 import (
+	"bytes"
+	"reflect"
 	"strings"
 	"testing"
 )
@@ -24,35 +26,46 @@ func TestValidateErrors(t *testing.T) {
 			ErrorSubstring: "missing commands",
 		},
 		{
-			Name: "package_missing_version",
+			Name: "package_missing_default_branch",
 			Item: &Package{
 				Repository: GithubRepository{Owner: "octocat", Name: "hello-world"},
 			},
+			ErrorSubstring: "missing default branch",
+		},
+		{
+			Name: "package_missing_version",
+			Item: &Package{
+				Repository:    GithubRepository{Owner: "octocat", Name: "hello-world"},
+				DefaultBranch: "main",
+			},
 			ErrorSubstring: "missing version",
 		},
 		{
 			Name: "package_missing_module",
 			Item: &Package{
-				Repository: GithubRepository{Owner: "octocat", Name: "hello-world"},
-				Version:    "v1.0.1",
+				Repository:    GithubRepository{Owner: "octocat", Name: "hello-world"},
+				DefaultBranch: "main",
+				Version:       "v1.0.1",
 			},
 			ErrorSubstring: "missing module",
 		},
 		{
 			Name: "package_no_generate_commands",
 			Item: &Package{
-				Repository: GithubRepository{Owner: "octocat", Name: "hello-world"},
-				Version:    "v1.0.1",
-				Module:     "avo/go.mod",
+				Repository:    GithubRepository{Owner: "octocat", Name: "hello-world"},
+				DefaultBranch: "main",
+				Version:       "v1.0.1",
+				Module:        "avo/go.mod",
 			},
 			ErrorSubstring: "no generate commands",
 		},
 		{
 			Name: "package_invalid_generate_commands",
 			Item: &Package{
-				Repository: GithubRepository{Owner: "octocat", Name: "hello-world"},
-				Version:    "v1.0.1",
-				Module:     "avo/go.mod",
+				Repository:    GithubRepository{Owner: "octocat", Name: "hello-world"},
+				DefaultBranch: "main",
+				Version:       "v1.0.1",
+				Module:        "avo/go.mod",
 				Generate: []*Step{
 					{},
 				},
@@ -66,7 +79,7 @@ func TestValidateErrors(t *testing.T) {
 					Repository: GithubRepository{Owner: "octocat", Name: "hello-world"},
 				},
 			},
-			ErrorSubstring: "missing version",
+			ErrorSubstring: "missing default branch",
 		},
 	}
 	for _, c := range cases {
@@ -134,3 +147,26 @@ func TestPackagesFileStepsValid(t *testing.T) {
 		}
 	}
 }
+
+func TestPackagesFileRoundtrip(t *testing.T) {
+	pkgs, err := LoadPackagesFile("packages.json")
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	// Write and read back.
+	buf := bytes.NewBuffer(nil)
+	if err := StorePackages(buf, pkgs); err != nil {
+		t.Fatal(err)
+	}
+
+	roundtrip, err := LoadPackages(buf)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	// Should be identical.
+	if !reflect.DeepEqual(pkgs, roundtrip) {
+		t.Fatal("roundtrip mismatch")
+	}
+}
diff --git a/tests/thirdparty/metadata_test.go b/tests/thirdparty/metadata_test.go
new file mode 100644
index 0000000..0ed31fb
--- /dev/null
+++ b/tests/thirdparty/metadata_test.go
@@ -0,0 +1,56 @@
+package thirdparty
+
+import (
+	"context"
+	"flag"
+	"testing"
+
+	"github.com/mmcloughlin/avo/internal/github"
+	"github.com/mmcloughlin/avo/internal/test"
+)
+
+var update = flag.Bool("update", false, "update package metadata")
+
+func TestPackagesFileMetadata(t *testing.T) {
+	test.RequiresNetwork(t)
+	ctx := context.Background()
+
+	pkgs, err := LoadPackagesFile("packages.json")
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	g := github.NewClient(github.WithTokenFromEnvironment())
+
+	for _, pkg := range pkgs {
+		// Fetch metadata.
+		r, err := g.Repository(ctx, pkg.Repository.Owner, pkg.Repository.Name)
+		if err != nil {
+			t.Fatal(err)
+		}
+
+		// Update, if requested.
+		if *update {
+			pkg.DefaultBranch = r.DefaultBranch
+			pkg.Metadata.Description = r.Description
+			pkg.Metadata.Homepage = r.Homepage
+			pkg.Metadata.Stars = r.StargazersCount
+
+			t.Logf("%s: metadata updated", pkg.ID())
+		}
+
+		// Check up to date. Potentially fast-changing properties not included.
+		uptodate := true
+		uptodate = pkg.DefaultBranch == r.DefaultBranch && uptodate
+		uptodate = pkg.Metadata.Description == r.Description && uptodate
+		uptodate = pkg.Metadata.Homepage == r.Homepage && uptodate
+
+		if !uptodate {
+			t.Errorf("%s: metadata out of date (use -update flag to fix)", pkg.ID())
+		}
+	}
+
+	if err := StorePackagesFile("packages.json", pkgs); err != nil {
+		t.Fatal(err)
+	}
+}
diff --git a/tests/thirdparty/packages.json b/tests/thirdparty/packages.json
index 57a51d3..feb4b69 100644
--- a/tests/thirdparty/packages.json
+++ b/tests/thirdparty/packages.json
@@ -4,6 +4,11 @@
             "owner": "zeebo",
             "name": "xxh3"
         },
+        "metadata": {
+            "description": "XXH3 algorithm in Go",
+            "stars": 198
+        },
+        "default_branch": "master",
         "version": "v1.0.0-rc1",
         "module": "avo/go.mod",
         "generate": [
@@ -21,6 +26,11 @@
             "owner": "dgryski",
             "name": "go-sip13"
         },
+        "metadata": {
+            "description": "siphash 1-3",
+            "stars": 31
+        },
+        "default_branch": "master",
         "version": "62edffca92457b3a66125c686137cc5f0fe81672",
         "module": "_avo/go.mod",
         "setup": [
@@ -55,6 +65,11 @@
             "owner": "phoreproject",
             "name": "bls"
         },
+        "metadata": {
+            "description": "Go implementation of the BLS12-381 pairing",
+            "stars": 81
+        },
+        "default_branch": "master",
         "version": "a88a5ae26844d7293359422888d7c7f69f43c845",
         "module": "asm/go.mod",
         "setup": [
@@ -90,6 +105,11 @@
             "owner": "minio",
             "name": "md5-simd"
         },
+        "metadata": {
+            "description": "Accelerate aggregated MD5 hashing performance up to 8x for AVX512 and 4x for AVX2. Useful for server applications that need to compute many MD5 sums in parallel.",
+            "stars": 87
+        },
+        "default_branch": "master",
         "version": "30ad8af83f6868c2a30c615f3edf1a9366bf3f89",
         "module": "_gen/go.mod",
         "generate": [
@@ -106,6 +126,11 @@
             "owner": "zeebo",
             "name": "blake3"
         },
+        "metadata": {
+            "description": "Pure Go implementation of BLAKE3 with AVX2 and SSE4.1 acceleration",
+            "stars": 252
+        },
+        "default_branch": "master",
         "version": "25dba572f0e78ec108f0dd79c9c15288f542d7d9",
         "module": "avo/go.mod",
         "generate": [
@@ -123,6 +148,11 @@
             "owner": "klauspost",
             "name": "reedsolomon"
         },
+        "metadata": {
+            "description": "Reed-Solomon Erasure Coding in Go",
+            "stars": 1343
+        },
+        "default_branch": "master",
         "version": "922778284547557265cff0f903ab5f4c27e40ae9",
         "module": "_gen/go.mod",
         "generate": [
@@ -139,6 +169,11 @@
             "owner": "orisano",
             "name": "wyhash"
         },
+        "metadata": {
+            "description": "A pure-Go wyhash implementation.",
+            "stars": 21
+        },
+        "default_branch": "master",
         "version": "32a3f7f6ba4797e2d87dab2969cc9dd63d39cce9",
         "module": "avo/go.mod",
         "setup": [
@@ -164,6 +199,11 @@
             "owner": "klauspost",
             "name": "compress"
         },
+        "metadata": {
+            "description": "Optimized Go Compression Packages",
+            "stars": 2442
+        },
+        "default_branch": "master",
         "version": "2adf487b3e02f95ce7efd6e4953fda0ae7ecd080",
         "pkg": "s2",
         "module": "_generate/go.mod",
@@ -181,6 +221,11 @@
             "owner": "dgryski",
             "name": "go-bloomindex"
         },
+        "metadata": {
+            "description": "Bloom-filter based search index",
+            "stars": 107
+        },
+        "default_branch": "master",
         "version": "0902316dce158c154b958ee5cfc706c62af29a42",
         "module": "avo/go.mod",
         "setup": [
@@ -221,6 +266,11 @@
             "owner": "dgryski",
             "name": "go-marvin32"
         },
+        "metadata": {
+            "description": "Assembly-optimized Marvin32 hash function",
+            "stars": 12
+        },
+        "default_branch": "master",
         "version": "7d18f4c6ea7c24b29d1c7a670f8ae40b0812f2e3",
         "module": "avo/go.mod",
         "setup": [
@@ -262,6 +312,11 @@
             "owner": "dgryski",
             "name": "go-speck"
         },
+        "metadata": {
+            "description": "SPECK cipher",
+            "stars": 10
+        },
+        "default_branch": "master",
         "version": "5b36d4c08d8840c352a153bf37281434ad550ec0",
         "module": "avo/go.mod",
         "setup": [
@@ -304,6 +359,11 @@
             "owner": "dgryski",
             "name": "go-chaskey"
         },
+        "metadata": {
+            "description": "go-chaskey: an implementation of chaskey, an efficient MAC for microcontrollers",
+            "stars": 7
+        },
+        "default_branch": "master",
         "version": "ba454392bc5ab6daae103e15147185f8f4a27dcc",
         "module": "avo/go.mod",
         "setup": [
@@ -346,6 +406,11 @@
             "owner": "lukechampine",
             "name": "us"
         },
+        "metadata": {
+            "description": "An alternative interface to Sia",
+            "stars": 49
+        },
+        "default_branch": "master",
         "version": "dff56a80f83653cb14eeeb57ba6ba3c3e942c412",
         "pkg": "merkle/blake2b",
         "module": "avo/go.mod",
@@ -380,6 +445,11 @@
             "owner": "segmentio",
             "name": "asm"
         },
+        "metadata": {
+            "description": "Go library providing algorithms optimized to leverage the characteristics of modern CPUs",
+            "stars": 548
+        },
+        "default_branch": "main",
         "version": "v1.0.0",
         "module": "build/go.mod",
         "generate": [
@@ -395,6 +465,11 @@
             "owner": "ericlagergren",
             "name": "lwcrypto"
         },
+        "metadata": {
+            "description": "NIST Lightweight Cryptography finalists",
+            "stars": 2
+        },
+        "default_branch": "main",
         "version": "0c42b05eddc34c58bf8e0cd4250c5cd2c256ea57",
         "pkg": "ascon",
         "module": "asm/go.mod",
@@ -413,6 +488,11 @@
             "owner": "ericlagergren",
             "name": "lwcrypto"
         },
+        "metadata": {
+            "description": "NIST Lightweight Cryptography finalists",
+            "stars": 2
+        },
+        "default_branch": "main",
         "version": "0c42b05eddc34c58bf8e0cd4250c5cd2c256ea57",
         "pkg": "grain",
         "module": "asm/go.mod",
@@ -431,6 +511,11 @@
             "owner": "oasisprotocol",
             "name": "curve25519-voi"
         },
+        "metadata": {
+            "description": "High-performance Curve25519/ristretto255 for Go",
+            "stars": 32
+        },
+        "default_branch": "master",
         "version": "d5a936accd94ef9da4c0fe9db0a6342dcdcfeadf",
         "module": "internal/asm/amd64/go.mod",
         "generate": [
@@ -447,6 +532,12 @@
             "owner": "golang",
             "name": "crypto"
         },
+        "metadata": {
+            "description": "[mirror] Go supplementary cryptography libraries",
+            "homepage": "https://golang.org/x/crypto",
+            "stars": 2283
+        },
+        "default_branch": "master",
         "version": "089bfa5675191fd96a44247682f76ebca03d7916",
         "pkg": "curve25519",
         "module": "internal/field/_asm/go.mod",