1
0
mirror of https://github.com/ChristianSch/gitea-release-drafter.git synced 2026-02-27 02:50:51 +00:00

feat: implemented first version

This commit is contained in:
Christian Schulze
2023-03-05 22:39:55 +01:00
parent 57390b874f
commit 37e0a3c892
12 changed files with 1122 additions and 0 deletions

139
src/action.go Normal file
View File

@@ -0,0 +1,139 @@
package src
import (
"bytes"
"context"
"fmt"
"strings"
"code.gitea.io/sdk/gitea"
"git.andinfinity.de/gitea-release-drafter/src/config"
"github.com/sethvargo/go-githubactions"
)
type Action struct {
config *config.DrafterConfig
globalContext context.Context
client *gitea.Client
}
// NewAction factory for a new action
func NewAction(ctx *context.Context, cfg *config.DrafterConfig) (*Action, error) {
gitea, err := gitea.NewClient(cfg.ApiUrl, gitea.SetToken(cfg.Token))
if err != nil {
return nil, err
}
return &Action{
config: cfg,
globalContext: *ctx,
client: gitea,
}, nil
}
// updateOrCreateDraftRelease
func updateOrCreateDraftRelease(a *Action, cfg *config.RepoConfig) (*gitea.Release, error) {
draft, last, err := FindReleases(a.client, a.config.RepoOwner, a.config.RepoName)
if err != nil {
return nil, err
}
changelog, err := GenerateChangelog(a.client, a.config.RepoOwner, a.config.RepoName, last)
if err != nil {
return nil, err
}
if len(*changelog) == 0 {
githubactions.Infof("No updates found")
return nil, nil
}
// render changelog
var b strings.Builder
b.WriteString("# What's Changed")
b.WriteString("\n\n")
// TODO: group by given label categories in config
// default to dumping everything by date desc.
if changelog != nil {
for label, prs := range *changelog {
if len(prs) > 0 {
// TODO: here we should take the label from the config and only default to the name
fmt.Fprintf(&b, "## %s\n\n", strings.Title(label))
for _, pr := range prs {
fmt.Fprintf(&b, "* %s (#%d) @%s", pr.Title, pr.ID, pr.Poster.UserName)
}
b.WriteString("\n\n")
}
}
}
nextVersion, err := ResolveVersion(cfg, last, changelog)
if err != nil {
return nil, err
}
if draft != nil {
UpdateExistingDraft(a.client, a.config.RepoOwner, a.config.RepoName, draft, nextVersion.String(), b.String())
return draft, nil
}
newDraft, err := CreateDraftRelease(a.client, a.config.RepoOwner, a.config.RepoName, cfg.DefaultBranch, fmt.Sprintf("v%s", nextVersion.String()), b.String())
if err != nil {
return nil, err
}
return newDraft, nil
}
// GetConfigFile reads the local configuration file in `.gitea/` in the ref branch (probably main/master)
func (a *Action) GetConfigFile(ref string) (*bytes.Reader, error) {
data, _, err := a.client.GetFile(a.config.RepoOwner, a.config.RepoName, ref, a.config.ConfigPath)
if err != nil {
return nil, err
}
return bytes.NewReader(data), err
}
// Run builds the configuration and executes the action logic
func (a *Action) Run() error {
// fetch the repo to retrieve the default branch to be set as the config default
repo, err := GetRepo(a.client, a.config.RepoOwner, a.config.RepoName)
if err != nil {
return err
}
githubactions.Debugf("Found default branch %s", repo.DefaultBranch)
// build repo config
configReader, err := a.GetConfigFile(repo.DefaultBranch)
if err != nil && err.Error() != "404 Not Found" {
return err
} else if err.Error() == "404 Not Found" {
// no config file found
githubactions.Warningf("No such config file: .gitea/%s", a.config.ConfigPath)
configReader = bytes.NewReader([]byte{})
}
config, err := config.ReadRepoConfig(configReader, repo.DefaultBranch)
if err != nil {
return err
}
draft, err := updateOrCreateDraftRelease(a, config)
if err != nil {
return err
}
if draft != nil {
githubactions.Infof("created draft release %s", draft.Title)
}
return nil
}

45
src/changelog.go Normal file
View File

@@ -0,0 +1,45 @@
package src
import (
"code.gitea.io/sdk/gitea"
"github.com/sethvargo/go-githubactions"
)
type Changelog map[string][]*gitea.PullRequest
// GenerateChangelog fetches all the pull requests merged into the default branch since the last release and groups them by label. note that duplicates might occur if a pull request has multiple labels.
func GenerateChangelog(c *gitea.Client, owner string, repo string, lastRelease *gitea.Release) (*Changelog, error) {
changelogByLabels := make(Changelog)
// FIXME: use pagination
prs, _, err := c.ListRepoPullRequests(owner, repo, gitea.ListPullRequestsOptions{
State: gitea.StateClosed,
})
if err != nil {
return nil, err
}
for _, pr := range prs {
// only consider merged pull requests. note that we can't filter by that in the API
if pr.HasMerged {
// if there was a release, only take into account pull requests that have been merged after that
if lastRelease == nil || lastRelease != nil && pr.Merged.After(lastRelease.CreatedAt) {
for _, l := range pr.Labels {
_, ok := changelogByLabels[l.Name]
if ok {
changelogByLabels[l.Name] = append(changelogByLabels[l.Name], pr)
} else {
changelogByLabels[l.Name] = []*gitea.PullRequest{pr}
}
}
if len(pr.Labels) == 0 {
githubactions.Warningf("PR #%d doesn't have any labels", pr.ID)
}
}
}
}
return &changelogByLabels, nil
}

49
src/config/action.go Normal file
View File

@@ -0,0 +1,49 @@
package config
import (
"os"
githubactions "github.com/sethvargo/go-githubactions"
)
// DrafterConfig holds all configurations we need for the drafter to run
type DrafterConfig struct {
// RepoOwner as provided by the github context
RepoOwner string
// RepoName as provided by the github context
RepoName string
// ApiUrl of gitea as provided by the "GITHUB_SERVER_URL" env var
ApiUrl string
// Token as provided by the "GITHUB_TOKEN" env var
Token string
// ConfigPath as provided by the "config-path" action input. defaults to ".gitea/release-drafter.yml"
ConfigPath string
}
// NewFromInputs creates a new drafter config by using the action inputs and the github context
func NewFromInputs(action *githubactions.Action) (*DrafterConfig, error) {
actionCtx, err := action.Context()
if err != nil {
return nil, err
}
var configPath string
inputConfigPath := action.GetInput("config-path")
if inputConfigPath == "" {
configPath = ".gitea/release-drafter.yml"
} else {
configPath = inputConfigPath
}
owner, name := actionCtx.Repo()
c := DrafterConfig{
RepoOwner: owner,
RepoName: name,
ApiUrl: actionCtx.ServerURL,
Token: os.Getenv("GITHUB_TOKEN"),
ConfigPath: configPath,
}
return &c, nil
}

63
src/config/repo.go Normal file
View File

@@ -0,0 +1,63 @@
// this file implements configurations for repositories
package config
import (
"io"
"github.com/spf13/viper"
)
// RepoConfig holds all configurations for the repo we're running on
type RepoConfig struct {
// DefaultBranch where we look for the configuration file
DefaultBranch string `mapstructure:"default-branch"`
// NameTemplate template for the release name
NameTemplate string `mapstructure:"name-template"`
// TagTemplate template for the release tag
TagTemplate string `mapstructure:"tag-template"`
VersionResolver struct {
Major struct {
Labels []string
}
Minor struct {
Labels []string
}
Patch struct {
Labels []string
}
Default string
} `mapstructure:"version-resolver"`
}
// ReadRepoConfig reads in the yaml config found in the default branch of the project and adds sensible defaults if values aren't set
func ReadRepoConfig(in io.Reader, defaultBranch string) (*RepoConfig, error) {
vv := viper.New()
vv.SetConfigType("yaml")
err := vv.ReadConfig(in)
if err != nil {
return nil, err
}
// we set defaults here but if they are present in the configuration file they will be overwritten
cfg := &RepoConfig{
DefaultBranch: defaultBranch,
NameTemplate: "v$RESOLVED_VERSION",
TagTemplate: "v$RESOLVED_VERSION",
VersionResolver: struct {
Major struct{ Labels []string }
Minor struct{ Labels []string }
Patch struct{ Labels []string }
Default string
}{
Major: struct{ Labels []string }{[]string{"major"}},
Minor: struct{ Labels []string }{[]string{"minor"}},
Patch: struct{ Labels []string }{[]string{"patch"}},
Default: "minor",
},
}
vv.Unmarshal(&cfg)
return cfg, nil
}

95
src/config/repo_test.go Normal file
View File

@@ -0,0 +1,95 @@
package config
import (
"strings"
"testing"
"github.com/stretchr/testify/assert"
)
func TestValidConfigShouldWork(t *testing.T) {
// Given
// A valid yml config
config := `
name-template: 'v$RESOLVED_VERSION 🌈'
tag-template: 'tag-v$RESOLVED_VERSION'
version-resolver:
major:
labels:
- 'major-test'
minor:
labels:
- 'minor-test'
patch:
labels:
- 'patch-test'
default: 'minor-test'`
in := strings.NewReader(config)
// When
// Reading in the config
cfg, err := ReadRepoConfig(in, "main")
// Then
// No error should've occurred
assert.NoError(t, err)
// The name template should've been read in properly
assert.Equal(t, "v$RESOLVED_VERSION 🌈", cfg.NameTemplate)
// The name template should've been read in properly
assert.Equal(t, "tag-v$RESOLVED_VERSION", cfg.TagTemplate)
// The version resolver major labels should've been read in properly
assert.Len(t, cfg.VersionResolver.Major.Labels, 1)
assert.Contains(t, cfg.VersionResolver.Major.Labels, "major-test")
// The version resolver minor labels should've been read in properly
assert.Len(t, cfg.VersionResolver.Minor.Labels, 1)
assert.Contains(t, cfg.VersionResolver.Minor.Labels, "minor-test")
// The version resolver patch labels should've been read in properly
assert.Len(t, cfg.VersionResolver.Patch.Labels, 1)
assert.Contains(t, cfg.VersionResolver.Patch.Labels, "patch-test")
// The version resolver default should've been read in properly
assert.Equal(t, "minor-test", cfg.VersionResolver.Default)
}
func TestEmptyConfigShouldUseDefaults(t *testing.T) {
// Given
// An empty yml config
config := ``
in := strings.NewReader(config)
// When
// Reading in the config
cfg, err := ReadRepoConfig(in, "main")
// Then
// No error should've occurred
assert.NoError(t, err)
// The name template should've been read in properly
assert.Equal(t, "v$RESOLVED_VERSION", cfg.NameTemplate)
// The name template should've been read in properly
assert.Equal(t, "v$RESOLVED_VERSION", cfg.TagTemplate)
// The version resolver major labels should've been read in properly
assert.Len(t, cfg.VersionResolver.Major.Labels, 1)
assert.Contains(t, cfg.VersionResolver.Major.Labels, "major")
// The version resolver minor labels should've been read in properly
assert.Len(t, cfg.VersionResolver.Minor.Labels, 1)
assert.Contains(t, cfg.VersionResolver.Minor.Labels, "minor")
// The version resolver patch labels should've been read in properly
assert.Len(t, cfg.VersionResolver.Patch.Labels, 1)
assert.Contains(t, cfg.VersionResolver.Patch.Labels, "patch")
// The version resolver default should've been read in properly
assert.Equal(t, "minor", cfg.VersionResolver.Default)
}

66
src/gitea.go Normal file
View File

@@ -0,0 +1,66 @@
package src
import (
"code.gitea.io/sdk/gitea"
)
func GetRepo(c *gitea.Client, owner string, repoName string) (*gitea.Repository, error) {
repo, _, err := c.GetRepo(owner, repoName)
if err != nil {
return nil, err
}
return repo, nil
}
func FindReleases(c *gitea.Client, owner string, repo string) (*gitea.Release, *gitea.Release, error) {
releases, _, err := c.ListReleases(owner, repo, gitea.ListReleasesOptions{})
if err != nil {
return nil, nil, err
}
var mostRecentRelease *gitea.Release
var mostRecentDraftRelease *gitea.Release
for _, r := range releases {
if !r.IsPrerelease { // we don't care for pre-releases atm
if r.IsDraft {
if mostRecentDraftRelease == nil || r.CreatedAt.After(mostRecentDraftRelease.CreatedAt) {
mostRecentDraftRelease = r
}
} else {
if mostRecentRelease == nil || r.CreatedAt.After(mostRecentRelease.CreatedAt) {
mostRecentRelease = r
}
}
}
}
return mostRecentDraftRelease, mostRecentRelease, err
}
func CreateDraftRelease(c *gitea.Client, owner string, repo string, targetBranch string, version string, body string) (*gitea.Release, error) {
release, _, err := c.CreateRelease(owner, repo, gitea.CreateReleaseOption{
TagName: version,
Target: targetBranch,
Title: version,
Note: body,
IsDraft: true,
IsPrerelease: false,
})
if err != nil {
return nil, err
}
return release, err
}
func UpdateExistingDraft(c *gitea.Client, owner string, repo string, draft *gitea.Release, nextVersion string, body string) (*gitea.Release, error) {
c.EditRelease(owner, repo, draft.ID, gitea.EditReleaseOption{
TagName: nextVersion,
Title: nextVersion,
Note: body,
})
return draft, nil
}

64
src/version.go Normal file
View File

@@ -0,0 +1,64 @@
package src
import (
"code.gitea.io/sdk/gitea"
"git.andinfinity.de/gitea-release-drafter/src/config"
"github.com/Masterminds/semver"
"golang.org/x/exp/slices"
)
// ResolveVersion determines the next version to be used for a release depending on the labels used in the pull requests merged after the last release.
func ResolveVersion(cfg *config.RepoConfig, last *gitea.Release, changelog *Changelog) (*semver.Version, error) {
// determine next version
var nextVersion semver.Version
// no prior release, starting with "v0.1.0"
if last == nil {
ver, err := semver.NewVersion("0.1")
if err != nil {
return nil, err
}
nextVersion = *ver
} else {
lastVersion, err := semver.NewVersion(last.TagName) // FIXME: what if it's not the version?
if err != nil {
return nil, err
}
incMajor := false
incMinor := false
incPatch := false
// check labels
for k := range *changelog {
if slices.Contains(cfg.VersionResolver.Major.Labels, k) {
incMajor = true
}
if slices.Contains(cfg.VersionResolver.Minor.Labels, k) {
incMinor = true
}
if slices.Contains(cfg.VersionResolver.Patch.Labels, k) {
incPatch = true
}
}
if incMajor {
nextVersion = lastVersion.IncMajor()
} else if incMinor {
nextVersion = lastVersion.IncMajor()
} else if incPatch {
nextVersion = lastVersion.IncPatch()
} else {
// default
if cfg.VersionResolver.Default == "major" {
nextVersion = lastVersion.IncMajor()
} else if cfg.VersionResolver.Default == "minor" {
nextVersion = lastVersion.IncMinor()
} else {
nextVersion = lastVersion.IncPatch()
}
}
}
return &nextVersion, nil
}