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:
139
src/action.go
Normal file
139
src/action.go
Normal 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
45
src/changelog.go
Normal 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
49
src/config/action.go
Normal 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
63
src/config/repo.go
Normal 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
95
src/config/repo_test.go
Normal 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
66
src/gitea.go
Normal 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
64
src/version.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user