From 0ac611a5c2be5d0410acda1171b737f44cf29f19 Mon Sep 17 00:00:00 2001 From: codebien <2103732+codebien@users.noreply.github.com> Date: Fri, 29 Sep 2023 12:20:28 +0200 Subject: [PATCH] cmd/relnot: Release notes generator --- cmd/relnot/change.go | 54 ++++++++ cmd/relnot/change_test.go | 18 +++ cmd/relnot/main.go | 234 +++++++++++++++++++++++++++++++++ cmd/relnot/parser_test.go | 40 ++++++ cmd/relnot/testdata/pulls.json | 42 ++++++ cmd/relnot/type.go | 15 +++ cmd/relnot/type_gen.go | 67 ++++++++++ cmd/relnot/type_test.go | 15 +++ 8 files changed, 485 insertions(+) create mode 100644 cmd/relnot/change.go create mode 100644 cmd/relnot/change_test.go create mode 100644 cmd/relnot/main.go create mode 100644 cmd/relnot/parser_test.go create mode 100644 cmd/relnot/testdata/pulls.json create mode 100644 cmd/relnot/type.go create mode 100644 cmd/relnot/type_gen.go create mode 100644 cmd/relnot/type_test.go diff --git a/cmd/relnot/change.go b/cmd/relnot/change.go new file mode 100644 index 000000000000..f2c97ebcbdfd --- /dev/null +++ b/cmd/relnot/change.go @@ -0,0 +1,54 @@ +package main + +import ( + "fmt" +) + +var contributors = map[string]bool{ + // core team + "mstoykov": true, + "codebien": true, + "olegbespalov": true, + "oleiade": true, + + // browser team + "ankur22": true, + "inancgumus": true, + "ka3de": true, +} + +type Change struct { + RawType string + Type PullType + Number int + Title string + Body string + Author string +} + +func (c Change) Format() string { + if c.isEpic() { + return c.formatEpic() + } + text := fmt.Sprintf("- [#%d](https://github.com/grafana/k6/pull/%d) %s", c.Number, c.Number, c.Title) + if c.isExternalContributor() { + text += fmt.Sprintf(" Thanks @%s for the contribution!.", c.Author) + } + return text +} + +func (c Change) formatEpic() string { + text := fmt.Sprintf("### %s\n\n%s", c.Title, c.Body) + if c.isExternalContributor() { + text += fmt.Sprintf("\nThanks @%s for the contribution!.", c.Author) + } + return text + "\n" +} + +func (c Change) isEpic() bool { + return c.Type == EpicFeature || c.Type == EpicBreaking +} + +func (c Change) isExternalContributor() bool { + return contributors[c.Author] +} diff --git a/cmd/relnot/change_test.go b/cmd/relnot/change_test.go new file mode 100644 index 000000000000..bb13014b7397 --- /dev/null +++ b/cmd/relnot/change_test.go @@ -0,0 +1,18 @@ +package main + +import ( + "testing" +) + +func TestChangeFormatTypeNoBody(t *testing.T) { + c := Change{ + Type: Bug, + Number: 3231, + Title: "Fixes the tracing module sampling option to default to 1.0 when not set by the user.", + Body: "", + } + exp := "- [#3231](https://github.com/grafana/k6/pull/3231) Fixes the tracing module sampling option to default to 1.0 when not set by the user." + if s := c.Format(); s != exp { + t.Errorf("unexpected formatted change: got: %s", s) + } +} diff --git a/cmd/relnot/main.go b/cmd/relnot/main.go new file mode 100644 index 000000000000..1c13958f5717 --- /dev/null +++ b/cmd/relnot/main.go @@ -0,0 +1,234 @@ +package main + +import ( + "bufio" + "bytes" + "encoding/json" + "fmt" + "io" + "log" + "os" + "os/exec" + "strings" +) + +const ( + // TODO: we may consider to input it as an argument + // e.g. + // $ relnot -m v0.47.0 + milestone = "v0.47.0" + + // TODO: we may consider to input it as an argument + // e.g. + // $ relnot -m v0.47.0 ./release\ notes/unreleased.md + unreleasedFilePath = "./../../release notes/unreleased.md" +) + +func main() { + f, err := openUnreleased(unreleasedFilePath) + if err != nil { + log.Fatalf("open Unreleased file failed: %v", err) + } + defer f.Close() + + fmt.Println("Unreleased file descriptor opened") + + pulls, err := fetchPullRequests() + if err != nil { + log.Fatalf("fetch pull requests failed: %v", err) + } + + if len(pulls) < 0 { + fmt.Println("There aren't pull requests to process, the generation will be stopped.") + } + + fmt.Printf("Pull requests fetched: %d\n\n", len(pulls)) + + changesByType := make(map[PullType][]Change) + changesContents := make(map[PullType]string) + + log.Println("Parsing changes from Pull request:") + for _, pull := range pulls { + fmt.Printf("#%d ", pull.Number) + + change, err := parseChange(pull) + if err != nil { + log.Fatalf("parse change (#%d) failed: %v", change.Number, err) + return + } + + changesByType[change.Type] = append(changesByType[change.Type], change) + if change.Type == Undefined { + continue + } + + text := change.Format() + changesContents[change.Type] += text + "\n" + } + + fmt.Println("\n\nMatching stage report:") + for typ, changes := range changesByType { + if typ == Undefined { + fmt.Printf("Type: %s, Count: %d, Pulls: %v\n", typ, len(changes), mapChangesNumbers(changes)) + continue + } + fmt.Printf("Type: %s, Count: %d\n", typ, len(changes)) + } + + ftemp, err := os.CreateTemp("", "") + if err != nil { + log.Fatalf("open the temporary Unreleased joiner failed: %v", err) + } + defer os.Remove(ftemp.Name()) + defer ftemp.Close() + + joiner := bufio.NewWriter(ftemp) + scanner := bufio.NewScanner(f) + + for scanner.Scan() { + text := scanner.Text() + if len(text) < 1 || text[0] != '<' { + joiner.Write([]byte(text + "\n")) + continue + } + + typeToAdd, err := typeFromPlaceholder(text) + if err != nil { + log.Fatalf("expected to parse a placeholder but it failed on the line: %s", err) + return + } + + changesToAdd, ok := changesContents[typeToAdd] + if !ok { + joiner.Write([]byte(placeholder(typeToAdd) + "\n")) + continue + } + joiner.Write([]byte(changesToAdd + placeholder(typeToAdd) + "\n")) + } + + if err := scanner.Err(); err != nil { + log.Fatal(err) + } + + joiner.Flush() + ftemp.Close() + + if err := os.Rename(ftemp.Name(), unreleasedFilePath); err != nil { + log.Fatalf("Moving the new unreleased version failed: %s", err) + return + } + + fmt.Println("\nRelease notes generation completed.") +} + +func placeholder(typ PullType) string { + return fmt.Sprintf("<%s>", typ.String()) +} + +func typeFromPlaceholder(text string) (PullType, error) { + text = strings.TrimPrefix(text, "<") + text = strings.TrimSuffix(text, ">") + return PullTypeString(text) +} + +type Pull struct { + Number int `json:"number"` + Body string `json:"body"` + Author struct { + Username string `json:"login"` + } `json:"author"` +} + +func parseChange(p Pull) (Change, error) { + c := Change{Number: p.Number} + _, changeBody, found := strings.Cut(p.Body, "### Changelog\r\n\r\n") + if !found { + return c, nil + } + + bodyParts := strings.SplitN(changeBody, "\r\n\r\n", 2) + firstLine := strings.SplitN(bodyParts[0], ":", 2) + + changeType, err := PullTypeString(strings.ToLower(firstLine[0])) + if err != nil { + return c, fmt.Errorf("pull request's type parser: %w", err) + } + + c.Number = p.Number + c.Type = changeType + c.Title = strings.Trim(firstLine[1], " ") + + if len(bodyParts) > 1 { + c.Body = bodyParts[1] + } + + return c, nil +} + +func fetchPullRequests() ([]Pull, error) { + // It requires pager to be set to `cat` => $ gh config set pager cat + fetchCmd := []string{ + "pr", "list", "--repo", "grafana/k6", "-s", "merged", + "--search", fmt.Sprintf("milestone:%s sort:created-desc", milestone), + "--json", "number,body,author", + "--limit", "1000"} + + cmd := exec.Command("gh", fetchCmd...) + + stdout, err := cmd.Output() + if err != nil { + return nil, fmt.Errorf("invoke CLI command: %w", err) + } + + var pulls []Pull + err = json.Unmarshal(stdout, &pulls) + if err != nil { + return nil, fmt.Errorf("JSON unmarhsal: %w", err) + } + + return pulls, err +} + +func mapChangesNumbers(changes []Change) []int { + nums := make([]int, 0, len(changes)) + for _, p := range changes { + nums = append(nums, p.Number) + } + return nums +} + +func openUnreleased(path string) (io.ReadCloser, error) { + f, err := os.Open(unreleasedFilePath) + if os.IsNotExist(err) { + return io.NopCloser(bytes.NewBufferString(template)), nil + } + if err != nil { + return nil, err + } + return f, err +} + +var template = `## Breaking changes + + + + + +## New features + + + + + +### UX improvements and enhancements + + + +## Bug fixes + + + +## Maintenance and internal improvements + + +` diff --git a/cmd/relnot/parser_test.go b/cmd/relnot/parser_test.go new file mode 100644 index 000000000000..7c21742e0202 --- /dev/null +++ b/cmd/relnot/parser_test.go @@ -0,0 +1,40 @@ +package main + +import ( + "encoding/json" + "io" + "os" + "testing" +) + +func TestParseChange(t *testing.T) { + testdata, err := os.Open("./testdata/pulls.json") + if err != nil { + t.Fatal(err) + } + + input, err := io.ReadAll(testdata) + if err != nil { + t.Fatal(err) + } + + var pulls []Pull + err = json.Unmarshal(input, &pulls) + if err != nil { + t.Fatal(err) + } + + var changes []Change + for _, pull := range pulls { + change, err := parseChange(pull) + if err != nil { + t.Fatal(err) + } + changes = append(changes, change) + } + + if len(changes) != 10 { + t.Errorf("unexpected identified changes") + + } +} diff --git a/cmd/relnot/testdata/pulls.json b/cmd/relnot/testdata/pulls.json new file mode 100644 index 000000000000..6e83da7c0f46 --- /dev/null +++ b/cmd/relnot/testdata/pulls.json @@ -0,0 +1,42 @@ +[ + { + "body": "## What?\r\n\r\nThis upgrades the [xk6-browser](https://github.com/grafana/xk6-browser) extension to [v1.1.0](https://github.com/grafana/xk6-browser/releases/tag/v1.1.0) in k6.\r\n\r\n## Why?\r\n\r\nUpgrade the extension in preparation for an upcoming release.\r\n\r\n## Checklist\r\n\r\n\r\n\r\n- [X] I have performed a self-review of my code.\r\n- [ ] I have added tests for my changes. (NA)\r\n- [X] I have run linter locally (`make ci-like-lint`) and all checks pass.\r\n- [ ] I have run tests locally (`make tests`) and all tests pass.\r\n- [ ] I have commented on my code, particularly in hard-to-understand areas. (NA)\r\n\r\n\r\n## Related PR(s)/Issue(s)\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n", + "number": 3355 + }, + { + "body": "## What?\r\n\r\nIt bumps the Prometheus remote write to the latest version\r\n\r\n---\r\n\r\n### Changelog\r\n\r\ninternal: Updated the Prometheus remote write output version. Check the specific [release notes](https://github.com/grafana/xk6-output-prometheus-remote/releases/tag/v0.3.0).\r\n\r\nThis is just an example body. Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s.\r\n", + "number": 3351 + }, + { + "body": "Updates #2982\r\n\r\n### Changelog\r\n\r\nbreaking: Deprecate statsd output\r\n\r\nStatsd output has not been maintained very actively by the k6 core team and due to it not being very standard that isn't very likely to change. As such it has been decided that it will be moved to an extension and given to an outside maintainer that can better work on it.\r\n\r\nThe new extension can be found at https://github.com/LeonAdato/xk6-output-statsd\r\n", + "number": 3347 + }, + { + "body": "Previous http.batch code did call `runtime.NewArrayBuffer` concurrently.\r\n\r\nWhich never has been guaranteed to be safe, but with the latest goja changes to having prototypes created on demand it races.\r\n\r\nThe fix *might* have some performance implications, but they are likely to be very small as `NewArrayBuffer` mostly wraps stuff so ... hopefully not a big deal.\r\n\r\n### Changelog\r\n\r\nbug: fix data race when using `http.batch` and binary response\r\n\r\nThere has been concurrent access of the goja runtime/js vm when `http.batch` is used with binary response for a long time. Up until recently that was not so relevant, but with latest goja changes an actual data race was detected and is now fixed.\r\n\r\n\r\n", + "number": 3346 + }, + { + "body": "## What?\r\n\r\nThis PR brings support of google wrappers to the k6.\r\n\r\n## Why?\r\n\r\nThe reported in #3232 k6 previously did not correctly marshal the google wrappers messages.\r\n\r\n## Checklist\r\n\r\n\r\n\r\n- [x] I have performed a self-review of my code.\r\n- [x] I have added tests for my changes.\r\n- [x] I have run linter locally (`make ci-like-lint`) and all checks pass.\r\n- [x] I have run tests locally (`make tests`) and all tests pass.\r\n- [ ] I have commented on my code, particularly in hard-to-understand areas.\r\n\r\n\r\n## Related PR(s)/Issue(s)\r\n\r\nhttps://github.com/grafana/xk6-grpc/pull/49\r\n\r\n\r\n\r\nResolves #3232\r\n\r\nCloses #3238 \r\n\r\n\r\n\r\n### Changelog\r\n\r\nbug: adds (fixes) the support of google protobuf wrappers\r\n\r\nadds (fixes) the support of google protobuf wrappers\r\n", + "number": 3344 + }, + { + "body": "## What?\r\n\r\nThis PR contains several parts:\r\n* First is refactoring of calling and connection params to make code more re-usable\r\n* Second is the significant (but is mostly moving & cleaning) refactoring of the gRPC client's test\r\n* last but not least is an introduction to the `reflectMetadata` \r\n\r\n## Why?\r\n\r\nThe refactoring was done to make the following changes tinier and pay some technical dept of removing copy-pasting.\r\n\r\nThe `reflectMetadata` was requested in #3241 \r\n\r\n## Checklist\r\n\r\n\r\n\r\n- [x] I have performed a self-review of my code.\r\n- [x] I have added tests for my changes.\r\n- [x] I have run linter locally (`make ci-like-lint`) and all checks pass.\r\n- [x] I have run tests locally (`make tests`) and all tests pass.\r\n- [x] I have commented on my code, particularly in hard-to-understand areas.\r\n\r\n\r\n## Related PR(s)/Issue(s)\r\n\r\nhttps://github.com/grafana/xk6-grpc/pull/51\r\n\r\n\r\n\r\nCloses: #3241\r\n\r\n\r\n\r\n### Changelog\r\n\r\nepic-feature: a new gRPC connection's parameter `reflectMetadata`\r\n\r\nIn some workflows, the reflection call should also include some metadata. This PR adds [a new connection parameter `reflectMetadata`](https://k6.io/docs/javascript-api/k6-net-grpc/client/client-connect/#connectparams) that allows to specify the metadata to be sent with the reflection call.", + "number": 3343 + }, + { + "body": "## What?\r\n\r\nThis PR updates xk6-grpc dependency to the latest available version.\r\n\r\nThis change brings the new `reflectMetadata` to the connection parameters.\r\n\r\n## Why?\r\n\r\nThis is likely the version of the `k6/experimental/grpc` module that will be released in the following k6 v0.47.\r\n\r\n## Checklist\r\n\r\n\r\n\r\n- [x] I have performed a self-review of my code.\r\n- [ ] I have added tests for my changes.\r\n- [ ] I have run linter locally (`make ci-like-lint`) and all checks pass.\r\n- [ ] I have run tests locally (`make tests`) and all tests pass.\r\n- [ ] I have commented on my code, particularly in hard-to-understand areas.\r\n\r\n\r\n## Related PR(s)/Issue(s)\r\n\r\nhttps://github.com/grafana/xk6-grpc/pull/51\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n### Changelog\r\n\r\ninternal: Updates xk6-grpc to the latest version.\r\n\r\nThis change brings all the latest fixes and improvements to the experimental gRPC module.\r\n", + "number": 3342 + }, + { + "body": "### Changelog\r\n\r\ninternal: update goja with some fixes for source indexes", + "number": 3341 + }, + { + "body": "## What?\r\n\r\nSets the `no-sandbox` chrome argument by default through the `K6_BROWSER_ARGS` ENV variable in the Dockerfile definition for the browser enabled docker image.\r\n\r\n## Why?\r\n\r\nIn order to allow chrome to execute in the browser enabled image, either `SYS_ADMIN` Docker capability or `no-sandbox` browser argument had to be set. This sets the `no-sandbox` option in the Dockerfile definition itself so this action is no longer necessary to run the Docker container. This is arguably also the better option, as we are running the chrome browser inside a container, versus using the Docker capability.\r\n\r\n## Checklist\r\n\r\n- [X] I have performed a self-review of my code.\r\n- [ ] I have added tests for my changes.\r\n- [X] I have run linter locally (`make ci-like-lint`) and all checks pass.\r\n- [X] I have run tests locally (`make tests`) and all tests pass.\r\n- [X] I have commented on my code, particularly in hard-to-understand areas.\r\n", + "number": 3340 + }, + { + "body": "This updates makes the runtime initialization faster and *lighter*.\r\n\r\nFor empty and fairly small scripts the observed initialization improvement by some fast \"benchmarking\" for k6 this means:\r\n1. around 3x faster initialization of VUs\r\n2. around 4x less memory usage.\r\n\r\nThis likely will be nearly unnoticeable as this is only reasonably easy to see with 100k VUs for the initialization speed where it goes from around 10s to a little over 3s.\r\n\r\nAdding more code increases both linearly, so bigger scripts will have some speed up, but it won't be 3x.\r\n\r\nSame seems to be for memory usages as well.\r\n\r\nBoth of those likely get way worse if more and more of the JavaScript \"standard library\" is used as in that case all of those now templated properties will be used.\r\n\r\n### Changelog\r\n\r\ninternal: update goja with some runtime initialization speed-ups\r\n\r\nThe speed-ups are around 3x and there is 4x memory reduction for *empty* scripts. Anything more complex adds and using JS types or importing libraries decrease this as it just adds to the actual initialization in ways this optimization does not help. \r\n", + "number": 3339 + } +] diff --git a/cmd/relnot/type.go b/cmd/relnot/type.go new file mode 100644 index 000000000000..319519df7013 --- /dev/null +++ b/cmd/relnot/type.go @@ -0,0 +1,15 @@ +package main + +//go:generate enumer -type=PullType -text -transform=kebab -output type_gen.go +type PullType int + +const ( + Undefined PullType = iota + EpicFeature + Feature + EpicBreaking + Breaking + UX + Internal + Bug +) diff --git a/cmd/relnot/type_gen.go b/cmd/relnot/type_gen.go new file mode 100644 index 000000000000..4c86c9cac309 --- /dev/null +++ b/cmd/relnot/type_gen.go @@ -0,0 +1,67 @@ +// Code generated by "enumer -type=PullType -text -transform=kebab -output type_gen.go"; DO NOT EDIT. + +package main + +import ( + "fmt" +) + +const _PullTypeName = "undefinedepic-featurefeatureepic-breakingbreakinguxinternalbug" + +var _PullTypeIndex = [...]uint8{0, 9, 21, 28, 41, 49, 51, 59, 62} + +func (i PullType) String() string { + if i < 0 || i >= PullType(len(_PullTypeIndex)-1) { + return fmt.Sprintf("PullType(%d)", i) + } + return _PullTypeName[_PullTypeIndex[i]:_PullTypeIndex[i+1]] +} + +var _PullTypeValues = []PullType{0, 1, 2, 3, 4, 5, 6, 7} + +var _PullTypeNameToValueMap = map[string]PullType{ + _PullTypeName[0:9]: 0, + _PullTypeName[9:21]: 1, + _PullTypeName[21:28]: 2, + _PullTypeName[28:41]: 3, + _PullTypeName[41:49]: 4, + _PullTypeName[49:51]: 5, + _PullTypeName[51:59]: 6, + _PullTypeName[59:62]: 7, +} + +// PullTypeString retrieves an enum value from the enum constants string name. +// Throws an error if the param is not part of the enum. +func PullTypeString(s string) (PullType, error) { + if val, ok := _PullTypeNameToValueMap[s]; ok { + return val, nil + } + return 0, fmt.Errorf("%s does not belong to PullType values", s) +} + +// PullTypeValues returns all values of the enum +func PullTypeValues() []PullType { + return _PullTypeValues +} + +// IsAPullType returns "true" if the value is listed in the enum definition. "false" otherwise +func (i PullType) IsAPullType() bool { + for _, v := range _PullTypeValues { + if i == v { + return true + } + } + return false +} + +// MarshalText implements the encoding.TextMarshaler interface for PullType +func (i PullType) MarshalText() ([]byte, error) { + return []byte(i.String()), nil +} + +// UnmarshalText implements the encoding.TextUnmarshaler interface for PullType +func (i *PullType) UnmarshalText(text []byte) error { + var err error + *i, err = PullTypeString(string(text)) + return err +} diff --git a/cmd/relnot/type_test.go b/cmd/relnot/type_test.go new file mode 100644 index 000000000000..bec75af3bb55 --- /dev/null +++ b/cmd/relnot/type_test.go @@ -0,0 +1,15 @@ +package main + +import ( + "testing" +) + +func TestPullTypeString(t *testing.T) { + typ, err := PullTypeString("epic-feature") + if err != nil { + t.Fatalf("got unexpected err: %v", err) + } + if typ != EpicFeature { + t.Errorf("got unexpected pull type: %T %v", typ, typ) + } +}