forked from misaka00251/go2spec
493 lines
12 KiB
Go
493 lines
12 KiB
Go
package main
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"errors"
|
|
"flag"
|
|
"fmt"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"github.com/mattn/go-isatty"
|
|
"github.com/sandrolain/httpcache"
|
|
"golang.org/x/mod/semver"
|
|
)
|
|
|
|
type specInfo struct {
|
|
FilePath string
|
|
GoImportPath string
|
|
Version string
|
|
Name string
|
|
Source0 string
|
|
LatestVersion string
|
|
HasUpdate bool
|
|
Error string
|
|
}
|
|
|
|
func parseSpecFile(path string) (*specInfo, error) {
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer f.Close()
|
|
|
|
info := &specInfo{FilePath: path}
|
|
scanner := bufio.NewScanner(f)
|
|
for scanner.Scan() {
|
|
line := strings.TrimSpace(scanner.Text())
|
|
if strings.HasPrefix(line, "#") {
|
|
continue
|
|
}
|
|
if strings.HasPrefix(line, "%define go_import_path") || strings.HasPrefix(line, "%global go_import_path") {
|
|
parts := strings.Fields(line)
|
|
if len(parts) >= 3 {
|
|
info.GoImportPath = parts[2]
|
|
}
|
|
}
|
|
if strings.HasPrefix(line, "%define _name") || strings.HasPrefix(line, "%global _name") {
|
|
parts := strings.Fields(line)
|
|
if len(parts) >= 3 {
|
|
info.Name = parts[2]
|
|
}
|
|
}
|
|
if strings.HasPrefix(line, "Version:") {
|
|
parts := strings.Fields(line)
|
|
if len(parts) >= 2 {
|
|
info.Version = parts[1]
|
|
}
|
|
}
|
|
if strings.HasPrefix(line, "Source0:") {
|
|
parts := strings.Fields(line)
|
|
if len(parts) >= 2 {
|
|
info.Source0 = parts[1]
|
|
}
|
|
}
|
|
}
|
|
return info, scanner.Err()
|
|
}
|
|
|
|
func normalizeVersion(v string) string {
|
|
v = strings.TrimSpace(v)
|
|
if !strings.HasPrefix(v, "v") {
|
|
v = "v" + v
|
|
}
|
|
if semver.IsValid(v) {
|
|
return v
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func checkLatestVersion(ctx context.Context, spec *specInfo) {
|
|
if spec.GoImportPath == "" {
|
|
spec.Error = "no go_import_path"
|
|
return
|
|
}
|
|
info, err := getPkgsiteInfo(ctx, spec.GoImportPath)
|
|
if err != nil {
|
|
spec.Error = fmt.Sprintf("pkgsite error: %v", err)
|
|
return
|
|
}
|
|
latestPkgVer := info.Package.Version
|
|
latestModVer := info.Module.Version
|
|
latestVer := latestPkgVer
|
|
if latestModVer != "" && (latestVer == "" || semver.Compare(normalizeVersion(latestModVer), normalizeVersion(latestVer)) > 0) {
|
|
latestVer = latestModVer
|
|
}
|
|
spec.LatestVersion = latestVer
|
|
currentNorm := normalizeVersion(spec.Version)
|
|
latestNorm := normalizeVersion(latestVer)
|
|
if currentNorm == "" || latestNorm == "" {
|
|
spec.HasUpdate = false
|
|
return
|
|
}
|
|
spec.HasUpdate = semver.Compare(latestNorm, currentNorm) > 0
|
|
}
|
|
|
|
func scanSpecDir(specDir string) ([]*specInfo, error) {
|
|
entries, err := os.ReadDir(specDir)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var specs []*specInfo
|
|
for _, entry := range entries {
|
|
if !entry.IsDir() || !strings.HasPrefix(entry.Name(), "go-") {
|
|
continue
|
|
}
|
|
specFiles, err := filepath.Glob(filepath.Join(specDir, entry.Name(), "*.spec"))
|
|
if err != nil {
|
|
continue
|
|
}
|
|
for _, sf := range specFiles {
|
|
spec, err := parseSpecFile(sf)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "Warning: failed to parse %s: %v\n", sf, err)
|
|
continue
|
|
}
|
|
if spec.GoImportPath != "" {
|
|
specs = append(specs, spec)
|
|
}
|
|
}
|
|
}
|
|
return specs, nil
|
|
}
|
|
|
|
func printBox(title string, lines []string) {
|
|
width := 56
|
|
contentWidth := width - 4
|
|
fmt.Println("+" + strings.Repeat("-", width) + "+")
|
|
if title != "" {
|
|
padding := width - len(title)
|
|
left := padding / 2
|
|
right := padding - left
|
|
fmt.Printf("|%s%s%s|\n", strings.Repeat(" ", left), title, strings.Repeat(" ", right))
|
|
fmt.Println("+" + strings.Repeat("-", width) + "+")
|
|
}
|
|
for _, line := range lines {
|
|
if len(line) > contentWidth {
|
|
line = line[:contentWidth-3] + "..."
|
|
}
|
|
fmt.Printf("| %-*s |\n", contentWidth, line)
|
|
}
|
|
fmt.Println("+" + strings.Repeat("-", width) + "+")
|
|
}
|
|
|
|
type outputMsg struct {
|
|
text string
|
|
}
|
|
|
|
type counters struct {
|
|
scanned int32
|
|
withUpdate int32
|
|
scanErrors int32
|
|
modified int32
|
|
processed int32
|
|
dlErrors int32
|
|
upErrors int32
|
|
total int
|
|
}
|
|
|
|
type outputLoop struct {
|
|
ch chan outputMsg
|
|
counts *counters
|
|
done chan struct{}
|
|
exited chan struct{}
|
|
lastProgress []string
|
|
interactiveTTY bool
|
|
progressShown int
|
|
}
|
|
|
|
func newOutputLoop(c *counters) *outputLoop {
|
|
return &outputLoop{
|
|
ch: make(chan outputMsg, 64),
|
|
counts: c,
|
|
done: make(chan struct{}),
|
|
exited: make(chan struct{}),
|
|
interactiveTTY: isatty.IsTerminal(os.Stdout.Fd()) || isatty.IsCygwinTerminal(os.Stdout.Fd()),
|
|
}
|
|
}
|
|
|
|
func (o *outputLoop) start() {
|
|
go o.loop()
|
|
}
|
|
|
|
func (o *outputLoop) loop() {
|
|
defer close(o.exited)
|
|
ticker := time.NewTicker(200 * time.Millisecond)
|
|
defer ticker.Stop()
|
|
for {
|
|
select {
|
|
case msg := <-o.ch:
|
|
o.printMessage(msg.text)
|
|
case <-ticker.C:
|
|
o.maybePrintProgress()
|
|
case <-o.done:
|
|
for {
|
|
select {
|
|
case msg := <-o.ch:
|
|
o.printMessage(msg.text)
|
|
default:
|
|
o.clearProgress()
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func (o *outputLoop) maybePrintProgress() {
|
|
progress := o.currentProgressLines()
|
|
if sameProgressLines(progress, o.lastProgress) {
|
|
return
|
|
}
|
|
o.lastProgress = append(o.lastProgress[:0], progress...)
|
|
|
|
if o.interactiveTTY {
|
|
o.renderProgress(progress)
|
|
return
|
|
}
|
|
|
|
for _, line := range progress {
|
|
fmt.Println(line)
|
|
}
|
|
}
|
|
|
|
func (o *outputLoop) currentProgressLines() []string {
|
|
c := o.counts
|
|
scanned := atomic.LoadInt32(&c.scanned)
|
|
withUpdate := atomic.LoadInt32(&c.withUpdate)
|
|
scanErrors := atomic.LoadInt32(&c.scanErrors)
|
|
processed := atomic.LoadInt32(&c.processed)
|
|
modified := atomic.LoadInt32(&c.modified)
|
|
dlErrors := atomic.LoadInt32(&c.dlErrors)
|
|
upErrors := atomic.LoadInt32(&c.upErrors)
|
|
|
|
return []string{
|
|
fmt.Sprintf(" [scan] %d/%d updates:%d errors:%d",
|
|
scanned, c.total, withUpdate, scanErrors),
|
|
fmt.Sprintf(" [verify] %d/%d modified:%d errors:%d",
|
|
processed, withUpdate, modified, dlErrors+upErrors),
|
|
}
|
|
}
|
|
|
|
func (o *outputLoop) print(text string) {
|
|
o.ch <- outputMsg{text: text}
|
|
}
|
|
|
|
func (o *outputLoop) stop() {
|
|
close(o.done)
|
|
<-o.exited
|
|
}
|
|
|
|
func (o *outputLoop) printMessage(text string) {
|
|
o.clearProgress()
|
|
fmt.Println(text)
|
|
if o.interactiveTTY {
|
|
progress := o.currentProgressLines()
|
|
o.lastProgress = append(o.lastProgress[:0], progress...)
|
|
o.renderProgress(progress)
|
|
}
|
|
}
|
|
|
|
func (o *outputLoop) clearProgress() {
|
|
if o.interactiveTTY && o.progressShown > 0 {
|
|
fmt.Print("\r\033[2K")
|
|
for i := 1; i < o.progressShown; i++ {
|
|
fmt.Print("\033[1A\r\033[2K")
|
|
}
|
|
o.progressShown = 0
|
|
}
|
|
}
|
|
|
|
func (o *outputLoop) renderProgress(lines []string) {
|
|
o.clearProgress()
|
|
for i, line := range lines {
|
|
if i > 0 {
|
|
fmt.Print("\n")
|
|
}
|
|
fmt.Print(line)
|
|
}
|
|
o.progressShown = len(lines)
|
|
}
|
|
|
|
func sameProgressLines(a, b []string) bool {
|
|
if len(a) != len(b) {
|
|
return false
|
|
}
|
|
for i := range a {
|
|
if a[i] != b[i] {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func mainUpdate(args []string) int {
|
|
fs := flag.NewFlagSet("update", flag.ContinueOnError)
|
|
fs.SetOutput(os.Stderr)
|
|
concurrency := fs.Int("j", 3, "number of concurrent checks")
|
|
dryRun := fs.Bool("n", false, "dry run: detect updates only, do not modify files")
|
|
fs.Usage = func() {
|
|
fmt.Fprintf(fs.Output(), "Usage: %s update [options] <SPECS_DIR>\n\n", os.Args[0])
|
|
fmt.Fprintf(fs.Output(), "Check Go module spec files for updates.\n\n")
|
|
fmt.Fprintf(fs.Output(), "Arguments:\n")
|
|
fmt.Fprintf(fs.Output(), " SPECS_DIR directory containing go-* subdirectories\n\n")
|
|
fmt.Fprintf(fs.Output(), "Options:\n")
|
|
fs.PrintDefaults()
|
|
}
|
|
if err := fs.Parse(args); err != nil {
|
|
if errors.Is(err, flag.ErrHelp) {
|
|
return 0
|
|
}
|
|
return 2
|
|
}
|
|
if *concurrency < 1 {
|
|
*concurrency = 1
|
|
}
|
|
if fs.NArg() != 1 {
|
|
fs.Usage()
|
|
return 1
|
|
}
|
|
|
|
specDir := fs.Arg(0)
|
|
specs, err := scanSpecDir(specDir)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error scanning spec dir: %v\n", err)
|
|
return 1
|
|
}
|
|
if len(specs) == 0 {
|
|
fmt.Fprintf(os.Stderr, "No spec files with go_import_path found in %s\n", specDir)
|
|
return 1
|
|
}
|
|
|
|
pkgsiteHTTPClient = &http.Client{
|
|
Timeout: 30 * time.Second,
|
|
Transport: httpcache.NewMemoryCacheTransport(),
|
|
}
|
|
|
|
ctx := context.Background()
|
|
total := len(specs)
|
|
c := &counters{total: total}
|
|
|
|
out := newOutputLoop(c)
|
|
out.start()
|
|
pkgsiteRetryLog = out.print
|
|
downloadRetryLog = out.print
|
|
|
|
fmt.Printf("Checking %d spec files for updates...\n", total)
|
|
fmt.Printf("Parameters: spec_dir=%s concurrency=%d dry_run=%t\n", specDir, *concurrency, *dryRun)
|
|
|
|
updateCh := make(chan *specInfo, *concurrency)
|
|
|
|
var wg2 sync.WaitGroup
|
|
if !*dryRun {
|
|
for i := 0; i < *concurrency; i++ {
|
|
wg2.Add(1)
|
|
go func() {
|
|
defer wg2.Done()
|
|
for sp := range updateCh {
|
|
err := verifyDownload(ctx, sp)
|
|
if err != nil {
|
|
sp.Error = err.Error()
|
|
out.print(fmt.Sprintf(" FAIL: %s - %v", sp.GoImportPath, err))
|
|
atomic.AddInt32(&c.dlErrors, 1)
|
|
} else {
|
|
newVer := specVersionForVersion(sp.LatestVersion)
|
|
remoteSHA256, err := remoteAssetSHA256(ctx, sp, newVer)
|
|
updateRemoteAsset := true
|
|
if errors.Is(err, errRemoteAssetSkip) {
|
|
out.print(fmt.Sprintf(" SKIP: %s remote asset sha256 - %v", sp.GoImportPath, err))
|
|
updateRemoteAsset = false
|
|
err = nil
|
|
}
|
|
if err != nil {
|
|
sp.Error = err.Error()
|
|
out.print(fmt.Sprintf(" FAIL: %s - %v", sp.GoImportPath, err))
|
|
atomic.AddInt32(&c.upErrors, 1)
|
|
} else if err := updateSpecVersion(sp, newVer, remoteSHA256, updateRemoteAsset); err != nil {
|
|
sp.Error = err.Error()
|
|
out.print(fmt.Sprintf(" FAIL: %s - %v", sp.GoImportPath, err))
|
|
atomic.AddInt32(&c.upErrors, 1)
|
|
} else {
|
|
atomic.AddInt32(&c.modified, 1)
|
|
out.print(fmt.Sprintf(" OK: %s updated to %s", sp.GoImportPath, newVer))
|
|
}
|
|
}
|
|
atomic.AddInt32(&c.processed, 1)
|
|
}
|
|
}()
|
|
}
|
|
}
|
|
|
|
var wg1 sync.WaitGroup
|
|
sem1 := make(chan struct{}, *concurrency)
|
|
|
|
for _, spec := range specs {
|
|
wg1.Add(1)
|
|
sem1 <- struct{}{}
|
|
go func(s *specInfo) {
|
|
defer wg1.Done()
|
|
defer func() { <-sem1 }()
|
|
|
|
checkLatestVersion(ctx, s)
|
|
|
|
atomic.AddInt32(&c.scanned, 1)
|
|
needSend := false
|
|
|
|
if s.Error != "" {
|
|
atomic.AddInt32(&c.scanErrors, 1)
|
|
out.print(fmt.Sprintf(" ERROR: %s - %s", s.GoImportPath, s.Error))
|
|
} else if s.HasUpdate {
|
|
atomic.AddInt32(&c.withUpdate, 1)
|
|
out.print(fmt.Sprintf(" UPDATE: %-50s %s -> %s", s.GoImportPath, s.Version, s.LatestVersion))
|
|
needSend = true
|
|
}
|
|
|
|
if needSend && !*dryRun {
|
|
updateCh <- s
|
|
}
|
|
}(spec)
|
|
}
|
|
|
|
wg1.Wait()
|
|
close(updateCh)
|
|
|
|
if !*dryRun {
|
|
wg2.Wait()
|
|
}
|
|
|
|
pkgsiteRetryLog = nil
|
|
downloadRetryLog = nil
|
|
out.stop()
|
|
|
|
var errSpecs []*specInfo
|
|
for _, s := range specs {
|
|
if s.Error != "" {
|
|
errSpecs = append(errSpecs, s)
|
|
}
|
|
}
|
|
|
|
summaryLines := []string{
|
|
fmt.Sprintf("%-12s %d/%d", "Scanned:", total, total),
|
|
fmt.Sprintf("%-12s %d", "Updates:", atomic.LoadInt32(&c.withUpdate)),
|
|
fmt.Sprintf("%-12s %d", "Modified:", atomic.LoadInt32(&c.modified)),
|
|
}
|
|
if atomic.LoadInt32(&c.scanErrors) > 0 || atomic.LoadInt32(&c.dlErrors) > 0 || atomic.LoadInt32(&c.upErrors) > 0 {
|
|
totalErrors := atomic.LoadInt32(&c.scanErrors) + atomic.LoadInt32(&c.dlErrors) + atomic.LoadInt32(&c.upErrors)
|
|
summaryLines = append(summaryLines, fmt.Sprintf("%-12s %d", "Errors:", totalErrors))
|
|
}
|
|
if *dryRun {
|
|
summaryLines = append(summaryLines, fmt.Sprintf("%-12s %s", "Mode:", "dry run"))
|
|
}
|
|
fmt.Println()
|
|
printBox("SUMMARY", summaryLines)
|
|
|
|
if atomic.LoadInt32(&c.withUpdate) == 0 {
|
|
fmt.Println("\nAll modules are up to date.")
|
|
} else if *dryRun {
|
|
fmt.Println("\nDry run complete. No files were modified.")
|
|
} else {
|
|
fmt.Println()
|
|
printBox("UPDATE RESULT", []string{
|
|
fmt.Sprintf("%-12s %d", "Attempted:", atomic.LoadInt32(&c.withUpdate)),
|
|
fmt.Sprintf("%-12s %d", "Modified:", atomic.LoadInt32(&c.modified)),
|
|
fmt.Sprintf("%-12s %d", "Dl errors:", atomic.LoadInt32(&c.dlErrors)),
|
|
fmt.Sprintf("%-12s %d", "Up errors:", atomic.LoadInt32(&c.upErrors)),
|
|
})
|
|
}
|
|
|
|
if len(errSpecs) > 0 {
|
|
fmt.Printf("\n=== %d modules with errors ===\n", len(errSpecs))
|
|
for _, s := range errSpecs {
|
|
fmt.Printf(" %-50s %s\n", s.GoImportPath, s.Error)
|
|
}
|
|
}
|
|
|
|
return 0
|
|
}
|