Skip to content

Commit 6c4caf0

Browse files
committed
feat(ci): linear issues sync
* Add linear-sync tool to update Linear issues when a release is created * Sync with changelog library to extract PRs between releases * Add sync_linear job to GitHub Actions release workflow * Update Linear issues to "Released" state when code is released * Extract Linear issue IDs from PR bodies and branch names * Add release date and version comments to Linear issues
1 parent 4d75f97 commit 6c4caf0

File tree

13 files changed

+1044
-0
lines changed

13 files changed

+1044
-0
lines changed

.github/workflows/release.yaml

+18
Original file line numberDiff line numberDiff line change
@@ -122,10 +122,28 @@ jobs:
122122
CHART_MUSEUM_PASSWORD: ${{ secrets.CHART_MUSEUM_PASSWORD }}
123123
# The workflow will only trigger on non-draft releases
124124
# https://docs.github.com/en/actions/writing-workflows/choosing-when-your-workflow-runs/events-that-trigger-workflows#release
125+
sync_linear:
126+
needs:
127+
- publish
128+
- publish-chart
129+
runs-on: ubuntu-latest
130+
steps:
131+
- uses: actions/checkout@v4
132+
- uses: actions/setup-go@v5
133+
with:
134+
go-version-file: go.mod
135+
- name: Update linear issues
136+
run: go run -mod vendor . -release-tag="${{ needs.publish.outputs.release_version }}"
137+
working-directory: hack/linear-sync
138+
env:
139+
GITHUB_TOKEN: ${{ secrets.GH_ACCESS_TOKEN }}
140+
LINEAR_TOKEN: ${{ secrets.LINEAR_TOKEN }}
141+
125142
notify_release:
126143
needs:
127144
- publish
128145
- publish-chart
146+
- sync_linear
129147
runs-on: ubuntu-22.04
130148
steps:
131149
- uses: actions/checkout@v4

hack/changelog/go.mod

+15
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
module github.com/loft-sh/changelog
2+
3+
go 1.22.5
4+
5+
require (
6+
github.com/blang/semver v3.5.1+incompatible
7+
github.com/google/go-github/v59 v59.0.0
8+
github.com/shurcooL/githubv4 v0.0.0-20240120211514-18a1ae0e79dc
9+
golang.org/x/oauth2 v0.25.0
10+
)
11+
12+
require (
13+
github.com/google/go-querystring v1.1.0 // indirect
14+
github.com/shurcooL/graphql v0.0.0-20230722043721-ed46e5a46466 // indirect
15+
)

hack/changelog/go.sum

+16
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdnnjpJbkM4JQ=
2+
github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk=
3+
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
4+
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
5+
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
6+
github.com/google/go-github/v59 v59.0.0 h1:7h6bgpF5as0YQLLkEiVqpgtJqjimMYhBkD4jT5aN3VA=
7+
github.com/google/go-github/v59 v59.0.0/go.mod h1:rJU4R0rQHFVFDOkqGWxfLNo6vEk4dv40oDjhV/gH6wM=
8+
github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
9+
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
10+
github.com/shurcooL/githubv4 v0.0.0-20240120211514-18a1ae0e79dc h1:vH0NQbIDk+mJLvBliNGfcQgUmhlniWBDXC79oRxfZA0=
11+
github.com/shurcooL/githubv4 v0.0.0-20240120211514-18a1ae0e79dc/go.mod h1:zqMwyHmnN/eDOZOdiTohqIUKUrTFX62PNlu7IJdu0q8=
12+
github.com/shurcooL/graphql v0.0.0-20230722043721-ed46e5a46466 h1:17JxqqJY66GmZVHkmAsGEkcIu0oCe3AM420QDgGwZx0=
13+
github.com/shurcooL/graphql v0.0.0-20230722043721-ed46e5a46466/go.mod h1:9dIRpgIY7hVhoqfe0/FcYp0bpInZaT7dc3BYOprrIUE=
14+
golang.org/x/oauth2 v0.25.0 h1:CY4y7XT9v0cRI9oupztF8AgiIu99L/ksR/Xp/6jrZ70=
15+
golang.org/x/oauth2 v0.25.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
16+
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=

hack/changelog/log/log.go

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
package log
2+
3+
var LoggerKey struct{}

hack/changelog/main.go

+162
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
package main
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"errors"
7+
"flag"
8+
"fmt"
9+
"io"
10+
"log/slog"
11+
"os"
12+
"os/signal"
13+
"sort"
14+
15+
"github.com/google/go-github/v59/github"
16+
"github.com/loft-sh/changelog/log"
17+
pullrequests "github.com/loft-sh/changelog/pull-requests"
18+
"github.com/loft-sh/changelog/releases"
19+
"github.com/shurcooL/githubv4"
20+
"golang.org/x/oauth2"
21+
)
22+
23+
var ErrMissingToken = errors.New("github token must be set")
24+
25+
func main() {
26+
if err := run(context.Background(), os.Stdout, os.Stderr, os.Args); err != nil {
27+
fmt.Fprintf(os.Stderr, "%s\n", err)
28+
os.Exit(1)
29+
}
30+
}
31+
32+
func run(
33+
ctx context.Context,
34+
stdout, stderr io.Writer,
35+
args []string,
36+
) error {
37+
flagset := flag.NewFlagSet(args[0], flag.ExitOnError)
38+
var (
39+
owner = flagset.String("owner", "loft-sh", "The GitHub owner of the repository")
40+
repo = flagset.String("repo", "vcluster", "The GitHub repository to generate the changelog for")
41+
githubToken = flagset.String("token", "", "The GitHub token to use for authentication")
42+
previousTag = flagset.String("previous-tag", "", "The previous tag to generate the changelog for (if not set, the last stable release will be used)")
43+
releaseTag = flagset.String("release-tag", "", "The tag of the release to generate the changelog for")
44+
updateNotes = flagset.Bool("update-notes", true, "Update the release notes of the release with the generated ones")
45+
overwrite = flagset.Bool("overwrite", false, "Overwrite the release notes with the generated ones")
46+
debug = flagset.Bool("debug", false, "Enable debug logging")
47+
)
48+
if err := flagset.Parse(args[1:]); err != nil {
49+
return fmt.Errorf("parse flags: %w", err)
50+
}
51+
52+
if *githubToken == "" {
53+
*githubToken = os.Getenv("GITHUB_TOKEN")
54+
}
55+
56+
if *githubToken == "" {
57+
return ErrMissingToken
58+
}
59+
60+
leveler := slog.LevelVar{}
61+
leveler.Set(slog.LevelInfo)
62+
if *debug {
63+
leveler.Set(slog.LevelDebug)
64+
}
65+
66+
logger := slog.New(slog.NewTextHandler(stderr, &slog.HandlerOptions{
67+
Level: &leveler,
68+
}))
69+
70+
ctx, stop := signal.NotifyContext(ctx, os.Interrupt, os.Kill)
71+
defer stop()
72+
73+
ctx = context.WithValue(ctx, log.LoggerKey, logger)
74+
75+
httpClient := oauth2.NewClient(ctx, oauth2.StaticTokenSource(
76+
&oauth2.Token{
77+
AccessToken: *githubToken,
78+
},
79+
))
80+
81+
client := github.NewClient(httpClient)
82+
gqlClient := githubv4.NewClient(httpClient)
83+
84+
var stableTag string
85+
86+
if *previousTag != "" {
87+
release, err := releases.FetchReleaseByTag(ctx, gqlClient, *owner, *repo, *previousTag)
88+
if err != nil {
89+
return fmt.Errorf("fetch release by tag: %w", err)
90+
}
91+
92+
stableTag = release.TagName
93+
} else {
94+
if prevRelease, err := releases.LastStableReleaseBeforeTag(ctx, gqlClient, *owner, *repo, *releaseTag); err != nil {
95+
return fmt.Errorf("get last stable release before tag: %w", err)
96+
} else if prevRelease != "" {
97+
stableTag = prevRelease
98+
} else {
99+
stableTag, _, err = releases.LastStableRelease(ctx, gqlClient, *owner, *repo)
100+
if err != nil {
101+
return fmt.Errorf("get last stable release: %w", err)
102+
}
103+
}
104+
}
105+
106+
if stableTag == "" {
107+
return errors.New("no stable release found")
108+
}
109+
110+
logger.Info("Last stable release", "stableTag", stableTag)
111+
112+
var currentRelease releases.Release
113+
if *releaseTag != "" {
114+
var err error
115+
currentRelease, err = releases.FetchReleaseByTag(ctx, gqlClient, *owner, *repo, *releaseTag)
116+
if err != nil {
117+
return fmt.Errorf("fetch release by tag: %w", err)
118+
}
119+
120+
if currentRelease.TagName != *releaseTag {
121+
return fmt.Errorf("release not found: %s", *releaseTag)
122+
}
123+
}
124+
125+
pullRequests, err := pullrequests.FetchAllPRsBetween(ctx, gqlClient, *owner, *repo, stableTag, *releaseTag)
126+
if err != nil {
127+
return fmt.Errorf("fetch all PRs until: %w", err)
128+
}
129+
130+
logger.Info("Found merged pull requests between releases", "count", len(pullRequests), "previous", stableTag, "current", *releaseTag)
131+
132+
notes := []Note{}
133+
for _, pr := range pullRequests {
134+
notes = append(notes, NewNotesFromPullRequest(pr)...)
135+
}
136+
sort.Slice(notes, SortNotes(notes))
137+
138+
buffer := bytes.Buffer{}
139+
140+
for _, note := range notes {
141+
if _, err := buffer.Write([]byte(note.String())); err != nil {
142+
return fmt.Errorf("write note: %w", err)
143+
}
144+
}
145+
146+
if *releaseTag != "" && *updateNotes {
147+
if currentRelease.Description == "" || *overwrite {
148+
if err := releases.UpdateReleaseNotes(ctx, client, *owner, *repo, currentRelease.DatabaseId, buffer.String()); err != nil {
149+
return fmt.Errorf("update release notes: %w", err)
150+
}
151+
logger.Info("Updated release notes", "releaseTag", *releaseTag)
152+
} else {
153+
logger.Warn("Release notes already exist for tag, skipping update", "releaseTag", *releaseTag)
154+
}
155+
}
156+
157+
if _, err := stdout.Write(buffer.Bytes()); err != nil {
158+
return fmt.Errorf("write changelog: %w", err)
159+
}
160+
161+
return nil
162+
}

hack/changelog/note.go

+97
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
package main
2+
3+
import (
4+
"fmt"
5+
"regexp"
6+
"sort"
7+
"strings"
8+
9+
pullrequests "github.com/loft-sh/changelog/pull-requests"
10+
)
11+
12+
var notesInBodyREs = []*regexp.Regexp{
13+
regexp.MustCompile("(?ms)^```release-note[s]?:(?P<type>[^\r\n]*)\r?\n?(?P<note>.*?)\r?\n?```"),
14+
regexp.MustCompile("(?ms)^```release-note[s]?\r?\ntype:\\s?(?P<type>[^\r\n]*)\r?\nnote:\\s?(?P<note>.*?)\r?\n?```"),
15+
}
16+
17+
type Note struct {
18+
Type string
19+
Author string
20+
Body string
21+
PR int
22+
}
23+
24+
func (n Note) String() string {
25+
return fmt.Sprintf("- %s: %s (by @%v in #%d)\n", n.Type, n.Body, n.Author, n.PR)
26+
}
27+
28+
func NewNotesFromPullRequest(p pullrequests.PullRequest) []Note {
29+
return NewNotes(p.Body, p.Author.Login, p.Number)
30+
}
31+
32+
func SortNotes(res []Note) func(i, j int) bool {
33+
return func(i, j int) bool {
34+
if res[i].Type < res[j].Type {
35+
return true
36+
} else if res[j].Type < res[i].Type {
37+
return false
38+
} else if res[i].Body < res[j].Body {
39+
return true
40+
} else if res[j].Body < res[i].Body {
41+
return false
42+
} else if res[i].PR < res[j].PR {
43+
return true
44+
} else if res[j].PR < res[i].PR {
45+
return false
46+
}
47+
return false
48+
}
49+
}
50+
51+
func NewNotes(body, author string, number int) []Note {
52+
var res []Note
53+
for _, re := range notesInBodyREs {
54+
matches := re.FindAllStringSubmatch(body, -1)
55+
if len(matches) == 0 {
56+
continue
57+
}
58+
59+
for _, match := range matches {
60+
note := ""
61+
typ := ""
62+
63+
for i, name := range re.SubexpNames() {
64+
switch name {
65+
case "note":
66+
note = match[i]
67+
case "type":
68+
typ = strings.ToLower(match[i])
69+
}
70+
if note != "" && typ != "" {
71+
break
72+
}
73+
}
74+
75+
note = strings.TrimSpace(note)
76+
typ = strings.TrimSpace(typ)
77+
78+
if typ == "type" || typ == "none" || note == "none" {
79+
note = ""
80+
typ = ""
81+
}
82+
if note == "" && typ == "" {
83+
continue
84+
}
85+
86+
res = append(res, Note{
87+
Type: typ,
88+
Body: note,
89+
PR: number,
90+
Author: author,
91+
})
92+
}
93+
}
94+
sort.Slice(res, SortNotes(res))
95+
96+
return res
97+
}

0 commit comments

Comments
 (0)