├── internal ├── git │ ├── testdata │ │ ├── repos │ │ │ └── .gitignore │ │ ├── create-commit-in-repo.sh │ │ ├── create-remote-repo.sh │ │ ├── create-tagged-repo.sh │ │ ├── Makefile │ │ ├── create-annotated-tagged-repo.sh │ │ └── create-tag-range-repo.sh │ ├── is_repository.go │ ├── remote_test.go │ ├── reference.go │ ├── first.go │ ├── mock_git.go │ ├── first_test.go │ ├── interface.go │ ├── head.go │ ├── remote.go │ ├── head_test.go │ ├── tag.go │ └── tag_test.go ├── constants.go ├── time_helper.go ├── bus │ └── bus.go ├── regex_helpers.go └── log │ └── log.go ├── .chronicle.yaml ├── chronicle ├── release │ ├── releasers │ │ └── github │ │ │ ├── testdata │ │ │ ├── repos │ │ │ │ └── .gitignore │ │ │ ├── Makefile │ │ │ ├── create-v0.1.0-dev-repo.sh │ │ │ ├── create-v0.2.0-repo.sh │ │ │ └── create-v0.3.0-dev-repo.sh │ │ │ ├── find_changelog_end_tag_test.go │ │ │ ├── find_changelog_end_tag.go │ │ │ ├── version_speculator.go │ │ │ ├── gh_release.go │ │ │ ├── version_speculator_test.go │ │ │ ├── gh_issue.go │ │ │ ├── gh_issue_test.go │ │ │ ├── gh_pull_request.go │ │ │ └── gh_pull_request_test.go │ ├── release.go │ ├── change │ │ ├── type_title.go │ │ ├── type_set.go │ │ ├── type.go │ │ ├── semver.go │ │ └── change.go │ ├── mock_version_speculator.go │ ├── format │ │ ├── json │ │ │ └── presenter.go │ │ ├── format.go │ │ └── markdown │ │ │ ├── __snapshots__ │ │ │ └── presenter_test.snap │ │ │ ├── presenter.go │ │ │ └── presenter_test.go │ ├── description.go │ ├── mock_summarizer.go │ ├── version_speculator.go │ ├── summarizer.go │ ├── changelog_info_test.go │ └── changelog_info.go └── lib.go ├── .bouncer.yaml ├── .github ├── workflows │ ├── dependabot-automation.yaml │ ├── remove-awaiting-response-label.yaml │ ├── oss-project-board-add.yaml │ ├── validate-github-actions.yaml │ ├── validations.yaml │ ├── codeql-analysis.yml │ └── release.yaml ├── ISSUE_TEMPLATE │ ├── config.yml │ ├── feature_request.md │ └── bug_report.md └── dependabot.yaml ├── .make ├── go.mod ├── main.go └── go.sum ├── Makefile ├── cmd └── chronicle │ ├── cli │ ├── options │ │ ├── next_version.go │ │ └── github.go │ ├── commands │ │ ├── root.go │ │ ├── create_presenter.go │ │ ├── next_version.go │ │ ├── create_github_worker.go │ │ ├── create.go │ │ └── create_config.go │ └── cli.go │ └── main.go ├── .gitignore ├── RELEASE.md ├── .binny.yaml ├── DEVELOPING.md ├── .goreleaser.yaml ├── .golangci.yaml ├── go.mod ├── CONTRIBUTING.md ├── README.md └── LICENSE /internal/git/testdata/repos/.gitignore: -------------------------------------------------------------------------------- 1 | *-repo -------------------------------------------------------------------------------- /.chronicle.yaml: -------------------------------------------------------------------------------- 1 | log: 2 | level: trace 3 | 4 | title: '' -------------------------------------------------------------------------------- /chronicle/release/releasers/github/testdata/repos/.gitignore: -------------------------------------------------------------------------------- 1 | *-repo -------------------------------------------------------------------------------- /internal/constants.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | const ( 4 | ApplicationName = "chronicle" 5 | ) 6 | -------------------------------------------------------------------------------- /internal/time_helper.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import "time" 4 | 5 | func FormatDateTime(t time.Time) string { 6 | return t.UTC().Format("2006-01-02 15:04:05 MST") 7 | } 8 | -------------------------------------------------------------------------------- /chronicle/release/release.go: -------------------------------------------------------------------------------- 1 | package release 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | // Release represents a version of software at a point in time. 8 | type Release struct { 9 | Version string 10 | Date time.Time 11 | } 12 | -------------------------------------------------------------------------------- /.bouncer.yaml: -------------------------------------------------------------------------------- 1 | permit: 2 | - BSD.* 3 | - MIT.* 4 | - Apache.* 5 | - MPL.* 6 | - ISC 7 | ignore-packages: 8 | # crypto/internal/boring is released under the openSSL license as a part of the Golang Standard Libary 9 | - crypto/internal/boring 10 | -------------------------------------------------------------------------------- /.github/workflows/dependabot-automation.yaml: -------------------------------------------------------------------------------- 1 | name: Dependabot Automation 2 | on: 3 | pull_request: 4 | 5 | permissions: 6 | pull-requests: write 7 | 8 | jobs: 9 | run: 10 | uses: anchore/workflows/.github/workflows/dependabot-automation.yaml@main 11 | -------------------------------------------------------------------------------- /internal/git/is_repository.go: -------------------------------------------------------------------------------- 1 | package git 2 | 3 | import ( 4 | "github.com/go-git/go-git/v5" 5 | ) 6 | 7 | func IsRepository(path string) bool { 8 | r, err := git.PlainOpen(path) 9 | if err != nil { 10 | return false 11 | } 12 | return r != nil 13 | } 14 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | contact_links: 2 | 3 | - name: Join the Slack community 💬 4 | # link to our community Slack registration page 5 | url: https://anchore.com/slack 6 | about: 'Come chat with us! Ask for help, join our software development efforts, or just give us feedback!' 7 | -------------------------------------------------------------------------------- /.make/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/anchore/chronicle/.make 2 | 3 | go 1.24.0 4 | 5 | require github.com/anchore/go-make v0.0.2 6 | 7 | require ( 8 | github.com/bmatcuk/doublestar/v4 v4.9.1 // indirect 9 | github.com/goccy/go-yaml v1.18.0 // indirect 10 | golang.org/x/mod v0.27.0 // indirect 11 | ) 12 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: test 2 | test: 3 | @go run -C .make . test 4 | 5 | .PHONY: snapshot 6 | snapshot: 7 | @go run -C .make . snapshot 8 | 9 | .PHONY: * 10 | .DEFAULT_GOAL: make-default 11 | 12 | make-default: 13 | @go run -C .make . 14 | 15 | .PHONY: * 16 | .DEFAULT: 17 | %: 18 | @go run -C .make . $@ 19 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | **What would you like to be added**: 11 | 12 | **Why is this needed**: 13 | 14 | **Additional context**: 15 | 16 | -------------------------------------------------------------------------------- /cmd/chronicle/cli/options/next_version.go: -------------------------------------------------------------------------------- 1 | package options 2 | 3 | import ( 4 | "github.com/anchore/fangs" 5 | ) 6 | 7 | type EnforceV0 bool 8 | 9 | func (c *EnforceV0) AddFlags(flags fangs.FlagSet) { 10 | flags.BoolVarP( 11 | (*bool)(c), 12 | "enforce-v0", "e", 13 | "major changes bump the minor version field for versions < 1.0", 14 | ) 15 | } 16 | 17 | var _ fangs.FlagAdder = (*EnforceV0)(nil) 18 | -------------------------------------------------------------------------------- /.github/workflows/remove-awaiting-response-label.yaml: -------------------------------------------------------------------------------- 1 | name: "Manage Awaiting Response Label" 2 | 3 | on: 4 | issue_comment: 5 | types: [created] 6 | 7 | permissions: 8 | issues: write 9 | pull-requests: write 10 | 11 | jobs: 12 | run: 13 | uses: "anchore/workflows/.github/workflows/remove-awaiting-response-label.yaml@main" 14 | secrets: 15 | token: ${{ secrets.OSS_PROJECT_GH_TOKEN }} 16 | -------------------------------------------------------------------------------- /chronicle/release/releasers/github/testdata/Makefile: -------------------------------------------------------------------------------- 1 | 2 | .PHONY: all 3 | all: repos/v0.1.0-dev-repo repos/v0.2.0-repo repos/v0.3.0-dev-repo 4 | 5 | repos/v0.1.0-dev-repo: 6 | ./create-v0.1.0-dev-repo.sh 7 | 8 | repos/v0.2.0-repo: 9 | ./create-v0.2.0-repo.sh 10 | 11 | repos/v0.3.0-dev-repo: 12 | ./create-v0.3.0-dev-repo.sh 13 | 14 | clean: 15 | rm -rf repos/v0.1.0-dev-repo repos/v0.2.0-repo repos/v0.3.0-dev-repo 16 | -------------------------------------------------------------------------------- /.github/workflows/oss-project-board-add.yaml: -------------------------------------------------------------------------------- 1 | name: Add to OSS board 2 | 3 | on: 4 | issues: 5 | types: 6 | - opened 7 | - reopened 8 | - transferred 9 | - labeled 10 | 11 | permissions: 12 | issues: read 13 | 14 | jobs: 15 | 16 | run: 17 | uses: "anchore/workflows/.github/workflows/oss-project-board-add.yaml@main" 18 | secrets: 19 | token: ${{ secrets.OSS_PROJECT_GH_TOKEN }} 20 | -------------------------------------------------------------------------------- /chronicle/release/change/type_title.go: -------------------------------------------------------------------------------- 1 | package change 2 | 3 | type TypeTitles []TypeTitle 4 | 5 | // TypeTitle is a changetype paired with the section title that should be used in the changelog. 6 | type TypeTitle struct { 7 | ChangeType Type 8 | Title string 9 | } 10 | 11 | func (tts TypeTitles) Types() (ty []Type) { 12 | for _, c := range tts { 13 | ty = append(ty, c.ChangeType) 14 | } 15 | return ty 16 | } 17 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **What happened**: 11 | 12 | **What you expected to happen**: 13 | 14 | **How to reproduce it (as minimally and precisely as possible)**: 15 | 16 | **Anything else we need to know?**: 17 | 18 | **Environment**: 19 | - Output of `chronicle version`: 20 | -------------------------------------------------------------------------------- /.github/dependabot.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | updates: 4 | 5 | - package-ecosystem: gomod 6 | directories: 7 | - "/" 8 | - "/.make" 9 | schedule: 10 | interval: "daily" 11 | open-pull-requests-limit: 10 12 | labels: 13 | - "dependencies" 14 | 15 | - package-ecosystem: "github-actions" 16 | directory: "/" 17 | schedule: 18 | interval: "daily" 19 | open-pull-requests-limit: 10 20 | labels: 21 | - "dependencies" 22 | -------------------------------------------------------------------------------- /.make/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | . "github.com/anchore/go-make" 5 | "github.com/anchore/go-make/tasks/golint" 6 | "github.com/anchore/go-make/tasks/goreleaser" 7 | "github.com/anchore/go-make/tasks/gotest" 8 | "github.com/anchore/go-make/tasks/release" 9 | ) 10 | 11 | func main() { 12 | Makefile( 13 | golint.Tasks(), 14 | release.ChangelogTask(), 15 | goreleaser.Tasks(), 16 | gotest.Tasks(), 17 | gotest.FixtureTasks().RunOn("ci-release"), 18 | ) 19 | } 20 | -------------------------------------------------------------------------------- /internal/git/testdata/create-commit-in-repo.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -eux -o pipefail 3 | 4 | if [ -d "/path/to/dir" ] 5 | then 6 | echo "fixture already exists!" 7 | exit 0 8 | else 9 | echo "creating fixture..." 10 | fi 11 | 12 | git init repos/commit-in-repo 13 | 14 | pushd repos/commit-in-repo 15 | 16 | git config --local user.email "nope@nope.com" 17 | git config --local user.name "nope" 18 | 19 | trap 'popd' EXIT 20 | 21 | git commit -m 'something' --allow-empty -------------------------------------------------------------------------------- /chronicle/release/change/type_set.go: -------------------------------------------------------------------------------- 1 | package change 2 | 3 | // TypeSet is a unique set of types indexed by their name 4 | type TypeSet map[string]Type 5 | 6 | func (l TypeSet) Names() (results []string) { 7 | for name := range l { 8 | results = append(results, name) 9 | } 10 | return results 11 | } 12 | 13 | func (l TypeSet) ChangeTypes(labels ...string) (results []Type) { 14 | for _, label := range labels { 15 | if ct, exists := l[label]; exists { 16 | results = append(results, ct) 17 | } 18 | } 19 | return results 20 | } 21 | -------------------------------------------------------------------------------- /chronicle/release/mock_version_speculator.go: -------------------------------------------------------------------------------- 1 | package release 2 | 3 | import "github.com/anchore/chronicle/chronicle/release/change" 4 | 5 | type MockVersionSpeculator struct { 6 | MockNextIdealVersion string 7 | MockNextUniqueVersion string 8 | } 9 | 10 | func (m MockVersionSpeculator) NextIdealVersion(_ string, _ change.Changes) (string, error) { 11 | return m.MockNextIdealVersion, nil 12 | } 13 | 14 | func (m MockVersionSpeculator) NextUniqueVersion(_ string, _ change.Changes) (string, error) { 15 | return m.MockNextUniqueVersion, nil 16 | } 17 | -------------------------------------------------------------------------------- /chronicle/lib.go: -------------------------------------------------------------------------------- 1 | package chronicle 2 | 3 | import ( 4 | "github.com/wagoodman/go-partybus" 5 | 6 | "github.com/anchore/chronicle/internal/bus" 7 | "github.com/anchore/chronicle/internal/log" 8 | "github.com/anchore/go-logger" 9 | ) 10 | 11 | // SetLogger sets the logger object used for all logging calls. 12 | func SetLogger(logger logger.Logger) { 13 | log.Set(logger) 14 | } 15 | 16 | // SetBus sets the event bus for all published events onto (in-library subscriptions are not allowed). 17 | func SetBus(b *partybus.Bus) { 18 | bus.SetPublisher(b) 19 | } 20 | -------------------------------------------------------------------------------- /cmd/chronicle/cli/commands/root.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | 6 | "github.com/anchore/clio" 7 | ) 8 | 9 | func Root(app clio.Application, createCmd *cobra.Command) *cobra.Command { 10 | appConfig := defaultCreateConfig() 11 | 12 | cmd := app.SetupRootCommand(&cobra.Command{ 13 | Short: createCmd.Short, 14 | Long: createCmd.Long, 15 | Args: createCmd.Args, 16 | RunE: func(_ *cobra.Command, _ []string) error { 17 | return runCreate(appConfig) 18 | }, 19 | }, appConfig) 20 | 21 | return cmd 22 | } 23 | -------------------------------------------------------------------------------- /internal/git/testdata/create-remote-repo.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -eux -o pipefail 3 | 4 | if [ -d "/path/to/dir" ] 5 | then 6 | echo "fixture already exists!" 7 | exit 0 8 | else 9 | echo "creating fixture..." 10 | fi 11 | 12 | git init repos/remote-repo 13 | 14 | pushd repos/remote-repo 15 | 16 | git config --local user.email "nope@nope.com" 17 | git config --local user.name "nope" 18 | 19 | trap 'popd' EXIT 20 | 21 | git remote add origin git@github.com:wagoodman/count-goober.git 22 | git remote add upstream git@github.com:upstream/count-goober.git 23 | -------------------------------------------------------------------------------- /internal/git/testdata/create-tagged-repo.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -eux -o pipefail 3 | 4 | if [ -d "/path/to/dir" ] 5 | then 6 | echo "fixture already exists!" 7 | exit 0 8 | else 9 | echo "creating fixture..." 10 | fi 11 | 12 | git init repos/tagged-repo 13 | 14 | pushd repos/tagged-repo 15 | 16 | git config --local user.email "nope@nope.com" 17 | git config --local user.name "nope" 18 | 19 | trap 'popd' EXIT 20 | 21 | git commit -m 'something' --allow-empty 22 | # show that the timestamp cannot be extracted from the lightweight tag 23 | sleep 3 24 | git tag v0.1.0 -------------------------------------------------------------------------------- /internal/bus/bus.go: -------------------------------------------------------------------------------- 1 | package bus 2 | 3 | import "github.com/wagoodman/go-partybus" 4 | 5 | var publisher partybus.Publisher 6 | 7 | // SetPublisher sets the singleton event bus publisher. This is optional; if no bus is provided, the library will 8 | // behave no differently than if a bus had been provided. 9 | func SetPublisher(p partybus.Publisher) { 10 | publisher = p 11 | } 12 | 13 | // Publish an event onto the bus. If there is no bus set by the calling application, this does nothing. 14 | func Publish(event partybus.Event) { 15 | if publisher != nil { 16 | publisher.Publish(event) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /chronicle/release/format/json/presenter.go: -------------------------------------------------------------------------------- 1 | package json 2 | 3 | import ( 4 | "encoding/json" 5 | "io" 6 | 7 | "github.com/anchore/chronicle/chronicle/release" 8 | ) 9 | 10 | type Presenter struct { 11 | description release.Description 12 | } 13 | 14 | func NewJSONPresenter(description release.Description) (*Presenter, error) { 15 | return &Presenter{ 16 | description: description, 17 | }, nil 18 | } 19 | 20 | func (m Presenter) Present(writer io.Writer) error { 21 | enc := json.NewEncoder(writer) 22 | enc.SetEscapeHTML(false) 23 | enc.SetIndent("", " ") 24 | return enc.Encode(m.description) 25 | } 26 | -------------------------------------------------------------------------------- /internal/git/testdata/Makefile: -------------------------------------------------------------------------------- 1 | 2 | .PHONY: all 3 | all: repos/remote-repo repos/tagged-repo repos/commit-in-repo repos/tag-range-repo repos/annotated-tagged-repo 4 | 5 | repos/remote-repo: 6 | ./create-remote-repo.sh 7 | 8 | repos/tagged-repo: 9 | ./create-tagged-repo.sh 10 | 11 | repos/commit-in-repo: 12 | ./create-commit-in-repo.sh 13 | 14 | repos/tag-range-repo: 15 | ./create-tag-range-repo.sh 16 | 17 | repos/annotated-tagged-repo: 18 | ./create-annotated-tagged-repo.sh 19 | 20 | clean: 21 | rm -rf repos/remote-repo repos/tagged-repo repos/commit-in-repo repos/tag-range-repo repos/annotated-tagged-repo 22 | -------------------------------------------------------------------------------- /chronicle/release/change/type.go: -------------------------------------------------------------------------------- 1 | package change 2 | 3 | // Type is the kind of change made (e.g. a bug, enhancement, breaking-change, etc.) and how that relates to a software version (e.g. should bump the patch semver field) 4 | type Type struct { 5 | Name string 6 | Kind SemVerKind 7 | } 8 | 9 | func NewType(name string, kind SemVerKind) Type { 10 | return Type{ 11 | Name: name, 12 | Kind: kind, 13 | } 14 | } 15 | 16 | func ContainsAny(query, against []Type) bool { 17 | for _, qt := range query { 18 | for _, at := range against { 19 | if qt.Name == at.Name { 20 | return true 21 | } 22 | } 23 | } 24 | return false 25 | } 26 | -------------------------------------------------------------------------------- /chronicle/release/format/format.go: -------------------------------------------------------------------------------- 1 | package format 2 | 3 | import "strings" 4 | 5 | type Format string 6 | 7 | var ( 8 | MarkdownFormat Format = "md" 9 | JSONFormat Format = "json" 10 | ) 11 | 12 | func FromString(option string) *Format { 13 | option = strings.ToLower(option) 14 | switch option { 15 | case "m", "md", "markdown": 16 | return &MarkdownFormat 17 | case "j", "json", "jason": 18 | return &JSONFormat 19 | default: 20 | return nil 21 | } 22 | } 23 | 24 | func All() []Format { 25 | return []Format{ 26 | MarkdownFormat, 27 | JSONFormat, 28 | } 29 | } 30 | 31 | func Default() Format { 32 | return MarkdownFormat 33 | } 34 | -------------------------------------------------------------------------------- /internal/git/testdata/create-annotated-tagged-repo.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -eux -o pipefail 3 | 4 | if [ -d "/path/to/dir" ] 5 | then 6 | echo "fixture already exists!" 7 | exit 0 8 | else 9 | echo "creating fixture..." 10 | fi 11 | 12 | git init repos/annotated-tagged-repo 13 | 14 | pushd repos/annotated-tagged-repo 15 | 16 | git config --local user.email "nope@nope.com" 17 | git config --local user.name "nope" 18 | 19 | trap 'popd' EXIT 20 | 21 | git commit -m 'something' --allow-empty 22 | # show that there is a difference between the resolved commit timestamp and the tag timestamp 23 | sleep 3 24 | git tag -a v0.1.0 -m "tagging v0.1.0" 25 | -------------------------------------------------------------------------------- /.make/go.sum: -------------------------------------------------------------------------------- 1 | github.com/anchore/go-make v0.0.2 h1:v1Wtxs8o42+njD+qWa4Rak3wscyf2+1te5a4WdiQfNQ= 2 | github.com/anchore/go-make v0.0.2/go.mod h1:jsd75YTZwUrbxnOs3ZaN2NGfxqbSuS9jAhyLjb55Nvs= 3 | github.com/bmatcuk/doublestar/v4 v4.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE= 4 | github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= 5 | github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= 6 | github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= 7 | golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ= 8 | golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc= 9 | -------------------------------------------------------------------------------- /internal/git/remote_test.go: -------------------------------------------------------------------------------- 1 | package git 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | "github.com/stretchr/testify/require" 8 | ) 9 | 10 | func TestRemoteUrl(t *testing.T) { 11 | tests := []struct { 12 | name string 13 | path string 14 | expects string 15 | }{ 16 | { 17 | name: "go case", 18 | path: "testdata/repos/remote-repo", 19 | expects: "git@github.com:wagoodman/count-goober.git", 20 | }, 21 | } 22 | for _, test := range tests { 23 | t.Run(test.name, func(t *testing.T) { 24 | actual, err := RemoteURL(test.path) 25 | require.NoError(t, err) 26 | assert.Equal(t, test.expects, actual) 27 | }) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /cmd/chronicle/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/anchore/chronicle/cmd/chronicle/cli" 5 | "github.com/anchore/chronicle/internal" 6 | "github.com/anchore/clio" 7 | ) 8 | 9 | const valueNotProvided = "[not provided]" 10 | 11 | // all variables here are provided as build-time arguments, with clear default values 12 | var version = valueNotProvided 13 | var buildDate = valueNotProvided 14 | var gitCommit = valueNotProvided 15 | var gitDescription = valueNotProvided 16 | 17 | func main() { 18 | app := cli.New( 19 | clio.Identification{ 20 | Name: internal.ApplicationName, 21 | Version: version, 22 | BuildDate: buildDate, 23 | GitCommit: gitCommit, 24 | GitDescription: gitDescription, 25 | }, 26 | ) 27 | 28 | app.Run() 29 | } 30 | -------------------------------------------------------------------------------- /internal/git/reference.go: -------------------------------------------------------------------------------- 1 | package git 2 | 3 | type Reference struct { 4 | Commit string 5 | Tags []string 6 | } 7 | 8 | // func commitFromGitReference(repoPath, name string) (string, error) { 9 | // r, err := git.PlainOpen(repoPath) 10 | // if err != nil { 11 | // return "", err 12 | // 13 | // } 14 | // 15 | // ref, err := r.Reference(plumbing.ReferenceName(path.Join("refs", "tags", name)), false) 16 | // if err != nil { 17 | // return "", err 18 | // } 19 | // 20 | // if ref != nil { 21 | // return ref.String(), nil 22 | // } 23 | // 24 | // tags, err := TagsFromLocal(repoPath) 25 | // if err != nil { 26 | // return "", err 27 | // } 28 | // 29 | // var commit string 30 | // for _, tag := range tags { 31 | // if tag.Name == sinceRef { 32 | // r.Tag() 33 | // } 34 | // } 35 | //} 36 | -------------------------------------------------------------------------------- /internal/git/first.go: -------------------------------------------------------------------------------- 1 | package git 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/go-git/go-git/v5" 7 | "github.com/go-git/go-git/v5/plumbing/object" 8 | ) 9 | 10 | func FirstCommit(repoPath string) (string, error) { 11 | r, err := git.PlainOpen(repoPath) 12 | if err != nil { 13 | return "", fmt.Errorf("unable to open repo: %w", err) 14 | } 15 | 16 | iter, err := r.Log(&git.LogOptions{}) 17 | if err != nil { 18 | return "", fmt.Errorf("unable to log commits: %w", err) 19 | } 20 | 21 | // the iterator works just like "git log", which is in reverse chronological order. That means the 22 | // first commit in the repo is the last item in the iterator. 23 | var last string 24 | err = iter.ForEach(func(c *object.Commit) error { 25 | if c != nil { 26 | last = c.Hash.String() 27 | } 28 | return nil 29 | }) 30 | return last, err 31 | } 32 | -------------------------------------------------------------------------------- /chronicle/release/description.go: -------------------------------------------------------------------------------- 1 | package release 2 | 3 | import "github.com/anchore/chronicle/chronicle/release/change" 4 | 5 | // Description contains all the data and metadata about a release that is pertinent to a changelog. 6 | type Description struct { 7 | Release // the release being described 8 | VCSReferenceURL string // the URL to find more information about this release, e.g. https://github.com/anchore/chronicle/releases/tag/v0.4.1 9 | VCSChangesURL string // the URL to find the specific source changes that makeup this release, e.g. https://github.com/anchore/chronicle/compare/v0.3.0...v0.4.1 10 | Notice string // manual note or summary that describes the changelog at a high level 11 | Changes change.Changes // all issues and PRs that makeup this release 12 | SupportedChanges []change.TypeTitle // the sections of the changelog and their display titles 13 | } 14 | -------------------------------------------------------------------------------- /.github/workflows/validate-github-actions.yaml: -------------------------------------------------------------------------------- 1 | name: "Validate GitHub Actions" 2 | 3 | on: 4 | pull_request: 5 | paths: 6 | - '.github/workflows/**' 7 | - '.github/actions/**' 8 | push: 9 | branches: 10 | - main 11 | paths: 12 | - '.github/workflows/**' 13 | - '.github/actions/**' 14 | 15 | permissions: 16 | contents: read 17 | 18 | jobs: 19 | zizmor: 20 | name: "Lint" 21 | runs-on: ubuntu-latest 22 | permissions: 23 | contents: read 24 | security-events: write # for uploading SARIF results 25 | steps: 26 | - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 27 | with: 28 | persist-credentials: false 29 | 30 | - name: "Run zizmor" 31 | uses: zizmorcore/zizmor-action@5ca5fc7a4779c5263a3ffa0e1f693009994446d1 # v0.1.2 32 | with: 33 | config-file: .github/zizmor.yml 34 | sarif-upload: true 35 | inputs: .github 36 | -------------------------------------------------------------------------------- /chronicle/release/change/semver.go: -------------------------------------------------------------------------------- 1 | package change 2 | 3 | import "strings" 4 | 5 | type SemVerKind int 6 | 7 | const ( 8 | SemVerUnknown SemVerKind = iota 9 | SemVerPatch 10 | SemVerMinor 11 | SemVerMajor 12 | ) 13 | 14 | var SemVerFields = []SemVerKind{ 15 | SemVerMajor, 16 | SemVerMinor, 17 | SemVerPatch, 18 | } 19 | 20 | func ParseSemVerKind(semver string) SemVerKind { 21 | for _, f := range SemVerFields { 22 | if f.String() == strings.ToLower(semver) { 23 | return f 24 | } 25 | } 26 | return SemVerUnknown 27 | } 28 | 29 | func (f SemVerKind) String() string { 30 | switch f { 31 | case SemVerMajor: 32 | return "major" 33 | case SemVerMinor: 34 | return "minor" 35 | case SemVerPatch: 36 | return "patch" 37 | } 38 | return "" 39 | } 40 | 41 | func Significance(changes []Change) SemVerKind { 42 | var current = SemVerUnknown 43 | for _, c := range changes { 44 | for _, t := range c.ChangeTypes { 45 | if t.Kind > current { 46 | current = t.Kind 47 | } 48 | } 49 | } 50 | return current 51 | } 52 | -------------------------------------------------------------------------------- /internal/git/testdata/create-tag-range-repo.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -eux -o pipefail 3 | 4 | if [ -d "/path/to/dir" ] 5 | then 6 | echo "fixture already exists!" 7 | exit 0 8 | else 9 | echo "creating fixture..." 10 | fi 11 | 12 | git init repos/tag-range-repo 13 | 14 | pushd repos/tag-range-repo 15 | 16 | git config --local user.email "nope@nope.com" 17 | git config --local user.name "nope" 18 | 19 | trap 'popd' EXIT 20 | 21 | git commit -m 'something' --allow-empty 22 | git commit -m 'something-else' --allow-empty 23 | git tag v0.1.0 24 | 25 | git commit -m 'fix: after-0.1.0' --allow-empty 26 | git commit -m 'fix: also-after-0.1.0' --allow-empty 27 | git commit -m 'fix: nothing was working' --allow-empty 28 | git tag v0.1.1 29 | 30 | git commit -m 'fix: bad release of 0.1.1' --allow-empty 31 | git commit -m 'feat: implement everything that wasnt there' --allow-empty 32 | git commit -m 'fix: missed something of everything' --allow-empty 33 | git tag v0.2.0 34 | 35 | git commit -m 'feat: working on next release item' --allow-empty 36 | -------------------------------------------------------------------------------- /chronicle/release/mock_summarizer.go: -------------------------------------------------------------------------------- 1 | package release 2 | 3 | import ( 4 | "github.com/anchore/chronicle/chronicle/release/change" 5 | ) 6 | 7 | type MockSummarizer struct { 8 | MockLastRelease string 9 | MockRelease string 10 | MockChanges []change.Change 11 | MockRefURL string 12 | MockChangesURL string 13 | } 14 | 15 | func (m MockSummarizer) LastRelease() (*Release, error) { 16 | if m.MockLastRelease == "" { 17 | return nil, nil 18 | } 19 | return &Release{ 20 | Version: m.MockLastRelease, 21 | }, nil 22 | } 23 | 24 | func (m MockSummarizer) Release(_ string) (*Release, error) { 25 | if m.MockRelease == "" { 26 | return nil, nil 27 | } 28 | return &Release{ 29 | Version: m.MockRelease, 30 | }, nil 31 | } 32 | 33 | func (m MockSummarizer) Changes(_, _ string) ([]change.Change, error) { 34 | return m.MockChanges, nil 35 | } 36 | 37 | func (m MockSummarizer) ReferenceURL(_ string) string { 38 | return m.MockRefURL 39 | } 40 | 41 | func (m MockSummarizer) ChangesURL(_, _ string) string { 42 | return m.MockChangesURL 43 | } 44 | -------------------------------------------------------------------------------- /chronicle/release/releasers/github/testdata/create-v0.1.0-dev-repo.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -eux -o pipefail 3 | 4 | if [ -d "/path/to/dir" ] 5 | then 6 | echo "fixture already exists!" 7 | exit 0 8 | else 9 | echo "creating fixture..." 10 | fi 11 | 12 | git init repos/v0.1.0-dev-repo 13 | 14 | pushd repos/v0.1.0-dev-repo 15 | 16 | git config --local user.email "nope@nope.com" 17 | git config --local user.name "nope" 18 | 19 | git remote add origin git@github.com:wagoodman/count-goober.git 20 | 21 | trap 'popd' EXIT 22 | 23 | git commit -m 'something' --allow-empty 24 | git commit -m 'something-else' --allow-empty 25 | 26 | git commit -m 'fix: bug ' --allow-empty 27 | git commit -m 'fix: also bug' --allow-empty 28 | git commit -m 'fix: nothing was working' --allow-empty 29 | 30 | git commit -m 'fix: bad bug' --allow-empty 31 | git commit -m 'feat: implement everything that wasnt there' --allow-empty 32 | git commit -m 'fix: missed something of everything' --allow-empty 33 | 34 | git commit -m 'feat: working on next release item' --allow-empty 35 | -------------------------------------------------------------------------------- /chronicle/release/releasers/github/testdata/create-v0.2.0-repo.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -eux -o pipefail 3 | 4 | if [ -d "/path/to/dir" ] 5 | then 6 | echo "fixture already exists!" 7 | exit 0 8 | else 9 | echo "creating fixture..." 10 | fi 11 | 12 | git init repos/v0.2.0-repo 13 | 14 | pushd repos/v0.2.0-repo 15 | 16 | git config --local user.email "nope@nope.com" 17 | git config --local user.name "nope" 18 | 19 | git remote add origin git@github.com:wagoodman/count-goober.git 20 | 21 | trap 'popd' EXIT 22 | 23 | git commit -m 'something' --allow-empty 24 | git commit -m 'something-else' --allow-empty 25 | git tag v0.1.0 26 | 27 | git commit -m 'fix: after-0.1.0' --allow-empty 28 | git commit -m 'fix: also-after-0.1.0' --allow-empty 29 | git commit -m 'fix: nothing was working' --allow-empty 30 | git tag v0.1.1 31 | 32 | git commit -m 'fix: bad release of 0.1.1' --allow-empty 33 | git commit -m 'feat: implement everything that wasnt there' --allow-empty 34 | git commit -m 'fix: missed something of everything' --allow-empty 35 | git tag v0.2.0 36 | -------------------------------------------------------------------------------- /chronicle/release/format/markdown/__snapshots__/presenter_test.snap: -------------------------------------------------------------------------------- 1 | 2 | [TestMarkdownPresenter_Present - 1] 3 | # v0.19.1 4 | 5 | ### Bug Fixes 6 | 7 | - Redirect cursor hide/show to stderr [[#456](https://github.com/anchore/syft/pull/456)] 8 | 9 | ### Added Features 10 | 11 | - added feature [[#457](https://github.com/anchore/syft/pull/457) @wagoodman] 12 | - another added feature 13 | 14 | ### Breaking Changes 15 | 16 | - breaking change [[#458](https://github.com/anchore/syft/pull/458) [#450](https://github.com/anchore/syft/issues/450) @wagoodman] 17 | 18 | **[(Full Changelog)](https://github.com/anchore/syft/compare/v0.19.0...v0.19.1)** 19 | 20 | --- 21 | 22 | [TestMarkdownPresenter_Present_NoTitle - 1] 23 | ### Bug Fixes 24 | 25 | - Redirect cursor hide/show to stderr [[#456](https://github.com/anchore/syft/pull/456)] 26 | 27 | **[(Full Changelog)](https://github.com/anchore/syft/compare/v0.19.0...v0.19.1)** 28 | 29 | --- 30 | 31 | [TestMarkdownPresenter_Present_NoChanges - 1] 32 | # Changelog 33 | 34 | **[(Full Changelog)](https://github.com/anchore/syft/compare/v0.19.0...v0.19.1)** 35 | 36 | --- 37 | -------------------------------------------------------------------------------- /chronicle/release/releasers/github/testdata/create-v0.3.0-dev-repo.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -eux -o pipefail 3 | 4 | if [ -d "/path/to/dir" ] 5 | then 6 | echo "fixture already exists!" 7 | exit 0 8 | else 9 | echo "creating fixture..." 10 | fi 11 | 12 | git init repos/v0.3.0-dev-repo 13 | 14 | pushd repos/v0.3.0-dev-repo 15 | 16 | git config --local user.email "nope@nope.com" 17 | git config --local user.name "nope" 18 | 19 | git remote add origin git@github.com:wagoodman/count-goober.git 20 | 21 | trap 'popd' EXIT 22 | 23 | git commit -m 'something' --allow-empty 24 | git commit -m 'something-else' --allow-empty 25 | git tag v0.1.0 26 | 27 | git commit -m 'fix: after-0.1.0' --allow-empty 28 | git commit -m 'fix: also-after-0.1.0' --allow-empty 29 | git commit -m 'fix: nothing was working' --allow-empty 30 | git tag v0.1.1 31 | 32 | git commit -m 'fix: bad release of 0.1.1' --allow-empty 33 | git commit -m 'feat: implement everything that wasnt there' --allow-empty 34 | git commit -m 'fix: missed something of everything' --allow-empty 35 | git tag v0.2.0 36 | 37 | git commit -m 'feat: working on next release item' --allow-empty 38 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # local development tailoring 2 | go.work 3 | go.work.sum 4 | .tool-versions 5 | .mise.toml 6 | 7 | # tool and bin directories 8 | .tmp/ 9 | bin/ 10 | /bin 11 | /.bin 12 | /build 13 | CHANGELOG.md 14 | VERSION 15 | .mise.toml 16 | .tool/ 17 | /bin 18 | /test/results 19 | /dist 20 | /snapshot 21 | /.tool 22 | /.task 23 | 24 | # changelog generation 25 | CHANGELOG.md 26 | VERSION 27 | 28 | # IDE configuration 29 | .vscode/ 30 | .idea/ 31 | .server/ 32 | .history/ 33 | 34 | # test related 35 | *.fingerprint 36 | /test/results 37 | coverage.txt 38 | *.log 39 | 40 | # probable archives 41 | .images 42 | *.tar 43 | *.jar 44 | *.war 45 | *.ear 46 | *.jpi 47 | *.hpi 48 | *.zip 49 | *.iml 50 | 51 | # Binaries for programs and plugins 52 | *.exe 53 | *.exe~ 54 | *.dll 55 | *.so 56 | *.dylib 57 | 58 | # Test binary, build with `go test -c` 59 | *.test 60 | 61 | # Output of the go coverage tool, specifically when used with LiteIDE 62 | *.out 63 | 64 | # macOS Finder metadata 65 | .DS_STORE 66 | 67 | *.profile 68 | 69 | # attestation 70 | cosign.key 71 | cosign.pub 72 | 73 | # Byte-compiled object files for python 74 | __pycache__/ 75 | *.py[cod] 76 | *$py.class -------------------------------------------------------------------------------- /cmd/chronicle/cli/commands/create_presenter.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/wagoodman/go-presenter" 7 | 8 | "github.com/anchore/chronicle/chronicle/release" 9 | "github.com/anchore/chronicle/chronicle/release/format" 10 | "github.com/anchore/chronicle/chronicle/release/format/json" 11 | "github.com/anchore/chronicle/chronicle/release/format/markdown" 12 | ) 13 | 14 | type presentationTask func(title string, description release.Description) (presenter.Presenter, error) 15 | 16 | func selectPresenter(f format.Format) (presentationTask, error) { 17 | switch f { 18 | case format.MarkdownFormat: 19 | return presentMarkdown, nil 20 | case format.JSONFormat: 21 | return presentJSON, nil 22 | default: 23 | return nil, fmt.Errorf("unsupported output format: %+v", f) 24 | } 25 | } 26 | 27 | func presentMarkdown(title string, description release.Description) (presenter.Presenter, error) { 28 | return markdown.NewMarkdownPresenter(markdown.Config{ 29 | Description: description, 30 | Title: title, 31 | }) 32 | } 33 | 34 | func presentJSON(_ string, description release.Description) (presenter.Presenter, error) { 35 | return json.NewJSONPresenter(description) 36 | } 37 | -------------------------------------------------------------------------------- /chronicle/release/version_speculator.go: -------------------------------------------------------------------------------- 1 | package release 2 | 3 | import "github.com/anchore/chronicle/chronicle/release/change" 4 | 5 | // SpeculationBehavior contains configuration that controls how to determine the next release version. 6 | type SpeculationBehavior struct { 7 | EnforceV0 bool // if true, and the version is currently < v1.0 breaking changes do NOT bump the major semver field; instead the minor version is bumped. 8 | NoChangesBumpsPatch bool // if true, and no changes make up the current release, still bump the patch semver field. 9 | } 10 | 11 | // VersionSpeculator is something that is capable of surmising the next release based on the set of changes from the last release. 12 | type VersionSpeculator interface { 13 | // NextIdealVersion reports the next version based on the currentVersion and a set of changes 14 | NextIdealVersion(currentVersion string, changes change.Changes) (string, error) 15 | 16 | // NextUniqueVersion is the same as NextIdealVersion, however, it additionally considers if the final speculated version is already released. If so, then the next non-released patch version (relative to the ideal version) is returned. 17 | NextUniqueVersion(currentVersion string, changes change.Changes) (string, error) 18 | } 19 | -------------------------------------------------------------------------------- /cmd/chronicle/cli/cli.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "github.com/anchore/chronicle/chronicle" 5 | "github.com/anchore/chronicle/cmd/chronicle/cli/commands" 6 | "github.com/anchore/clio" 7 | ) 8 | 9 | func New(id clio.Identification) clio.Application { 10 | clioCfg := clio.NewSetupConfig(id). 11 | WithGlobalConfigFlag(). // add persistent -c for reading an application config from 12 | WithGlobalLoggingFlags(). // add persistent -v and -q flags tied to the logging config 13 | WithConfigInRootHelp(). // --help on the root command renders the full application config in the help text 14 | WithNoBus(). 15 | WithInitializers( 16 | func(state *clio.State) error { 17 | // clio is setting up and providing the bus, redact store, and logger to the application. Once loaded, 18 | // we can hoist them into the internal packages for global use. 19 | chronicle.SetBus(state.Bus) 20 | chronicle.SetLogger(state.Logger) 21 | return nil 22 | }, 23 | ) 24 | 25 | app := clio.New(*clioCfg) 26 | 27 | create := commands.Create(app) 28 | 29 | root := commands.Root(app, create) 30 | 31 | root.AddCommand(create) 32 | root.AddCommand(commands.NextVersion(app)) 33 | root.AddCommand(clio.VersionCommand(id)) 34 | 35 | return app 36 | } 37 | -------------------------------------------------------------------------------- /internal/git/mock_git.go: -------------------------------------------------------------------------------- 1 | package git 2 | 3 | var _ Interface = (*MockInterface)(nil) 4 | 5 | type MockInterface struct { 6 | MockHeadOrTagCommit string 7 | MockHeadTag string 8 | MockTags []string 9 | MockRemoteURL string 10 | MockSearchTag string 11 | MockCommitsBetween []string 12 | MockFirstCommit string 13 | } 14 | 15 | func (m MockInterface) CommitsBetween(_ Range) ([]string, error) { 16 | return m.MockCommitsBetween, nil 17 | } 18 | 19 | func (m MockInterface) HeadTagOrCommit() (string, error) { 20 | return m.MockHeadOrTagCommit, nil 21 | } 22 | 23 | func (m MockInterface) HeadTag() (string, error) { 24 | return m.MockHeadTag, nil 25 | } 26 | 27 | func (m MockInterface) RemoteURL() (string, error) { 28 | return m.MockRemoteURL, nil 29 | } 30 | 31 | func (m MockInterface) SearchForTag(_ string) (*Tag, error) { 32 | if m.MockSearchTag == "" { 33 | return nil, nil 34 | } 35 | return &Tag{Name: m.MockSearchTag}, nil 36 | } 37 | 38 | func (m MockInterface) TagsFromLocal() ([]Tag, error) { 39 | var tags []Tag 40 | for _, t := range m.MockTags { 41 | tags = append(tags, Tag{ 42 | Name: t, 43 | }) 44 | } 45 | return tags, nil 46 | } 47 | 48 | func (m MockInterface) FirstCommit() (string, error) { 49 | return m.MockFirstCommit, nil 50 | } 51 | -------------------------------------------------------------------------------- /internal/git/first_test.go: -------------------------------------------------------------------------------- 1 | package git 2 | 3 | import ( 4 | "fmt" 5 | "os/exec" 6 | "strings" 7 | "testing" 8 | 9 | "github.com/stretchr/testify/assert" 10 | "github.com/stretchr/testify/require" 11 | ) 12 | 13 | func TestFirstCommit(t *testing.T) { 14 | tests := []struct { 15 | name string 16 | repoPath string 17 | want string 18 | wantErr assert.ErrorAssertionFunc 19 | }{ 20 | { 21 | name: "gocase", 22 | repoPath: "testdata/repos/tag-range-repo", 23 | want: gitFirstCommit(t, "testdata/repos/tag-range-repo"), 24 | }, 25 | } 26 | for _, tt := range tests { 27 | t.Run(tt.name, func(t *testing.T) { 28 | if tt.wantErr == nil { 29 | tt.wantErr = assert.NoError 30 | } 31 | got, err := FirstCommit(tt.repoPath) 32 | if !tt.wantErr(t, err, fmt.Sprintf("FirstCommit(%v)", tt.repoPath)) { 33 | return 34 | } 35 | assert.Equalf(t, tt.want, got, "FirstCommit(%v)", tt.repoPath) 36 | }) 37 | } 38 | } 39 | 40 | func gitFirstCommit(t *testing.T, path string) string { 41 | t.Helper() 42 | 43 | cmd := exec.Command("git", "--no-pager", "log", "--reverse", `--pretty=format:%H`) 44 | cmd.Dir = path 45 | output, err := cmd.Output() 46 | require.NoError(t, err) 47 | 48 | rows := strings.Split(strings.TrimSpace(string(output)), "\n") 49 | require.NotEmpty(t, rows) 50 | return rows[0] 51 | } 52 | -------------------------------------------------------------------------------- /chronicle/release/releasers/github/find_changelog_end_tag_test.go: -------------------------------------------------------------------------------- 1 | package github 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | "github.com/stretchr/testify/require" 8 | 9 | "github.com/anchore/chronicle/chronicle/release" 10 | "github.com/anchore/chronicle/internal/git" 11 | ) 12 | 13 | func TestFindChangelogEndTag(t *testing.T) { 14 | tests := []struct { 15 | name string 16 | summer release.Summarizer 17 | gitter git.Interface 18 | want string 19 | wantErr require.ErrorAssertionFunc 20 | }{ 21 | { 22 | name: "no release for existing tag at head should return head tag", 23 | summer: release.MockSummarizer{}, 24 | gitter: git.MockInterface{ 25 | MockHeadTag: "v0.1.0", 26 | }, 27 | want: "v0.1.0", 28 | }, 29 | { 30 | name: "release for existing tag at head should return no tag", 31 | gitter: git.MockInterface{ 32 | MockHeadTag: "v0.1.0", 33 | }, 34 | summer: release.MockSummarizer{ 35 | MockRelease: "v0.1.0", 36 | }, 37 | want: "", 38 | }, 39 | } 40 | for _, tt := range tests { 41 | t.Run(tt.name, func(t *testing.T) { 42 | if tt.wantErr == nil { 43 | tt.wantErr = require.NoError 44 | } 45 | got, err := FindChangelogEndTag(tt.summer, tt.gitter) 46 | tt.wantErr(t, err) 47 | assert.Equal(t, tt.want, got) 48 | }) 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /chronicle/release/summarizer.go: -------------------------------------------------------------------------------- 1 | package release 2 | 3 | import ( 4 | "github.com/anchore/chronicle/chronicle/release/change" 5 | ) 6 | 7 | // Summarizer is an abstraction for summarizing release information from a source (e.g. GitBub, GitLab, local repo tags, etc). 8 | type Summarizer interface { 9 | // LastRelease returns the last posted release (chronologically) from a source (e.g. a GitHub Release entry via the API). If no release can be found then nil is returned (without an error). 10 | LastRelease() (*Release, error) 11 | 12 | // Release returns the specific release for the given ref (e.g. a tag or commit that has a GitHub Release entry via the API). If no release can be found then nil is returned (without an error) 13 | Release(ref string) (*Release, error) 14 | 15 | // Changes returns all changes between the two given references (e.g. tag or commits). If `untilRef` is not provided then the latest VCS change found will be used. 16 | Changes(sinceRef, untilRef string) ([]change.Change, error) 17 | 18 | // ReferenceURL is the URL to find more information about this release, e.g. https://github.com/anchore/chronicle/releases/tag/v0.4.1 . 19 | ReferenceURL(tag string) string 20 | 21 | // ChangesURL is the URL to find the specific source changes that makeup this release, e.g. https://github.com/anchore/chronicle/compare/v0.3.0...v0.4.1 . 22 | ChangesURL(sinceRef, untilRef string) string 23 | } 24 | -------------------------------------------------------------------------------- /chronicle/release/releasers/github/find_changelog_end_tag.go: -------------------------------------------------------------------------------- 1 | package github 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/anchore/chronicle/chronicle/release" 7 | "github.com/anchore/chronicle/internal/git" 8 | "github.com/anchore/chronicle/internal/log" 9 | ) 10 | 11 | func FindChangelogEndTag(summer release.Summarizer, gitter git.Interface) (string, error) { 12 | // check if the current commit is tagged, then use that 13 | currentTag, err := gitter.HeadTag() 14 | if err != nil { 15 | return "", fmt.Errorf("problem while attempting to find head tag: %w", err) 16 | } 17 | if currentTag == "" { 18 | return "", nil 19 | } 20 | 21 | if taggedRelease, err := summer.Release(currentTag); err != nil { 22 | // TODO: assert the error specifically confirms that the release does not exist, not just any error 23 | // no release found, assume that this is the correct release info 24 | return "", fmt.Errorf("unable to fetch release=%q : %w", currentTag, err) 25 | } else if taggedRelease != nil { 26 | log.WithFields("tag", currentTag).Debug("found existing tag however, it already has an associated release. ignoring...") 27 | // return commitRef, nil 28 | return "", nil 29 | } 30 | 31 | log.WithFields("tag", currentTag).Debug("found existing tag at HEAD which does not have an associated release") 32 | 33 | // a tag was found and there is no existing release for this tag 34 | return currentTag, nil 35 | } 36 | -------------------------------------------------------------------------------- /internal/git/interface.go: -------------------------------------------------------------------------------- 1 | package git 2 | 3 | import "fmt" 4 | 5 | var _ Interface = (*gitter)(nil) 6 | 7 | type Interface interface { 8 | FirstCommit() (string, error) 9 | HeadTagOrCommit() (string, error) 10 | HeadTag() (string, error) 11 | RemoteURL() (string, error) 12 | SearchForTag(tagRef string) (*Tag, error) 13 | TagsFromLocal() ([]Tag, error) 14 | CommitsBetween(Range) ([]string, error) 15 | } 16 | 17 | type gitter struct { 18 | repoPath string 19 | } 20 | 21 | func New(repoPath string) (Interface, error) { 22 | if !IsRepository(repoPath) { 23 | return nil, fmt.Errorf("not a git repository: %q", repoPath) 24 | } 25 | return gitter{ 26 | repoPath: repoPath, 27 | }, nil 28 | } 29 | 30 | func (g gitter) CommitsBetween(cfg Range) ([]string, error) { 31 | return CommitsBetween(g.repoPath, cfg) 32 | } 33 | 34 | func (g gitter) HeadTagOrCommit() (string, error) { 35 | return HeadTagOrCommit(g.repoPath) 36 | } 37 | 38 | func (g gitter) HeadTag() (string, error) { 39 | return HeadTag(g.repoPath) 40 | } 41 | 42 | func (g gitter) RemoteURL() (string, error) { 43 | return RemoteURL(g.repoPath) 44 | } 45 | 46 | func (g gitter) SearchForTag(tagRef string) (*Tag, error) { 47 | return SearchForTag(g.repoPath, tagRef) 48 | } 49 | 50 | func (g gitter) TagsFromLocal() ([]Tag, error) { 51 | return TagsFromLocal(g.repoPath) 52 | } 53 | 54 | func (g gitter) FirstCommit() (string, error) { 55 | return FirstCommit(g.repoPath) 56 | } 57 | -------------------------------------------------------------------------------- /chronicle/release/change/change.go: -------------------------------------------------------------------------------- 1 | package change 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | var UnknownType = NewType("unknown", SemVerUnknown) 8 | var UnknownTypes = []Type{UnknownType} 9 | 10 | type Changes []Change 11 | 12 | // Change represents the smallest unit within a release that can be summarized. 13 | type Change struct { 14 | Text string // title or short summary describing the change (e.g. GitHub issue or PR title) 15 | ChangeTypes []Type // the kind(s) of change(s) this specific change description represents (e.g. breaking, enhancement, patch, etc.) 16 | Timestamp time.Time // the timestamp best representing when the change was committed to the VCS baseline (e.g. GitHub PR merged). 17 | References []Reference // any URLs that relate to the change 18 | EntryType string // a free-form helper string that indicates where the change came from (e.g. a "github-issue"). This can be useful for parsing the `Entry` field. 19 | Entry interface{} // the original data entry from the source that represents the change. The `EntryType` field should be used to help indicate how the shape should be interpreted. 20 | } 21 | 22 | // Reference indicates where you can find additional information about a particular change. 23 | type Reference struct { 24 | Text string 25 | URL string 26 | } 27 | 28 | // ByChangeType returns the set of changes that match one of the given change types. 29 | func (s Changes) ByChangeType(types ...Type) (result Changes) { 30 | for _, summary := range s { 31 | if ContainsAny(types, summary.ChangeTypes) { 32 | result = append(result, summary) 33 | } 34 | } 35 | return result 36 | } 37 | -------------------------------------------------------------------------------- /RELEASE.md: -------------------------------------------------------------------------------- 1 | # Release 2 | 3 | A release of chronicle comprises: 4 | - a new semver git tag from the current tip of the main branch 5 | - a new [github release](https://github.com/anchore/chronicle/releases) with a changelog and archived binary assets 6 | 7 | Ideally releasing should be done often with small increments when possible. Unless a 8 | breaking change is blocking the release, or no fixes/features have been merged, a good 9 | target release cadence is between every 1 or 2 weeks. 10 | 11 | 12 | ## Creating a release 13 | 14 | This release process itself should be as automated as possible, and has only a few steps: 15 | 16 | 1. **Trigger a new release with `make release`**. At this point you'll see a preview 17 | changelog in the terminal. If you're happy with the changelog, press `y` to continue, otherwise 18 | you can abort and adjust the labels on the PRs and issues to be included in the release and 19 | re-run the release trigger command. 20 | 21 | 1. A release admin must approve the release on the GitHub Actions [release pipeline](https://github.com/anchore/chronicle/actions/workflows/release.yaml) run page. 22 | Once approved, the release pipeline will generate all assets and publish a GitHub Release. 23 | 24 | 25 | ## Retracting a release 26 | 27 | If a release is found to be problematic, it can be retracted with the following steps: 28 | 29 | - Deleting the GitHub Release 30 | - Add a new `retract` entry in the go.mod for the versioned release 31 | 32 | **Note**: do not delete release tags from the git repository since there may already be references to the release 33 | in the go proxy, which will cause confusion when trying to reuse the tag later (the H1 hash will not match and there 34 | will be a warning when users try to pull the new release). 35 | 36 | -------------------------------------------------------------------------------- /internal/regex_helpers.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import "regexp" 4 | 5 | // MatchNamedCaptureGroups takes a regular expression and string and returns all of the named capture group results in a map. 6 | // This is only for the first match in the regex. Callers shouldn't be providing regexes with multiple capture groups with the same name. 7 | func MatchNamedCaptureGroups(regEx *regexp.Regexp, content string) map[string]string { 8 | // note: we are looking across all matches and stopping on the first non-empty match. Why? Take the following example: 9 | // input: "cool something to match against" pattern: `((?Pmatch) (?Pagainst))?`. Since the pattern is 10 | // encapsulated in an optional capture group, there will be results for each character, but the results will match 11 | // on nothing. The only "true" match will be at the end ("match against"). 12 | allMatches := regEx.FindAllStringSubmatch(content, -1) 13 | var results map[string]string 14 | for _, match := range allMatches { 15 | // fill a candidate results map with named capture group results, accepting empty values, but not groups with 16 | // no names 17 | for nameIdx, name := range regEx.SubexpNames() { 18 | if nameIdx > len(match) || len(name) == 0 { 19 | continue 20 | } 21 | if results == nil { 22 | results = make(map[string]string) 23 | } 24 | results[name] = match[nameIdx] 25 | } 26 | // note: since we are looking for the first best potential match we should stop when we find the first one 27 | // with non-empty results. 28 | if !isEmptyMap(results) { 29 | break 30 | } 31 | } 32 | return results 33 | } 34 | 35 | func isEmptyMap(m map[string]string) bool { 36 | if len(m) == 0 { 37 | return true 38 | } 39 | for _, value := range m { 40 | if value != "" { 41 | return false 42 | } 43 | } 44 | return true 45 | } 46 | -------------------------------------------------------------------------------- /cmd/chronicle/cli/commands/next_version.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/spf13/cobra" 8 | 9 | "github.com/anchore/chronicle/cmd/chronicle/cli/options" 10 | "github.com/anchore/chronicle/internal/git" 11 | "github.com/anchore/chronicle/internal/log" 12 | "github.com/anchore/clio" 13 | ) 14 | 15 | type nextVersion struct { 16 | RepoPath string `yaml:"repo-path" json:"repo-path" mapstructure:"-"` 17 | EnforceV0 options.EnforceV0 `yaml:"enforce-v0" json:"enforce-v0" mapstructure:"enforce-v0"` 18 | } 19 | 20 | func NextVersion(app clio.Application) *cobra.Command { 21 | cfg := &nextVersion{} 22 | 23 | return app.SetupCommand(&cobra.Command{ 24 | Use: "next-version [PATH]", 25 | Short: "Guess the next version based on the changelog diff from the last release", 26 | Args: func(cmd *cobra.Command, args []string) error { 27 | if err := cobra.MaximumNArgs(1)(cmd, args); err != nil { 28 | return err 29 | } 30 | 31 | var repo = "./" 32 | if len(args) == 1 { 33 | if !git.IsRepository(args[0]) { 34 | return fmt.Errorf("given path is not a git repository: %s", args[0]) 35 | } 36 | repo = args[0] 37 | } else { 38 | log.Infof("no repository path given, assuming %q", repo) 39 | } 40 | cfg.RepoPath = repo 41 | return nil 42 | }, 43 | RunE: func(_ *cobra.Command, _ []string) error { 44 | return runNextVersion(cfg) 45 | }, 46 | }, cfg) 47 | } 48 | 49 | func runNextVersion(cfg *nextVersion) error { 50 | appConfig := &createConfig{ 51 | EnforceV0: cfg.EnforceV0, 52 | RepoPath: cfg.RepoPath, 53 | } 54 | appConfig.SpeculateNextVersion = true 55 | worker := selectWorker(cfg.RepoPath) 56 | 57 | _, description, err := worker(appConfig) 58 | if err != nil { 59 | return err 60 | } 61 | 62 | _, err = os.Stdout.Write([]byte(description.Version)) 63 | 64 | return err 65 | } 66 | -------------------------------------------------------------------------------- /internal/git/head.go: -------------------------------------------------------------------------------- 1 | package git 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/go-git/go-git/v5" 7 | "github.com/go-git/go-git/v5/plumbing" 8 | ) 9 | 10 | func HeadTagOrCommit(repoPath string) (string, error) { 11 | return headTag(repoPath, true) 12 | } 13 | 14 | func HeadTag(repoPath string) (string, error) { 15 | return headTag(repoPath, false) 16 | } 17 | 18 | func headTag(repoPath string, orCommit bool) (string, error) { 19 | r, err := git.PlainOpen(repoPath) 20 | if err != nil { 21 | return "", fmt.Errorf("unable to open repo: %w", err) 22 | } 23 | ref, err := r.Head() 24 | if err != nil { 25 | return "", fmt.Errorf("unable fetch head: %w", err) 26 | } 27 | 28 | tagRefs, _ := r.Tags() 29 | var tagName string 30 | 31 | _ = tagRefs.ForEach(func(t *plumbing.Reference) error { 32 | if t.Hash().String() == ref.Hash().String() { 33 | // for lightweight tags 34 | tagName = t.Name().Short() 35 | return fmt.Errorf("found") 36 | } 37 | 38 | // this is an annotated tag... since annotated tags are stored within their own commit we need to resolve the 39 | // revision to get the commit the tag object points to (that is the commit with the code blob). 40 | revHash, err := r.ResolveRevision(plumbing.Revision(t.Name())) 41 | if err != nil { 42 | return nil 43 | } 44 | 45 | if revHash == nil { 46 | return nil 47 | } 48 | 49 | if *revHash == ref.Hash() { 50 | tagName = t.Name().Short() 51 | return fmt.Errorf("found") 52 | } 53 | return nil 54 | }) 55 | 56 | if tagName != "" { 57 | return tagName, nil 58 | } 59 | 60 | if orCommit { 61 | return ref.Hash().String(), nil 62 | } 63 | return "", nil 64 | } 65 | 66 | func HeadCommit(repoPath string) (string, error) { 67 | r, err := git.PlainOpen(repoPath) 68 | if err != nil { 69 | return "", fmt.Errorf("unable to open repo: %w", err) 70 | } 71 | ref, err := r.Head() 72 | if err != nil { 73 | return "", fmt.Errorf("unable fetch head: %w", err) 74 | } 75 | return ref.Hash().String(), nil 76 | } 77 | -------------------------------------------------------------------------------- /internal/log/log.go: -------------------------------------------------------------------------------- 1 | package log 2 | 3 | import ( 4 | "github.com/anchore/go-logger" 5 | "github.com/anchore/go-logger/adapter/discard" 6 | ) 7 | 8 | // log is the singleton used to facilitate logging internally within chronicle 9 | var log = discard.New() 10 | 11 | func Set(logger logger.Logger) { 12 | log = logger 13 | } 14 | 15 | // Errorf takes a formatted template string and template arguments for the error logging level. 16 | func Errorf(format string, args ...interface{}) { 17 | log.Errorf(format, args...) 18 | } 19 | 20 | // Error logs the given arguments at the error logging level. 21 | func Error(args ...interface{}) { 22 | log.Error(args...) 23 | } 24 | 25 | // Warnf takes a formatted template string and template arguments for the warning logging level. 26 | func Warnf(format string, args ...interface{}) { 27 | log.Warnf(format, args...) 28 | } 29 | 30 | // Warn logs the given arguments at the warning logging level. 31 | func Warn(args ...interface{}) { 32 | log.Warn(args...) 33 | } 34 | 35 | // Infof takes a formatted template string and template arguments for the info logging level. 36 | func Infof(format string, args ...interface{}) { 37 | log.Infof(format, args...) 38 | } 39 | 40 | // Info logs the given arguments at the info logging level. 41 | func Info(args ...interface{}) { 42 | log.Info(args...) 43 | } 44 | 45 | // Debugf takes a formatted template string and template arguments for the debug logging level. 46 | func Debugf(format string, args ...interface{}) { 47 | log.Debugf(format, args...) 48 | } 49 | 50 | // Debug logs the given arguments at the debug logging level. 51 | func Debug(args ...interface{}) { 52 | log.Debug(args...) 53 | } 54 | 55 | // Tracef takes a formatted template string and template arguments for the trace logging level. 56 | func Tracef(format string, args ...interface{}) { 57 | log.Tracef(format, args...) 58 | } 59 | 60 | // Trace logs the given arguments at the trace logging level. 61 | func Trace(args ...interface{}) { 62 | log.Trace(args...) 63 | } 64 | 65 | // WithFields returns a message logger with multiple key-value fields. 66 | func WithFields(fields ...interface{}) logger.MessageLogger { 67 | return log.WithFields(fields...) 68 | } 69 | 70 | // Nested returns a new logger with hard coded key-value pairs 71 | func Nested(fields ...interface{}) logger.Logger { 72 | return log.Nested(fields...) 73 | } 74 | -------------------------------------------------------------------------------- /cmd/chronicle/cli/commands/create_github_worker.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/anchore/chronicle/chronicle/release" 7 | "github.com/anchore/chronicle/chronicle/release/change" 8 | "github.com/anchore/chronicle/chronicle/release/releasers/github" 9 | "github.com/anchore/chronicle/internal/git" 10 | "github.com/anchore/chronicle/internal/log" 11 | ) 12 | 13 | func createChangelogFromGithub(appConfig *createConfig) (*release.Release, *release.Description, error) { 14 | ghConfig := appConfig.Github.ToGithubConfig() 15 | 16 | gitter, err := git.New(appConfig.RepoPath) 17 | if err != nil { 18 | return nil, nil, err 19 | } 20 | 21 | summer, err := github.NewSummarizer(gitter, ghConfig) 22 | if err != nil { 23 | return nil, nil, fmt.Errorf("unable to create summarizer: %w", err) 24 | } 25 | 26 | changeTypeTitles := getGithubSupportedChanges(appConfig) 27 | 28 | var untilTag = appConfig.UntilTag 29 | if untilTag == "" { 30 | untilTag, err = github.FindChangelogEndTag(summer, gitter) 31 | if err != nil { 32 | return nil, nil, err 33 | } 34 | } 35 | 36 | if untilTag != "" { 37 | log.WithFields("tag", untilTag).Trace("until the given tag") 38 | } else { 39 | log.Trace("until the current revision") 40 | } 41 | 42 | var speculator release.VersionSpeculator 43 | if appConfig.SpeculateNextVersion { 44 | speculator = github.NewVersionSpeculator(gitter, release.SpeculationBehavior{ 45 | EnforceV0: bool(appConfig.EnforceV0), 46 | NoChangesBumpsPatch: true, 47 | }) 48 | } 49 | 50 | changelogConfig := release.ChangelogInfoConfig{ 51 | RepoPath: appConfig.RepoPath, 52 | SinceTag: appConfig.SinceTag, 53 | UntilTag: untilTag, 54 | VersionSpeculator: speculator, 55 | ChangeTypeTitles: changeTypeTitles, 56 | } 57 | 58 | return release.ChangelogInfo(summer, changelogConfig) 59 | } 60 | 61 | func getGithubSupportedChanges(appConfig *createConfig) []change.TypeTitle { 62 | var supportedChanges []change.TypeTitle 63 | for _, c := range appConfig.Github.Changes { 64 | // TODO: this could be one source of truth upstream 65 | k := change.ParseSemVerKind(c.SemVerKind) 66 | t := change.NewType(c.Type, k) 67 | supportedChanges = append(supportedChanges, change.TypeTitle{ 68 | ChangeType: t, 69 | Title: c.Title, 70 | }) 71 | } 72 | return supportedChanges 73 | } 74 | -------------------------------------------------------------------------------- /.binny.yaml: -------------------------------------------------------------------------------- 1 | tools: 2 | # we want to use a pinned version of binny to manage the toolchain (so binny manages itself!) 3 | - name: binny 4 | version: 5 | want: v0.9.0 6 | method: github-release 7 | with: 8 | repo: anchore/binny 9 | 10 | # used to produce SBOMs during release 11 | - name: syft 12 | version: 13 | want: latest 14 | method: github-release 15 | with: 16 | repo: anchore/syft 17 | 18 | # used for signing the checksums file at release 19 | - name: cosign 20 | version: 21 | want: v2.4.1 22 | method: github-release 23 | with: 24 | repo: sigstore/cosign 25 | 26 | # used to sign mac binaries at release 27 | - name: quill 28 | version: 29 | want: v0.4.2 30 | method: github-release 31 | with: 32 | repo: anchore/quill 33 | 34 | # used for linting 35 | - name: golangci-lint 36 | version: 37 | want: v2.3.0 38 | method: github-release 39 | with: 40 | repo: golangci/golangci-lint 41 | 42 | # used for showing the changelog at release 43 | - name: glow 44 | version: 45 | want: v2.0.0 46 | method: github-release 47 | with: 48 | repo: charmbracelet/glow 49 | 50 | # used to release all artifacts 51 | - name: goreleaser 52 | version: 53 | want: v2.3.2 54 | method: github-release 55 | with: 56 | repo: goreleaser/goreleaser 57 | 58 | # used for organizing imports during static analysis 59 | - name: gosimports 60 | version: 61 | want: v0.3.8 62 | method: github-release 63 | with: 64 | repo: rinchsan/gosimports 65 | 66 | # used at release to generate the changelog 67 | - name: chronicle 68 | version: 69 | want: v0.8.0 70 | method: github-release 71 | with: 72 | repo: anchore/chronicle 73 | 74 | # used during static analysis for license compliance 75 | - name: bouncer 76 | version: 77 | want: v0.4.0 78 | method: github-release 79 | with: 80 | repo: wagoodman/go-bouncer 81 | 82 | # used for running all local and CI tasks 83 | - name: task 84 | version: 85 | want: v3.39.2 86 | method: github-release 87 | with: 88 | repo: go-task/task 89 | 90 | # used for triggering a release 91 | - name: gh 92 | version: 93 | want: v2.58.0 94 | method: github-release 95 | with: 96 | repo: cli/cli 97 | -------------------------------------------------------------------------------- /.github/workflows/validations.yaml: -------------------------------------------------------------------------------- 1 | name: "Validations" 2 | on: 3 | merge_group: 4 | workflow_dispatch: 5 | pull_request: 6 | push: 7 | branches: 8 | - main 9 | 10 | permissions: 11 | contents: read 12 | 13 | jobs: 14 | Static-Analysis: 15 | # Note: changing this job name requires making the same update in the .github/workflows/release.yaml pipeline 16 | name: "Static analysis" 17 | runs-on: ubuntu-22.04 18 | permissions: 19 | contents: read 20 | steps: 21 | - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 #v5.0.0 22 | with: 23 | persist-credentials: false 24 | 25 | - name: Bootstrap environment 26 | uses: anchore/go-make/.github/actions/bootstrap@latest 27 | 28 | - name: Run static analysis 29 | run: make static-analysis 30 | 31 | Unit-Test: 32 | # Note: changing this job name requires making the same update in the .github/workflows/release.yaml pipeline 33 | name: "Unit tests" 34 | needs: All-Unit-Tests 35 | runs-on: ubuntu-latest 36 | steps: 37 | - run: echo All Unit Tests passed 38 | 39 | All-Unit-Tests: 40 | runs-on: ${{ matrix.os }} 41 | strategy: 42 | matrix: 43 | os: 44 | - ubuntu-22.04 45 | - windows-2022 46 | - macos-14 47 | permissions: 48 | contents: read 49 | steps: 50 | - name: Bootstrap environment 51 | uses: anchore/go-make/.github/actions/bootstrap@latest 52 | 53 | - name: Run unit tests 54 | run: make unit 55 | 56 | Build-Snapshot-Artifacts: 57 | name: "Build snapshot artifacts" 58 | runs-on: ubuntu-22.04 59 | permissions: 60 | contents: read 61 | steps: 62 | - name: Bootstrap environment 63 | uses: anchore/go-make/.github/actions/bootstrap@latest 64 | with: 65 | # why have another build cache key? We don't want unit/integration/etc test build caches to replace 66 | # the snapshot build cache, which includes builds for all OSs and architectures. As long as this key is 67 | # unique from the build-cache-key-prefix in other CI jobs, we should be fine. 68 | # 69 | # note: ideally this value should match what is used in release (just to help with build times). 70 | cache-key-prefix: "snapshot" 71 | 72 | - name: Build snapshot artifacts 73 | run: make snapshot 74 | -------------------------------------------------------------------------------- /DEVELOPING.md: -------------------------------------------------------------------------------- 1 | # Developing 2 | 3 | ## Getting started 4 | 5 | In order to test and develop in this repo you will need the following dependencies installed: 6 | - make 7 | 8 | After cloning do the following: 9 | 1. run `make bootstrap` to download go mod dependencies, create the `/.tmp` dir, and download helper utilities. 10 | 2. run `make` to run linting, tests, and other verifications to make certain everything is working alright. 11 | 12 | Checkout `make help` to see what other actions you can take. 13 | 14 | The main make tasks for common static analysis and testing are `lint`, `format`, `lint-fix`, and `unit`. 15 | 16 | ## Architecture 17 | 18 | At the highest level chronicle creates a changelog based off of a source repo. This is done by the following flow: 19 | 20 | ```text 21 | since tag -> release.ChangelogInfo(...) -> release.Description -> presenter.Present(io.Writer) 22 | until tag -> 23 | repo path -> 24 | ... -> 25 | ``` 26 | 27 | The only support source to generate changelogs from at the time of this writing is GitHub, which enables chronicle 28 | to use GitHub releases, issues, and PRs to figure the contents of the next changelog. There are a few abstractions 29 | that this functionality has been implemented behind so that future support for other sources will be easier (e.g. GitLab): 30 | 31 | - `release.Summarizer` : This is meant to be the interface between the application and the source for all release information. Allows one to get information about the previous releases, the changes between two VCS references, and supporting URLs that one can visit to learn more. Implementing this is the first step to adding a new source. For the `github.Summarizer`, most of the work is done within `github.Summarizer.Changes()` fetching all issues and PRs and filtering down by date and label. 32 | 33 | - `release.VersionSpeculator` : an object that knows how to figure the next release version given the current release and a set of changes. 34 | 35 | In the `cmd` package, a worker that encapsulates creating the correct implementation of these abstractions for the detected or configured source are instantiated and passed to the common `release.ChangelogInfo` helper which returns a static description of all of the information necessary for changelog presentation. 36 | 37 | As of today chronicle supports outputting this description as either `json` or `markdown` which can be found in the `chronicle/format` package. -------------------------------------------------------------------------------- /internal/git/remote.go: -------------------------------------------------------------------------------- 1 | package git 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "os" 7 | "path" 8 | "regexp" 9 | 10 | "github.com/anchore/chronicle/internal" 11 | ) 12 | 13 | var remotePattern = regexp.MustCompile(`\[remote\s*"origin"]\s*\n\s*url\s*=\s*(?P[^\s]+)\s+`) 14 | 15 | // TODO: can't use r.Config for same validation reasons 16 | func RemoteURL(p string) (string, error) { 17 | f, err := os.Open(path.Join(p, ".git", "config")) 18 | if err != nil { 19 | return "", fmt.Errorf("unable to open git config: %w", err) 20 | } 21 | contents, err := io.ReadAll(f) 22 | if err != nil { 23 | return "", fmt.Errorf("unable to read git config: %w", err) 24 | } 25 | matches := internal.MatchNamedCaptureGroups(remotePattern, string(contents)) 26 | 27 | return matches["url"], nil 28 | } 29 | 30 | // TODO: can't use r.Config for same validation reasons 31 | // func RemoteURL(path string) (string, error) { 32 | // r, err := git.PlainOpen(path) 33 | // if err != nil { 34 | // return "", fmt.Errorf("unable to open repo: %w", err) 35 | // } 36 | // c, err := r.Config() 37 | // if err != nil { 38 | // return "", fmt.Errorf("unable to get config: %+v", err) 39 | // } 40 | // 41 | // for _, section := range c.Raw.Sections { 42 | // if section.Name == "remote" { 43 | // for _, subsection := range section.Subsections { 44 | // // TODO: make configurable 45 | // if subsection.Name == "origin" { 46 | // for _, option := range subsection.Options { 47 | // if option.Key == "url" { 48 | // return option.Value, nil 49 | // } 50 | // } 51 | // } 52 | // } 53 | // } 54 | // } 55 | // 56 | // return "", fmt.Errorf("unable to find origin url") 57 | //} 58 | 59 | // TODO: it seems that this lib has a config validation problem :( 60 | // func RemoteURL(path string) (string, error) { 61 | // r, err := git.PlainOpen(path) 62 | // if err != nil { 63 | // return "", fmt.Errorf("unable to open repo: %w", err) 64 | // } 65 | // 66 | // remotes, err := r.Remotes() 67 | // if err != nil { 68 | // return "", fmt.Errorf("unable to list repo remotes: %w", err) 69 | // } 70 | // 71 | // var repoUrl string 72 | // for _, remote := range remotes { 73 | // // TODO: this shouldn't be so absolutist about the origin ref 74 | // if remote.Config().Name == "origin" { 75 | // for _, url := range remote.Config().URLs { 76 | // // TODO: doesn't support enterprise instances 77 | // if strings.Contains(url, "github.com") { 78 | // repoUrl = url 79 | // } 80 | // } 81 | // } 82 | // } 83 | // 84 | // if repoUrl == "" { 85 | // return "", errors.New("failed to find repo URL") 86 | // } 87 | // return repoUrl, nil 88 | //} 89 | -------------------------------------------------------------------------------- /.goreleaser.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | release: 4 | # If set to auto, will mark the release as not ready for production in case there is an indicator for this in the 5 | # tag (e.g. v1.0.0-rc1). If set to true, will mark the release as not ready for production. 6 | prerelease: auto 7 | draft: false 8 | 9 | builds: 10 | - id: linux-build 11 | dir: ./cmd/chronicle 12 | binary: chronicle 13 | goos: 14 | - linux 15 | goarch: 16 | - amd64 17 | - arm64 18 | - ppc64le 19 | - s390x 20 | # set the modified timestamp on the output binary to the git timestamp to ensure a reproducible build 21 | mod_timestamp: &build-timestamp '{{ .CommitTimestamp }}' 22 | ldflags: &build-ldflags | 23 | -w 24 | -s 25 | -extldflags '-static' 26 | -X main.version={{.Version}} 27 | -X main.gitCommit={{.Commit}} 28 | -X main.buildDate={{.Date}} 29 | -X main.gitDescription={{.Summary}} 30 | 31 | - id: darwin-build 32 | dir: ./cmd/chronicle 33 | binary: chronicle 34 | goos: 35 | - darwin 36 | goarch: 37 | - amd64 38 | - arm64 39 | mod_timestamp: *build-timestamp 40 | ldflags: *build-ldflags 41 | hooks: 42 | post: 43 | - cmd: .tool/quill sign-and-notarize "{{ .Path }}" --dry-run={{ .IsSnapshot }} --ad-hoc={{ .IsSnapshot }} -vv 44 | env: 45 | - QUILL_LOG_FILE=/tmp/quill-{{ .Target }}.log 46 | 47 | - id: windows-build 48 | dir: ./cmd/chronicle 49 | binary: chronicle 50 | goos: [windows] 51 | goarch: [amd64] 52 | mod_timestamp: *build-timestamp 53 | ldflags: *build-ldflags 54 | 55 | 56 | nfpms: 57 | - vendor: "anchore" 58 | homepage: "https://github.com/anchore/chronicle" 59 | maintainer: "Alex Goodman " 60 | description: "A fast changelog generator sourced from PRs and Issues" 61 | license: "Apache-2.0" 62 | formats: 63 | - deb 64 | - rpm 65 | 66 | 67 | archives: 68 | - id: linux-archives 69 | builds: 70 | - linux-build 71 | 72 | # note: the signing process is depending on tar.gz archives. If this format changes then .github/scripts/apple-signing/*.sh will need to be adjusted 73 | - id: darwin-archives 74 | builds: 75 | - darwin-build 76 | 77 | sboms: 78 | - artifacts: archive 79 | # this is relative to the snapshot/dist directory, not the root of the repo 80 | cmd: ../.tool/syft 81 | documents: 82 | - "{{ .Binary }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}.sbom" 83 | args: 84 | - "scan" 85 | - "$artifact" 86 | - "--output" 87 | - "json=$document" 88 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | name: "CodeQL Security Scan" 7 | 8 | on: 9 | push: 10 | branches: [main] 11 | pull_request: 12 | # The branches below must be a subset of the branches above 13 | branches: [main] 14 | schedule: 15 | - cron: '0 0 * * 3' 16 | 17 | permissions: 18 | security-events: write 19 | 20 | jobs: 21 | analyze: 22 | name: Analyze 23 | runs-on: ubuntu-latest 24 | 25 | strategy: 26 | fail-fast: false 27 | matrix: 28 | # Override automatic language detection by changing the below list 29 | # Supported options are ['csharp', 'cpp', 'go', 'java', 'javascript', 'python'] 30 | language: ['go'] 31 | # Learn more... 32 | # https://docs.github.com/en/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#overriding-automatic-language-detection 33 | 34 | steps: 35 | - name: Checkout repository 36 | uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 #v5.0.0 37 | with: 38 | persist-credentials: false 39 | 40 | - name: Install Go 41 | uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 #v5.5.0 42 | with: 43 | go-version-file: go.mod 44 | 45 | # Initializes the CodeQL tools for scanning. 46 | - name: Initialize CodeQL 47 | uses: github/codeql-action/init@4e94bd11f71e507f7f87df81788dff88d1dacbfb #v4.31.0 48 | with: 49 | languages: ${{ matrix.language }} 50 | # If you wish to specify custom queries, you can do so here or in a config file. 51 | # By default, queries listed here will override any specified in a config file. 52 | # Prefix the list here with "+" to use these queries and those in the config file. 53 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 54 | 55 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 56 | # If this step fails, then you should remove it and run the build manually (see below) 57 | - name: Autobuild 58 | uses: github/codeql-action/autobuild@4e94bd11f71e507f7f87df81788dff88d1dacbfb #4.31.0 59 | 60 | # ℹ️ Command-line programs to run using the OS shell. 61 | # 📚 https://git.io/JvXDl 62 | 63 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 64 | # and modify them (or add more) to build your code if your project 65 | # uses a compiled language 66 | 67 | #- run: | 68 | # make bootstrap 69 | # make release 70 | 71 | - name: Perform CodeQL Analysis 72 | uses: github/codeql-action/analyze@4e94bd11f71e507f7f87df81788dff88d1dacbfb #v4.31.0 73 | -------------------------------------------------------------------------------- /internal/git/head_test.go: -------------------------------------------------------------------------------- 1 | package git 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestHeadTagOrCommit(t *testing.T) { 10 | tests := []struct { 11 | name string 12 | path string 13 | expects string 14 | expectsLength int 15 | }{ 16 | { 17 | name: "head has tag", 18 | path: "testdata/repos/tagged-repo", 19 | expects: "v0.1.0", 20 | }, 21 | { 22 | name: "head has annotated tag", 23 | path: "testdata/repos/annotated-tagged-repo", 24 | expects: "v0.1.0", 25 | }, 26 | { 27 | name: "head has no tag", 28 | path: "testdata/repos/commit-in-repo", 29 | // since we don't commit the exact fixture, we don't know what the value will be (but the length 30 | // of a commit string is fixed and is a good proxy here) 31 | expectsLength: 40, 32 | }, 33 | } 34 | for _, test := range tests { 35 | t.Run(test.name, func(t *testing.T) { 36 | actual, err := HeadTagOrCommit(test.path) 37 | assert.NoError(t, err) 38 | if test.expects != "" { 39 | assert.Equal(t, test.expects, actual) 40 | } 41 | if test.expectsLength != 0 { 42 | assert.Len(t, actual, test.expectsLength) 43 | } 44 | }) 45 | } 46 | } 47 | 48 | func TestHeadTag(t *testing.T) { 49 | tests := []struct { 50 | name string 51 | path string 52 | expects string 53 | }{ 54 | { 55 | name: "head has tag", 56 | path: "testdata/repos/tagged-repo", 57 | expects: "v0.1.0", 58 | }, 59 | { 60 | name: "head has no tag", 61 | path: "testdata/repos/commit-in-repo", 62 | }, 63 | } 64 | for _, test := range tests { 65 | t.Run(test.name, func(t *testing.T) { 66 | actual, err := HeadTag(test.path) 67 | assert.NoError(t, err) 68 | assert.Equal(t, test.expects, actual) 69 | }) 70 | } 71 | } 72 | 73 | func TestHeadCommit(t *testing.T) { 74 | tests := []struct { 75 | name string 76 | path string 77 | expects string 78 | expectsLength int 79 | }{ 80 | { 81 | name: "head has tag", 82 | path: "testdata/repos/tagged-repo", 83 | // since we don't commit the exact fixture, we don't know what the value will be (but the length 84 | // of a commit string is fixed and is a good proxy here) 85 | expectsLength: 40, 86 | }, 87 | { 88 | name: "head has no tag", 89 | path: "testdata/repos/commit-in-repo", 90 | // since we don't commit the exact fixture, we don't know what the value will be (but the length 91 | // of a commit string is fixed and is a good proxy here) 92 | expectsLength: 40, 93 | }, 94 | } 95 | for _, test := range tests { 96 | t.Run(test.name, func(t *testing.T) { 97 | actual, err := HeadCommit(test.path) 98 | assert.NoError(t, err) 99 | if test.expects != "" { 100 | assert.Equal(t, test.expects, actual) 101 | } 102 | if test.expectsLength != 0 { 103 | assert.Len(t, actual, test.expectsLength) 104 | } 105 | }) 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /cmd/chronicle/cli/commands/create.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/spf13/cobra" 8 | 9 | "github.com/anchore/chronicle/chronicle/release" 10 | "github.com/anchore/chronicle/chronicle/release/format" 11 | "github.com/anchore/chronicle/internal/git" 12 | "github.com/anchore/chronicle/internal/log" 13 | "github.com/anchore/clio" 14 | ) 15 | 16 | func Create(app clio.Application) *cobra.Command { 17 | appConfig := defaultCreateConfig() 18 | 19 | return app.SetupCommand(&cobra.Command{ 20 | Use: "create [PATH]", 21 | Short: "Generate a changelog from GitHub issues and PRs", 22 | Long: `Generate a changelog from GitHub issues and PRs. 23 | 24 | chronicle [flags] [PATH] 25 | 26 | Create a changelog representing the changes from tag v0.14.0 until the present (for ./) 27 | chronicle --since-tag v0.14.0 28 | 29 | Create a changelog representing the changes from tag v0.14.0 until v0.18.0 (for ../path/to/repo) 30 | chronicle --since-tag v0.14.0 --until-tag v0.18.0 ../path/to/repo 31 | 32 | `, 33 | Args: func(cmd *cobra.Command, args []string) error { 34 | if err := cobra.MaximumNArgs(1)(cmd, args); err != nil { 35 | _ = cmd.Help() 36 | return err 37 | } 38 | var repo = "./" 39 | if len(args) == 1 { 40 | if !git.IsRepository(args[0]) { 41 | return fmt.Errorf("given path is not a git repository: %s", args[0]) 42 | } 43 | repo = args[0] 44 | } else { 45 | log.Infof("no repository path given, assuming %q", repo) 46 | } 47 | appConfig.RepoPath = repo 48 | return nil 49 | }, 50 | RunE: func(_ *cobra.Command, _ []string) error { 51 | return runCreate(appConfig) 52 | }, 53 | }, appConfig) 54 | } 55 | 56 | func runCreate(appConfig *createConfig) error { 57 | worker := selectWorker(appConfig.RepoPath) 58 | 59 | _, description, err := worker(appConfig) 60 | if err != nil { 61 | return err 62 | } 63 | 64 | if appConfig.VersionFile != "" { 65 | f, err := os.OpenFile(appConfig.VersionFile, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0644) 66 | if err != nil { 67 | return fmt.Errorf("unable to open version file %q: %w", appConfig.VersionFile, err) 68 | } 69 | if _, err := f.WriteString(description.Version); err != nil { 70 | return fmt.Errorf("unable to write version to file %q: %w", appConfig.VersionFile, err) 71 | } 72 | } 73 | 74 | f := format.FromString(appConfig.Output) 75 | if f == nil { 76 | return fmt.Errorf("unable to parse output format: %q", appConfig.Output) 77 | } 78 | 79 | presenterTask, err := selectPresenter(*f) 80 | if err != nil { 81 | return err 82 | } 83 | 84 | p, err := presenterTask(appConfig.Title, *description) 85 | if err != nil { 86 | return err 87 | } 88 | 89 | return p.Present(os.Stdout) 90 | } 91 | 92 | //nolint:revive 93 | func selectWorker(repo string) func(*createConfig) (*release.Release, *release.Description, error) { 94 | // TODO: we only support github, but this is the spot to add support for other providers such as GitLab or Bitbucket or other VCSs altogether, such as subversion. 95 | return createChangelogFromGithub 96 | } 97 | -------------------------------------------------------------------------------- /cmd/chronicle/cli/commands/create_config.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/anchore/chronicle/chronicle/release/format" 7 | "github.com/anchore/chronicle/cmd/chronicle/cli/options" 8 | "github.com/anchore/fangs" 9 | ) 10 | 11 | type createConfig struct { 12 | Output string `yaml:"output" json:"output" mapstructure:"output"` // -o, the Presenter hint string to use for report formatting 13 | VersionFile string `yaml:"version-file" json:"version-file" mapstructure:"version-file"` // --version-file, the path to a file containing the version to use for the changelog 14 | SinceTag string `yaml:"since-tag" json:"since-tag" mapstructure:"since-tag"` // -s, the tag to start the changelog from 15 | UntilTag string `yaml:"until-tag" json:"until-tag" mapstructure:"until-tag"` // -u, the tag to end the changelog at 16 | Title string `yaml:"title" json:"title" mapstructure:"title"` 17 | Github options.GithubSummarizer `yaml:"github" json:"github" mapstructure:"github"` 18 | SpeculateNextVersion bool `yaml:"speculate-next-version" json:"speculate-next-version" mapstructure:"speculate-next-version"` // -n, guess the next version based on issues and PRs 19 | RepoPath string `yaml:"repo-path" json:"repo-path" mapstructure:"-"` 20 | EnforceV0 options.EnforceV0 `yaml:"enforce-v0" json:"enforce-v0" mapstructure:"enforce-v0"` 21 | } 22 | 23 | var _ fangs.FlagAdder = (*createConfig)(nil) 24 | 25 | func (c *createConfig) AddFlags(flags fangs.FlagSet) { 26 | flags.StringVarP( 27 | &c.Output, 28 | "output", "o", 29 | fmt.Sprintf("output format to use: %+v", format.All()), 30 | ) 31 | 32 | flags.StringVarP( 33 | &c.VersionFile, 34 | "version-file", "", 35 | "output the current version of the generated changelog to the given file", 36 | ) 37 | 38 | flags.StringVarP( 39 | &c.SinceTag, 40 | "since-tag", "s", 41 | "tag to start changelog processing from (inclusive)", 42 | ) 43 | 44 | flags.StringVarP( 45 | &c.UntilTag, 46 | "until-tag", "u", 47 | "tag to end changelog processing at (inclusive)", 48 | ) 49 | 50 | flags.StringVarP( 51 | &c.Title, 52 | "title", "t", 53 | "The title of the changelog output", 54 | ) 55 | 56 | flags.BoolVarP( 57 | &c.SpeculateNextVersion, 58 | "speculate-next-version", "n", 59 | "guess the next release version based off of issues and PRs in cases where there is no semver tag after --since-tag (cannot use with --until-tag)", 60 | ) 61 | } 62 | 63 | func defaultCreateConfig() *createConfig { 64 | return &createConfig{ 65 | Output: string(format.MarkdownFormat), 66 | VersionFile: "", 67 | SinceTag: "", 68 | UntilTag: "", 69 | Title: `{{ .Version }}`, 70 | RepoPath: "", 71 | SpeculateNextVersion: false, 72 | EnforceV0: false, 73 | Github: options.DefaultGithubSimmarizer(), 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /chronicle/release/releasers/github/version_speculator.go: -------------------------------------------------------------------------------- 1 | package github 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/coreos/go-semver/semver" 8 | 9 | "github.com/anchore/chronicle/chronicle/release" 10 | "github.com/anchore/chronicle/chronicle/release/change" 11 | "github.com/anchore/chronicle/internal/git" 12 | ) 13 | 14 | var _ release.VersionSpeculator = (*VersionSpeculator)(nil) 15 | 16 | type VersionSpeculator struct { 17 | git git.Interface 18 | release.SpeculationBehavior 19 | } 20 | 21 | func NewVersionSpeculator(gitter git.Interface, behavior release.SpeculationBehavior) VersionSpeculator { 22 | return VersionSpeculator{ 23 | git: gitter, 24 | SpeculationBehavior: behavior, 25 | } 26 | } 27 | 28 | func (s VersionSpeculator) NextIdealVersion(currentVersion string, changes change.Changes) (string, error) { 29 | var breaking, feature, patch bool 30 | for _, c := range changes { 31 | for _, chTy := range c.ChangeTypes { 32 | switch chTy.Kind { 33 | case change.SemVerMajor: 34 | if s.EnforceV0 { 35 | feature = true 36 | } else { 37 | breaking = true 38 | } 39 | case change.SemVerMinor: 40 | feature = true 41 | case change.SemVerPatch: 42 | patch = true 43 | } 44 | } 45 | } 46 | 47 | v, err := semver.NewVersion(strings.TrimLeft(currentVersion, "v")) 48 | if err != nil { 49 | return "", fmt.Errorf("invalid current version given: %q: %w", currentVersion, err) 50 | } 51 | original := *v 52 | 53 | if patch { 54 | v.BumpPatch() 55 | } 56 | 57 | if feature { 58 | v.BumpMinor() 59 | } 60 | 61 | if breaking { 62 | v.BumpMajor() 63 | } 64 | 65 | if v.String() == original.String() { 66 | if !s.NoChangesBumpsPatch { 67 | return "", fmt.Errorf("no changes found that affect the version (changes=%d)", len(changes)) 68 | } 69 | v.BumpPatch() 70 | } 71 | 72 | prefix := "" 73 | if strings.HasPrefix(currentVersion, "v") { 74 | prefix = "v" 75 | } 76 | return prefix + v.String(), nil 77 | } 78 | 79 | func (s VersionSpeculator) NextUniqueVersion(currentVersion string, changes change.Changes) (string, error) { 80 | nextReleaseVersion, err := s.NextIdealVersion(currentVersion, changes) 81 | if err != nil { 82 | return "", err 83 | } 84 | 85 | tags, err := s.git.TagsFromLocal() 86 | if err != nil { 87 | return "", err 88 | } 89 | retry: 90 | for { 91 | for _, t := range tags { 92 | if t.Name == nextReleaseVersion { 93 | // looks like there is already a tag for this speculative release, let's choose a patch variant of this 94 | verObj, err := semver.NewVersion(strings.TrimLeft(nextReleaseVersion, "v")) 95 | if err != nil { 96 | return "", err 97 | } 98 | verObj.BumpPatch() 99 | 100 | var prefix string 101 | if strings.HasPrefix(nextReleaseVersion, "v") { 102 | prefix = "v" 103 | } 104 | 105 | releaseVersionCandidate := prefix + verObj.String() 106 | 107 | nextReleaseVersion = releaseVersionCandidate 108 | continue retry 109 | } 110 | } 111 | // we've checked that there are no existing tags that match the next release 112 | break 113 | } 114 | 115 | return nextReleaseVersion, nil 116 | } 117 | -------------------------------------------------------------------------------- /.golangci.yaml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | run: 3 | tests: false 4 | linters: 5 | default: none 6 | enable: 7 | - asciicheck 8 | - bodyclose 9 | - copyloopvar 10 | - dogsled 11 | - dupl 12 | - errcheck 13 | - funlen 14 | - gocognit 15 | - goconst 16 | - gocritic 17 | - gocyclo 18 | - goprintffuncname 19 | - gosec 20 | - govet 21 | - ineffassign 22 | - misspell 23 | - nakedret 24 | - nolintlint 25 | - revive 26 | - staticcheck 27 | - unconvert 28 | - unparam 29 | - unused 30 | - whitespace 31 | settings: 32 | funlen: 33 | lines: 70 34 | statements: 50 35 | gocritic: 36 | enabled-checks: 37 | - deferInLoop 38 | gosec: 39 | excludes: 40 | - G115 41 | exclusions: 42 | generated: lax 43 | presets: 44 | - comments 45 | - common-false-positives 46 | - legacy 47 | - std-error-handling 48 | paths: 49 | - third_party$ 50 | - builtin$ 51 | - examples$ 52 | 53 | # do not enable... 54 | # - deadcode # The owner seems to have abandoned the linter. Replaced by "unused". 55 | # - depguard # We don't have a configuration for this yet 56 | # - goprintffuncname # does not catch all cases and there are exceptions 57 | # - nakedret # does not catch all cases and should not fail a build 58 | # - gochecknoglobals 59 | # - gochecknoinits # this is too aggressive 60 | # - rowserrcheck disabled per generics https://github.com/golangci/golangci-lint/issues/2649 61 | # - godot 62 | # - godox 63 | # - goerr113 64 | # - goimports # we're using gosimports now instead to account for extra whitespaces (see https://github.com/golang/go/issues/20818) 65 | # - golint # deprecated 66 | # - gomnd # this is too aggressive 67 | # - interfacer # this is a good idea, but is no longer supported and is prone to false positives 68 | # - lll # without a way to specify per-line exception cases, this is not usable 69 | # - maligned # this is an excellent linter, but tricky to optimize and we are not sensitive to memory layout optimizations 70 | # - nestif 71 | # - prealloc # following this rule isn't consistently a good idea, as it sometimes forces unnecessary allocations that result in less idiomatic code 72 | # - rowserrcheck # not in a repo with sql, so this is not useful 73 | # - scopelint # deprecated 74 | # - structcheck # The owner seems to have abandoned the linter. Replaced by "unused". 75 | # - testpackage 76 | # - varcheck # The owner seems to have abandoned the linter. Replaced by "unused". 77 | # - wsl # this doens't have an auto-fixer yet and is pretty noisy (https://github.com/bombsimon/wsl/issues/90) 78 | 79 | issues: 80 | max-same-issues: 25 81 | uniq-by-line: false 82 | 83 | # TODO: enable this when we have coverage on docstring comments 84 | # # The list of ids of default excludes to include or disable. 85 | # include: 86 | # - EXC0002 # disable excluding of issues about comments from golint 87 | 88 | formatters: 89 | enable: 90 | - gofmt 91 | - goimports 92 | exclusions: 93 | generated: lax 94 | paths: 95 | - third_party$ 96 | - builtin$ 97 | - examples$ 98 | -------------------------------------------------------------------------------- /chronicle/release/changelog_info_test.go: -------------------------------------------------------------------------------- 1 | package release 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | "github.com/stretchr/testify/require" 8 | ) 9 | 10 | func Test_getChangelogStartingRelease(t *testing.T) { 11 | tests := []struct { 12 | name string 13 | summer Summarizer 14 | sinceTag string 15 | want *Release 16 | wantErr require.ErrorAssertionFunc 17 | }{ 18 | { 19 | name: "use the last release when no since-tag is provided", 20 | sinceTag: "", 21 | summer: MockSummarizer{ 22 | MockLastRelease: "v0.1.0", 23 | }, 24 | want: &Release{ 25 | Version: "v0.1.0", 26 | }, 27 | }, 28 | { 29 | name: "error when fallback to last release does not exist", 30 | sinceTag: "", 31 | summer: MockSummarizer{ 32 | MockLastRelease: "", 33 | }, 34 | wantErr: require.Error, 35 | }, 36 | { 37 | name: "use given release (which exists)", 38 | sinceTag: "v0.1.0", 39 | summer: MockSummarizer{ 40 | MockRelease: "v0.1.0", 41 | }, 42 | want: &Release{ 43 | Version: "v0.1.0", 44 | }, 45 | }, 46 | { 47 | name: "use given release (which does not exist)", 48 | sinceTag: "v0.1.0", 49 | summer: MockSummarizer{}, 50 | wantErr: require.Error, 51 | }, 52 | } 53 | for _, tt := range tests { 54 | t.Run(tt.name, func(t *testing.T) { 55 | if tt.wantErr == nil { 56 | tt.wantErr = require.NoError 57 | } 58 | got, err := getChangelogStartingRelease(tt.summer, tt.sinceTag) 59 | tt.wantErr(t, err) 60 | assert.Equal(t, tt.want, got) 61 | }) 62 | } 63 | } 64 | 65 | func Test_changelogChanges(t *testing.T) { 66 | tests := []struct { 67 | name string 68 | startReleaseVersion string 69 | summer Summarizer 70 | config ChangelogInfoConfig 71 | endReleaseVersion string 72 | endReleaseDisplay string 73 | wantErr require.ErrorAssertionFunc 74 | }{ 75 | { 76 | name: "no end release tag discovered - speculate", 77 | startReleaseVersion: "v0.1.0", 78 | summer: MockSummarizer{}, 79 | config: ChangelogInfoConfig{ 80 | VersionSpeculator: MockVersionSpeculator{ 81 | MockNextIdealVersion: "v0.2.0", 82 | MockNextUniqueVersion: "v0.2.0", 83 | }, 84 | }, 85 | endReleaseVersion: "v0.2.0", 86 | endReleaseDisplay: "v0.2.0", 87 | }, 88 | { 89 | name: "no end release tag discovered - speculate unique version", 90 | startReleaseVersion: "v0.1.0", 91 | summer: MockSummarizer{}, 92 | config: ChangelogInfoConfig{ 93 | VersionSpeculator: MockVersionSpeculator{ 94 | MockNextIdealVersion: "v0.2.0", 95 | MockNextUniqueVersion: "v0.2.1", 96 | }, 97 | }, 98 | endReleaseVersion: "v0.2.1", 99 | endReleaseDisplay: "v0.2.1", 100 | }, 101 | } 102 | for _, tt := range tests { 103 | t.Run(tt.name, func(t *testing.T) { 104 | if tt.wantErr == nil { 105 | tt.wantErr = require.NoError 106 | } 107 | endReleaseVersion, _, err := changelogChanges(tt.startReleaseVersion, tt.summer, tt.config) 108 | tt.wantErr(t, err) 109 | 110 | assert.Equal(t, tt.endReleaseVersion, endReleaseVersion) 111 | }) 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/anchore/chronicle 2 | 3 | go 1.24.0 4 | 5 | require ( 6 | github.com/anchore/clio v0.0.0-20230802135737-4778c80552e5 7 | github.com/anchore/fangs v0.0.0-20230725134830-329a9a4d20e7 8 | github.com/anchore/go-logger v0.0.0-20230725134548-c21dafa1ec5a 9 | github.com/coreos/go-semver v0.3.1 10 | github.com/gkampitakis/go-snaps v0.5.15 11 | github.com/go-git/go-git/v5 v5.16.2 12 | github.com/google/go-cmp v0.7.0 13 | github.com/leodido/go-conventionalcommits v0.12.0 14 | github.com/scylladb/go-set v1.0.2 15 | github.com/shurcooL/githubv4 v0.0.0-20201206200315-234843c633fa 16 | github.com/spf13/cobra v1.9.1 17 | github.com/stretchr/testify v1.10.0 18 | github.com/wagoodman/go-partybus v0.0.0-20230516145632-8ccac152c651 19 | github.com/wagoodman/go-presenter v0.0.0-20211015174752-f9c01afc824b 20 | golang.org/x/oauth2 v0.30.0 21 | ) 22 | 23 | require ( 24 | dario.cat/mergo v1.0.0 // indirect 25 | github.com/Microsoft/go-winio v0.6.2 // indirect 26 | github.com/ProtonMail/go-crypto v1.1.6 // indirect 27 | github.com/adrg/xdg v0.4.0 // indirect 28 | github.com/cloudflare/circl v1.6.1 // indirect 29 | github.com/cyphar/filepath-securejoin v0.4.1 // indirect 30 | github.com/davecgh/go-spew v1.1.1 // indirect 31 | github.com/emirpasic/gods v1.18.1 // indirect 32 | github.com/felixge/fgprof v0.9.3 // indirect 33 | github.com/fsnotify/fsnotify v1.6.0 // indirect 34 | github.com/gkampitakis/ciinfo v0.3.2 // indirect 35 | github.com/gkampitakis/go-diff v1.3.2 // indirect 36 | github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect 37 | github.com/go-git/go-billy/v5 v5.6.2 // indirect 38 | github.com/goccy/go-yaml v1.18.0 // indirect 39 | github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect 40 | github.com/google/pprof v0.0.0-20211214055906-6f57359322fd // indirect 41 | github.com/google/uuid v1.3.0 // indirect 42 | github.com/gookit/color v1.5.4 // indirect 43 | github.com/hashicorp/errwrap v1.0.0 // indirect 44 | github.com/hashicorp/go-multierror v1.1.1 // indirect 45 | github.com/hashicorp/hcl v1.0.0 // indirect 46 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 47 | github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect 48 | github.com/kevinburke/ssh_config v1.2.0 // indirect 49 | github.com/kr/pretty v0.3.1 // indirect 50 | github.com/kr/text v0.2.0 // indirect 51 | github.com/magiconair/properties v1.8.7 // indirect 52 | github.com/maruel/natural v1.1.1 // indirect 53 | github.com/mattn/go-colorable v0.1.12 // indirect 54 | github.com/mattn/go-isatty v0.0.14 // indirect 55 | github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect 56 | github.com/mitchellh/go-homedir v1.1.0 // indirect 57 | github.com/mitchellh/mapstructure v1.5.0 // indirect 58 | github.com/pborman/indent v1.2.1 // indirect 59 | github.com/pelletier/go-toml/v2 v2.0.8 // indirect 60 | github.com/pjbgf/sha1cd v0.3.2 // indirect 61 | github.com/pkg/profile v1.7.0 // indirect 62 | github.com/pmezard/go-difflib v1.0.0 // indirect 63 | github.com/rogpeppe/go-internal v1.14.1 // indirect 64 | github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect 65 | github.com/shurcooL/graphql v0.0.0-20200928012149-18c5c3165e3a // indirect 66 | github.com/sirupsen/logrus v1.9.3 // indirect 67 | github.com/skeema/knownhosts v1.3.1 // indirect 68 | github.com/spf13/afero v1.9.5 // indirect 69 | github.com/spf13/cast v1.5.1 // indirect 70 | github.com/spf13/jwalterweatherman v1.1.0 // indirect 71 | github.com/spf13/pflag v1.0.6 // indirect 72 | github.com/spf13/viper v1.16.0 // indirect 73 | github.com/subosito/gotenv v1.4.2 // indirect 74 | github.com/tidwall/gjson v1.18.0 // indirect 75 | github.com/tidwall/match v1.1.1 // indirect 76 | github.com/tidwall/pretty v1.2.1 // indirect 77 | github.com/tidwall/sjson v1.2.5 // indirect 78 | github.com/xanzy/ssh-agent v0.3.3 // indirect 79 | github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778 // indirect 80 | golang.org/x/crypto v0.37.0 // indirect 81 | golang.org/x/net v0.39.0 // indirect 82 | golang.org/x/sys v0.32.0 // indirect 83 | golang.org/x/term v0.31.0 // indirect 84 | golang.org/x/text v0.24.0 // indirect 85 | gopkg.in/ini.v1 v1.67.0 // indirect 86 | gopkg.in/warnings.v0 v0.1.2 // indirect 87 | gopkg.in/yaml.v3 v3.0.1 // indirect 88 | ) 89 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Syft 2 | 3 | If you are looking to contribute to this project and want to open a GitHub pull request ("PR"), there are a few guidelines of what we are looking for in patches. Make sure you go through this document and ensure that your code proposal is aligned. 4 | 5 | ## Sign off your work 6 | 7 | The `sign-off` is an added line at the end of the explanation for the commit, certifying that you wrote it or otherwise have the right to submit it as an open-source patch. By submitting a contribution, you agree to be bound by the terms of the DCO Version 1.1 and Apache License Version 2.0. 8 | 9 | Signing off a commit certifies the below Developer's Certificate of Origin (DCO): 10 | 11 | ```text 12 | Developer's Certificate of Origin 1.1 13 | 14 | By making a contribution to this project, I certify that: 15 | 16 | (a) The contribution was created in whole or in part by me and I 17 | have the right to submit it under the open source license 18 | indicated in the file; or 19 | 20 | (b) The contribution is based upon previous work that, to the best 21 | of my knowledge, is covered under an appropriate open source 22 | license and I have the right under that license to submit that 23 | work with modifications, whether created in whole or in part 24 | by me, under the same open source license (unless I am 25 | permitted to submit under a different license), as indicated 26 | in the file; or 27 | 28 | (c) The contribution was provided directly to me by some other 29 | person who certified (a), (b) or (c) and I have not modified 30 | it. 31 | 32 | (d) I understand and agree that this project and the contribution 33 | are public and that a record of the contribution (including all 34 | personal information I submit with it, including my sign-off) is 35 | maintained indefinitely and may be redistributed consistent with 36 | this project or the open source license(s) involved. 37 | ``` 38 | 39 | All contributions to this project are licensed under the [Apache License Version 2.0, January 2004](http://www.apache.org/licenses/). 40 | 41 | When committing your change, you can add the required line manually so that it looks like this: 42 | 43 | ```text 44 | Signed-off-by: John Doe 45 | ``` 46 | 47 | Alternatively, configure your Git client with your name and email to use the `-s` flag when creating a commit: 48 | 49 | ```text 50 | $ git config --global user.name "John Doe" 51 | $ git config --global user.email "john.doe@example.com" 52 | ``` 53 | 54 | Creating a signed-off commit is then possible with `-s` or `--signoff`: 55 | 56 | ```text 57 | $ git commit -s -m "this is a commit message" 58 | ``` 59 | 60 | To double-check that the commit was signed-off, look at the log output: 61 | 62 | ```text 63 | $ git log -1 64 | commit 37ceh170e4hb283bb73d958f2036ee5k07e7fde7 (HEAD -> issue-35, origin/main, main) 65 | Author: John Doe 66 | Date: Mon Aug 1 11:27:13 2020 -0400 67 | 68 | this is a commit message 69 | 70 | Signed-off-by: John Doe 71 | ``` 72 | 73 | 74 | [//]: # (TODO: Commit guidelines, granular commits) 75 | 76 | 77 | [//]: # (TODO: Commit guidelines, descriptive messages) 78 | 79 | 80 | [//]: # (TODO: Commit guidelines, commit title, extra body description) 81 | 82 | 83 | [//]: # (TODO: PR title and description) 84 | 85 | ## Test your changes 86 | 87 | This project has a `Makefile` which includes many helpers running unit tests and linters. Although PRs will have automatic checks for these, it is useful to run them locally, ensuring they pass before submitting changes. Ensure you've bootstrapped once before running tests: 88 | 89 | ```text 90 | $ make bootstrap 91 | ``` 92 | 93 | You only need to bootstrap once. After the bootstrap process, you can run the tests and linters as many times as needed: 94 | ```text 95 | $ make 96 | ``` 97 | 98 | To automatically fix linter issues: 99 | ```text 100 | $ make lint-fix 101 | ``` 102 | 103 | ## Document your changes 104 | 105 | When proposed changes are modifying user-facing functionality or output, it is expected the PR will include updates to the documentation as well. 106 | -------------------------------------------------------------------------------- /chronicle/release/format/markdown/presenter.go: -------------------------------------------------------------------------------- 1 | package markdown 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "io" 7 | "strings" 8 | "text/template" 9 | 10 | "github.com/leodido/go-conventionalcommits" 11 | cc "github.com/leodido/go-conventionalcommits/parser" 12 | "github.com/wagoodman/go-presenter" 13 | 14 | "github.com/anchore/chronicle/chronicle/release" 15 | "github.com/anchore/chronicle/chronicle/release/change" 16 | ) 17 | 18 | const ( 19 | markdownHeaderTemplate = `{{if .Title }}# {{.Title}} 20 | 21 | {{ end }}{{if .Changes }}{{ formatChangeSections .Changes }} 22 | 23 | {{ end }}**[(Full Changelog)]({{.VCSChangesURL}})** 24 | ` 25 | ) 26 | 27 | var _ presenter.Presenter = (*Presenter)(nil) 28 | 29 | type Presenter struct { 30 | config Config 31 | templater *template.Template 32 | } 33 | 34 | type ChangeSection struct { 35 | ChangeType change.Type 36 | Title string 37 | } 38 | 39 | type Sections []ChangeSection 40 | 41 | type Config struct { 42 | release.Description 43 | Title string 44 | } 45 | 46 | func NewMarkdownPresenter(config Config) (*Presenter, error) { 47 | p := Presenter{ 48 | config: config, 49 | } 50 | 51 | funcMap := template.FuncMap{ 52 | "formatChangeSections": p.formatChangeSections, 53 | } 54 | templater, err := template.New("markdown").Funcs(funcMap).Parse(markdownHeaderTemplate) 55 | if err != nil { 56 | return nil, fmt.Errorf("unable to parse markdown presenter template: %w", err) 57 | } 58 | 59 | titleTemplater, err := template.New("title").Funcs(funcMap).Parse(config.Title) 60 | if err != nil { 61 | return nil, fmt.Errorf("unable to parse markdown presenter title template: %w", err) 62 | } 63 | 64 | buf := bytes.Buffer{} 65 | if err := titleTemplater.Execute(&buf, config); err != nil { 66 | return nil, fmt.Errorf("unable to template title: %w", err) 67 | } 68 | p.config.Title = buf.String() 69 | 70 | p.templater = templater 71 | 72 | return &p, nil 73 | } 74 | 75 | func (m Presenter) Present(writer io.Writer) error { 76 | return m.templater.Execute(writer, m.config) 77 | } 78 | 79 | func (m Presenter) formatChangeSections(changes change.Changes) string { 80 | var result string 81 | for _, section := range m.config.SupportedChanges { 82 | summaries := changes.ByChangeType(section.ChangeType) 83 | if len(summaries) > 0 { 84 | result += formatChangeSection(section.Title, summaries) + "\n" 85 | } 86 | } 87 | return strings.TrimRight(result, "\n") 88 | } 89 | 90 | func formatChangeSection(title string, summaries []change.Change) string { 91 | result := fmt.Sprintf("### %s\n\n", title) 92 | for _, summary := range summaries { 93 | result += formatSummary(summary) 94 | } 95 | return result 96 | } 97 | 98 | func formatSummary(summary change.Change) string { 99 | result := removeConventionalCommitPrefix(strings.TrimSpace(summary.Text)) 100 | result = fmt.Sprintf("- %s", result) 101 | if endsWithPunctuation(result) { 102 | result = result[:len(result)-1] 103 | } 104 | 105 | var refs string 106 | for _, ref := range summary.References { 107 | switch { 108 | case ref.URL == "": 109 | refs += fmt.Sprintf(" %s", ref.Text) 110 | case strings.HasPrefix(ref.Text, "@") && strings.HasPrefix(ref.URL, "https://github.com/"): 111 | // the github release page will automatically show all contributors as a footer. However, if you 112 | // embed the contributor's github handle in a link, then this feature will not work. 113 | refs += fmt.Sprintf(" %s", ref.Text) 114 | default: 115 | refs += fmt.Sprintf(" [%s](%s)", ref.Text, ref.URL) 116 | } 117 | } 118 | 119 | refs = strings.TrimSpace(refs) 120 | if refs != "" { 121 | result += fmt.Sprintf(" [%s]", refs) 122 | } 123 | 124 | return result + "\n" 125 | } 126 | 127 | func endsWithPunctuation(s string) bool { 128 | if len(s) == 0 { 129 | return false 130 | } 131 | return strings.Contains("!.?", s[len(s)-1:]) //nolint:gocritic 132 | } 133 | 134 | func removeConventionalCommitPrefix(s string) string { 135 | res, err := cc.NewMachine(cc.WithTypes(conventionalcommits.TypesConventional)).Parse([]byte(s)) 136 | if err != nil || res == nil || (res != nil && !res.Ok()) { 137 | // probably not a conventional commit 138 | return s 139 | } 140 | 141 | // conventional commits always have a prefix and the message starts after the first ":" 142 | fields := strings.SplitN(s, ":", 2) 143 | if len(fields) == 2 { 144 | return strings.TrimSpace(fields[1]) 145 | } 146 | 147 | return s 148 | } 149 | -------------------------------------------------------------------------------- /chronicle/release/releasers/github/gh_release.go: -------------------------------------------------------------------------------- 1 | package github 2 | 3 | import ( 4 | "context" 5 | "os" 6 | "sort" 7 | "time" 8 | 9 | "github.com/shurcooL/githubv4" 10 | "golang.org/x/oauth2" 11 | ) 12 | 13 | type releaseFetcher func(user, repo, tag string) (*ghRelease, error) 14 | 15 | type ghRelease struct { 16 | Tag string 17 | Date time.Time 18 | IsLatest bool 19 | IsDraft bool 20 | } 21 | 22 | func latestNonDraftRelease(releases []ghRelease) *ghRelease { 23 | for i := len(releases) - 1; i >= 0; i-- { 24 | if !releases[i].IsDraft { 25 | return &releases[i] 26 | } 27 | } 28 | return nil 29 | } 30 | 31 | func fetchAllReleases(user, repo string) ([]ghRelease, error) { 32 | src := oauth2.StaticTokenSource( 33 | // TODO: DI this 34 | &oauth2.Token{AccessToken: os.Getenv("GITHUB_TOKEN")}, 35 | ) 36 | httpClient := oauth2.NewClient(context.Background(), src) 37 | client := githubv4.NewClient(httpClient) 38 | var allReleases []ghRelease 39 | 40 | // Query some details about a repository, an ghIssue in it, and its comments. 41 | { 42 | // TODO: act on hitting a rate limit 43 | type rateLimit struct { 44 | Cost githubv4.Int 45 | Limit githubv4.Int 46 | Remaining githubv4.Int 47 | ResetAt githubv4.DateTime 48 | } 49 | 50 | var query struct { 51 | Repository struct { 52 | DatabaseID githubv4.Int 53 | URL githubv4.URI 54 | Releases struct { 55 | PageInfo struct { 56 | EndCursor githubv4.String 57 | HasNextPage bool 58 | } 59 | Edges []struct { 60 | Node struct { 61 | TagName githubv4.String 62 | IsLatest githubv4.Boolean 63 | IsDraft githubv4.Boolean 64 | PublishedAt githubv4.DateTime 65 | } 66 | } 67 | } `graphql:"releases(first:100, after:$releasesCursor)"` 68 | } `graphql:"repository(owner:$repositoryOwner, name:$repositoryName)"` 69 | 70 | RateLimit rateLimit 71 | } 72 | variables := map[string]interface{}{ 73 | "repositoryOwner": githubv4.String(user), 74 | "repositoryName": githubv4.String(repo), 75 | "releasesCursor": (*githubv4.String)(nil), // Null after argument to get first page. 76 | } 77 | 78 | // var limit rateLimit 79 | for { 80 | err := client.Query(context.Background(), &query, variables) 81 | if err != nil { 82 | return nil, err 83 | } 84 | // limit = query.RateLimit 85 | 86 | for _, iEdge := range query.Repository.Releases.Edges { 87 | allReleases = append(allReleases, ghRelease{ 88 | Tag: string(iEdge.Node.TagName), 89 | IsLatest: bool(iEdge.Node.IsLatest), 90 | IsDraft: bool(iEdge.Node.IsDraft), 91 | Date: iEdge.Node.PublishedAt.Time, 92 | }) 93 | } 94 | 95 | if !query.Repository.Releases.PageInfo.HasNextPage { 96 | break 97 | } 98 | variables["releasesCursor"] = githubv4.NewString(query.Repository.Releases.PageInfo.EndCursor) 99 | } 100 | 101 | // for idx, is := range allReleases { 102 | // fmt.Printf("%d: %+v\n", idx, is) 103 | //} 104 | // printJSON(limit) 105 | } 106 | 107 | sort.Slice(allReleases, func(i, j int) bool { 108 | return allReleases[i].Date.Before(allReleases[j].Date) 109 | }) 110 | 111 | return allReleases, nil 112 | } 113 | 114 | func fetchRelease(user, repo, tag string) (*ghRelease, error) { 115 | src := oauth2.StaticTokenSource( 116 | // TODO: DI this 117 | &oauth2.Token{AccessToken: os.Getenv("GITHUB_TOKEN")}, 118 | ) 119 | httpClient := oauth2.NewClient(context.Background(), src) 120 | client := githubv4.NewClient(httpClient) 121 | 122 | // TODO: act on hitting a rate limit 123 | type rateLimit struct { 124 | Cost githubv4.Int 125 | Limit githubv4.Int 126 | Remaining githubv4.Int 127 | ResetAt githubv4.DateTime 128 | } 129 | 130 | var query struct { 131 | Repository struct { 132 | DatabaseID githubv4.Int 133 | URL githubv4.URI 134 | Release struct { 135 | TagName githubv4.String 136 | IsLatest githubv4.Boolean 137 | IsDraft githubv4.Boolean 138 | PublishedAt githubv4.DateTime 139 | } `graphql:"release(tagName:$tagName)"` 140 | } `graphql:"repository(owner:$repositoryOwner, name:$repositoryName)"` 141 | 142 | RateLimit rateLimit 143 | } 144 | variables := map[string]interface{}{ 145 | "repositoryOwner": githubv4.String(user), 146 | "repositoryName": githubv4.String(repo), 147 | "tagName": githubv4.String(tag), // Null after argument to get first page. 148 | } 149 | 150 | err := client.Query(context.Background(), &query, variables) 151 | if err != nil { 152 | return nil, err 153 | } 154 | 155 | return &ghRelease{ 156 | Tag: string(query.Repository.Release.TagName), 157 | IsLatest: bool(query.Repository.Release.IsLatest), 158 | IsDraft: bool(query.Repository.Release.IsDraft), 159 | Date: query.Repository.Release.PublishedAt.Time, 160 | }, nil 161 | } 162 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | permissions: 2 | contents: read 3 | 4 | on: 5 | workflow_dispatch: 6 | inputs: 7 | version: 8 | description: tag the latest commit on main with the given version (prefixed with v) 9 | required: true 10 | 11 | jobs: 12 | quality-gate: 13 | environment: release 14 | runs-on: ubuntu-22.04 15 | steps: 16 | - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 #v5.0.0 17 | with: 18 | persist-credentials: false 19 | 20 | - name: Check if tag already exists 21 | # note: this will fail if the tag already exists 22 | env: 23 | VERSION: ${{ github.event.inputs.version }} 24 | run: | 25 | [[ "$VERSION" == v* ]] || (echo "version '$VERSION' does not have a 'v' prefix" && exit 1) 26 | git tag "$VERSION" 27 | 28 | - name: Check static analysis results 29 | uses: fountainhead/action-wait-for-check@5a908a24814494009c4bb27c242ea38c93c593be #v1.2.0 30 | id: static-analysis 31 | with: 32 | token: ${{ secrets.GITHUB_TOKEN }} 33 | # This check name is defined as the github action job name (in .github/workflows/testing.yaml) 34 | checkName: "Static analysis" 35 | ref: ${{ github.event.pull_request.head.sha || github.sha }} 36 | 37 | - name: Check unit test results 38 | uses: fountainhead/action-wait-for-check@5a908a24814494009c4bb27c242ea38c93c593be #v1.2.0 39 | id: unit 40 | with: 41 | token: ${{ secrets.GITHUB_TOKEN }} 42 | # This check name is defined as the github action job name (in .github/workflows/testing.yaml) 43 | checkName: "Unit tests" 44 | ref: ${{ github.event.pull_request.head.sha || github.sha }} 45 | 46 | - name: Quality gate 47 | if: steps.static-analysis.outputs.conclusion != 'success' || steps.unit.outputs.conclusion != 'success' 48 | env: 49 | STATIC_ANALYSIS_STATUS: ${{ steps.static-analysis.outputs.conclusion }} 50 | UNIT_TEST_STATUS: ${{ steps.unit.outputs.conclusion }} 51 | run: | 52 | echo "Static Analysis Status: $STATIC_ANALYSIS_STATUS" 53 | echo "Unit Test Status: $UNIT_TEST_STATUS" 54 | false 55 | 56 | release: 57 | needs: [quality-gate] 58 | runs-on: ubuntu-22.04 59 | permissions: 60 | packages: write 61 | contents: write 62 | steps: 63 | - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 #v5.0.0 64 | with: 65 | fetch-depth: 0 66 | persist-credentials: true 67 | 68 | - name: Bootstrap environment 69 | uses: anchore/go-make/.github/actions/bootstrap@latest 70 | with: 71 | # use the same cache we used for building snapshots 72 | cache-key-prefix: "snapshot" 73 | 74 | - name: Tag release 75 | env: 76 | VERSION: ${{ github.event.inputs.version }} 77 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 78 | run: | 79 | git config --global user.name "anchoreci" 80 | git config --global user.email "anchoreci@users.noreply.github.com" 81 | git tag -a "$VERSION" -m "Release $VERSION" 82 | git push origin --tags 83 | 84 | - name: Build & publish release artifacts 85 | run: make ci-release 86 | env: 87 | # for creating the release (requires write access to packages and content) 88 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 89 | 90 | - uses: anchore/sbom-action@f8bdd1d8ac5e901a77a92f111440fdb1b593736b #v0.20.6 91 | continue-on-error: true 92 | with: 93 | artifact-name: sbom.spdx.json 94 | 95 | - uses: 8398a7/action-slack@1750b5085f3ec60384090fb7c52965ef822e869e #v3.18.0 96 | with: 97 | status: ${{ job.status }} 98 | fields: repo,workflow,action,eventName 99 | text: "A new Chronicle release has been published: https://github.com/anchore/chronicle/releases" 100 | env: 101 | SLACK_WEBHOOK_URL: ${{ secrets.SLACK_TOOLBOX_WEBHOOK_URL }} 102 | if: ${{ success() }} 103 | 104 | release-install-script: 105 | needs: [release] 106 | if: ${{ needs.release.result == 'success' }} 107 | uses: "anchore/workflows/.github/workflows/release-install-script.yaml@main" 108 | with: 109 | tag: ${{ github.event.inputs.version }} 110 | secrets: 111 | # needed for r2... 112 | R2_INSTALL_ACCESS_KEY_ID: ${{ secrets.OSS_R2_INSTALL_ACCESS_KEY_ID }} 113 | R2_INSTALL_SECRET_ACCESS_KEY: ${{ secrets.OSS_R2_INSTALL_SECRET_ACCESS_KEY }} 114 | R2_ENDPOINT: ${{ secrets.TOOLBOX_CLOUDFLARE_R2_ENDPOINT }} 115 | # needed for s3... 116 | S3_INSTALL_AWS_ACCESS_KEY_ID: ${{ secrets.TOOLBOX_AWS_ACCESS_KEY_ID }} 117 | S3_INSTALL_AWS_SECRET_ACCESS_KEY: ${{ secrets.TOOLBOX_AWS_SECRET_ACCESS_KEY }} 118 | -------------------------------------------------------------------------------- /internal/git/tag.go: -------------------------------------------------------------------------------- 1 | package git 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "time" 7 | 8 | git "github.com/go-git/go-git/v5" 9 | "github.com/go-git/go-git/v5/plumbing" 10 | "github.com/go-git/go-git/v5/plumbing/object" 11 | "github.com/go-git/go-git/v5/plumbing/storer" 12 | 13 | "github.com/anchore/chronicle/internal/log" 14 | ) 15 | 16 | type Tag struct { 17 | Name string 18 | Timestamp time.Time 19 | Commit string 20 | Annotated bool 21 | } 22 | 23 | type Range struct { 24 | SinceRef string 25 | UntilRef string 26 | IncludeStart bool 27 | IncludeEnd bool 28 | } 29 | 30 | func CommitsBetween(repoPath string, cfg Range) ([]string, error) { 31 | r, err := git.PlainOpen(repoPath) 32 | if err != nil { 33 | return nil, err 34 | } 35 | 36 | var sinceHash *plumbing.Hash 37 | if cfg.SinceRef != "" { 38 | sinceHash, err = r.ResolveRevision(plumbing.Revision(cfg.SinceRef)) 39 | if err != nil { 40 | return nil, fmt.Errorf("unable to find since git ref=%q: %w", cfg.SinceRef, err) 41 | } 42 | } 43 | 44 | untilHash, err := r.ResolveRevision(plumbing.Revision(cfg.UntilRef)) 45 | if err != nil { 46 | return nil, fmt.Errorf("unable to find until git ref=%q: %w", cfg.UntilRef, err) 47 | } 48 | 49 | iter, err := r.Log(&git.LogOptions{From: *untilHash}) 50 | if err != nil { 51 | return nil, fmt.Errorf("unable to find until git log for ref=%q: %w", cfg.UntilRef, err) 52 | } 53 | 54 | log.WithFields("since", sinceHash, "until", untilHash).Trace("searching commit range") 55 | 56 | var commits []string 57 | err = iter.ForEach(func(c *object.Commit) (retErr error) { 58 | hash := c.Hash.String() 59 | 60 | switch { 61 | case untilHash != nil && c.Hash == *untilHash: 62 | if cfg.IncludeEnd { 63 | commits = append(commits, hash) 64 | } 65 | case sinceHash != nil && c.Hash == *sinceHash: 66 | retErr = storer.ErrStop 67 | if cfg.IncludeStart { 68 | commits = append(commits, hash) 69 | } 70 | default: 71 | commits = append(commits, hash) 72 | } 73 | 74 | return 75 | }) 76 | 77 | return commits, err 78 | } 79 | 80 | func SearchForTag(repoPath, tagRef string) (*Tag, error) { 81 | r, err := git.PlainOpen(repoPath) 82 | if err != nil { 83 | return nil, err 84 | } 85 | 86 | // TODO: only supports tags, should support commits and other tree-ish things 87 | ref, err := r.Reference(plumbing.NewTagReferenceName(tagRef), false) 88 | if err != nil { 89 | return nil, fmt.Errorf("unable to find git ref=%q: %w", tagRef, err) 90 | } 91 | if ref == nil { 92 | return nil, fmt.Errorf("unable to find git ref=%q", tagRef) 93 | } 94 | 95 | return newTag(r, ref) 96 | } 97 | 98 | func TagsFromLocal(repoPath string) ([]Tag, error) { 99 | r, err := git.PlainOpen(repoPath) 100 | if err != nil { 101 | return nil, err 102 | } 103 | 104 | tagRefs, err := r.Tags() 105 | if err != nil { 106 | return nil, err 107 | } 108 | 109 | var tags []Tag 110 | for { 111 | t, err := tagRefs.Next() 112 | if err == io.EOF || t == nil { 113 | break 114 | } else if err != nil { 115 | return nil, err 116 | } 117 | 118 | tag, err := newTag(r, t) 119 | if err != nil { 120 | return nil, err 121 | } 122 | if tag == nil { 123 | continue 124 | } 125 | 126 | tags = append(tags, *tag) 127 | } 128 | return tags, nil 129 | } 130 | 131 | func newTag(r *git.Repository, t *plumbing.Reference) (*Tag, error) { 132 | // the plumbing reference is to the tag. For a lightweight tag, the tag object points directly to the commit 133 | // with the code blob. For an annotated tag, the tag object has a commit for the tag itself, but resolves to 134 | // the commit with the code blob. It's important to use the timestamp from the tag object when available 135 | // for annotated tags and to use the commit timestamp for lightweight tags. 136 | 137 | if !t.Name().IsTag() { 138 | return nil, nil 139 | } 140 | 141 | c, err := r.CommitObject(t.Hash()) 142 | if err == nil && c != nil { 143 | // this is a lightweight tag... the tag hash points directly to the commit object 144 | return &Tag{ 145 | Name: t.Name().Short(), 146 | Timestamp: c.Committer.When, 147 | Commit: c.Hash.String(), 148 | Annotated: false, 149 | }, nil 150 | } 151 | 152 | // this is an annotated tag... the tag hash points to a tag object, which points to the commit object 153 | // use the timestamp info from the tag object 154 | 155 | tagObj, err := object.GetTag(r.Storer, t.Hash()) 156 | if err != nil { 157 | return nil, fmt.Errorf("unable to resolve tag for %q: %w", t.Name(), err) 158 | } 159 | 160 | if tagObj == nil { 161 | return nil, fmt.Errorf("unable to resolve tag for %q", t.Name()) 162 | } 163 | 164 | return &Tag{ 165 | Name: t.Name().Short(), 166 | // it is possible for this git lib to return timestamps parsed from the underlying data that have the timezone 167 | // but not the name of the timezone. This can result in odd suffixes like "-0400 -0400" instead of "-0400 EDT". 168 | // This causes some difficulty in testing since the user's local git config and env may result in different 169 | // values. Here I've normalized to the local timezone which tends to be the most common case. Downstream of 170 | // this function, the timestamp is converted to UTC. 171 | Timestamp: tagObj.Tagger.When.In(time.Local), 172 | Commit: tagObj.Target.String(), 173 | Annotated: true, 174 | }, nil 175 | } 176 | -------------------------------------------------------------------------------- /chronicle/release/changelog_info.go: -------------------------------------------------------------------------------- 1 | package release 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "sort" 7 | "time" 8 | 9 | "github.com/scylladb/go-set/strset" 10 | 11 | "github.com/anchore/chronicle/chronicle/release/change" 12 | "github.com/anchore/chronicle/internal" 13 | "github.com/anchore/chronicle/internal/log" 14 | ) 15 | 16 | type ChangelogInfoConfig struct { 17 | VersionSpeculator 18 | RepoPath string 19 | SinceTag string 20 | UntilTag string 21 | ChangeTypeTitles []change.TypeTitle 22 | } 23 | 24 | // ChangelogInfo identifies the last release (the start of the changelog) and returns a description of the current (potentially speculative) release. 25 | func ChangelogInfo(summer Summarizer, config ChangelogInfoConfig) (*Release, *Description, error) { 26 | startRelease, err := getChangelogStartingRelease(summer, config.SinceTag) 27 | if err != nil { 28 | return nil, nil, err 29 | } 30 | 31 | if startRelease != nil { 32 | log.WithFields("tag", startRelease.Version, "release-timestamp", internal.FormatDateTime(startRelease.Date)).Trace("since") 33 | } else { 34 | log.Trace("since the beginning of git history") 35 | } 36 | 37 | releaseVersion, changes, err := changelogChanges(startRelease.Version, summer, config) 38 | if err != nil { 39 | return nil, nil, err 40 | } 41 | 42 | var releaseDisplayVersion = releaseVersion 43 | if releaseVersion == "" { 44 | releaseDisplayVersion = "(Unreleased)" 45 | } 46 | 47 | logChanges(changes) 48 | 49 | return startRelease, &Description{ 50 | Release: Release{ 51 | Version: releaseDisplayVersion, 52 | Date: time.Now(), 53 | }, 54 | VCSReferenceURL: summer.ReferenceURL(releaseVersion), 55 | VCSChangesURL: summer.ChangesURL(startRelease.Version, releaseVersion), 56 | Changes: changes, 57 | SupportedChanges: config.ChangeTypeTitles, 58 | Notice: "", // TODO... 59 | }, nil 60 | } 61 | 62 | func changelogChanges(startReleaseVersion string, summer Summarizer, config ChangelogInfoConfig) (string, []change.Change, error) { 63 | endReleaseVersion := config.UntilTag 64 | 65 | changes, err := summer.Changes(startReleaseVersion, config.UntilTag) 66 | if err != nil { 67 | return "", nil, fmt.Errorf("unable to summarize changes: %w", err) 68 | } 69 | 70 | if config.VersionSpeculator != nil { 71 | if endReleaseVersion == "" { 72 | specEndReleaseVersion, err := speculateNextVersion(config.VersionSpeculator, startReleaseVersion, changes) 73 | if err != nil { 74 | log.Warnf("unable to speculate next release version: %+v", err) 75 | } else { 76 | endReleaseVersion = specEndReleaseVersion 77 | } 78 | } else { 79 | log.Infof("not speculating next version current head tag=%q", endReleaseVersion) 80 | } 81 | } 82 | 83 | return endReleaseVersion, changes, nil 84 | } 85 | 86 | func speculateNextVersion(speculator VersionSpeculator, startReleaseVersion string, changes []change.Change) (string, error) { 87 | // TODO: make this behavior configurable (follow semver on change or bump patch only) 88 | nextIdealVersion, err := speculator.NextIdealVersion(startReleaseVersion, changes) 89 | if err != nil { 90 | return "", err 91 | } 92 | nextUniqueVersion, err := speculator.NextUniqueVersion(startReleaseVersion, changes) 93 | if err != nil { 94 | return "", err 95 | } 96 | if nextUniqueVersion != nextIdealVersion { 97 | log.Debugf("speculated a release version that matches an existing tag=%q, selecting the next best version...", nextIdealVersion) 98 | } 99 | log.WithFields("version", nextUniqueVersion).Info("speculative release version") 100 | return nextUniqueVersion, nil 101 | } 102 | 103 | func getChangelogStartingRelease(summer Summarizer, sinceTag string) (*Release, error) { 104 | var lastRelease *Release 105 | var err error 106 | if sinceTag != "" { 107 | lastRelease, err = summer.Release(sinceTag) 108 | if err != nil { 109 | return nil, fmt.Errorf("unable to fetch specific release: %w", err) 110 | } else if lastRelease == nil { 111 | return nil, errors.New("unable to fetch release") 112 | } 113 | } else { 114 | lastRelease, err = summer.LastRelease() 115 | if err != nil { 116 | return nil, fmt.Errorf("unable to determine last release: %w", err) 117 | } else if lastRelease == nil { 118 | // TODO: support when there hasn't been the first release (use date of first repo commit) 119 | return nil, errors.New("unable to determine last release") 120 | } 121 | } 122 | return lastRelease, nil 123 | } 124 | 125 | func logChanges(changes change.Changes) { 126 | log.Infof("discovered changes: %d", len(changes)) 127 | 128 | set := strset.New() 129 | count := make(map[string]int) 130 | lookup := make(map[string]change.Type) 131 | for _, c := range changes { 132 | for _, ty := range c.ChangeTypes { 133 | _, exists := count[ty.Name] 134 | if !exists { 135 | count[ty.Name] = 0 136 | } 137 | count[ty.Name]++ 138 | set.Add(ty.Name) 139 | lookup[ty.Name] = ty 140 | } 141 | } 142 | 143 | typeNames := set.List() 144 | sort.Strings(typeNames) 145 | 146 | for idx, tyName := range typeNames { 147 | var branch = "├──" 148 | if idx == len(typeNames)-1 { 149 | branch = "└──" 150 | } 151 | t := lookup[tyName] 152 | if t.Kind != change.SemVerUnknown { 153 | log.Debugf(" %s %s (%s bump): %d", branch, tyName, t.Kind, count[tyName]) 154 | } else { 155 | log.Debugf(" %s %s: %d", branch, tyName, count[tyName]) 156 | } 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /chronicle/release/releasers/github/version_speculator_test.go: -------------------------------------------------------------------------------- 1 | package github 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | "github.com/stretchr/testify/require" 8 | 9 | "github.com/anchore/chronicle/chronicle/release" 10 | "github.com/anchore/chronicle/chronicle/release/change" 11 | "github.com/anchore/chronicle/internal/git" 12 | ) 13 | 14 | func TestFindNextVersion(t *testing.T) { 15 | majorChange := change.Type{ 16 | Kind: change.SemVerMajor, 17 | } 18 | 19 | minorChange := change.Type{ 20 | Kind: change.SemVerMinor, 21 | } 22 | 23 | patchChange := change.Type{ 24 | Kind: change.SemVerPatch, 25 | } 26 | 27 | tests := []struct { 28 | name string 29 | release string 30 | changes change.Changes 31 | enforceV0 bool 32 | bumpPatchOnNoChange bool 33 | want string 34 | wantErr require.ErrorAssertionFunc 35 | }{ 36 | { 37 | name: "bump major version", 38 | release: "v0.1.5", 39 | changes: []change.Change{ 40 | { 41 | ChangeTypes: []change.Type{majorChange, minorChange, patchChange}, 42 | }, 43 | }, 44 | want: "v1.0.0", 45 | }, 46 | { 47 | name: "bump major version -- enforce v0", 48 | release: "v0.1.5", 49 | enforceV0: true, 50 | changes: []change.Change{ 51 | { 52 | ChangeTypes: []change.Type{majorChange, minorChange, patchChange}, 53 | }, 54 | }, 55 | want: "v0.2.0", 56 | }, 57 | { 58 | name: "bump major version -- enforce v0 -- keep major", 59 | release: "v6.1.5", 60 | enforceV0: true, 61 | changes: []change.Change{ 62 | { 63 | ChangeTypes: []change.Type{majorChange, minorChange, patchChange}, 64 | }, 65 | }, 66 | want: "v6.2.0", 67 | }, 68 | { 69 | name: "bump major version -- ignore dups", 70 | release: "v0.1.5", 71 | changes: []change.Change{ 72 | { 73 | ChangeTypes: []change.Type{majorChange, majorChange, majorChange, majorChange, majorChange, majorChange}, 74 | }, 75 | }, 76 | want: "v1.0.0", 77 | }, 78 | { 79 | name: "bump minor version", 80 | release: "v0.1.5", 81 | changes: []change.Change{ 82 | { 83 | ChangeTypes: []change.Type{minorChange, patchChange}, 84 | }, 85 | }, 86 | want: "v0.2.0", 87 | }, 88 | { 89 | name: "bump patch version", 90 | release: "v0.1.5", 91 | changes: []change.Change{ 92 | { 93 | ChangeTypes: []change.Type{patchChange}, 94 | }, 95 | }, 96 | want: "v0.1.6", 97 | }, 98 | { 99 | name: "honor no prefix", 100 | release: "0.1.5", 101 | changes: []change.Change{ 102 | { 103 | ChangeTypes: []change.Type{patchChange}, 104 | }, 105 | }, 106 | want: "0.1.6", 107 | }, 108 | { 109 | name: "no changes -- bump patch", 110 | release: "0.1.5", 111 | bumpPatchOnNoChange: true, 112 | changes: []change.Change{ 113 | { 114 | ChangeTypes: []change.Type{}, 115 | }, 116 | }, 117 | want: "0.1.6", 118 | }, 119 | { 120 | name: "no changes -- error", 121 | release: "0.1.5", 122 | bumpPatchOnNoChange: false, 123 | changes: []change.Change{ 124 | { 125 | ChangeTypes: []change.Type{}, 126 | }, 127 | }, 128 | wantErr: require.Error, 129 | }, 130 | { 131 | name: "error on bad version", 132 | release: "a10", 133 | wantErr: require.Error, 134 | }, 135 | } 136 | for _, tt := range tests { 137 | t.Run(tt.name, func(t *testing.T) { 138 | if tt.wantErr == nil { 139 | tt.wantErr = require.NoError 140 | } 141 | s := NewVersionSpeculator(nil, release.SpeculationBehavior{ 142 | EnforceV0: tt.enforceV0, 143 | NoChangesBumpsPatch: tt.bumpPatchOnNoChange, 144 | }) 145 | 146 | got, err := s.NextIdealVersion(tt.release, tt.changes) 147 | tt.wantErr(t, err) 148 | assert.Equal(t, tt.want, got) 149 | }) 150 | } 151 | } 152 | 153 | func TestFindNextUniqueVersion(t *testing.T) { 154 | majorChange := change.Type{ 155 | Kind: change.SemVerMajor, 156 | } 157 | 158 | minorChange := change.Type{ 159 | Kind: change.SemVerMinor, 160 | } 161 | 162 | patchChange := change.Type{ 163 | Kind: change.SemVerPatch, 164 | } 165 | 166 | tests := []struct { 167 | name string 168 | release string 169 | git git.Interface 170 | changes change.Changes 171 | enforceV0 bool 172 | bumpPatchOnNoChange bool 173 | want string 174 | wantErr require.ErrorAssertionFunc 175 | }{ 176 | { 177 | name: "bump major version -- major conflict", 178 | release: "v0.1.5", 179 | git: git.MockInterface{ 180 | MockTags: []string{ 181 | "v1.0.0", 182 | }, 183 | }, 184 | changes: []change.Change{ 185 | { 186 | ChangeTypes: []change.Type{majorChange, minorChange, patchChange}, 187 | }, 188 | }, 189 | want: "v1.0.1", 190 | }, 191 | } 192 | for _, tt := range tests { 193 | t.Run(tt.name, func(t *testing.T) { 194 | if tt.wantErr == nil { 195 | tt.wantErr = require.NoError 196 | } 197 | s := NewVersionSpeculator(tt.git, release.SpeculationBehavior{ 198 | EnforceV0: tt.enforceV0, 199 | NoChangesBumpsPatch: tt.bumpPatchOnNoChange, 200 | }) 201 | 202 | got, err := s.NextUniqueVersion(tt.release, tt.changes) 203 | tt.wantErr(t, err) 204 | assert.Equal(t, tt.want, got) 205 | }) 206 | } 207 | } 208 | -------------------------------------------------------------------------------- /cmd/chronicle/cli/options/github.go: -------------------------------------------------------------------------------- 1 | package options 2 | 3 | import ( 4 | "github.com/anchore/chronicle/chronicle/release/change" 5 | "github.com/anchore/chronicle/chronicle/release/releasers/github" 6 | ) 7 | 8 | type GithubSummarizer struct { 9 | Host string `yaml:"host" json:"host" mapstructure:"host"` 10 | ExcludeLabels []string `yaml:"exclude-labels" json:"exclude-labels" mapstructure:"exclude-labels"` 11 | IncludeIssuePRAuthors bool `yaml:"include-issue-pr-authors" json:"include-issue-pr-authors" mapstructure:"include-issue-pr-authors"` 12 | IncludeIssuePRs bool `yaml:"include-issue-prs" json:"include-issue-prs" mapstructure:"include-issue-prs"` 13 | IncludeIssuesClosedAsNotPlanned bool `yaml:"include-issues-not-planned" json:"include-issues-not-planned" mapstructure:"include-issues-not-planned"` 14 | IncludePRs bool `yaml:"include-prs" json:"include-prs" mapstructure:"include-prs"` 15 | IncludeIssues bool `yaml:"include-issues" json:"include-issues" mapstructure:"include-issues"` 16 | IncludeUnlabeledIssues bool `yaml:"include-unlabeled-issues" json:"include-unlabeled-issues" mapstructure:"include-unlabeled-issues"` 17 | IncludeUnlabeledPRs bool `yaml:"include-unlabeled-prs" json:"include-unlabeled-prs" mapstructure:"include-unlabeled-prs"` 18 | IssuesRequireLinkedPR bool `yaml:"issues-require-linked-prs" json:"issues-require-linked-prs" mapstructure:"issues-require-linked-prs"` 19 | ConsiderPRMergeCommits bool `yaml:"consider-pr-merge-commits" json:"consider-pr-merge-commits" mapstructure:"consider-pr-merge-commits"` 20 | Changes []GithubChange `yaml:"changes" json:"changes" mapstructure:"changes"` 21 | } 22 | 23 | type GithubChange struct { 24 | Type string `yaml:"name" json:"name" mapstructure:"name"` 25 | Title string `yaml:"title" json:"title" mapstructure:"title"` 26 | SemVerKind string `yaml:"semver-field" json:"semver-field" mapstructure:"semver-field"` 27 | Labels []string `yaml:"labels" json:"labels" mapstructure:"labels"` 28 | } 29 | 30 | func (c GithubSummarizer) ToGithubConfig() github.Config { 31 | typeSet := make(change.TypeSet) 32 | for _, c := range c.Changes { 33 | k := change.ParseSemVerKind(c.SemVerKind) 34 | t := change.NewType(c.Type, k) 35 | for _, l := range c.Labels { 36 | typeSet[l] = t 37 | } 38 | } 39 | return github.Config{ 40 | Host: c.Host, 41 | IncludeIssuePRAuthors: c.IncludeIssuePRAuthors, 42 | IncludeIssuePRs: c.IncludeIssuePRs, 43 | IncludeIssues: c.IncludeIssues, 44 | IncludeIssuesClosedAsNotPlanned: c.IncludeIssuesClosedAsNotPlanned, 45 | IncludePRs: c.IncludePRs, 46 | IncludeUnlabeledIssues: c.IncludeUnlabeledIssues, 47 | IncludeUnlabeledPRs: c.IncludeUnlabeledPRs, 48 | ExcludeLabels: c.ExcludeLabels, 49 | IssuesRequireLinkedPR: c.IssuesRequireLinkedPR, 50 | ConsiderPRMergeCommits: c.ConsiderPRMergeCommits, 51 | ChangeTypesByLabel: typeSet, 52 | } 53 | } 54 | 55 | func DefaultGithubSimmarizer() GithubSummarizer { 56 | return GithubSummarizer{ 57 | Host: "github.com", 58 | IssuesRequireLinkedPR: false, 59 | ConsiderPRMergeCommits: true, 60 | IncludePRs: true, 61 | IncludeIssuePRAuthors: true, 62 | IncludeIssuePRs: true, 63 | IncludeIssues: true, 64 | IncludeIssuesClosedAsNotPlanned: false, 65 | IncludeUnlabeledIssues: true, 66 | IncludeUnlabeledPRs: true, 67 | ExcludeLabels: []string{"duplicate", "question", "invalid", "wontfix", "wont-fix", "release-ignore", "changelog-ignore", "ignore"}, 68 | Changes: []GithubChange{ 69 | { 70 | Type: "security-fixes", 71 | Title: "Security Fixes", 72 | Labels: []string{"security", "vulnerability"}, 73 | SemVerKind: change.SemVerPatch.String(), 74 | }, 75 | { 76 | Type: "added-feature", 77 | Title: "Added Features", 78 | Labels: []string{"enhancement", "feature", "minor"}, 79 | SemVerKind: change.SemVerMinor.String(), 80 | }, 81 | { 82 | Type: "bug-fix", 83 | Title: "Bug Fixes", 84 | Labels: []string{"bug", "fix", "bug-fix", "patch"}, 85 | SemVerKind: change.SemVerPatch.String(), 86 | }, 87 | { 88 | Type: "breaking-feature", 89 | Title: "Breaking Changes", 90 | Labels: []string{"breaking", "backwards-incompatible", "breaking-change", "breaking-feature", "major"}, 91 | SemVerKind: change.SemVerMajor.String(), 92 | }, 93 | { 94 | Type: "removed-feature", 95 | Title: "Removed Features", 96 | Labels: []string{"removed"}, 97 | SemVerKind: change.SemVerMajor.String(), 98 | }, 99 | { 100 | Type: "deprecated-feature", 101 | Title: "Deprecated Features", 102 | Labels: []string{"deprecated"}, 103 | SemVerKind: change.SemVerMinor.String(), 104 | }, 105 | { 106 | Type: change.UnknownType.Name, 107 | Title: "Additional Changes", 108 | Labels: []string{}, 109 | SemVerKind: change.UnknownType.Kind.String(), 110 | }, 111 | }, 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # chronicle 2 | 3 | [![Validations](https://github.com/anchore/chronicle/actions/workflows/validations.yaml/badge.svg)](https://github.com/anchore/chronicle/actions/workflows/validations.yaml) 4 | [![Go Report Card](https://goreportcard.com/badge/github.com/anchore/chronicle)](https://goreportcard.com/report/github.com/anchore/chronicle) 5 | [![GitHub release](https://img.shields.io/github/release/anchore/chronicle.svg)](https://github.com/anchore/chronicle/releases/latest) 6 | [![GitHub go.mod Go version](https://img.shields.io/github/go-mod/go-version/anchore/chronicle.svg)](https://github.com/anchore/chronicle) 7 | [![License: Apache-2.0](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://github.com/anchore/chronicle/blob/main/LICENSE) 8 | [![Slack Invite](https://img.shields.io/badge/Slack-Join-blue?logo=slack)](https://anchore.com/slack) 9 | 10 | 11 | **A fast changelog generator that sources changes from GitHub PRs and issues, organized by labels.** 12 | 13 | 14 | Create a changelog from the last GitHib release until the current git HEAD tag/commit for the git repo in the current directory: 15 | ```bash 16 | chronicle 17 | ``` 18 | 19 | Create a changelog with all changes from v0.16.0 until current git HEAD tag/commit for the git repo in the current directory: 20 | ```bash 21 | chronicle --since-tag v0.16.0 22 | ``` 23 | 24 | Create a changelog between two specific tags for a repo at the given path 25 | ```bash 26 | chronicle --since-tag v0.16.0 --until-tag v0.18.0 ./path/to/git/repo 27 | ``` 28 | 29 | Create a changelog and guess the release version from the set of changes in the changelog 30 | ```bash 31 | chronicle -n 32 | ``` 33 | 34 | Just guess the next release version based on the set of changes (don't create a changelog) 35 | ```bash 36 | chronicle next-version 37 | ``` 38 | 39 | ## Installation 40 | 41 | ```bash 42 | curl -sSfL https://get.anchore.io/chronicle | sudo sh -s -- -b /usr/local/bin 43 | ``` 44 | 45 | ...or, you can specify a release version and destination directory for the installation: 46 | 47 | ``` 48 | curl -sSfL https://get.anchore.io/chronicle | sudo sh -s -- -b 49 | ``` 50 | 51 | ## Configuration 52 | 53 | Configuration search paths: 54 | - `.chronicle.yaml` 55 | - `.chronicle/config.yaml` 56 | - `~/.chronicle.yaml` 57 | - `/chronicle/config.yaml` 58 | 59 | ### Default values 60 | 61 | Configuration options (example values are the default): 62 | 63 | ```yaml 64 | # the output format of the changelog 65 | # same as -o, --output, and CHRONICLE_OUTPUT env var 66 | output: md 67 | 68 | # suppress all logging output 69 | # same as -q ; CHRONICLE_QUIET env var 70 | quiet: false 71 | 72 | # all logging options 73 | log: 74 | # use structured logging 75 | # same as CHRONICLE_LOG_STRUCTURED env var 76 | structured: false 77 | 78 | # the log level 79 | # same as CHRONICLE_LOG_LEVEL env var 80 | level: "warn" 81 | 82 | # location to write the log file (default is not to have a log file) 83 | # same as CHRONICLE_LOG_FILE env var 84 | file: "" 85 | 86 | # guess what the next release version is based on the current version and set of changes (cannot be used with --until-tag) 87 | # same as --speculate-next-version / -n ; CHRONICLE_SPECULATE_NEXT_VERSION env var 88 | speculate-next-version: false 89 | 90 | # override the starting git tag for the changelog (default is to detect the last release automatically) 91 | # same as --since-tag / -s ; CHRONICLE_SINCE_TAG env var 92 | since-tag: "" 93 | 94 | # override the ending git tag for the changelog (default is to use the tag or commit at git HEAD) 95 | # same as --until-tag / -u ; CHRONICLE_SINCE_TAG env var 96 | until-tag: "" 97 | 98 | # if the current release version is < v1.0 then breaking changes will bump the minor version field 99 | # same as CHRONICLE_ENFORCE_V0 env var 100 | enforce-v0: false 101 | 102 | # the title used for the changelog 103 | # same as CHRONICLE_TITLE 104 | title: Changelog 105 | 106 | # all github-related settings 107 | github: 108 | 109 | # the github host to use (override for github enterprise deployments) 110 | # same as CHRONICLE_GITHUB_HOST env var 111 | host: github.com 112 | 113 | # do not consider any issues or PRs with any of the given labels 114 | # same as CHRONICLE_GITHUB_EXCLUDE_LABELS env var 115 | exclude-labels: 116 | - duplicate 117 | - question 118 | - invalid 119 | - wontfix 120 | - wont-fix 121 | - release-ignore 122 | - changelog-ignore 123 | - ignore 124 | 125 | # consider merged PRs as candidate changelog entries (must have a matching label from a 'github.changes' entry) 126 | # same as CHRONICLE_GITHUB_INCLUDE_PRS env var 127 | include-prs: true 128 | 129 | # consider closed issues as candidate changelog entries (must have a matching label from a 'github.changes' entry) 130 | # same as CHRONICLE_GITHUB_INCLUDE_ISSUES env var 131 | include-issues: true 132 | 133 | # issues can only be considered for changelog candidates if they have linked PRs that are merged (note: does NOT require github.include-issues to be set) 134 | # same as CHRONICLE_GITHUB_ISSUES_REQUIRE_LINKED_PRS env var 135 | issues-require-linked-prs: false 136 | 137 | # list of definitions of what labels applied to issues or PRs constitute a changelog entry. These entries also dictate 138 | # the changelog section, the changelog title, and the semver field that best represents the class of change. 139 | # note: cannot be set via environment variables 140 | changes: [......] # See "Default GitHub change definitions" section for more details 141 | 142 | ``` 143 | 144 | ### Default GitHub change definitions 145 | 146 | The `github.changes` configurable is a list of mappings, each that take the following fields: 147 | 148 | - `name`: _[string]_ singular, lowercase, hyphen-separated (no spaces) name that best represents the change (e.g. "breaking-change", "security", "added-feature", "enhancement", "new-feature", etc). 149 | - `title`: _[string]_ title of the section in the changelog listing all entries. 150 | - `semver-field`: _[string]_ change entries will bump the respective semver field when guessing the next release version. Allowable values: `major`, `minor`, or `patch`. 151 | - `labels`: _[list of strings]_ all issue or PR labels that should match this change section. 152 | 153 | The default value for `github.changes` is: 154 | 155 | ```yaml 156 | - name: security-fixes 157 | title: Security Fixes 158 | semver-field: patch 159 | labels: 160 | - security 161 | - vulnerability 162 | 163 | - name: added-feature 164 | title: Added Features 165 | semver-field: minor 166 | labels: 167 | - enhancement 168 | - feature 169 | - minor 170 | 171 | - name: bug-fix 172 | title: Bug Fixes 173 | semver-field: patch 174 | labels: 175 | - bug 176 | - fix 177 | - bug-fix 178 | - patch 179 | 180 | - name: breaking-feature 181 | title: Breaking Changes 182 | semver-field: major 183 | labels: 184 | - breaking 185 | - backwards-incompatible 186 | - breaking-change 187 | - breaking-feature 188 | - major 189 | 190 | - name: removed-feature 191 | title: Removed Features 192 | semver-field: major 193 | labels: 194 | - removed 195 | 196 | - name: deprecated-feature 197 | title: Deprecated Features 198 | semver-field: minor 199 | labels: 200 | - deprecated 201 | 202 | - name: unknown 203 | title: Additional Changes 204 | ``` 205 | -------------------------------------------------------------------------------- /chronicle/release/releasers/github/gh_issue.go: -------------------------------------------------------------------------------- 1 | package github 2 | 3 | import ( 4 | "context" 5 | "os" 6 | "strings" 7 | "time" 8 | 9 | "github.com/shurcooL/githubv4" 10 | "golang.org/x/oauth2" 11 | 12 | "github.com/anchore/chronicle/internal" 13 | "github.com/anchore/chronicle/internal/log" 14 | ) 15 | 16 | type ghIssue struct { 17 | Title string 18 | Number int 19 | Author string 20 | ClosedAt time.Time 21 | Closed bool 22 | NotPlanned bool 23 | Labels []string 24 | URL string 25 | } 26 | 27 | type issueFilter func(issue ghIssue) bool 28 | 29 | func filterIssues(issues []ghIssue, filters ...issueFilter) []ghIssue { 30 | if len(filters) == 0 { 31 | return issues 32 | } 33 | 34 | results := make([]ghIssue, 0, len(issues)) 35 | 36 | issueLoop: 37 | for _, r := range issues { 38 | for _, f := range filters { 39 | if !f(r) { 40 | continue issueLoop 41 | } 42 | } 43 | results = append(results, r) 44 | } 45 | 46 | return results 47 | } 48 | 49 | //nolint:unused 50 | func issuesAtOrAfter(since time.Time) issueFilter { 51 | return func(issue ghIssue) bool { 52 | keep := issue.ClosedAt.After(since) || issue.ClosedAt.Equal(since) 53 | if !keep { 54 | log.Tracef("issue #%d filtered out: closed at or before %s (closed %s)", issue.Number, internal.FormatDateTime(since), internal.FormatDateTime(issue.ClosedAt)) 55 | } 56 | return keep 57 | } 58 | } 59 | 60 | func issuesAtOrBefore(since time.Time) issueFilter { 61 | return func(issue ghIssue) bool { 62 | keep := issue.ClosedAt.Before(since) || issue.ClosedAt.Equal(since) 63 | if !keep { 64 | log.Tracef("issue #%d filtered out: closed at or after %s (closed %s)", issue.Number, internal.FormatDateTime(since), internal.FormatDateTime(issue.ClosedAt)) 65 | } 66 | return keep 67 | } 68 | } 69 | 70 | func issuesAfter(since time.Time) issueFilter { 71 | return func(issue ghIssue) bool { 72 | keep := issue.ClosedAt.After(since) 73 | if !keep { 74 | log.Tracef("issue #%d filtered out: closed before %s (closed %s)", issue.Number, internal.FormatDateTime(since), internal.FormatDateTime(issue.ClosedAt)) 75 | } 76 | return keep 77 | } 78 | } 79 | 80 | //nolint:unused 81 | func issuesBefore(since time.Time) issueFilter { 82 | return func(issue ghIssue) bool { 83 | keep := issue.ClosedAt.Before(since) 84 | if !keep { 85 | log.Tracef("issue #%d filtered out: closed after %s (closed %s)", issue.Number, internal.FormatDateTime(since), internal.FormatDateTime(issue.ClosedAt)) 86 | } 87 | return keep 88 | } 89 | } 90 | 91 | func issuesWithLabel(labels ...string) issueFilter { 92 | return func(issue ghIssue) bool { 93 | for _, targetLabel := range labels { 94 | for _, l := range issue.Labels { 95 | if l == targetLabel { 96 | return true 97 | } 98 | } 99 | } 100 | 101 | log.Tracef("issue #%d filtered out: missing required label", issue.Number) 102 | 103 | return false 104 | } 105 | } 106 | 107 | func issuesWithoutLabel(labels ...string) issueFilter { 108 | return func(issue ghIssue) bool { 109 | for _, targetLabel := range labels { 110 | for _, l := range issue.Labels { 111 | if l == targetLabel { 112 | log.Tracef("issue #%d filtered out: has label %q", issue.Number, l) 113 | 114 | return false 115 | } 116 | } 117 | } 118 | return true 119 | } 120 | } 121 | 122 | func excludeIssuesNotPlanned(allMergedPRs []ghPullRequest) issueFilter { 123 | return func(issue ghIssue) bool { 124 | if issue.NotPlanned { 125 | if len(getLinkedPRs(allMergedPRs, issue)) > 0 { 126 | log.Tracef("issue #%d included: is closed as not planned but has linked PRs", issue.Number) 127 | return true 128 | } 129 | log.Tracef("issue #%d filtered out: as not planned", issue.Number) 130 | return false 131 | } 132 | return true 133 | } 134 | } 135 | 136 | func issuesWithChangeTypes(config Config) issueFilter { 137 | return func(issue ghIssue) bool { 138 | changeTypes := config.ChangeTypesByLabel.ChangeTypes(issue.Labels...) 139 | keep := len(changeTypes) > 0 140 | if !keep { 141 | log.Tracef("issue #%d filtered out: no change types", issue.Number) 142 | } 143 | return keep 144 | } 145 | } 146 | 147 | func issuesWithoutLabels() issueFilter { 148 | return func(issue ghIssue) bool { 149 | keep := len(issue.Labels) == 0 150 | if !keep { 151 | log.Tracef("issue #%d filtered out: has labels", issue.Number) 152 | } 153 | return keep 154 | } 155 | } 156 | 157 | //nolint:funlen 158 | func fetchClosedIssues(user, repo string, since *time.Time) ([]ghIssue, error) { 159 | src := oauth2.StaticTokenSource( 160 | // TODO: DI this 161 | &oauth2.Token{AccessToken: os.Getenv("GITHUB_TOKEN")}, 162 | ) 163 | httpClient := oauth2.NewClient(context.Background(), src) 164 | client := githubv4.NewClient(httpClient) 165 | var ( 166 | pages = 1 167 | saw = 0 168 | allIssues []ghIssue 169 | ) 170 | 171 | { 172 | // TODO: act on hitting a rate limit 173 | type rateLimit struct { 174 | Cost githubv4.Int 175 | Limit githubv4.Int 176 | Remaining githubv4.Int 177 | ResetAt githubv4.DateTime 178 | } 179 | 180 | var query struct { 181 | Repository struct { 182 | DatabaseID githubv4.Int 183 | URL githubv4.URI 184 | Issues struct { 185 | PageInfo struct { 186 | EndCursor githubv4.String 187 | HasNextPage bool 188 | } 189 | Edges []struct { 190 | Node struct { 191 | Title githubv4.String 192 | Number githubv4.Int 193 | URL githubv4.String 194 | Author struct { 195 | Login githubv4.String 196 | } 197 | Closed githubv4.Boolean 198 | ClosedAt githubv4.DateTime 199 | UpdatedAt githubv4.DateTime 200 | StateReason githubv4.String 201 | Labels struct { 202 | Edges []struct { 203 | Node struct { 204 | Name githubv4.String 205 | } 206 | } 207 | } `graphql:"labels(first:100)"` 208 | } 209 | } 210 | } `graphql:"issues(first:100, states:CLOSED, after:$issuesCursor, orderBy:{field: UPDATED_AT, direction: DESC})"` 211 | } `graphql:"repository(owner:$repositoryOwner, name:$repositoryName)"` 212 | 213 | RateLimit rateLimit 214 | } 215 | variables := map[string]interface{}{ 216 | "repositoryOwner": githubv4.String(user), 217 | "repositoryName": githubv4.String(repo), 218 | "issuesCursor": (*githubv4.String)(nil), // Null after argument to get first page. 219 | } 220 | 221 | // var limit rateLimit 222 | var ( 223 | process bool 224 | terminate = false 225 | ) 226 | 227 | for !terminate { 228 | log.WithFields("user", user, "repo", repo, "page", pages).Trace("fetching closed issues from github.com") 229 | 230 | err := client.Query(context.Background(), &query, variables) 231 | if err != nil { 232 | return nil, err 233 | } 234 | 235 | // limit = query.RateLimit 236 | 237 | for i := range query.Repository.Issues.Edges { 238 | iEdge := query.Repository.Issues.Edges[i] 239 | saw++ 240 | process, terminate = checkSearchTermination(since, &iEdge.Node.UpdatedAt, &iEdge.Node.ClosedAt) 241 | if !process || terminate { 242 | continue 243 | } 244 | 245 | var labels []string 246 | for _, lEdge := range iEdge.Node.Labels.Edges { 247 | labels = append(labels, string(lEdge.Node.Name)) 248 | } 249 | allIssues = append(allIssues, ghIssue{ 250 | Title: string(iEdge.Node.Title), 251 | Author: string(iEdge.Node.Author.Login), 252 | ClosedAt: iEdge.Node.ClosedAt.Time, 253 | Closed: bool(iEdge.Node.Closed), 254 | Labels: labels, 255 | URL: string(iEdge.Node.URL), 256 | Number: int(iEdge.Node.Number), 257 | NotPlanned: strings.EqualFold("NOT_PLANNED", string(iEdge.Node.StateReason)), 258 | }) 259 | } 260 | 261 | if !query.Repository.Issues.PageInfo.HasNextPage { 262 | break 263 | } 264 | variables["issuesCursor"] = githubv4.NewString(query.Repository.Issues.PageInfo.EndCursor) 265 | pages++ 266 | } 267 | 268 | // for idx, is := range allIssues { 269 | // fmt.Printf("%d: %+v\n", idx, is) 270 | //} 271 | // printJSON(limit) 272 | } 273 | 274 | log.WithFields("kept", len(allIssues), "saw", saw, "pages", pages, "since", since).Trace("closed PRs fetched from github.com") 275 | 276 | return allIssues, nil 277 | } 278 | -------------------------------------------------------------------------------- /chronicle/release/format/markdown/presenter_test.go: -------------------------------------------------------------------------------- 1 | package markdown 2 | 3 | import ( 4 | "bytes" 5 | "testing" 6 | "time" 7 | 8 | "github.com/gkampitakis/go-snaps/snaps" 9 | "github.com/stretchr/testify/assert" 10 | "github.com/stretchr/testify/require" 11 | "github.com/wagoodman/go-presenter" 12 | 13 | "github.com/anchore/chronicle/chronicle/release" 14 | "github.com/anchore/chronicle/chronicle/release/change" 15 | ) 16 | 17 | func TestMarkdownPresenter_Present(t *testing.T) { 18 | must := func(m *Presenter, err error) *Presenter { 19 | require.NoError(t, err) 20 | return m 21 | } 22 | assertPresenterAgainstGoldenSnapshot( 23 | t, 24 | must( 25 | NewMarkdownPresenter(Config{ 26 | // this is the default configuration from the CLI 27 | Title: `{{ .Version }}`, 28 | Description: release.Description{ 29 | SupportedChanges: []change.TypeTitle{ 30 | { 31 | ChangeType: change.NewType("bug", change.SemVerPatch), 32 | Title: "Bug Fixes", 33 | }, 34 | { 35 | ChangeType: change.NewType("added", change.SemVerMinor), 36 | Title: "Added Features", 37 | }, 38 | { 39 | ChangeType: change.NewType("breaking", change.SemVerMajor), 40 | Title: "Breaking Changes", 41 | }, 42 | { 43 | ChangeType: change.NewType("removed", change.SemVerMajor), 44 | Title: "Removed Features", 45 | }, 46 | }, 47 | Release: release.Release{ 48 | Version: "v0.19.1", 49 | Date: time.Date(2021, time.September, 16, 19, 34, 0, 0, time.UTC), 50 | }, 51 | VCSReferenceURL: "https://github.com/anchore/syft/tree/v0.19.1", 52 | VCSChangesURL: "https://github.com/anchore/syft/compare/v0.19.0...v0.19.1", 53 | Changes: []change.Change{ 54 | { 55 | ChangeTypes: []change.Type{change.NewType("bug", change.SemVerPatch)}, 56 | Text: "fix: Redirect cursor hide/show to stderr.", 57 | References: []change.Reference{ 58 | { 59 | Text: "#456", 60 | URL: "https://github.com/anchore/syft/pull/456", 61 | }, 62 | }, 63 | }, 64 | { 65 | ChangeTypes: []change.Type{change.NewType("added", change.SemVerMinor)}, 66 | Text: "added feature!", 67 | References: []change.Reference{ 68 | { 69 | Text: "#457", 70 | URL: "https://github.com/anchore/syft/pull/457", 71 | }, 72 | { 73 | Text: "@wagoodman", 74 | URL: "https://github.com/wagoodman", 75 | }, 76 | }, 77 | }, 78 | { 79 | ChangeTypes: []change.Type{change.NewType("added", change.SemVerMinor)}, 80 | Text: "feat(api)!: another added feature", 81 | }, 82 | { 83 | ChangeTypes: []change.Type{change.NewType("breaking", change.SemVerMajor)}, 84 | Text: "breaking change?", 85 | References: []change.Reference{ 86 | { 87 | Text: "#458", 88 | URL: "https://github.com/anchore/syft/pull/458", 89 | }, 90 | { 91 | Text: "#450", 92 | URL: "https://github.com/anchore/syft/issues/450", 93 | }, 94 | { 95 | Text: "@wagoodman", 96 | URL: "https://github.com/wagoodman", 97 | }, 98 | }, 99 | }, 100 | }, 101 | Notice: "notice!", 102 | }, 103 | }), 104 | ), 105 | ) 106 | } 107 | 108 | func TestMarkdownPresenter_Present_NoTitle(t *testing.T) { 109 | must := func(m *Presenter, err error) *Presenter { 110 | require.NoError(t, err) 111 | return m 112 | } 113 | assertPresenterAgainstGoldenSnapshot( 114 | t, 115 | must( 116 | NewMarkdownPresenter(Config{ 117 | Title: "", 118 | Description: release.Description{ 119 | SupportedChanges: []change.TypeTitle{ 120 | { 121 | ChangeType: change.NewType("bug", change.SemVerPatch), 122 | Title: "Bug Fixes", 123 | }, 124 | }, 125 | Release: release.Release{ 126 | Version: "v0.19.1", 127 | Date: time.Date(2021, time.September, 16, 19, 34, 0, 0, time.UTC), 128 | }, 129 | VCSReferenceURL: "https://github.com/anchore/syft/tree/v0.19.1", 130 | VCSChangesURL: "https://github.com/anchore/syft/compare/v0.19.0...v0.19.1", 131 | Changes: []change.Change{ 132 | { 133 | ChangeTypes: []change.Type{change.NewType("bug", change.SemVerPatch)}, 134 | Text: "Redirect cursor hide/show to stderr", 135 | References: []change.Reference{ 136 | { 137 | Text: "#456", 138 | URL: "https://github.com/anchore/syft/pull/456", 139 | }, 140 | }, 141 | }, 142 | }, 143 | Notice: "notice!", 144 | }, 145 | }), 146 | ), 147 | ) 148 | } 149 | 150 | func TestMarkdownPresenter_Present_NoChanges(t *testing.T) { 151 | must := func(m *Presenter, err error) *Presenter { 152 | require.NoError(t, err) 153 | return m 154 | } 155 | assertPresenterAgainstGoldenSnapshot( 156 | t, 157 | must( 158 | NewMarkdownPresenter(Config{ 159 | Title: "Changelog", 160 | Description: release.Description{ 161 | SupportedChanges: []change.TypeTitle{}, 162 | Release: release.Release{ 163 | Version: "v0.19.1", 164 | Date: time.Date(2021, time.September, 16, 19, 34, 0, 0, time.UTC), 165 | }, 166 | VCSReferenceURL: "https://github.com/anchore/syft/tree/v0.19.1", 167 | VCSChangesURL: "https://github.com/anchore/syft/compare/v0.19.0...v0.19.1", 168 | Changes: []change.Change{}, 169 | Notice: "notice!", 170 | }, 171 | }), 172 | ), 173 | ) 174 | } 175 | 176 | type redactor func(s []byte) []byte 177 | 178 | func assertPresenterAgainstGoldenSnapshot(t *testing.T, pres presenter.Presenter, redactors ...redactor) { 179 | t.Helper() 180 | 181 | var buffer bytes.Buffer 182 | err := pres.Present(&buffer) 183 | assert.NoError(t, err) 184 | actual := buffer.Bytes() 185 | 186 | // remove dynamic values, which should be tested independently 187 | for _, r := range redactors { 188 | actual = r(actual) 189 | } 190 | 191 | snaps.MatchSnapshot(t, string(actual)) 192 | } 193 | 194 | func Test_removeConventionalCommitPrefix(t *testing.T) { 195 | tests := []struct { 196 | name string 197 | want string 198 | }{ 199 | // positive cases 200 | { 201 | name: "feat: add user authentication", 202 | want: "add user authentication", 203 | }, 204 | { 205 | name: "fix: resolve null pointer exception", 206 | want: "resolve null pointer exception", 207 | }, 208 | { 209 | name: "docs: update README", 210 | want: "update README", 211 | }, 212 | { 213 | name: "style: format code according to style guide", 214 | want: "format code according to style guide", 215 | }, 216 | { 217 | name: "refactor: extract reusable function", 218 | want: "extract reusable function", 219 | }, 220 | { 221 | name: "perf: optimize database queries", 222 | want: "optimize database queries", 223 | }, 224 | { 225 | name: "test: add unit tests", 226 | want: "add unit tests", 227 | }, 228 | { 229 | name: "build: update build process", 230 | want: "update build process", 231 | }, 232 | { 233 | name: "ci: configure Travis CI", 234 | want: "configure Travis CI", 235 | }, 236 | { 237 | name: "chore: perform maintenance tasks", 238 | want: "perform maintenance tasks", 239 | }, 240 | // positive case odd balls 241 | { 242 | name: "chore: can end with punctuation.", 243 | want: "can end with punctuation.", 244 | }, 245 | { 246 | name: "revert!: revert: previous: commit", 247 | want: "revert: previous: commit", 248 | }, 249 | { 250 | name: "feat(api)!: implement new: API endpoints", 251 | want: "implement new: API endpoints", 252 | }, 253 | { 254 | name: "feat!: add awesome new feature (closes #123)", 255 | want: "add awesome new feature (closes #123)", 256 | }, 257 | { 258 | name: "fix(ui): fix layout issue (fixes #456)", 259 | want: "fix layout issue (fixes #456)", 260 | }, 261 | // negative cases 262 | { 263 | name: "reallycoolthing: is done!", 264 | want: "reallycoolthing: is done!", 265 | }, 266 | { 267 | name: "feature: is done!", 268 | want: "feature: is done!", 269 | }, 270 | { 271 | // missing description... just leave it alone 272 | name: "feat(scope): ", 273 | want: "feat(scope): ", 274 | }, 275 | { 276 | // missing whitespace in description (yes, that's a cc requirement) 277 | name: "feat(scope):something", 278 | want: "feat(scope):something", 279 | }, 280 | { 281 | // has a newline 282 | name: "feat: something\n wicked this way comes", 283 | want: "feat: something\n wicked this way comes", 284 | }, 285 | } 286 | for _, tt := range tests { 287 | t.Run(tt.name, func(t *testing.T) { 288 | assert.Equalf(t, tt.want, removeConventionalCommitPrefix(tt.name), "removeConventionalCommitPrefix(%v)", tt.name) 289 | }) 290 | } 291 | } 292 | -------------------------------------------------------------------------------- /chronicle/release/releasers/github/gh_issue_test.go: -------------------------------------------------------------------------------- 1 | package github 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | "github.com/stretchr/testify/assert" 8 | 9 | "github.com/anchore/chronicle/chronicle/release/change" 10 | ) 11 | 12 | func Test_issuesAtOrAfter(t *testing.T) { 13 | tests := []struct { 14 | name string 15 | issue ghIssue 16 | since time.Time 17 | expected bool 18 | }{ 19 | { 20 | name: "issue is before compare date", 21 | since: time.Date(2021, time.September, 18, 19, 34, 0, 0, time.UTC), 22 | issue: ghIssue{ 23 | ClosedAt: time.Date(2021, time.September, 16, 19, 34, 0, 0, time.UTC), 24 | }, 25 | expected: false, 26 | }, 27 | { 28 | name: "issue is equal to compare date", 29 | since: time.Date(2021, time.September, 18, 19, 34, 0, 0, time.UTC), 30 | issue: ghIssue{ 31 | ClosedAt: time.Date(2021, time.September, 18, 19, 34, 0, 0, time.UTC), 32 | }, 33 | expected: true, 34 | }, 35 | { 36 | name: "issue is after compare date", 37 | since: time.Date(2021, time.September, 16, 19, 34, 0, 0, time.UTC), 38 | issue: ghIssue{ 39 | ClosedAt: time.Date(2021, time.September, 18, 19, 34, 0, 0, time.UTC), 40 | }, 41 | expected: true, 42 | }, 43 | } 44 | for _, test := range tests { 45 | t.Run(test.name, func(t *testing.T) { 46 | assert.Equal(t, test.expected, issuesAtOrAfter(test.since)(test.issue)) 47 | }) 48 | } 49 | } 50 | 51 | func Test_issuesAfter(t *testing.T) { 52 | tests := []struct { 53 | name string 54 | issue ghIssue 55 | since time.Time 56 | expected bool 57 | }{ 58 | { 59 | name: "issue is before compare date", 60 | since: time.Date(2021, time.September, 18, 19, 34, 0, 0, time.UTC), 61 | issue: ghIssue{ 62 | ClosedAt: time.Date(2021, time.September, 16, 19, 34, 0, 0, time.UTC), 63 | }, 64 | expected: false, 65 | }, 66 | { 67 | name: "issue is equal to compare date", 68 | since: time.Date(2021, time.September, 18, 19, 34, 0, 0, time.UTC), 69 | issue: ghIssue{ 70 | ClosedAt: time.Date(2021, time.September, 18, 19, 34, 0, 0, time.UTC), 71 | }, 72 | expected: false, 73 | }, 74 | { 75 | name: "issue is after compare date", 76 | since: time.Date(2021, time.September, 16, 19, 34, 0, 0, time.UTC), 77 | issue: ghIssue{ 78 | ClosedAt: time.Date(2021, time.September, 18, 19, 34, 0, 0, time.UTC), 79 | }, 80 | expected: true, 81 | }, 82 | } 83 | for _, test := range tests { 84 | t.Run(test.name, func(t *testing.T) { 85 | assert.Equal(t, test.expected, issuesAfter(test.since)(test.issue)) 86 | }) 87 | } 88 | } 89 | 90 | func Test_issuesAtOrBefore(t *testing.T) { 91 | tests := []struct { 92 | name string 93 | issue ghIssue 94 | until time.Time 95 | expected bool 96 | }{ 97 | { 98 | name: "issue is after compare date", 99 | until: time.Date(2021, time.September, 16, 19, 34, 0, 0, time.UTC), 100 | issue: ghIssue{ 101 | ClosedAt: time.Date(2021, time.September, 18, 19, 34, 0, 0, time.UTC), 102 | }, 103 | expected: false, 104 | }, 105 | { 106 | name: "issue is equal to compare date", 107 | until: time.Date(2021, time.September, 18, 19, 34, 0, 0, time.UTC), 108 | issue: ghIssue{ 109 | ClosedAt: time.Date(2021, time.September, 18, 19, 34, 0, 0, time.UTC), 110 | }, 111 | expected: true, 112 | }, 113 | { 114 | name: "issue is before compare date", 115 | until: time.Date(2021, time.September, 18, 19, 34, 0, 0, time.UTC), 116 | issue: ghIssue{ 117 | ClosedAt: time.Date(2021, time.September, 16, 19, 34, 0, 0, time.UTC), 118 | }, 119 | expected: true, 120 | }, 121 | } 122 | for _, test := range tests { 123 | t.Run(test.name, func(t *testing.T) { 124 | assert.Equal(t, test.expected, issuesAtOrBefore(test.until)(test.issue)) 125 | }) 126 | } 127 | } 128 | 129 | func Test_issuesBefore(t *testing.T) { 130 | tests := []struct { 131 | name string 132 | issue ghIssue 133 | until time.Time 134 | expected bool 135 | }{ 136 | { 137 | name: "issue is after compare date", 138 | until: time.Date(2021, time.September, 16, 19, 34, 0, 0, time.UTC), 139 | issue: ghIssue{ 140 | ClosedAt: time.Date(2021, time.September, 18, 19, 34, 0, 0, time.UTC), 141 | }, 142 | expected: false, 143 | }, 144 | { 145 | name: "issue is equal to compare date", 146 | until: time.Date(2021, time.September, 18, 19, 34, 0, 0, time.UTC), 147 | issue: ghIssue{ 148 | ClosedAt: time.Date(2021, time.September, 18, 19, 34, 0, 0, time.UTC), 149 | }, 150 | expected: false, 151 | }, 152 | { 153 | name: "issue is before compare date", 154 | until: time.Date(2021, time.September, 18, 19, 34, 0, 0, time.UTC), 155 | issue: ghIssue{ 156 | ClosedAt: time.Date(2021, time.September, 16, 19, 34, 0, 0, time.UTC), 157 | }, 158 | expected: true, 159 | }, 160 | } 161 | for _, test := range tests { 162 | t.Run(test.name, func(t *testing.T) { 163 | assert.Equal(t, test.expected, issuesBefore(test.until)(test.issue)) 164 | }) 165 | } 166 | } 167 | 168 | func Test_issuesWithLabel(t *testing.T) { 169 | tests := []struct { 170 | name string 171 | issue ghIssue 172 | labels []string 173 | expected bool 174 | }{ 175 | { 176 | name: "matches on label", 177 | labels: []string{ 178 | "positive", 179 | }, 180 | issue: ghIssue{ 181 | Labels: []string{"something-else", "positive"}, 182 | }, 183 | expected: true, 184 | }, 185 | { 186 | name: "does not match on label", 187 | labels: []string{ 188 | "positive", 189 | }, 190 | issue: ghIssue{ 191 | Labels: []string{"something-else", "negative"}, 192 | }, 193 | expected: false, 194 | }, 195 | } 196 | for _, test := range tests { 197 | t.Run(test.name, func(t *testing.T) { 198 | assert.Equal(t, test.expected, issuesWithLabel(test.labels...)(test.issue)) 199 | }) 200 | } 201 | } 202 | 203 | func Test_issuesWithoutLabel(t *testing.T) { 204 | tests := []struct { 205 | name string 206 | issue ghIssue 207 | labels []string 208 | expected bool 209 | }{ 210 | { 211 | name: "matches on label", 212 | labels: []string{ 213 | "positive", 214 | }, 215 | issue: ghIssue{ 216 | Labels: []string{"something-else", "positive"}, 217 | }, 218 | expected: false, 219 | }, 220 | { 221 | name: "does not match on label", 222 | labels: []string{ 223 | "positive", 224 | }, 225 | issue: ghIssue{ 226 | Labels: []string{"something-else", "negative"}, 227 | }, 228 | expected: true, 229 | }, 230 | } 231 | for _, test := range tests { 232 | t.Run(test.name, func(t *testing.T) { 233 | assert.Equal(t, test.expected, issuesWithoutLabel(test.labels...)(test.issue)) 234 | }) 235 | } 236 | } 237 | 238 | func Test_issuesWithoutLabels(t *testing.T) { 239 | tests := []struct { 240 | name string 241 | issue ghIssue 242 | expected bool 243 | }{ 244 | { 245 | name: "omitted when labels", 246 | issue: ghIssue{ 247 | Labels: []string{"something-else", "positive"}, 248 | }, 249 | expected: false, 250 | }, 251 | { 252 | name: "included with no labels", 253 | issue: ghIssue{ 254 | Labels: []string{}, 255 | }, 256 | expected: true, 257 | }, 258 | } 259 | for _, test := range tests { 260 | t.Run(test.name, func(t *testing.T) { 261 | assert.Equal(t, test.expected, issuesWithoutLabels()(test.issue)) 262 | }) 263 | } 264 | } 265 | 266 | func Test_excludeIssuesNotPlanned(t *testing.T) { 267 | issue1 := ghIssue{ 268 | Title: "Issue 1", 269 | Number: 1, 270 | URL: "issue-1-url", 271 | Closed: true, 272 | NotPlanned: true, 273 | } 274 | 275 | issue2 := ghIssue{ 276 | Title: "Issue 2", 277 | Number: 2, 278 | URL: "issue-2-url", 279 | } 280 | 281 | issue3 := ghIssue{ 282 | Title: "Issue 3 no links", 283 | Number: 3, 284 | URL: "issue-3-url", 285 | Closed: true, 286 | NotPlanned: true, 287 | } 288 | 289 | prWithLinkedIssues1 := ghPullRequest{ 290 | Title: "pr 1 with linked issues", 291 | Number: 1, 292 | LinkedIssues: []ghIssue{ 293 | issue1, 294 | }, 295 | } 296 | 297 | prWithLinkedIssues2 := ghPullRequest{ 298 | Title: "pr 2 with linked issues", 299 | Number: 2, 300 | Author: "some-author-2", 301 | URL: "some-url-2", 302 | LinkedIssues: []ghIssue{ 303 | issue2, 304 | }, 305 | } 306 | 307 | prWithoutLinkedIssues1 := ghPullRequest{ 308 | Title: "pr 3 without linked issues", 309 | Number: 3, 310 | Author: "some-author", 311 | URL: "some-url", 312 | } 313 | 314 | tests := []struct { 315 | name string 316 | config Config 317 | prs []ghPullRequest 318 | issues []ghIssue 319 | expected []ghIssue 320 | }{ 321 | { 322 | name: "excludes not planned issues with no linked PRs", 323 | config: Config{}, 324 | prs: []ghPullRequest{ 325 | prWithLinkedIssues1, 326 | prWithLinkedIssues2, 327 | prWithoutLinkedIssues1, 328 | }, 329 | issues: []ghIssue{ 330 | issue1, 331 | issue2, 332 | issue3, 333 | }, 334 | expected: []ghIssue{ 335 | issue1, 336 | issue2, 337 | }, 338 | }, 339 | } 340 | 341 | for _, test := range tests { 342 | t.Run(test.name, func(t *testing.T) { 343 | filtered := filterIssues(test.issues, excludeIssuesNotPlanned(test.prs)) 344 | assert.Equal(t, test.expected, filtered) 345 | }) 346 | } 347 | } 348 | 349 | func Test_issuesWithChangeTypes(t *testing.T) { 350 | tests := []struct { 351 | name string 352 | issue ghIssue 353 | label string 354 | expected bool 355 | }{ 356 | { 357 | name: "matches on label", 358 | label: "positive", 359 | issue: ghIssue{ 360 | Labels: []string{"something-else", "positive"}, 361 | }, 362 | expected: true, 363 | }, 364 | { 365 | name: "does not match on label", 366 | label: "positive", 367 | issue: ghIssue{ 368 | Labels: []string{"something-else", "negative"}, 369 | }, 370 | expected: false, 371 | }, 372 | { 373 | name: "does not have change types", 374 | label: "positive", 375 | issue: ghIssue{ 376 | Labels: []string{}, 377 | }, 378 | expected: false, 379 | }, 380 | } 381 | for _, test := range tests { 382 | t.Run(test.name, func(t *testing.T) { 383 | assert.Equal(t, test.expected, issuesWithChangeTypes(Config{ 384 | ChangeTypesByLabel: change.TypeSet{ 385 | test.label: change.NewType(test.label, change.SemVerMinor), 386 | }, 387 | })(test.issue)) 388 | }) 389 | } 390 | } 391 | -------------------------------------------------------------------------------- /internal/git/tag_test.go: -------------------------------------------------------------------------------- 1 | package git 2 | 3 | import ( 4 | "fmt" 5 | "os/exec" 6 | "strings" 7 | "testing" 8 | "time" 9 | 10 | "github.com/google/go-cmp/cmp" 11 | "github.com/stretchr/testify/assert" 12 | "github.com/stretchr/testify/require" 13 | ) 14 | 15 | func TestTagsFromLocal(t *testing.T) { 16 | tests := []struct { 17 | name string 18 | path string 19 | expects []string 20 | }{ 21 | { 22 | name: "go case", 23 | path: "testdata/repos/tag-range-repo", 24 | expects: []string{ 25 | "v0.1.0", 26 | "v0.1.1", 27 | "v0.2.0", 28 | }, 29 | }, 30 | { 31 | name: "annotated tags", 32 | path: "testdata/repos/annotated-tagged-repo", 33 | expects: []string{ 34 | "v0.1.0", 35 | }, 36 | }, 37 | } 38 | for _, test := range tests { 39 | t.Run(test.name, func(t *testing.T) { 40 | actual, err := TagsFromLocal(test.path) 41 | var names []string 42 | for _, a := range actual { 43 | names = append(names, a.Name) 44 | } 45 | require.NoError(t, err) 46 | assert.Equal(t, test.expects, names) 47 | }) 48 | } 49 | } 50 | 51 | func TestTagsFromLocal_processTag_timestamp(t *testing.T) { 52 | tests := []struct { 53 | name string 54 | path string 55 | expects []Tag 56 | }{ 57 | { 58 | name: "lightweight tags case", 59 | path: "testdata/repos/tag-range-repo", 60 | expects: expectedTags(t, "testdata/repos/tag-range-repo"), 61 | }, 62 | { 63 | name: "annotated tags", 64 | path: "testdata/repos/annotated-tagged-repo", 65 | expects: expectedTags(t, "testdata/repos/annotated-tagged-repo"), 66 | }, 67 | } 68 | for _, test := range tests { 69 | t.Run(test.name, func(t *testing.T) { 70 | actual, err := TagsFromLocal(test.path) 71 | require.NoError(t, err) 72 | if d := cmp.Diff(test.expects, actual); d != "" { 73 | t.Fatalf("unexpected tags (-want +got):\n%s", d) 74 | } 75 | }) 76 | } 77 | } 78 | 79 | func TestSearchForTag(t *testing.T) { 80 | tests := []struct { 81 | name string 82 | path string 83 | tag string 84 | hasMatch bool 85 | }{ 86 | { 87 | name: "first tag exists", 88 | path: "testdata/repos/tag-range-repo", 89 | tag: "v0.1.0", 90 | hasMatch: true, 91 | }, 92 | { 93 | name: "last tag exists", 94 | path: "testdata/repos/tag-range-repo", 95 | tag: "v0.2.0", 96 | hasMatch: true, 97 | }, 98 | { 99 | name: "fake tag", 100 | path: "testdata/repos/tag-range-repo", 101 | tag: "v1.84793.23849", 102 | hasMatch: false, 103 | }, 104 | { 105 | name: "annotated tag exists", 106 | path: "testdata/repos/annotated-tagged-repo", 107 | tag: "v0.1.0", 108 | hasMatch: true, 109 | }, 110 | } 111 | for _, test := range tests { 112 | t.Run(test.name, func(t *testing.T) { 113 | actual, err := SearchForTag(test.path, test.tag) 114 | 115 | if test.hasMatch { 116 | require.NoError(t, err) 117 | expectedCommit := gitTagCommit(t, test.path, test.tag) 118 | require.Equal(t, expectedCommit, actual.Commit) 119 | require.Equal(t, test.tag, actual.Name) 120 | } else { 121 | require.Nil(t, actual) 122 | require.Error(t, err) 123 | } 124 | }) 125 | } 126 | } 127 | 128 | func TestCommitsBetween(t *testing.T) { 129 | tests := []struct { 130 | name string 131 | path string 132 | config Range 133 | count int 134 | }{ 135 | { 136 | name: "all inclusive", 137 | path: "testdata/repos/tag-range-repo", 138 | config: Range{ 139 | SinceRef: "v0.1.0", 140 | UntilRef: "v0.2.0", 141 | IncludeStart: true, 142 | IncludeEnd: true, 143 | }, 144 | count: 7, 145 | }, 146 | { 147 | name: "exclude start", 148 | path: "testdata/repos/tag-range-repo", 149 | config: Range{ 150 | SinceRef: "v0.1.0", 151 | UntilRef: "v0.2.0", 152 | IncludeStart: false, 153 | IncludeEnd: true, 154 | }, 155 | count: 6, 156 | }, 157 | { 158 | name: "exclude end", 159 | path: "testdata/repos/tag-range-repo", 160 | config: Range{ 161 | SinceRef: "v0.1.0", 162 | UntilRef: "v0.2.0", 163 | IncludeStart: true, 164 | IncludeEnd: false, 165 | }, 166 | count: 6, 167 | }, 168 | { 169 | name: "exclude start and end", 170 | path: "testdata/repos/tag-range-repo", 171 | config: Range{ 172 | SinceRef: "v0.1.0", 173 | UntilRef: "v0.2.0", 174 | IncludeStart: false, 175 | IncludeEnd: false, 176 | }, 177 | count: 5, 178 | }, 179 | } 180 | for _, test := range tests { 181 | t.Run(test.name, func(t *testing.T) { 182 | actual, err := CommitsBetween(test.path, test.config) 183 | require.NoError(t, err) 184 | 185 | // the answer is based off the the current (dynamically created) git log test fixture 186 | expected := gitLogRange(t, test.path, test.config.SinceRef, test.config.UntilRef) 187 | require.NotEmpty(t, expected) 188 | 189 | if !test.config.IncludeStart { 190 | // remember: git log is in reverse chronological order 191 | expected = popBack(expected) 192 | } 193 | 194 | if !test.config.IncludeEnd { 195 | // remember: git log is in reverse chronological order 196 | expected = popFront(expected) 197 | } 198 | 199 | require.Len(t, expected, test.count, "BAD job building expected commits: expected %d, got %d", test.count, len(expected)) 200 | 201 | assert.Equal(t, expected, actual) 202 | 203 | // make certain that the commit values match the extracted tag commit values 204 | if test.config.IncludeEnd { 205 | // remember: git log is in reverse chronological order 206 | assert.Equal(t, gitTagCommit(t, test.path, test.config.UntilRef), actual[0]) 207 | } 208 | 209 | // make certain that the commit values match the extracted tag commit values 210 | if test.config.IncludeStart { 211 | // remember: git log is in reverse chronological order 212 | assert.Equal(t, gitTagCommit(t, test.path, test.config.SinceRef), actual[len(actual)-1]) 213 | } 214 | }) 215 | } 216 | } 217 | 218 | func gitLogRange(t *testing.T, path, since, until string) []string { 219 | t.Helper() 220 | 221 | since = strings.TrimSpace(since) 222 | if since == "" { 223 | t.Fatal("require 'since'") 224 | } 225 | 226 | // why the ~1? we want git log to return inclusive results 227 | cmd := exec.Command("git", "--no-pager", "log", `--pretty=format:%H`, fmt.Sprintf("%s~1..%s", since, until)) 228 | cmd.Dir = path 229 | output, err := cmd.Output() 230 | require.NoError(t, err) 231 | 232 | rows := strings.Split(strings.TrimSpace(string(output)), "\n") 233 | return rows 234 | } 235 | 236 | func gitTagCommit(t *testing.T, path, tag string) string { 237 | t.Helper() 238 | 239 | tag = strings.TrimSpace(tag) 240 | if tag == "" { 241 | t.Fatal("require 'tag'") 242 | } 243 | 244 | // note: the -1 is to stop listing entries after the first entry 245 | cmd := exec.Command("git", "--no-pager", "log", `--pretty=format:%H`, "-1", tag) 246 | cmd.Dir = path 247 | output, err := cmd.Output() 248 | require.NoError(t, err) 249 | 250 | rows := strings.Split(strings.TrimSpace(string(output)), "\n") 251 | if len(rows) != 1 { 252 | t.Fatalf("unable to get commit for tag=%s: %q", tag, output) 253 | } 254 | return rows[0] 255 | } 256 | 257 | func popFront(items []string) []string { 258 | if len(items) == 0 { 259 | return items 260 | } 261 | return items[1:] 262 | } 263 | 264 | func popBack(items []string) []string { 265 | if len(items) == 0 { 266 | return items 267 | } 268 | return items[:len(items)-1] 269 | } 270 | 271 | func expectedTags(t *testing.T, path string) []Tag { 272 | t.Helper() 273 | 274 | cmd := exec.Command("git", "--no-pager", "for-each-ref", "refs/tags") 275 | cmd.Dir = path 276 | output, err := cmd.Output() 277 | require.NoError(t, err) 278 | 279 | rows := strings.Split(strings.TrimSpace(string(output)), "\n") 280 | 281 | var tags []Tag 282 | for _, row := range rows { 283 | // process rows like: "55b45584644cc820f0c0d64a64321d69b3def778 commit\trefs/tags/v0.1.0" 284 | fields := strings.Split(strings.ReplaceAll(row, "\t", " "), " ") 285 | if len(fields) != 3 { 286 | t.Fatalf("unexpected row: %q", row) 287 | } 288 | 289 | // type commit = lightweight tag... the tag commit is the ref to the blob 290 | // type tag = annotated tag... the tag commit has tag info 291 | tagCommit, ty, name := fields[0], fields[1], fields[2] 292 | nameFields := strings.Split(name, "/") 293 | date := dateForCommit(t, path, tagCommit) 294 | var annotated bool 295 | switch ty { 296 | case "tag": 297 | annotated = true 298 | date = dateForAnnotatedTag(t, path, name) 299 | case "commit": 300 | annotated = false 301 | date = dateForCommit(t, path, tagCommit) 302 | default: 303 | t.Fatalf("unexpected type: %q", ty) 304 | } 305 | 306 | tags = append(tags, Tag{ 307 | Name: nameFields[len(nameFields)-1], 308 | Timestamp: date, 309 | Commit: tagHash(t, path, name), 310 | Annotated: annotated, 311 | }) 312 | } 313 | 314 | return tags 315 | } 316 | 317 | func dateForCommit(t *testing.T, path string, commit string) time.Time { 318 | // note: %ci is the committer date in an ISO 8601-like format 319 | cmd := exec.Command("git", "--no-pager", "show", "-s", "--format=%ci", fmt.Sprintf("%s^{commit}", commit)) 320 | cmd.Dir = path 321 | output, err := cmd.Output() 322 | require.NoError(t, err) 323 | 324 | rows := strings.Split(strings.TrimSpace(string(output)), "\n") 325 | if len(rows) != 1 { 326 | t.Fatalf("unable to get commit for commit=%s: %q", commit, output) 327 | } 328 | 329 | // output should be something like: "2023-09-18 15:15:40 -0400" 330 | tt, err := time.Parse("2006-01-02 15:04:05 -0700", rows[0]) 331 | require.NoError(t, err) 332 | return tt 333 | } 334 | 335 | func dateForAnnotatedTag(t *testing.T, path string, tag string) time.Time { 336 | // for-each-ref is a nice way to get the raw information about a tag object ad not the information about the commit 337 | // the tag object points to (in this case we're interested in the tag object's timestamp). 338 | cmd := exec.Command("git", "--no-pager", "for-each-ref", `--format="%(creatordate)"`, tag) 339 | cmd.Dir = path 340 | output, err := cmd.Output() 341 | require.NoError(t, err) 342 | 343 | rows := strings.Split(strings.TrimSpace(string(output)), "\n") 344 | if len(rows) != 1 { 345 | t.Fatalf("unable to get commit for tag=%s: %q", tag, output) 346 | } 347 | 348 | // output should be something like: "Mon Sep 18 17:22:13 2023 -0400" 349 | tt, err := time.Parse(`"Mon Jan 2 15:04:05 2006 -0700"`, rows[0]) 350 | require.NoError(t, err) 351 | return tt 352 | } 353 | 354 | func tagHash(t *testing.T, repo string, tag string) string { 355 | // note: this will work for both lightweight and annotated tags since we are dereferencing the tag to the closest 356 | // commit object with the ^{commit} syntax 357 | cmd := exec.Command("git", "--no-pager", "show", "-s", "--format=%H", fmt.Sprintf("%s^{commit}", tag)) 358 | cmd.Dir = repo 359 | output, err := cmd.Output() 360 | require.NoError(t, err) 361 | 362 | rows := strings.Split(strings.TrimSpace(string(output)), "\n") 363 | if len(rows) != 1 { 364 | t.Fatalf("unable to get commit for tag=%s: %q", tag, output) 365 | } 366 | 367 | return rows[0] 368 | } 369 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /chronicle/release/releasers/github/gh_pull_request.go: -------------------------------------------------------------------------------- 1 | package github 2 | 3 | import ( 4 | "context" 5 | "os" 6 | "time" 7 | 8 | "github.com/scylladb/go-set/strset" 9 | "github.com/shurcooL/githubv4" 10 | "golang.org/x/oauth2" 11 | 12 | "github.com/anchore/chronicle/internal" 13 | "github.com/anchore/chronicle/internal/git" 14 | "github.com/anchore/chronicle/internal/log" 15 | ) 16 | 17 | type ghPullRequest struct { 18 | Title string 19 | Number int 20 | Author string 21 | MergedAt time.Time 22 | Labels []string 23 | URL string 24 | LinkedIssues []ghIssue 25 | MergeCommit string 26 | } 27 | 28 | type prFilter func(issue ghPullRequest) bool 29 | 30 | func applyPRFilters(allMergedPRs []ghPullRequest, config Config, sinceTag, untilTag *git.Tag, includeCommits []string, filters ...prFilter) []ghPullRequest { 31 | // first pass: exclude PRs which are not within the date range derived from the tags 32 | log.Trace("filtering PRs by chronology") 33 | includedPRs, excludedPRs := filterPRs(allMergedPRs, standardChronologicalPrFilters(config, sinceTag, untilTag, includeCommits)...) 34 | 35 | if config.ConsiderPRMergeCommits { 36 | // second pass: include PRs that are outside of the date range but have commits within what is considered for release explicitly 37 | log.Trace("considering re-inclusion of PRs based on merge commits") 38 | includedPRs = append(includedPRs, keepPRsWithCommits(excludedPRs, includeCommits)...) 39 | } 40 | 41 | // third pass: now that we have a list of PRs considered for release, we can filter down to those which have the correct traits (e.g. labels) 42 | log.Trace("filtering remaining PRs by qualitative traits") 43 | includedPRs, _ = filterPRs(includedPRs, filters...) 44 | 45 | return includedPRs 46 | } 47 | 48 | func filterPRs(prs []ghPullRequest, filters ...prFilter) ([]ghPullRequest, []ghPullRequest) { 49 | if len(filters) == 0 { 50 | return prs, nil 51 | } 52 | 53 | results := make([]ghPullRequest, 0, len(prs)) 54 | removed := make([]ghPullRequest, 0, len(prs)) 55 | 56 | prLoop: 57 | for _, r := range prs { 58 | for _, f := range filters { 59 | if !f(r) { 60 | removed = append(removed, r) 61 | continue prLoop 62 | } 63 | } 64 | results = append(results, r) 65 | } 66 | 67 | return results, removed 68 | } 69 | 70 | //nolint:unused 71 | func prsAtOrAfter(since time.Time) prFilter { 72 | return func(pr ghPullRequest) bool { 73 | keep := pr.MergedAt.After(since) || pr.MergedAt.Equal(since) 74 | if !keep { 75 | log.Tracef("PR #%d filtered out: merged at or before %s (merged %s)", pr.Number, internal.FormatDateTime(since), internal.FormatDateTime(pr.MergedAt)) 76 | } 77 | return keep 78 | } 79 | } 80 | 81 | func prsAtOrBefore(since time.Time) prFilter { 82 | return func(pr ghPullRequest) bool { 83 | keep := pr.MergedAt.Before(since) || pr.MergedAt.Equal(since) 84 | if !keep { 85 | log.Tracef("PR #%d filtered out: merged at or after %s (merged %s)", pr.Number, internal.FormatDateTime(since), internal.FormatDateTime(pr.MergedAt)) 86 | } 87 | return keep 88 | } 89 | } 90 | 91 | func prsAfter(since time.Time) prFilter { 92 | return func(pr ghPullRequest) bool { 93 | keep := pr.MergedAt.After(since) 94 | if !keep { 95 | log.Tracef("PR #%d filtered out: merged before %s (merged %s)", pr.Number, internal.FormatDateTime(since), internal.FormatDateTime(pr.MergedAt)) 96 | } 97 | return keep 98 | } 99 | } 100 | 101 | //nolint:unused 102 | func prsBefore(since time.Time) prFilter { 103 | return func(pr ghPullRequest) bool { 104 | keep := pr.MergedAt.Before(since) 105 | if !keep { 106 | log.Tracef("PR #%d filtered out: merged after %s (merged %s)", pr.Number, internal.FormatDateTime(since), internal.FormatDateTime(pr.MergedAt)) 107 | } 108 | return keep 109 | } 110 | } 111 | 112 | func prsWithoutClosedLinkedIssue() prFilter { 113 | return func(pr ghPullRequest) bool { 114 | for _, i := range pr.LinkedIssues { 115 | if i.Closed { 116 | log.Tracef("PR #%d filtered out: has closed linked issue", pr.Number) 117 | return false 118 | } 119 | } 120 | return true 121 | } 122 | } 123 | 124 | func prsWithClosedLinkedIssue() prFilter { 125 | return func(pr ghPullRequest) bool { 126 | for _, i := range pr.LinkedIssues { 127 | if i.Closed { 128 | return true 129 | } 130 | } 131 | log.Tracef("PR #%d filtered out: does not have a closed linked issue", pr.Number) 132 | return false 133 | } 134 | } 135 | 136 | func prsWithoutOpenLinkedIssue() prFilter { 137 | return func(pr ghPullRequest) bool { 138 | for _, i := range pr.LinkedIssues { 139 | if !i.Closed { 140 | log.Tracef("PR #%d filtered out: has linked issue that is still open: issue %d", pr.Number, i.Number) 141 | 142 | return false 143 | } 144 | } 145 | return true 146 | } 147 | } 148 | 149 | func prsWithLabel(labels ...string) prFilter { 150 | return func(pr ghPullRequest) bool { 151 | for _, targetLabel := range labels { 152 | for _, l := range pr.Labels { 153 | if l == targetLabel { 154 | return true 155 | } 156 | } 157 | } 158 | log.Tracef("PR #%d filtered out: missing required label", pr.Number) 159 | 160 | return false 161 | } 162 | } 163 | 164 | func prsWithoutLabels() prFilter { 165 | return func(pr ghPullRequest) bool { 166 | keep := len(pr.Labels) == 0 167 | if !keep { 168 | log.Tracef("PR #%d filtered out: has labels", pr.Number) 169 | } 170 | return keep 171 | } 172 | } 173 | 174 | func prsWithoutLinkedIssues() prFilter { 175 | return func(pr ghPullRequest) bool { 176 | keep := len(pr.LinkedIssues) == 0 177 | if !keep { 178 | log.Tracef("PR #%d filtered out: has linked issues", pr.Number) 179 | } 180 | return keep 181 | } 182 | } 183 | 184 | func prsWithChangeTypes(config Config) prFilter { 185 | return func(pr ghPullRequest) bool { 186 | changeTypes := config.ChangeTypesByLabel.ChangeTypes(pr.Labels...) 187 | 188 | keep := len(changeTypes) > 0 189 | if !keep { 190 | log.Tracef("PR #%d filtered out: no change types", pr.Number) 191 | } 192 | return keep 193 | } 194 | } 195 | 196 | func prsWithoutLabel(labels ...string) prFilter { 197 | return func(pr ghPullRequest) bool { 198 | for _, targetLabel := range labels { 199 | for _, l := range pr.Labels { 200 | if l == targetLabel { 201 | log.Tracef("PR #%d filtered out: has label %q", pr.Number, l) 202 | return false 203 | } 204 | } 205 | } 206 | 207 | return true 208 | } 209 | } 210 | 211 | func prsWithoutMergeCommit(commits ...string) prFilter { 212 | commitSet := strset.New(commits...) 213 | return func(pr ghPullRequest) bool { 214 | if !commitSet.Has(pr.MergeCommit) { 215 | log.Tracef("PR #%d filtered out: has merge commit outside of valid set %s", pr.Number, pr.MergeCommit) 216 | return false 217 | } 218 | 219 | return true 220 | } 221 | } 222 | 223 | func keepPRsWithCommits(prs []ghPullRequest, commits []string, filters ...prFilter) []ghPullRequest { 224 | results := make([]ghPullRequest, 0, len(prs)) 225 | 226 | commitSet := strset.New(commits...) 227 | for _, pr := range prs { 228 | if commitSet.Has(pr.MergeCommit) { 229 | log.Tracef("PR #%d included: has selected commit %s", pr.Number, pr.MergeCommit) 230 | keep, _ := filterPRs([]ghPullRequest{pr}, filters...) 231 | results = append(results, keep...) 232 | } else { 233 | log.Tracef("PR #%d filtered out: does not have merge commit %s", pr.Number, pr.MergeCommit) 234 | } 235 | } 236 | 237 | return results 238 | } 239 | 240 | //nolint:funlen 241 | func fetchMergedPRs(user, repo string, since *time.Time) ([]ghPullRequest, error) { 242 | src := oauth2.StaticTokenSource( 243 | // TODO: DI this 244 | &oauth2.Token{AccessToken: os.Getenv("GITHUB_TOKEN")}, 245 | ) 246 | httpClient := oauth2.NewClient(context.Background(), src) 247 | client := githubv4.NewClient(httpClient) 248 | var ( 249 | pages = 1 250 | saw = 0 251 | allPRs []ghPullRequest 252 | ) 253 | 254 | { 255 | // TODO: act on hitting a rate limit 256 | type rateLimit struct { 257 | Cost githubv4.Int 258 | Limit githubv4.Int 259 | Remaining githubv4.Int 260 | ResetAt githubv4.DateTime 261 | } 262 | 263 | var query struct { 264 | Repository struct { 265 | DatabaseID githubv4.Int 266 | URL githubv4.URI 267 | PullRequests struct { 268 | PageInfo struct { 269 | EndCursor githubv4.String 270 | HasNextPage bool 271 | } 272 | Edges []struct { 273 | Node struct { 274 | Title githubv4.String 275 | Number githubv4.Int 276 | URL githubv4.String 277 | Author struct { 278 | Login githubv4.String 279 | } 280 | MergeCommit struct { 281 | OID githubv4.String 282 | } 283 | UpdatedAt githubv4.DateTime 284 | MergedAt githubv4.DateTime 285 | Labels struct { 286 | Edges []struct { 287 | Node struct { 288 | Name githubv4.String 289 | } 290 | } 291 | } `graphql:"labels(first:50)"` 292 | ClosingIssuesReferences struct { 293 | Nodes []struct { 294 | Title githubv4.String 295 | Number githubv4.Int 296 | URL githubv4.String 297 | Author struct { 298 | Login githubv4.String 299 | } 300 | ClosedAt githubv4.DateTime 301 | Closed githubv4.Boolean 302 | Labels struct { 303 | Edges []struct { 304 | Node struct { 305 | Name githubv4.String 306 | } 307 | } 308 | } `graphql:"labels(first:50)"` 309 | } 310 | } `graphql:"closingIssuesReferences(last:10)"` 311 | } 312 | } 313 | } `graphql:"pullRequests(first:100, states:MERGED, after:$prCursor, orderBy:{field: UPDATED_AT, direction: DESC})"` 314 | } `graphql:"repository(owner:$repositoryOwner, name:$repositoryName)"` 315 | 316 | RateLimit rateLimit 317 | } 318 | variables := map[string]interface{}{ 319 | "repositoryOwner": githubv4.String(user), 320 | "repositoryName": githubv4.String(repo), 321 | "prCursor": (*githubv4.String)(nil), // Null after argument to get first page. 322 | } 323 | 324 | // var limit rateLimit 325 | var ( 326 | process bool 327 | terminate = false 328 | ) 329 | 330 | for !terminate { 331 | log.WithFields("user", user, "repo", repo, "page", pages).Trace("fetching merged PRs from github.com") 332 | 333 | err := client.Query(context.Background(), &query, variables) 334 | if err != nil { 335 | return nil, err 336 | } 337 | 338 | // limit = query.RateLimit 339 | 340 | for i := range query.Repository.PullRequests.Edges { 341 | prEdge := query.Repository.PullRequests.Edges[i] 342 | saw++ 343 | process, terminate = checkSearchTermination(since, &prEdge.Node.UpdatedAt, &prEdge.Node.MergedAt) 344 | if !process || terminate { 345 | continue 346 | } 347 | 348 | var labels []string 349 | for _, lEdge := range prEdge.Node.Labels.Edges { 350 | labels = append(labels, string(lEdge.Node.Name)) 351 | } 352 | 353 | var linkedIssues []ghIssue 354 | for _, iNodes := range prEdge.Node.ClosingIssuesReferences.Nodes { 355 | linkedIssues = append(linkedIssues, ghIssue{ 356 | Title: string(iNodes.Title), 357 | Author: string(iNodes.Author.Login), 358 | ClosedAt: iNodes.ClosedAt.Time, 359 | Closed: bool(iNodes.Closed), 360 | Labels: labels, 361 | URL: string(iNodes.URL), 362 | Number: int(iNodes.Number), 363 | }) 364 | } 365 | 366 | allPRs = append(allPRs, ghPullRequest{ 367 | Title: string(prEdge.Node.Title), 368 | Author: string(prEdge.Node.Author.Login), 369 | MergedAt: prEdge.Node.MergedAt.Time, 370 | Labels: labels, 371 | URL: string(prEdge.Node.URL), 372 | Number: int(prEdge.Node.Number), 373 | LinkedIssues: linkedIssues, 374 | MergeCommit: string(prEdge.Node.MergeCommit.OID), 375 | }) 376 | } 377 | 378 | if !query.Repository.PullRequests.PageInfo.HasNextPage { 379 | break 380 | } 381 | variables["prCursor"] = githubv4.NewString(query.Repository.PullRequests.PageInfo.EndCursor) 382 | pages++ 383 | } 384 | } 385 | 386 | log.WithFields("kept", len(allPRs), "saw", saw, "pages", pages, "since", since).Trace("merged PRs fetched from github.com") 387 | 388 | return allPRs, nil 389 | } 390 | 391 | func checkSearchTermination(since *time.Time, updatedAt, closedAt *githubv4.DateTime) (process bool, terminate bool) { 392 | process = true 393 | if since == nil { 394 | return 395 | } 396 | 397 | if closedAt.Before(*since) { 398 | process = false 399 | } 400 | 401 | if updatedAt.Before(*since) { 402 | terminate = true 403 | } 404 | 405 | return 406 | } 407 | -------------------------------------------------------------------------------- /chronicle/release/releasers/github/gh_pull_request_test.go: -------------------------------------------------------------------------------- 1 | package github 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | "github.com/shurcooL/githubv4" 8 | "github.com/stretchr/testify/assert" 9 | 10 | "github.com/anchore/chronicle/chronicle/release/change" 11 | ) 12 | 13 | func Test_prsAtOrAfter(t *testing.T) { 14 | tests := []struct { 15 | name string 16 | pr ghPullRequest 17 | since time.Time 18 | keep bool 19 | }{ 20 | { 21 | name: "pr is before compare date", 22 | since: time.Date(2021, time.September, 18, 19, 34, 0, 0, time.UTC), 23 | pr: ghPullRequest{ 24 | MergedAt: time.Date(2021, time.September, 16, 19, 34, 0, 0, time.UTC), 25 | }, 26 | keep: false, 27 | }, 28 | { 29 | name: "pr is equal to compare date", 30 | since: time.Date(2021, time.September, 16, 19, 34, 0, 0, time.UTC), 31 | pr: ghPullRequest{ 32 | MergedAt: time.Date(2021, time.September, 16, 19, 34, 0, 0, time.UTC), 33 | }, 34 | keep: true, 35 | }, 36 | { 37 | name: "pr is after compare date", 38 | since: time.Date(2021, time.September, 16, 19, 34, 0, 0, time.UTC), 39 | pr: ghPullRequest{ 40 | MergedAt: time.Date(2021, time.September, 18, 19, 34, 0, 0, time.UTC), 41 | }, 42 | keep: true, 43 | }, 44 | } 45 | for _, test := range tests { 46 | t.Run(test.name, func(t *testing.T) { 47 | assert.Equal(t, test.keep, prsAtOrAfter(test.since)(test.pr)) 48 | }) 49 | } 50 | } 51 | 52 | func Test_prsAfter(t *testing.T) { 53 | tests := []struct { 54 | name string 55 | pr ghPullRequest 56 | since time.Time 57 | keep bool 58 | }{ 59 | { 60 | name: "pr is before compare date", 61 | since: time.Date(2021, time.September, 18, 19, 34, 0, 0, time.UTC), 62 | pr: ghPullRequest{ 63 | MergedAt: time.Date(2021, time.September, 16, 19, 34, 0, 0, time.UTC), 64 | }, 65 | keep: false, 66 | }, 67 | { 68 | name: "pr is equal to compare date", 69 | since: time.Date(2021, time.September, 16, 19, 34, 0, 0, time.UTC), 70 | pr: ghPullRequest{ 71 | MergedAt: time.Date(2021, time.September, 16, 19, 34, 0, 0, time.UTC), 72 | }, 73 | keep: false, 74 | }, 75 | { 76 | name: "pr is after compare date", 77 | since: time.Date(2021, time.September, 16, 19, 34, 0, 0, time.UTC), 78 | pr: ghPullRequest{ 79 | MergedAt: time.Date(2021, time.September, 18, 19, 34, 0, 0, time.UTC), 80 | }, 81 | keep: true, 82 | }, 83 | } 84 | for _, test := range tests { 85 | t.Run(test.name, func(t *testing.T) { 86 | assert.Equal(t, test.keep, prsAfter(test.since)(test.pr)) 87 | }) 88 | } 89 | } 90 | 91 | func Test_prsAtOrBefore(t *testing.T) { 92 | tests := []struct { 93 | name string 94 | pr ghPullRequest 95 | until time.Time 96 | keep bool 97 | }{ 98 | { 99 | name: "pr is after compare date", 100 | until: time.Date(2021, time.September, 16, 19, 34, 0, 0, time.UTC), 101 | pr: ghPullRequest{ 102 | MergedAt: time.Date(2021, time.September, 18, 19, 34, 0, 0, time.UTC), 103 | }, 104 | keep: false, 105 | }, 106 | { 107 | name: "pr is equal to compare date", 108 | until: time.Date(2021, time.September, 18, 19, 34, 0, 0, time.UTC), 109 | pr: ghPullRequest{ 110 | MergedAt: time.Date(2021, time.September, 18, 19, 34, 0, 0, time.UTC), 111 | }, 112 | keep: true, 113 | }, 114 | { 115 | name: "pr is before compare date", 116 | until: time.Date(2021, time.September, 18, 19, 34, 0, 0, time.UTC), 117 | pr: ghPullRequest{ 118 | MergedAt: time.Date(2021, time.September, 16, 19, 34, 0, 0, time.UTC), 119 | }, 120 | keep: true, 121 | }, 122 | } 123 | for _, test := range tests { 124 | t.Run(test.name, func(t *testing.T) { 125 | assert.Equal(t, test.keep, prsAtOrBefore(test.until)(test.pr)) 126 | }) 127 | } 128 | } 129 | 130 | func Test_prsBefore(t *testing.T) { 131 | tests := []struct { 132 | name string 133 | pr ghPullRequest 134 | until time.Time 135 | keep bool 136 | }{ 137 | { 138 | name: "pr is after compare date", 139 | until: time.Date(2021, time.September, 16, 19, 34, 0, 0, time.UTC), 140 | pr: ghPullRequest{ 141 | MergedAt: time.Date(2021, time.September, 18, 19, 34, 0, 0, time.UTC), 142 | }, 143 | keep: false, 144 | }, 145 | { 146 | name: "pr is equal to compare date", 147 | until: time.Date(2021, time.September, 18, 19, 34, 0, 0, time.UTC), 148 | pr: ghPullRequest{ 149 | MergedAt: time.Date(2021, time.September, 18, 19, 34, 0, 0, time.UTC), 150 | }, 151 | keep: false, 152 | }, 153 | { 154 | name: "pr is before compare date", 155 | until: time.Date(2021, time.September, 18, 19, 34, 0, 0, time.UTC), 156 | pr: ghPullRequest{ 157 | MergedAt: time.Date(2021, time.September, 16, 19, 34, 0, 0, time.UTC), 158 | }, 159 | keep: true, 160 | }, 161 | } 162 | for _, test := range tests { 163 | t.Run(test.name, func(t *testing.T) { 164 | assert.Equal(t, test.keep, prsBefore(test.until)(test.pr)) 165 | }) 166 | } 167 | } 168 | 169 | func Test_prsWithLabel(t *testing.T) { 170 | tests := []struct { 171 | name string 172 | pr ghPullRequest 173 | labels []string 174 | keep bool 175 | }{ 176 | { 177 | name: "matches on label", 178 | labels: []string{ 179 | "positive", 180 | }, 181 | pr: ghPullRequest{ 182 | Labels: []string{"something-else", "positive"}, 183 | }, 184 | keep: true, 185 | }, 186 | { 187 | name: "does not match on label", 188 | labels: []string{ 189 | "positive", 190 | }, 191 | pr: ghPullRequest{ 192 | Labels: []string{"something-else", "negative"}, 193 | }, 194 | keep: false, 195 | }, 196 | } 197 | for _, test := range tests { 198 | t.Run(test.name, func(t *testing.T) { 199 | assert.Equal(t, test.keep, prsWithLabel(test.labels...)(test.pr)) 200 | }) 201 | } 202 | } 203 | 204 | func Test_prsWithoutLabel(t *testing.T) { 205 | tests := []struct { 206 | name string 207 | pr ghPullRequest 208 | labels []string 209 | keep bool 210 | }{ 211 | { 212 | name: "matches on label", 213 | labels: []string{ 214 | "positive", 215 | }, 216 | pr: ghPullRequest{ 217 | Labels: []string{"something-else", "positive"}, 218 | }, 219 | keep: false, 220 | }, 221 | { 222 | name: "does not match on label", 223 | labels: []string{ 224 | "positive", 225 | }, 226 | pr: ghPullRequest{ 227 | Labels: []string{"something-else", "negative"}, 228 | }, 229 | keep: true, 230 | }, 231 | } 232 | for _, test := range tests { 233 | t.Run(test.name, func(t *testing.T) { 234 | assert.Equal(t, test.keep, prsWithoutLabel(test.labels...)(test.pr)) 235 | }) 236 | } 237 | } 238 | 239 | func Test_prsWithoutClosedLinkedIssue(t *testing.T) { 240 | tests := []struct { 241 | name string 242 | pr ghPullRequest 243 | keep bool 244 | }{ 245 | { 246 | name: "has closed linked issue", 247 | pr: ghPullRequest{ 248 | LinkedIssues: []ghIssue{ 249 | { 250 | Closed: true, 251 | }, 252 | }, 253 | }, 254 | keep: false, 255 | }, 256 | { 257 | name: "open linked issue", 258 | pr: ghPullRequest{ 259 | LinkedIssues: []ghIssue{ 260 | { 261 | Closed: false, 262 | }, 263 | }, 264 | }, 265 | keep: true, 266 | }, 267 | { 268 | name: "no linked issue", 269 | pr: ghPullRequest{ 270 | LinkedIssues: []ghIssue{}, 271 | }, 272 | keep: true, 273 | }, 274 | } 275 | for _, test := range tests { 276 | t.Run(test.name, func(t *testing.T) { 277 | assert.Equal(t, test.keep, prsWithoutClosedLinkedIssue()(test.pr)) 278 | }) 279 | } 280 | } 281 | 282 | func Test_prsWithoutOpenLinkedIssue(t *testing.T) { 283 | tests := []struct { 284 | name string 285 | pr ghPullRequest 286 | labels []string 287 | expected bool 288 | }{ 289 | { 290 | name: "has closed linked issue", 291 | pr: ghPullRequest{ 292 | LinkedIssues: []ghIssue{ 293 | { 294 | Closed: true, 295 | }, 296 | }, 297 | }, 298 | expected: true, 299 | }, 300 | { 301 | name: "open linked issue", 302 | pr: ghPullRequest{ 303 | LinkedIssues: []ghIssue{ 304 | { 305 | Closed: false, 306 | }, 307 | }, 308 | }, 309 | expected: false, 310 | }, 311 | { 312 | name: "no linked issue", 313 | pr: ghPullRequest{ 314 | LinkedIssues: []ghIssue{}, 315 | }, 316 | expected: true, 317 | }, 318 | } 319 | for _, test := range tests { 320 | t.Run(test.name, func(t *testing.T) { 321 | assert.Equal(t, test.expected, prsWithoutOpenLinkedIssue()(test.pr)) 322 | }) 323 | } 324 | } 325 | 326 | func Test_prsWithoutMergeCommit(t *testing.T) { 327 | tests := []struct { 328 | name string 329 | pr ghPullRequest 330 | commits []string 331 | expected bool 332 | }{ 333 | { 334 | name: "has merge commit within range", 335 | pr: ghPullRequest{ 336 | MergeCommit: "commit-1", 337 | }, 338 | commits: []string{ 339 | "commit-1", 340 | "commit-2", 341 | "commit-3", 342 | }, 343 | expected: true, 344 | }, 345 | { 346 | name: "has merge commit within range", 347 | pr: ghPullRequest{ 348 | MergeCommit: "commit-bogosity", 349 | }, 350 | commits: []string{ 351 | "commit-1", 352 | "commit-2", 353 | "commit-3", 354 | }, 355 | expected: false, 356 | }, 357 | } 358 | for _, tt := range tests { 359 | t.Run(tt.name, func(t *testing.T) { 360 | assert.Equal(t, tt.expected, prsWithoutMergeCommit(tt.commits...)(tt.pr)) 361 | }) 362 | } 363 | } 364 | 365 | func Test_prsWithChangeTypes(t *testing.T) { 366 | tests := []struct { 367 | name string 368 | pr ghPullRequest 369 | label string 370 | expected bool 371 | }{ 372 | { 373 | name: "matches on label", 374 | label: "positive", 375 | pr: ghPullRequest{ 376 | Labels: []string{"something-else", "positive"}, 377 | }, 378 | expected: true, 379 | }, 380 | { 381 | name: "does not match on label", 382 | label: "positive", 383 | pr: ghPullRequest{ 384 | Labels: []string{"something-else", "negative"}, 385 | }, 386 | expected: false, 387 | }, 388 | { 389 | name: "does not have change types", 390 | label: "positive", 391 | pr: ghPullRequest{ 392 | Labels: []string{}, 393 | }, 394 | expected: false, 395 | }, 396 | } 397 | for _, test := range tests { 398 | t.Run(test.name, func(t *testing.T) { 399 | assert.Equal(t, test.expected, prsWithChangeTypes(Config{ 400 | ChangeTypesByLabel: change.TypeSet{ 401 | test.label: change.NewType(test.label, change.SemVerMinor), 402 | }, 403 | })(test.pr)) 404 | }) 405 | } 406 | } 407 | 408 | func Test_prsWithoutLabels(t *testing.T) { 409 | tests := []struct { 410 | name string 411 | pr ghPullRequest 412 | expected bool 413 | }{ 414 | { 415 | name: "omitted when labels", 416 | pr: ghPullRequest{ 417 | Labels: []string{"something-else", "positive"}, 418 | }, 419 | expected: false, 420 | }, 421 | { 422 | name: "included with no labels", 423 | pr: ghPullRequest{ 424 | Labels: []string{}, 425 | }, 426 | expected: true, 427 | }, 428 | } 429 | for _, test := range tests { 430 | t.Run(test.name, func(t *testing.T) { 431 | assert.Equal(t, test.expected, prsWithoutLabels()(test.pr)) 432 | }) 433 | } 434 | } 435 | 436 | func Test_prsWithoutLinkedIssues(t *testing.T) { 437 | tests := []struct { 438 | name string 439 | pr ghPullRequest 440 | expected bool 441 | }{ 442 | { 443 | name: "matches when unlinked", 444 | pr: ghPullRequest{}, 445 | expected: true, 446 | }, 447 | { 448 | name: "does not match when linked", 449 | pr: ghPullRequest{ 450 | LinkedIssues: []ghIssue{ 451 | { 452 | Number: 1, 453 | Title: "an issue", 454 | }, 455 | }, 456 | }, 457 | expected: false, 458 | }, 459 | } 460 | for _, test := range tests { 461 | t.Run(test.name, func(t *testing.T) { 462 | assert.Equal(t, test.expected, prsWithoutLinkedIssues()(test.pr)) 463 | }) 464 | } 465 | } 466 | 467 | func Test_checkSearchTermination(t *testing.T) { 468 | since := githubv4.DateTime{Time: time.Date(1987, time.September, 16, 19, 34, 0, 0, time.UTC)} 469 | hourAfter := &githubv4.DateTime{Time: since.Add(time.Hour)} 470 | minuteAfter := &githubv4.DateTime{Time: since.Add(time.Minute)} 471 | minuteBefore := &githubv4.DateTime{Time: since.Add(-time.Minute)} 472 | 473 | type args struct { 474 | since *time.Time 475 | updatedAt *githubv4.DateTime 476 | mergedAt *githubv4.DateTime 477 | } 478 | tests := []struct { 479 | name string 480 | args args 481 | wantProcess bool 482 | wantTerminate bool 483 | }{ 484 | { 485 | name: "go case candidate", 486 | args: args{ 487 | since: &since.Time, 488 | updatedAt: hourAfter, 489 | mergedAt: hourAfter, 490 | }, 491 | wantProcess: true, 492 | wantTerminate: false, 493 | }, 494 | { 495 | name: "candidate updated after the merge, and merged after the compare date", 496 | args: args{ 497 | since: &since.Time, 498 | updatedAt: hourAfter, 499 | mergedAt: minuteAfter, 500 | }, 501 | wantProcess: true, 502 | wantTerminate: false, 503 | }, 504 | { 505 | name: "candidate updated after the merge, but merged before the compare date", 506 | args: args{ 507 | since: &since.Time, 508 | updatedAt: hourAfter, 509 | mergedAt: minuteBefore, 510 | }, 511 | wantProcess: false, 512 | wantTerminate: false, 513 | }, 514 | { 515 | name: "candidate updated before the merge, and merged before the compare date", 516 | args: args{ 517 | since: &since.Time, 518 | updatedAt: minuteBefore, 519 | mergedAt: minuteBefore, 520 | }, 521 | wantProcess: false, 522 | wantTerminate: true, 523 | }, 524 | { 525 | name: "impossible: candidate updated before the merge, but merged after the compare date", 526 | args: args{ 527 | since: &since.Time, 528 | updatedAt: minuteBefore, 529 | mergedAt: minuteAfter, 530 | }, 531 | wantProcess: true, 532 | wantTerminate: true, 533 | }, 534 | } 535 | for _, tt := range tests { 536 | t.Run(tt.name, func(t *testing.T) { 537 | gotProcess, gotTerminate := checkSearchTermination(tt.args.since, tt.args.updatedAt, tt.args.mergedAt) 538 | assert.Equalf(t, tt.wantProcess, gotProcess, "wantProcess: checkSearchTermination(%v, %v, %v)", tt.args.since, tt.args.updatedAt, tt.args.mergedAt) 539 | assert.Equalf(t, tt.wantTerminate, gotTerminate, "wantTerminate: checkSearchTermination(%v, %v, %v)", tt.args.since, tt.args.updatedAt, tt.args.mergedAt) 540 | }) 541 | } 542 | } 543 | --------------------------------------------------------------------------------