Skip to content
Snippets Groups Projects
Forked from GitLab.org / gitlab-runner
8115 commits behind the upstream repository.
main.go 11.81 KiB
package main

import (
	"bufio"
	"bytes"
	"encoding/json"
	"flag"
	"fmt"
	"io/ioutil"
	"net/http"
	"net/url"
	"os"
	"regexp"
	"strconv"
	"strings"
	"text/template"
	"time"

	"gopkg.in/yaml.v2"
)

type ReleaseMetadata struct {
	Major                   int
	Minor                   int
	ReleaseManagerHandle    string
	ReleaseBlogPostMR       int
	ReleaseBlogPostDeadline string
	HelmChartMajor          int
	HelmChartMinor          int
}

const (
	GitLabRunnerProjectID          = "gitlab-org/gitlab-runner"
	GitLabRunnerHelmChartProjectID = "charts/gitlab-runner"
	WWWGitlabComProjectID          = "gitlab-com/www-gitlab-com"

	ReleasePostLabel = "release post"

	ReleaseManagerHandleEnvVariable = "GITLAB_RUNNER_RELEASE_MANAGER_HANDLE"

	VersionFile          = "./VERSION"
	HelmChartVersionFile = "Chart.yaml"

	LayoutDay = "2006-01-02"
)

var (
	reader          *bufio.Reader
	releaseMetadata ReleaseMetadata

	detectedVersion          []string
	detectedMergeRequest     []string
	detectedHelmChartVersion []string

	templateFilePath = flag.String("issue-template-file", ".gitlab/issue_templates/Release Checklist.md", "Path to a file with issue template")

	dryRun        = flag.Bool("dry-run", false, "Show issue content instead of creating it in GitLab")
	noInteractive = flag.Bool("no-interactive", false, "Don't ask, just try to work!")

	major                   = flag.String("major", detectVersion()[0], "Major version number")
	minor                   = flag.String("minor", detectVersion()[1], "Minor version number")
	releaseManagerHandle    = flag.String("release-manager-handle", defaultReleaseManagerHandle(), "GitLab.com handle of the release manager")
	releaseBlogPostMR       = flag.String("release-blog-post-mr", detectBlogPostMergeRequest()[0], "ID of the Release Blog Post MR")
	releaseBlogPostDeadline = flag.String("release-blog-post-deadline", detectReleaseMergeRequestDeadline(), "Deadline for adding Runner specific content to the Release Blog Post")

	helmChartMajor = flag.String("helm-chart-major", detectHelmChartVersion()[0], "Major version number of GitLab Runner Helm Chart")
	helmChartMinor = flag.String("helm-chart-minor", detectHelmChartVersion()[1], "Minor version number of GitLab Runner Helm Chart")
)

func detectVersion() []string {
	if len(detectedVersion) > 0 {
		return detectedVersion
	}

	fmt.Println("Auto-detecting version...")

	content, err := ioutil.ReadFile(VersionFile)
	if err != nil {
		fmt.Printf("Error while reading version file %q: %v", VersionFile, err)

		return []string{"", ""}
	}

	fmt.Printf("Found: %s\n", content)

	detectedVersion = strings.Split(string(content), ".")

	return detectedVersion
}

type HelmChartData struct {
	Version string `yaml:"version"`
}

func detectHelmChartVersion() []string {
	if len(detectedHelmChartVersion) > 0 {
		return detectedHelmChartVersion
	}

	fmt.Println("Auto-detecting Helm Chart version...")

	charVersionFileURL := fmt.Sprintf("https://gitlab.com/%s/raw/master/%s", GitLabRunnerHelmChartProjectID, HelmChartVersionFile)
	req, err := http.NewRequest(http.MethodGet, charVersionFileURL, nil)
	if err != nil {
		panic(fmt.Errorf("error while creating helm chart version detection request: %v", err))
	}

	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		panic(fmt.Errorf("error while requesting helm chart version: %v", err))
	}

	defer resp.Body.Close()
	content, err := ioutil.ReadAll(resp.Body)
	if err != nil {
		panic(fmt.Errorf("error while reading helm chart version response: %v", err))
	}

	helmChartData := new(HelmChartData)
	err = yaml.Unmarshal(content, helmChartData)
	if err != nil {
		fmt.Printf("Error while parsing Helm Chart version file: %v", err)

		return []string{"", "", ""}
	}

	fmt.Printf("Found: %s\n", helmChartData.Version)

	versionRx := regexp.MustCompile(`([0-9]+)\.([0-9]+)\.([0-9]+)`)
	versions := versionRx.FindAllStringSubmatch(helmChartData.Version, -1)

	if len(versions) != 1 {
		fmt.Printf("Couldn't parse the version string")

		return []string{"", "", ""}
	}

	detectedHelmChartVersion = []string{
		versions[0][1],
		versions[0][2],
		versions[0][3],
	}

	return detectedHelmChartVersion
}

func defaultReleaseManagerHandle() string {
	fmt.Println("Auto-detecting Release Manager handle...")

	handle := os.Getenv(ReleaseManagerHandleEnvVariable)
	fmt.Printf("Found: %s\n", handle)

	return handle
}

type listMergeRequestsResponse []listMergeRequestsResponseEntry

type listMergeRequestsResponseEntry struct {
	ID          int    `json:"iid"`
	WebURL      string `json:"web_url"`
	Title       string `json:"title"`
	Description string `json:"description"`
}

func detectBlogPostMergeRequest() []string {
	if len(detectedMergeRequest) > 0 {
		return detectedMergeRequest
	}

	fmt.Println("Auto-detecting Release Post merge request...")

	version := detectVersion()

	mergeRequests := listBlogPostMergeRequests(version)
	if mergeRequests == nil {
		return []string{"", ""}
	}

	printEntry := func(entry listMergeRequestsResponseEntry) {
		fmt.Printf("\t%-40q %s\n", entry.Title, entry.WebURL)
	}

	fmt.Println("Found following www-gitlab-com merge requests:")
	for _, entry := range mergeRequests {
		printEntry(entry)
	}

	for _, chosen := range mergeRequests {
		r := regexp.MustCompile("gitlab.com/gitlab-com/www-gitlab-com/blob/release-\\d+-\\d+/data/release_posts/(\\d+)_(\\d+)_(\\d+)_gitlab_\\d+_\\d+_released.yml")
		dateParts := r.FindStringSubmatch(chosen.Description)

		if len(dateParts) < 1 {
			continue
		}

		fmt.Println("Choosing:")
		printEntry(chosen)

		detectedMergeRequest = []string{
			strconv.Itoa(chosen.ID),
			fmt.Sprintf("%s-%s-%s", dateParts[1], dateParts[2], dateParts[3]),
		}

		return detectedMergeRequest
	}

	fmt.Println("Release Post merge request was not auto-detected. Please enter the ID manually")

	return []string{"", ""}
}

func listBlogPostMergeRequests(version []string) listMergeRequestsResponse {
	q := url.Values{}
	q.Add("labels", ReleasePostLabel)
	q.Add("state", "opened")
	q.Add("milestone", fmt.Sprintf("%s.%s", version[0], version[1]))

	rawURL := fmt.Sprintf("https://gitlab.com/api/v4/projects/%s/merge_requests?%s", url.QueryEscape(WWWGitlabComProjectID), q.Encode())

	findMergeRequestURL, err := url.Parse(rawURL)
	if err != nil {
		fmt.Printf("Error while parsing findMergeRequestURL: %v", err)

		return nil
	}

	req, err := http.NewRequest(http.MethodGet, findMergeRequestURL.String(), nil)
	if err != nil {
		fmt.Printf("Error while creating HTTP Request: %v", err)

		return nil
	}

	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		fmt.Printf("Error while requesting API endpoint: %v", err)

		return nil
	}

	defer resp.Body.Close()
	body, err := ioutil.ReadAll(resp.Body)
	if err != nil {
		fmt.Printf("Error while reading response body: %v", err)

		return nil
	}

	var response listMergeRequestsResponse

	err = json.Unmarshal(body, &response)
	if err != nil {
		fmt.Printf("Error while parsing response JSON: %v", err)

		return nil
	}

	return response
}

func detectReleaseMergeRequestDeadline() string {
	fmt.Println("Auto-detecting Release Post entry deadline...")

	offsetMap := map[time.Weekday]int{
		time.Monday:    -11,
		time.Tuesday:   -11,
		time.Wednesday: -9,
		time.Thursday:  -9,
		time.Friday:    -9,
		time.Saturday:  -9,
		time.Sunday:    -10,
	}

	date := detectBlogPostMergeRequest()[1]
	if len(date) < 1 {
		fmt.Println("Could not detect the date of Release...")

		return ""
	}
	releaseDate, err := time.Parse(LayoutDay, date)
	if err != nil {
		fmt.Printf("Could not parse detected date %q: %v", date, err)

		return ""
	}

	offset := offsetMap[releaseDate.Weekday()]

	deadlineTime := releaseDate.Add(time.Duration(24*offset) * time.Hour)
	deadline := deadlineTime.Format(LayoutDay)

	fmt.Printf("Decided to use %q. Please adjust if required!\n", deadline)

	return deadline
}

func main() {
	fmt.Println()
	fmt.Println("\nGitLab Runner release checklist issue generator")
	fmt.Println()

	flag.Usage = func() {
		fmt.Fprintf(os.Stderr, "Usage of %s:\n", os.Args[0])
		fmt.Fprintf(os.Stderr, "\n  %s [OPTIONS]\n\nOptions:\n", os.Args[0])
		flag.PrintDefaults()
	}
	flag.Parse()

	if *noInteractive {
		fmt.Println("Running in non-interactive mode.")
	}
	prepareMetadata()

	content := prepareIssueContent()
	title := prepareIssueTitle()

	if *dryRun {
		fmt.Println("Running in dry-run mode. No real changes will be done")
		printIssue(title, content)
	} else {
		fmt.Println("Running in standard mode. A new issue will be created")
		postIssue(title, content)
	}
}

func prepareMetadata() {
	var err error

	askOnce("Major version number", major)
	releaseMetadata.Major, err = strconv.Atoi(*major)
	if err != nil {
		panic(err)
	}

	askOnce("Minor version number", minor)
	releaseMetadata.Minor, err = strconv.Atoi(*minor)
	if err != nil {
		panic(err)
	}

	askOnce("Helm Chart Major version number", helmChartMajor)
	releaseMetadata.HelmChartMajor, err = strconv.Atoi(*helmChartMajor)
	if err != nil {
		panic(err)
	}

	askOnce("Helm Chart Minor version number", helmChartMinor)
	releaseMetadata.HelmChartMinor, err = strconv.Atoi(*helmChartMinor)
	if err != nil {
		panic(err)
	}

	askOnce("GitLab.com handle of the release manager", releaseManagerHandle)
	releaseMetadata.ReleaseManagerHandle = *releaseManagerHandle

	askOnce("ID of the Release Blog Post MR", releaseBlogPostMR)
	releaseMetadata.ReleaseBlogPostMR, err = strconv.Atoi(*releaseBlogPostMR)
	if err != nil {
		panic(err)
	}

	askOnce("Deadline for adding Runner specific content to the Release Blog Post", releaseBlogPostDeadline)
	releaseMetadata.ReleaseBlogPostDeadline = *releaseBlogPostDeadline
}

func askOnce(prompt string, result *string) {
	fmt.Printf("%s [%s]: ", prompt, *result)
	if *noInteractive {
		fmt.Println()

		return
	}

	if reader == nil {
		reader = bufio.NewReader(os.Stdin)
	}

	data, _, err := reader.ReadLine()
	if err != nil {
		panic(err)
	}

	newResult := string(data)
	newResult = strings.TrimSpace(newResult)

	if newResult != "" {
		*result = newResult
	}

	if *result == "" {
		panic("Can't be left empty!")
	}
}

func prepareIssueContent() string {
	data, err := ioutil.ReadFile(*templateFilePath)
	if err != nil {
		panic(err)
	}

	tpl := template.New("release-issue")
	tpl.Funcs(template.FuncMap{
		"inc": func(i int) int {
			return i + 1
		},
		"dec": func(i int) int {
			return i - 1
		},
	})

	tpl, err = tpl.Parse(string(data))
	if err != nil {
		panic(err)
	}

	var output []byte
	buffer := bytes.NewBuffer(output)
	err = tpl.Execute(buffer, releaseMetadata)
	if err != nil {
		panic(err)
	}

	return buffer.String()
}

func prepareIssueTitle() string {
	return fmt.Sprintf("GitLab Runner %d.%d release checklist", releaseMetadata.Major, releaseMetadata.Minor)
}

func printIssue(title, content string) {
	fmt.Println()
	fmt.Println("====================================")
	fmt.Printf("  Title: %s\n", title)
	fmt.Printf("Content:\n\n%s\n", content)
	fmt.Println("====================================")
	fmt.Println()
}

type createIssueOptions struct {
	Title       string `json:"title"`
	Description string `json:"description"`
}

type createIssueResponse struct {
	WebURL string `json:"web_url"`
}

func postIssue(title, content string) {
	newIssueURL := fmt.Sprintf("https://gitlab.com/api/v4/projects/%s/issues", url.QueryEscape(GitLabRunnerProjectID))

	options := &createIssueOptions{
		Title:       title,
		Description: content,
	}

	jsonBody, err := json.Marshal(options)
	if err != nil {
		panic(err)
	}

	req, err := http.NewRequest(http.MethodPost, newIssueURL, bytes.NewBuffer(jsonBody))
	if err != nil {
		panic(err)
	}

	token := os.Getenv("GITLAB_API_PRIVATE_TOKEN")
	req.Header.Set("Private-Token", token)
	req.Header.Set("Content-Type", "application/json")

	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		panic(err)
	}

	defer resp.Body.Close()
	body, err := ioutil.ReadAll(resp.Body)
	if err != nil {
		panic(err)
	}

	var response createIssueResponse
	err = json.Unmarshal(body, &response)
	if err != nil {
		panic(err)
	}

	fmt.Printf("Created new issue: %s", response.WebURL)
}