diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 3c5e9cd425..8b6d182c6e 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -122,10 +122,28 @@ jobs: CHART_MUSEUM_PASSWORD: ${{ secrets.CHART_MUSEUM_PASSWORD }} # The workflow will only trigger on non-draft releases # https://docs.github.com/en/actions/writing-workflows/choosing-when-your-workflow-runs/events-that-trigger-workflows#release + sync_linear: + needs: + - publish + - publish-chart + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + - name: Update linear issues + run: go run -mod vendor . -release-tag="${{ needs.publish.outputs.release_version }}" + working-directory: hack/linear-sync + env: + GITHUB_TOKEN: ${{ secrets.GH_ACCESS_TOKEN }} + LINEAR_TOKEN: ${{ secrets.LINEAR_TOKEN }} + notify_release: needs: - publish - publish-chart + - sync_linear runs-on: ubuntu-22.04 steps: - uses: actions/checkout@v4 diff --git a/hack/changelog/go.mod b/hack/changelog/go.mod new file mode 100644 index 0000000000..a0fc102ebd --- /dev/null +++ b/hack/changelog/go.mod @@ -0,0 +1,15 @@ +module github.com/loft-sh/changelog + +go 1.22.5 + +require ( + github.com/blang/semver v3.5.1+incompatible + github.com/google/go-github/v59 v59.0.0 + github.com/shurcooL/githubv4 v0.0.0-20240120211514-18a1ae0e79dc + golang.org/x/oauth2 v0.25.0 +) + +require ( + github.com/google/go-querystring v1.1.0 // indirect + github.com/shurcooL/graphql v0.0.0-20230722043721-ed46e5a46466 // indirect +) diff --git a/hack/changelog/go.sum b/hack/changelog/go.sum new file mode 100644 index 0000000000..32261787b0 --- /dev/null +++ b/hack/changelog/go.sum @@ -0,0 +1,16 @@ +github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdnnjpJbkM4JQ= +github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk= +github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-github/v59 v59.0.0 h1:7h6bgpF5as0YQLLkEiVqpgtJqjimMYhBkD4jT5aN3VA= +github.com/google/go-github/v59 v59.0.0/go.mod h1:rJU4R0rQHFVFDOkqGWxfLNo6vEk4dv40oDjhV/gH6wM= +github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= +github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= +github.com/shurcooL/githubv4 v0.0.0-20240120211514-18a1ae0e79dc h1:vH0NQbIDk+mJLvBliNGfcQgUmhlniWBDXC79oRxfZA0= +github.com/shurcooL/githubv4 v0.0.0-20240120211514-18a1ae0e79dc/go.mod h1:zqMwyHmnN/eDOZOdiTohqIUKUrTFX62PNlu7IJdu0q8= +github.com/shurcooL/graphql v0.0.0-20230722043721-ed46e5a46466 h1:17JxqqJY66GmZVHkmAsGEkcIu0oCe3AM420QDgGwZx0= +github.com/shurcooL/graphql v0.0.0-20230722043721-ed46e5a46466/go.mod h1:9dIRpgIY7hVhoqfe0/FcYp0bpInZaT7dc3BYOprrIUE= +golang.org/x/oauth2 v0.25.0 h1:CY4y7XT9v0cRI9oupztF8AgiIu99L/ksR/Xp/6jrZ70= +golang.org/x/oauth2 v0.25.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/hack/changelog/log/log.go b/hack/changelog/log/log.go new file mode 100644 index 0000000000..b078c39ea4 --- /dev/null +++ b/hack/changelog/log/log.go @@ -0,0 +1,3 @@ +package log + +var LoggerKey struct{} diff --git a/hack/changelog/main.go b/hack/changelog/main.go new file mode 100644 index 0000000000..9b95332d03 --- /dev/null +++ b/hack/changelog/main.go @@ -0,0 +1,162 @@ +package main + +import ( + "bytes" + "context" + "errors" + "flag" + "fmt" + "io" + "log/slog" + "os" + "os/signal" + "sort" + + "github.com/google/go-github/v59/github" + "github.com/loft-sh/changelog/log" + pullrequests "github.com/loft-sh/changelog/pull-requests" + "github.com/loft-sh/changelog/releases" + "github.com/shurcooL/githubv4" + "golang.org/x/oauth2" +) + +var ErrMissingToken = errors.New("github token must be set") + +func main() { + if err := run(context.Background(), os.Stdout, os.Stderr, os.Args); err != nil { + fmt.Fprintf(os.Stderr, "%s\n", err) + os.Exit(1) + } +} + +func run( + ctx context.Context, + stdout, stderr io.Writer, + args []string, +) error { + flagset := flag.NewFlagSet(args[0], flag.ExitOnError) + var ( + owner = flagset.String("owner", "loft-sh", "The GitHub owner of the repository") + repo = flagset.String("repo", "vcluster", "The GitHub repository to generate the changelog for") + githubToken = flagset.String("token", "", "The GitHub token to use for authentication") + previousTag = flagset.String("previous-tag", "", "The previous tag to generate the changelog for (if not set, the last stable release will be used)") + releaseTag = flagset.String("release-tag", "", "The tag of the release to generate the changelog for") + updateNotes = flagset.Bool("update-notes", true, "Update the release notes of the release with the generated ones") + overwrite = flagset.Bool("overwrite", false, "Overwrite the release notes with the generated ones") + debug = flagset.Bool("debug", false, "Enable debug logging") + ) + if err := flagset.Parse(args[1:]); err != nil { + return fmt.Errorf("parse flags: %w", err) + } + + if *githubToken == "" { + *githubToken = os.Getenv("GITHUB_TOKEN") + } + + if *githubToken == "" { + return ErrMissingToken + } + + leveler := slog.LevelVar{} + leveler.Set(slog.LevelInfo) + if *debug { + leveler.Set(slog.LevelDebug) + } + + logger := slog.New(slog.NewTextHandler(stderr, &slog.HandlerOptions{ + Level: &leveler, + })) + + ctx, stop := signal.NotifyContext(ctx, os.Interrupt, os.Kill) + defer stop() + + ctx = context.WithValue(ctx, log.LoggerKey, logger) + + httpClient := oauth2.NewClient(ctx, oauth2.StaticTokenSource( + &oauth2.Token{ + AccessToken: *githubToken, + }, + )) + + client := github.NewClient(httpClient) + gqlClient := githubv4.NewClient(httpClient) + + var stableTag string + + if *previousTag != "" { + release, err := releases.FetchReleaseByTag(ctx, gqlClient, *owner, *repo, *previousTag) + if err != nil { + return fmt.Errorf("fetch release by tag: %w", err) + } + + stableTag = release.TagName + } else { + if prevRelease, err := releases.LastStableReleaseBeforeTag(ctx, gqlClient, *owner, *repo, *releaseTag); err != nil { + return fmt.Errorf("get last stable release before tag: %w", err) + } else if prevRelease != "" { + stableTag = prevRelease + } else { + stableTag, _, err = releases.LastStableRelease(ctx, gqlClient, *owner, *repo) + if err != nil { + return fmt.Errorf("get last stable release: %w", err) + } + } + } + + if stableTag == "" { + return errors.New("no stable release found") + } + + logger.Info("Last stable release", "stableTag", stableTag) + + var currentRelease releases.Release + if *releaseTag != "" { + var err error + currentRelease, err = releases.FetchReleaseByTag(ctx, gqlClient, *owner, *repo, *releaseTag) + if err != nil { + return fmt.Errorf("fetch release by tag: %w", err) + } + + if currentRelease.TagName != *releaseTag { + return fmt.Errorf("release not found: %s", *releaseTag) + } + } + + pullRequests, err := pullrequests.FetchAllPRsBetween(ctx, gqlClient, *owner, *repo, stableTag, *releaseTag) + if err != nil { + return fmt.Errorf("fetch all PRs until: %w", err) + } + + logger.Info("Found merged pull requests between releases", "count", len(pullRequests), "previous", stableTag, "current", *releaseTag) + + notes := []Note{} + for _, pr := range pullRequests { + notes = append(notes, NewNotesFromPullRequest(pr)...) + } + sort.Slice(notes, SortNotes(notes)) + + buffer := bytes.Buffer{} + + for _, note := range notes { + if _, err := buffer.Write([]byte(note.String())); err != nil { + return fmt.Errorf("write note: %w", err) + } + } + + if *releaseTag != "" && *updateNotes { + if currentRelease.Description == "" || *overwrite { + if err := releases.UpdateReleaseNotes(ctx, client, *owner, *repo, currentRelease.DatabaseId, buffer.String()); err != nil { + return fmt.Errorf("update release notes: %w", err) + } + logger.Info("Updated release notes", "releaseTag", *releaseTag) + } else { + logger.Warn("Release notes already exist for tag, skipping update", "releaseTag", *releaseTag) + } + } + + if _, err := stdout.Write(buffer.Bytes()); err != nil { + return fmt.Errorf("write changelog: %w", err) + } + + return nil +} diff --git a/hack/changelog/note.go b/hack/changelog/note.go new file mode 100644 index 0000000000..07262bc327 --- /dev/null +++ b/hack/changelog/note.go @@ -0,0 +1,97 @@ +package main + +import ( + "fmt" + "regexp" + "sort" + "strings" + + pullrequests "github.com/loft-sh/changelog/pull-requests" +) + +var notesInBodyREs = []*regexp.Regexp{ + regexp.MustCompile("(?ms)^```release-note[s]?:(?P[^\r\n]*)\r?\n?(?P.*?)\r?\n?```"), + regexp.MustCompile("(?ms)^```release-note[s]?\r?\ntype:\\s?(?P[^\r\n]*)\r?\nnote:\\s?(?P.*?)\r?\n?```"), +} + +type Note struct { + Type string + Author string + Body string + PR int +} + +func (n Note) String() string { + return fmt.Sprintf("- %s: %s (by @%v in #%d)\n", n.Type, n.Body, n.Author, n.PR) +} + +func NewNotesFromPullRequest(p pullrequests.PullRequest) []Note { + return NewNotes(p.Body, p.Author.Login, p.Number) +} + +func SortNotes(res []Note) func(i, j int) bool { + return func(i, j int) bool { + if res[i].Type < res[j].Type { + return true + } else if res[j].Type < res[i].Type { + return false + } else if res[i].Body < res[j].Body { + return true + } else if res[j].Body < res[i].Body { + return false + } else if res[i].PR < res[j].PR { + return true + } else if res[j].PR < res[i].PR { + return false + } + return false + } +} + +func NewNotes(body, author string, number int) []Note { + var res []Note + for _, re := range notesInBodyREs { + matches := re.FindAllStringSubmatch(body, -1) + if len(matches) == 0 { + continue + } + + for _, match := range matches { + note := "" + typ := "" + + for i, name := range re.SubexpNames() { + switch name { + case "note": + note = match[i] + case "type": + typ = strings.ToLower(match[i]) + } + if note != "" && typ != "" { + break + } + } + + note = strings.TrimSpace(note) + typ = strings.TrimSpace(typ) + + if typ == "type" || typ == "none" || note == "none" { + note = "" + typ = "" + } + if note == "" && typ == "" { + continue + } + + res = append(res, Note{ + Type: typ, + Body: note, + PR: number, + Author: author, + }) + } + } + sort.Slice(res, SortNotes(res)) + + return res +} diff --git a/hack/changelog/pull-requests/pr.go b/hack/changelog/pull-requests/pr.go new file mode 100644 index 0000000000..bb47d9a887 --- /dev/null +++ b/hack/changelog/pull-requests/pr.go @@ -0,0 +1,91 @@ +package pullrequests + +import ( + "context" + "fmt" + + "github.com/shurcooL/githubv4" +) + +const PageSize = 100 + +type PullRequest struct { + Merged bool + Body string + HeadRefName string + Author struct { + Login string + } + Number int +} + +// FetchAllPRsBetween fetches all merged PRs between the given tags +// It uses the GitHub GraphQL API to fetch the PRs. +func FetchAllPRsBetween(ctx context.Context, client *githubv4.Client, owner, repo, previousTag, currentTag string) ([]PullRequest, error) { + var query struct { + Repository struct { + Ref struct { + Compare struct { + Commits struct { + PageInfo struct { + EndCursor githubv4.String + HasNextPage bool + } + Nodes []struct { + AssociatedPullRequests struct { + PageInfo struct { + EndCursor githubv4.String + HasNextPage bool + } + Nodes []PullRequest + } `graphql:"associatedPullRequests(first: $pageSize)"` + } + } `graphql:"commits(first: $pageSize, after: $cursor)"` + } `graphql:"compare(headRef: $currTag)"` + } `graphql:"ref(qualifiedName: $prevTag)"` + } `graphql:"repository(owner: $owner, name: $repo)"` + } + + var cursor *githubv4.String + pullRequestsByNumber := map[int]PullRequest{} + + // Paginate through the Commits + for { + if err := client.Query(ctx, &query, map[string]interface{}{ + "owner": githubv4.String(owner), + "repo": githubv4.String(repo), + "prevTag": githubv4.String(previousTag), + "currTag": githubv4.String(currentTag), + "pageSize": githubv4.Int(PageSize), + "cursor": cursor, + }); err != nil { + return nil, fmt.Errorf("query repository: %w", err) + } + + cursor = &query.Repository.Ref.Compare.Commits.PageInfo.EndCursor + + for _, commit := range query.Repository.Ref.Compare.Commits.Nodes { + for _, pr := range commit.AssociatedPullRequests.Nodes { + if !pr.Merged { + continue + } + + if _, ok := pullRequestsByNumber[pr.Number]; ok { + continue + } + + pullRequestsByNumber[pr.Number] = pr + } + } + + if !query.Repository.Ref.Compare.Commits.PageInfo.HasNextPage { + break + } + } + + var pullRequests []PullRequest + for _, pr := range pullRequestsByNumber { + pullRequests = append(pullRequests, pr) + } + return pullRequests, nil +} diff --git a/hack/changelog/releases/releases.go b/hack/changelog/releases/releases.go new file mode 100644 index 0000000000..5207a095b7 --- /dev/null +++ b/hack/changelog/releases/releases.go @@ -0,0 +1,195 @@ +package releases + +import ( + "context" + "fmt" + "strings" + + "github.com/blang/semver" + "github.com/google/go-github/v59/github" + "github.com/shurcooL/githubv4" +) + +const PageSize = 100 + +// LastStableRelease returns the last stable release for the given repository. +// It returns the tag name, id and the creation time of the release. +func LastStableRelease(ctx context.Context, client *githubv4.Client, owner, repo string) (string, int, error) { + var query struct { + Repository struct { + LatestRelease struct { + CreatedAt githubv4.DateTime + TagName string + DatabaseId int + } + } `graphql:"repository(owner: $owner, name: $repo)"` + } + + if err := client.Query(ctx, &query, map[string]interface{}{ + "owner": githubv4.String(owner), + "repo": githubv4.String(repo), + }); err != nil { + return "", 0, fmt.Errorf("query latest release: %w", err) + } + + return query.Repository.LatestRelease.TagName, query.Repository.LatestRelease.DatabaseId, nil +} + +func LastStableReleaseBeforeTag(ctx context.Context, client *githubv4.Client, owner, repo, tag string) (string, error) { + sanitizedTag, _ := strings.CutPrefix(tag, "v") + tagSemver, err := semver.ParseTolerant(sanitizedTag) + if err != nil { + return "", fmt.Errorf("failed to parse tag: %w", err) + } + + return LatestStableSemverRange(ctx, client, owner, repo, "<"+tagSemver.String()) +} + +func LatestStableSemverRange(ctx context.Context, client *githubv4.Client, owner, repo, tagRangeExpr string) (string, error) { + tagRange, err := semver.ParseRange(tagRangeExpr) + if err != nil { + // Ignore bad ranges for now. + return "", fmt.Errorf("failed to parse tag: %w", err) + } + + var query struct { + Repository struct { + Releases struct { + PageInfo struct { + EndCursor githubv4.String + HasNextPage bool + } + Nodes []struct { + TagName string + IsPrerelease bool + } + } `graphql:"releases(first: $pageSize, after: $cursor, orderBy: { direction: DESC, field: CREATED_AT})"` + } `graphql:"repository(owner: $owner, name: $repo)"` + } + + var cursor *githubv4.String + + // Paginate through the Releases + for { + if err := client.Query(ctx, &query, map[string]interface{}{ + "owner": githubv4.String(owner), + "repo": githubv4.String(repo), + "pageSize": githubv4.Int(PageSize), + "cursor": cursor, + }); err != nil { + return "", fmt.Errorf("query repository: %w", err) + } + + cursor = &query.Repository.Releases.PageInfo.EndCursor + + for _, release := range query.Repository.Releases.Nodes { + releaseSemver, err := semver.ParseTolerant(release.TagName) + if err != nil { + continue + } + + if len(releaseSemver.Pre) > 0 { + continue + } + + if release.IsPrerelease { + continue + } + + if tagRange(releaseSemver) { + return release.TagName, nil + } + } + + if !query.Repository.Releases.PageInfo.HasNextPage { + break + } + } + + return "", nil +} + +func LatestRelease(ctx context.Context, client *githubv4.Client, owner, repo string) (string, error) { + var query struct { + Repository struct { + Releases struct { + PageInfo struct { + EndCursor githubv4.String + HasNextPage bool + } + Nodes []struct { + TagName string + IsPrerelease bool + } + } `graphql:"releases(first: $pageSize, orderBy: { direction: DESC, field: CREATED_AT})"` + } `graphql:"repository(owner: $owner, name: $repo)"` + } + + // Get the latest out of the top page + if err := client.Query(ctx, &query, map[string]interface{}{ + "owner": githubv4.String(owner), + "repo": githubv4.String(repo), + "pageSize": githubv4.Int(PageSize), + }); err != nil { + return "", fmt.Errorf("query repository: %w", err) + } + + latestTag := "" + latestSemver := semver.MustParse("0.0.0") + for _, release := range query.Repository.Releases.Nodes { + releaseSemver, err := semver.ParseTolerant(release.TagName) + if err != nil { + continue + } + + if releaseSemver.Compare(latestSemver) == 1 { + latestSemver = releaseSemver + latestTag = release.TagName + } else { + continue + } + } + + return latestTag, nil +} + +type Release struct { + PublishedAt githubv4.DateTime + Description string + Name string + TagName string + DatabaseId int64 +} + +// FetchReleaseByTag fetches a release by its tag name. +// It returns the release or an error if the release could not be found. +func FetchReleaseByTag(ctx context.Context, client *githubv4.Client, owner, repo, tag string) (Release, error) { + var query struct { + Repository struct { + Release Release `graphql:"release(tagName: $tag)"` + } `graphql:"repository(owner: $owner, name: $repo)"` + } + + if err := client.Query(ctx, &query, map[string]interface{}{ + "owner": githubv4.String(owner), + "repo": githubv4.String(repo), + "tag": githubv4.String(tag), + }); err != nil { + return Release{}, fmt.Errorf("query release by tag: %w", err) + } + + return query.Repository.Release, nil +} + +// UpdateReleaseNotes updates the release notes of the given release. +// It returns an error if the release notes could not be updated. +func UpdateReleaseNotes(ctx context.Context, client *github.Client, owner, repo string, releaseId int64, notes string) error { + _, _, err := client.Repositories.EditRelease(ctx, owner, repo, releaseId, &github.RepositoryRelease{ + Body: ¬es, + }) + if err != nil { + return fmt.Errorf("update release notes: %w", err) + } + + return nil +} diff --git a/hack/linear-sync/go.mod b/hack/linear-sync/go.mod new file mode 100644 index 0000000000..3a9a6b8b8f --- /dev/null +++ b/hack/linear-sync/go.mod @@ -0,0 +1,18 @@ +module github.com/loft-sh/linear-sync + +go 1.22.5 + +require ( + github.com/loft-sh/changelog v0.0.0-00010101000000-000000000000 + github.com/shurcooL/githubv4 v0.0.0-20240120211514-18a1ae0e79dc + github.com/shurcooL/graphql v0.0.0-20230722043721-ed46e5a46466 + golang.org/x/oauth2 v0.25.0 +) + +require ( + github.com/blang/semver v3.5.1+incompatible // indirect + github.com/google/go-github/v59 v59.0.0 // indirect + github.com/google/go-querystring v1.1.0 // indirect +) + +replace github.com/loft-sh/changelog => ../changelog diff --git a/hack/linear-sync/go.sum b/hack/linear-sync/go.sum new file mode 100644 index 0000000000..32261787b0 --- /dev/null +++ b/hack/linear-sync/go.sum @@ -0,0 +1,16 @@ +github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdnnjpJbkM4JQ= +github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk= +github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-github/v59 v59.0.0 h1:7h6bgpF5as0YQLLkEiVqpgtJqjimMYhBkD4jT5aN3VA= +github.com/google/go-github/v59 v59.0.0/go.mod h1:rJU4R0rQHFVFDOkqGWxfLNo6vEk4dv40oDjhV/gH6wM= +github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= +github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= +github.com/shurcooL/githubv4 v0.0.0-20240120211514-18a1ae0e79dc h1:vH0NQbIDk+mJLvBliNGfcQgUmhlniWBDXC79oRxfZA0= +github.com/shurcooL/githubv4 v0.0.0-20240120211514-18a1ae0e79dc/go.mod h1:zqMwyHmnN/eDOZOdiTohqIUKUrTFX62PNlu7IJdu0q8= +github.com/shurcooL/graphql v0.0.0-20230722043721-ed46e5a46466 h1:17JxqqJY66GmZVHkmAsGEkcIu0oCe3AM420QDgGwZx0= +github.com/shurcooL/graphql v0.0.0-20230722043721-ed46e5a46466/go.mod h1:9dIRpgIY7hVhoqfe0/FcYp0bpInZaT7dc3BYOprrIUE= +golang.org/x/oauth2 v0.25.0 h1:CY4y7XT9v0cRI9oupztF8AgiIu99L/ksR/Xp/6jrZ70= +golang.org/x/oauth2 v0.25.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/hack/linear-sync/linear.go b/hack/linear-sync/linear.go new file mode 100644 index 0000000000..56e19a479a --- /dev/null +++ b/hack/linear-sync/linear.go @@ -0,0 +1,170 @@ +package main + +import ( + "context" + "errors" + "fmt" + "log/slog" + "net/http" + "strings" + + "github.com/shurcooL/graphql" +) + +var ErrNoWorkflowFound = errors.New("no workflow state found") + +type LinearClient struct { + client *graphql.Client +} + +var _ http.RoundTripper = (*transport)(nil) + +type transport struct { + token string +} + +// RoundTrip implements http.RoundTripper. +func (t *transport) RoundTrip(req *http.Request) (*http.Response, error) { + req.Header.Set("Authorization", t.token) + return http.DefaultTransport.RoundTrip(req) +} + +// NewLinearClient creates a new LinearClient. +func NewLinearClient(ctx context.Context, token string) LinearClient { + httpClient := &http.Client{ + Transport: &transport{token: token}, + } + client := graphql.NewClient("https://api.linear.app/graphql", httpClient) + + return LinearClient{client: client} +} + +// WorkflowStateID returns the ID of the a workflow state for the given team. +func (l *LinearClient) WorkflowStateID(ctx context.Context, stateName, linearTeamName string) (string, error) { + var query struct { + WorkflowStates struct { + Nodes []struct { + Id string + } + } `graphql:"workflowStates(filter: { name: { eq: $name }, team: { name: { eq: $team } } })"` + } + + variables := map[string]any{ + "name": graphql.String(stateName), + "team": graphql.String(linearTeamName), + } + + if err := l.client.Query(ctx, &query, variables); err != nil { + return "", fmt.Errorf("query failed: %w", err) + } + + if len(query.WorkflowStates.Nodes) == 0 { + return "", ErrNoWorkflowFound + } + + return query.WorkflowStates.Nodes[0].Id, nil +} + +// IssueState returns the current state of the issue. +func (l *LinearClient) IssueState(ctx context.Context, issueID string) (string, error) { + var query struct { + Issue struct { + State struct { + Id string + } + } `graphql:"issue(id: $id)"` + } + + variables := map[string]any{ + "id": graphql.String(issueID), + } + + if err := l.client.Query(ctx, &query, variables); err != nil { + return "", fmt.Errorf("query failed (issue ID: %v): %w", issueID, err) + } + + return query.Issue.State.Id, nil +} + +// MoveIssueToState moves the issue to the given state if it's not already there. +// It also adds a comment to the issue about when it was first released and on which tag. +func (l *LinearClient) MoveIssueToState(ctx context.Context, dryRun bool, issueID, releasedStateID, releaseTagName, releaseDate string) error { + // (ThomasK33): Skip CVEs + if strings.HasPrefix(strings.ToLower(issueID), "cve") { + return nil + } + + currentIssueState, err := l.IssueState(ctx, issueID) + if err != nil { + return fmt.Errorf("get issue state: %w", err) + } + + logger := ctx.Value(LoggerKey).(*slog.Logger) + + if currentIssueState == releasedStateID { + logger.Debug("Issue already has desired state", "issueID", issueID, "stateID", releasedStateID) + return nil + } + + if !dryRun { + if err := l.updateIssueState(ctx, issueID, releasedStateID); err != nil { + return fmt.Errorf("update issue state: %w", err) + } + } else { + logger.Info("Would update issue state", "issueID", issueID, "releasedStateID", releasedStateID) + } + + releaseComment := fmt.Sprintf("This issue was first released in %v on %v", releaseTagName, releaseDate) + + if !dryRun { + if err := l.createComment(ctx, issueID, releaseComment); err != nil { + return fmt.Errorf("create comment: %w", err) + } + } else { + logger.Info("Would create comment on issue", "issueID", issueID, "comment", releaseComment) + } + + logger.Info("Moved issue to desired state", "issueID", issueID, "stateID", releasedStateID) + + return nil +} + +// updateIssueState updates the state of the given issue. +func (l *LinearClient) updateIssueState(ctx context.Context, issueID, releasedStateID string) error { + var mutation struct { + IssueUpdate struct { + Success bool + } `graphql:"issueUpdate(input: { stateId: $stateID }, id: $issueID)"` + } + + variables := map[string]any{ + "issueID": graphql.String(issueID), + "stateID": graphql.String(releasedStateID), + } + + if err := l.client.Mutate(ctx, &mutation, variables); err != nil || !mutation.IssueUpdate.Success { + return fmt.Errorf("mutation failed: %w", err) + } + + return nil +} + +// createComment creates a comment on the given issue. +func (l *LinearClient) createComment(ctx context.Context, issueID, releaseComment string) error { + var mutation struct { + CommentCreate struct { + Success bool + } `graphql:"commentCreate(input: { issueId: $issueID, body: $body, doNotSubscribeToIssue: true })"` + } + + variables := map[string]any{ + "issueID": graphql.String(issueID), + "body": graphql.String(releaseComment), + } + + if err := l.client.Mutate(ctx, &mutation, variables); err != nil || !mutation.CommentCreate.Success { + return fmt.Errorf("mutation failed: %w", err) + } + + return nil +} \ No newline at end of file diff --git a/hack/linear-sync/main.go b/hack/linear-sync/main.go new file mode 100644 index 0000000000..b8d411b671 --- /dev/null +++ b/hack/linear-sync/main.go @@ -0,0 +1,176 @@ +package main + +import ( + "context" + "errors" + "flag" + "fmt" + "io" + "log/slog" + "os" + "os/signal" + + pullrequests "github.com/loft-sh/changelog/pull-requests" + "github.com/loft-sh/changelog/releases" + "github.com/shurcooL/githubv4" + "golang.org/x/oauth2" +) + +var ( + ErrMissingGitHubToken = errors.New("github token must be set") + ErrMissingLinearToken = errors.New("linear token must be set") + ErrMissingReleaseTag = errors.New("release tag must be set") +) + +var LoggerKey struct{} + +func main() { + if err := run(context.Background(), io.Writer(os.Stderr), os.Args); err != nil { + fmt.Fprintf(os.Stderr, "%s\n", err) + os.Exit(1) + } +} + +func run( + ctx context.Context, + stderr io.Writer, + args []string, +) error { + flagset := flag.NewFlagSet(args[0], flag.ExitOnError) + var ( + owner = flagset.String("owner", "loft-sh", "The GitHub owner of the repository") + repo = flagset.String("repo", "vcluster", "The GitHub repository to generate the changelog for") + githubToken = flagset.String("token", "", "The GitHub token to use for authentication") + previousTag = flagset.String("previous-tag", "", "The previous tag to generate the changelog for (if not set, the last stable release will be used)") + releaseTag = flagset.String("release-tag", "", "The tag of the new release") + debug = flagset.Bool("debug", false, "Enable debug logging") + linearToken = flagset.String("linear-token", "", "The Linear token to use for authentication") + releasedStateName = flagset.String("released-state-name", "Released", "The name of the state to use for the released state") + linearTeamName = flagset.String("linear-team-name", "vCluster / Platform", "The name of the team to use for the linear team") + dryRun = flagset.Bool("dry-run", false, "Do not actually move issues to the released state") + ) + if err := flagset.Parse(args[1:]); err != nil { + return fmt.Errorf("parse flags: %w", err) + } + + if *githubToken == "" { + *githubToken = os.Getenv("GITHUB_TOKEN") + } + + if *linearToken == "" { + *linearToken = os.Getenv("LINEAR_TOKEN") + } + + if *githubToken == "" { + return ErrMissingGitHubToken + } + + if *releaseTag == "" { + return ErrMissingReleaseTag + } + + if *linearToken == "" { + return ErrMissingLinearToken + } + + leveler := slog.LevelVar{} + leveler.Set(slog.LevelInfo) + if *debug { + leveler.Set(slog.LevelDebug) + } + + logger := slog.New(slog.NewTextHandler(stderr, &slog.HandlerOptions{ + Level: &leveler, + })) + + ctx, stop := signal.NotifyContext(ctx, os.Interrupt, os.Kill) + defer stop() + + ctx = context.WithValue(ctx, LoggerKey, logger) + + httpClient := oauth2.NewClient(ctx, oauth2.StaticTokenSource( + &oauth2.Token{ + AccessToken: *githubToken, + }, + )) + + gqlClient := githubv4.NewClient(httpClient) + + var stableTag string + + if *previousTag != "" { + release, err := releases.FetchReleaseByTag(ctx, gqlClient, *owner, *repo, *previousTag) + if err != nil { + return fmt.Errorf("fetch release by tag: %w", err) + } + + stableTag = release.TagName + } else { + if prevRelease, err := releases.LastStableReleaseBeforeTag(ctx, gqlClient, *owner, *repo, *releaseTag); err != nil { + return fmt.Errorf("get last stable release before tag: %w", err) + } else if prevRelease != "" { + stableTag = prevRelease + } else { + stableTag, _, err = releases.LastStableRelease(ctx, gqlClient, *owner, *repo) + if err != nil { + return fmt.Errorf("get last stable release: %w", err) + } + } + } + + if stableTag == "" { + return errors.New("no stable release found") + } + + logger.Info("Last stable release", "stableTag", stableTag) + + currentRelease, err := releases.FetchReleaseByTag(ctx, gqlClient, *owner, *repo, *releaseTag) + if err != nil { + return fmt.Errorf("fetch release by tag: %w", err) + } + + if currentRelease.TagName != *releaseTag { + return fmt.Errorf("release not found: %s", *releaseTag) + } + + prs, err := pullrequests.FetchAllPRsBetween(ctx, gqlClient, *owner, *repo, stableTag, *releaseTag) + if err != nil { + return fmt.Errorf("fetch all PRs until: %w", err) + } + + pullRequests := NewLinearPullRequests(prs) + + logger.Info("Found merged pull requests between releases", "count", len(pullRequests), "previous", stableTag, "current", *releaseTag) + + releasedIssues := []string{} + + for _, pr := range pullRequests { + if issueIDs := pr.IssueIDs(); len(issueIDs) > 0 { + for _, issueID := range issueIDs { + releasedIssues = append(releasedIssues, issueID) + logger.Debug("Found issue in pull request", "issueID", issueID, "pr", pr.Number) + } + } + } + + logger.Info("Found issues in pull requests", "count", len(releasedIssues)) + + linearClient := NewLinearClient(ctx, *linearToken) + + releasedStateID, err := linearClient.WorkflowStateID(ctx, *releasedStateName, *linearTeamName) + if err != nil { + return fmt.Errorf("get released workflow ID: %w", err) + } + + logger.Debug("Found released workflow ID", "workflowID", releasedStateID) + + currentReleaseDateStr := currentRelease.PublishedAt.Format("2006-01-02") + + for _, issueID := range releasedIssues { + if err := linearClient.MoveIssueToState(ctx, *dryRun, issueID, releasedStateID, currentRelease.TagName, currentReleaseDateStr); err != nil { + logger.Error("Failed to move issue to state", "issueID", issueID, "error", err) + } + } + + return nil +} \ No newline at end of file diff --git a/hack/linear-sync/pr.go b/hack/linear-sync/pr.go new file mode 100644 index 0000000000..790fd77598 --- /dev/null +++ b/hack/linear-sync/pr.go @@ -0,0 +1,67 @@ +package main + +import ( + "regexp" + "strings" + + pullrequests "github.com/loft-sh/changelog/pull-requests" +) + +var issuesInBodyREs = []*regexp.Regexp{ + regexp.MustCompile(`(?P\w{3}-\d{4})`), +} + +const PageSize = 100 + +type LinearPullRequest struct { + pullrequests.PullRequest +} + +func NewLinearPullRequests(prs []pullrequests.PullRequest) []LinearPullRequest { + linearPRs := make([]LinearPullRequest, 0, len(prs)) + + for _, pr := range prs { + linearPRs = append(linearPRs, LinearPullRequest{pr}) + } + + return linearPRs +} + +// IssueIDs extracts the Linear issue IDs from either the pull requests body +// or it's branch name. +// +// Will return an empty string if it did not manage to find an issue. +func (p LinearPullRequest) IssueIDs() []string { + issueIDs := []string{} + + for _, re := range issuesInBodyREs { + for _, body := range []string{p.Body, p.HeadRefName} { + matches := re.FindAllStringSubmatch(body, -1) + if len(matches) == 0 { + continue + } + + for _, match := range matches { + for i, name := range re.SubexpNames() { + issueID := "" + + switch name { + case "issue": + issueID = strings.ToLower(match[i]) + issueID = strings.TrimSpace(issueID) + } + + if strings.HasPrefix(strings.ToLower(issueID), "cve") { + issueID = "" + } + + if issueID != "" { + issueIDs = append(issueIDs, issueID) + } + } + } + } + } + + return issueIDs +} \ No newline at end of file