diff --git a/main.go b/main.go index 99f3d0d..5d6e260 100644 --- a/main.go +++ b/main.go @@ -1,141 +1,432 @@ +// Copyright (C) 2023 Lee Wannacott +// See end of file for extended copyright information. + package main import ( "bufio" "bytes" "encoding/json" + "flag" "fmt" "io/ioutil" "log" "net/http" + "net/url" "os" + "path/filepath" "regexp" + "runtime" + "strings" + "sync" + "time" ) -type Tag struct { - Name string `json:"name"` - ZipballURL string `json:"zipball_url"` - TarballURL string `json:"tarball_url"` +type tag struct { + Name string `json:"name"` } -func getTagsFromApi(owner string, repo string) []Tag { - // https://docs.github.com/en/rest/reference/repos#list-repository-tags - pathToTags := fmt.Sprintf("https://api.github.com/repos/%v/%v/tags", owner, repo) - resp, err := http.Get(pathToTags) +type releaseForUpdate struct { + ID int `json:"id"` + Name string `json:"name"` + Body string `json:"body"` + URL string `json:"url"` + TagName string `json:"tag_name"` +} + +type changeLogVersion struct { + lineNumber int + title string + number string +} + +type releaseMetaData struct { + TagVersionReleaseBodyMap map[string]string + ReleaseVersionTagMap map[string]string + ReleaseVersionTitleMap map[string]string + TagReleaseVersionMap map[string]string + LatestReleaseVersion string +} + +type changeLog struct { + changeLogText []string + changeLogLength int + versions []changeLogVersion +} + +type releaseTagValidationInput struct { + authToken string + tags []tag + changeLog changeLog + releaseNotes []string + repoPath string +} + +type releaseRequest struct { + authToken string + repoPath string + requestType string + urlWithID string + tagForRelease string + versionTitle string + releaseNote string + makeLatestRelease string +} + +// Syntax highlighting for CLI warning messages. +var redColor = "\033[31m" +var resetColor = "\033[0m" + +func main() { + start := time.Now() + help, authTokenPtr, repoPtr, tagsRepoPtr := parseFlags() + displayHelp(help) + pathToChangeLog := getChangeLogPath() + file, err := os.Open(pathToChangeLog) if err != nil { - log.Fatalln(err) + log.Fatal(err) + } + defer file.Close() + changeLog := getChangeLogInfo(bufio.NewScanner(file)) + releaseNotes := createReleaseNotes(changeLog) + tags := getTagsFromGitHub(*tagsRepoPtr) + repoPath := *repoPtr + releaseTagValidationInput := releaseTagValidationInput{ + authToken: *authTokenPtr, + tags: tags, + changeLog: changeLog, + releaseNotes: releaseNotes, + repoPath: repoPath, + } + releaseMetaData := validateTagsHaveReleases(releaseTagValidationInput) + createMissingReleases(releaseMetaData, *authTokenPtr, repoPath) + fmt.Println("Created missing releases.") + updateReleasesIfChanged(releaseMetaData, *authTokenPtr, repoPath) + fmt.Println("Updated releases.") + elapsed := time.Since(start) + fmt.Println("Program finished in: ", elapsed) +} + +func parseFlags() (*bool, *string, *string, *string) { + help := flag.Bool("help", false, "Print usage information. Example: $ go run main.go -Repo=leewannacott/quick-lint-js -TagsRepo=quick-lint/quick-lint-js -AuthToken=$(cat token.txt)") + authTokenPtr := flag.String("AuthToken", "", "Visit: (https://github.com/settings/tokens) generate a token with 'public_repo' or 'repo' permissions. Store access token in a file (token.txt). Example usage: -AuthToken=$(cat token.txt)") + repoPtr := flag.String("Repo", "leewannacott/quick-lint-js", "GitHub repo where release notes to be released.") + tagsRepoPtr := flag.String("TagsRepo", "quick-lint/quick-lint-js", "GitHub repo to get release tags from.") + flag.Parse() + if *authTokenPtr == "" { + fmt.Println(redColor + "WARNING: No GitHub access token given for flag -AuthToken. Refer to --help" + resetColor) + } + return help, authTokenPtr, repoPtr, tagsRepoPtr +} + +func displayHelp(help *bool) { + if *help { + fmt.Fprintf(os.Stderr, "Usage: %s [OPTIONS]\n", os.Args[0]) + flag.PrintDefaults() + os.Exit(0) + } +} + +func getChangeLogPath() string { + _, filename, _, _ := runtime.Caller(0) + pathToChangeLog := filepath.Join(filepath.Dir(filename), "docs/CHANGELOG.md") + return pathToChangeLog +} + +func handleChannels(channel chan bool, waitGroup *sync.WaitGroup) { + timeout := time.After(10 * time.Second) + for { + select { + case <-channel: + if len(channel) == 0 { + waitGroup.Wait() + close(channel) + return + } + case <-timeout: + fmt.Println("Timeout: Some goroutines did not complete") + close(channel) + return + } + } +} + +func createMissingReleases(releaseMetaData releaseMetaData, authToken string, repoPath string) { + createReleaseWaitGroup := &sync.WaitGroup{} + createReleaseChannel := make(chan bool, len(releaseMetaData.ReleaseVersionTagMap)) + + spawnedThread := false + makeLatestRelease := "false" + for releaseVersion, tagVersion := range releaseMetaData.ReleaseVersionTagMap { + if releaseMetaData.ReleaseVersionTagMap[releaseVersion] == releaseMetaData.TagReleaseVersionMap[tagVersion] { + if releaseVersion == releaseMetaData.LatestReleaseVersion { + makeLatestRelease = "true" + } else { + makeLatestRelease = "false" + } + repoOwner, repoName := splitAndEncodeURLPath(repoPath) + requestURL := fmt.Sprintf("https://api.github.com/repos/%v/%v/releases", repoOwner, repoName) + postRequest := releaseRequest{ + authToken: authToken, + repoPath: repoPath, + requestType: "POST", + tagForRelease: tagVersion, + versionTitle: releaseMetaData.ReleaseVersionTitleMap[releaseVersion], + releaseNote: releaseMetaData.TagVersionReleaseBodyMap[tagVersion], + makeLatestRelease: makeLatestRelease, + } + createReleaseWaitGroup.Add(1) + go updateOrCreateGitHubRelease(postRequest, requestURL, createReleaseChannel, createReleaseWaitGroup) + spawnedThread = true + } + } + if spawnedThread { + handleChannels(createReleaseChannel, createReleaseWaitGroup) + } else { + close(createReleaseChannel) + } +} + +func updateReleasesIfChanged(releaseMetaData releaseMetaData, authToken string, repoPath string) { + updateReleaseWaitGroup := &sync.WaitGroup{} + updateChannel := make(chan bool, len(releaseMetaData.ReleaseVersionTagMap)) + + repoOwner, repoName := splitAndEncodeURLPath(repoPath) + releases := getReleases(authToken, repoPath) + + spawnedThread := false + for _, release := range releases { + if release.Body != releaseMetaData.TagVersionReleaseBodyMap[release.TagName] { + requestURL := fmt.Sprintf("https://api.github.com/repos/%v/%v/releases/%v", repoOwner, repoName, release.ID) + patchRequest := releaseRequest{ + authToken: authToken, + repoPath: repoPath, + requestType: "PATCH", + tagForRelease: release.TagName, + versionTitle: release.Name, + releaseNote: releaseMetaData.TagVersionReleaseBodyMap[release.TagName], + makeLatestRelease: "false", + } + updateReleaseWaitGroup.Add(1) + go updateOrCreateGitHubRelease(patchRequest, requestURL, updateChannel, updateReleaseWaitGroup) + spawnedThread = true + } + } + if spawnedThread { + handleChannels(updateChannel, updateReleaseWaitGroup) + } else { + close(updateChannel) + } +} + +func validateTagsHaveReleases(releaseTagValidationInput releaseTagValidationInput) releaseMetaData { + releaseMetaData := releaseMetaData{ + TagVersionReleaseBodyMap: make(map[string]string), + ReleaseVersionTagMap: make(map[string]string), + ReleaseVersionTitleMap: make(map[string]string), + TagReleaseVersionMap: make(map[string]string), + LatestReleaseVersion: "", + } + + for i, release := range releaseTagValidationInput.changeLog.versions { + releaseVersionHasTag := false + for _, tagVersion := range releaseTagValidationInput.tags { + if release.number == tagVersion.Name { + releaseVersionHasTag = true + } + } + + if releaseVersionHasTag { + if i == 0 { + releaseMetaData.LatestReleaseVersion = release.number + } + releaseMetaData.ReleaseVersionTitleMap[release.number] = release.title + releaseMetaData.ReleaseVersionTagMap[release.number] = release.number + releaseMetaData.TagVersionReleaseBodyMap[release.number] = releaseTagValidationInput.releaseNotes[i] + } else { + fmt.Println(redColor+"WARNING: release", release, "missing tag"+resetColor) + } + } + + for _, tag := range releaseTagValidationInput.tags { + tagHasVersionNumber := false + for _, release := range releaseTagValidationInput.changeLog.versions { + if tag.Name == release.number { + tagHasVersionNumber = true + } + } + if tagHasVersionNumber { + releaseMetaData.TagReleaseVersionMap[tag.Name] = tag.Name + } else { + fmt.Println(redColor+"WARNING: tag", tag.Name, "missing changelog entry"+resetColor) + } + } + return releaseMetaData +} + +func splitAndEncodeURLPath(urlPath string) (string, string) { + urlPathSplit := strings.Split(urlPath, "/") + owner, name := url.QueryEscape(urlPathSplit[0]), url.QueryEscape(urlPathSplit[1]) + return owner, name +} + +func getReleases(authToken string, repoPath string) []releaseForUpdate { + repoOwner, repoName := splitAndEncodeURLPath(repoPath) + releasePath := fmt.Sprintf("https://api.github.com/repos/%v/%v/releases", repoOwner, repoName) + req, err := http.NewRequest("GET", releasePath, nil) + req.Header.Set("Accept", "application/vnd.github.v3+json") + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "token "+authToken) + resp, err := http.DefaultClient.Do(req) + if err != nil { + log.Fatal(err) } body, err := ioutil.ReadAll(resp.Body) if err != nil { - log.Fatalln(err) + log.Fatal(err) } - sb := string(body) - var tagsForEachRelease []Tag - json.Unmarshal([]byte(sb), &tagsForEachRelease) - return tagsForEachRelease + var eachRelease []releaseForUpdate + err = json.Unmarshal(body, &eachRelease) + if err != nil { + fmt.Println(redColor + string(body) + resetColor) + log.Fatal(err) + } + return eachRelease } -func getChangeLogInfo(scanner *bufio.Scanner) ([]int, []string, int, []string) { - // regexp for: ## 1.0.0 (2021-12-13) - r, err := regexp.Compile(`## \d+\.\d+\.\d+`) - if (err) != nil { +func getTagsFromGitHub(tagsRepoPath string) []tag { + // https://docs.github.com/en/rest/reference/repos#list-repository-tags + tagsRepoOwner, tagsRepoName := splitAndEncodeURLPath(tagsRepoPath) + pathToTags := fmt.Sprintf("https://api.github.com/repos/%v/%v/tags", tagsRepoOwner, tagsRepoName) + resp, err := http.Get(pathToTags) + if err != nil { + log.Fatal(err) + } + body, err := ioutil.ReadAll(resp.Body) + if err != nil { + log.Fatal(err) + } + responseFromAPI := []byte(body) + var tags []tag + err = json.Unmarshal(responseFromAPI, &tags) + if err != nil { log.Fatal(err) } - lineCount := 0 - counterForChangeLogLength := 0 - var versionLineNumbers []int + return tags +} + +func getChangeLogInfo(scanner *bufio.Scanner) changeLog { + + titleAndVersionRE := regexp.MustCompile(`##\s*(?P