package gitea import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "net/http" "os/exec" "regexp" "strings" "text/template" "time" "github.com/go-git/go-git/v5" gitconfig "github.com/go-git/go-git/v5/config" "github.com/go-git/go-git/v5/plumbing" "github.com/go-git/go-git/v5/plumbing/object" httpauth "github.com/go-git/go-git/v5/plumbing/transport/http" "go.unistack.org/micro/v3/logger" "go.unistack.org/pkgdash/internal/configcli" "go.unistack.org/pkgdash/internal/modules" //gogit "go.unistack.org/pkgdash/internal/source/git" ) var ErrPRNotExist = errors.New("pull request does not exist") type Gitea struct { logger logger.Logger URL string Username string Password string PRTitle string PRBody string Repository string Owner string pulls []*giteaPull } func NewGitea(cfg configcli.Config, log logger.Logger) *Gitea { return &Gitea{ logger: log, URL: cfg.Source.APIURL, Username: cfg.Source.Username, Password: cfg.Source.Password, PRTitle: cfg.PullRequestTitle, PRBody: cfg.PullRequestBody, Repository: cfg.Source.Repository, Owner: cfg.Source.Owner, } } type giteaPull struct { URL string `json:"url"` Title string `json:"title"` Base struct { Ref string `json:"ref"` } `json:"base"` Head struct { Ref string `json:"ref"` } `json:"head"` ID int64 `json:"id"` } func (g *Gitea) Name() string { return "gitea" } func (g *Gitea) RequestOpen(ctx context.Context, branch string, path string, mod modules.Update) error { g.logger.Debug(ctx, fmt.Sprintf("RequestOpen start, mod title: %s", path)) var buf []byte var err error // создания шаблона названия для пулл реквеста tplTitle, err := template.New("pull_request_title").Parse(g.PRTitle) if err != nil { g.logger.Fatal(ctx, fmt.Sprintf("failed to parse template: %v", err)) } wTitle := bytes.NewBuffer(nil) // создания шаблона тела для пулл реквеста tplBody, err := template.New("pull_request_body").Parse(g.PRTitle) if err != nil { g.logger.Fatal(ctx, fmt.Sprintf("failed to parse template: %v", err)) } wBody := bytes.NewBuffer(nil) data := map[string]string{ "Name": path, "VersionOld": mod.Module.Version, "VersionNew": mod.Version, } if err = tplTitle.Execute(wTitle, data); err != nil { g.logger.Error(ctx, "failed to execute template", err) return err } if err = tplBody.Execute(wBody, data); err != nil { g.logger.Error(ctx, "failed to execute template", err) return err } // открытие гит репозитория с опцией обхода репозитория для нахождения .git repo, err := git.PlainOpenWithOptions(".", &git.PlainOpenOptions{DetectDotGit: true}) if err != nil { g.logger.Fatal(ctx, fmt.Sprintf("failed to open repo: %v", err)) } wtree, headRef, err := g.fetchCheckout(ctx, repo, branch, path, mod) if err != nil { g.logger.Error(ctx, "failed to checkout", err) } defer func() { _ = g.checkout(wtree, headRef) }() if err = g.scopeUpdateDep(ctx, path, mod); err != nil { return err } g.logger.Info(ctx, "worktree add go.mod") if err = wtree.AddWithOptions(&git.AddOptions{Path: "go.mod"}); err != nil { g.logger.Error(ctx, fmt.Sprintf("failed to add file: %v", err)) return err } g.logger.Info(ctx, "worktree add go.sum") if err = wtree.AddWithOptions(&git.AddOptions{Path: "go.sum"}); err != nil { g.logger.Error(ctx, fmt.Sprintf("failed to add file: %v", err)) return err } g.logger.Info(ctx, "worktree commit") _, err = wtree.Commit(wTitle.String(), &git.CommitOptions{ Parents: []plumbing.Hash{headRef.Hash()}, Author: &object.Signature{ Name: "gitea-actions", Email: "info@unistack.org", When: time.Now(), }, }) // хотим за коммитить изменения if err != nil { g.logger.Error(ctx, fmt.Sprintf("failed to commit: %v", err)) return err } refspec := gitconfig.RefSpec(fmt.Sprintf("+refs/heads/pkgdash/go_modules/%s-%s:refs/heads/pkgdash/go_modules/%s-%s", path, mod.Version, path, mod.Version)) // todo как будто нужно переделать g.logger.Info(ctx, fmt.Sprintf("try to push refspec %s", refspec)) if err = repo.PushContext(ctx, &git.PushOptions{ RefSpecs: []gitconfig.RefSpec{refspec}, Auth: &httpauth.BasicAuth{Username: g.Username, Password: g.Password}, Force: true, }); err != nil { g.logger.Error(ctx, "failed to push repo branch", err) return err } // пытаемся за пушить изменения rsp, err := g.postPullRequest(ctx, wBody, wTitle, branch, path, mod) if err != nil { return err } // Вроде создаем новый реквест на создание пулл реквеста if rsp.StatusCode != http.StatusCreated { buf, _ = io.ReadAll(rsp.Body) return fmt.Errorf("unknown error: %s", buf) } g.logger.Info(ctx, fmt.Sprintf("PR create for %s-%s", path, mod.Version)) return nil } func (g *Gitea) RequestClose(ctx context.Context, branch string, path string) error { g.logger.Debug(ctx, fmt.Sprintf("RequestClose start, mod title: %s", path)) var err error g.pulls, err = g.GetPulls(ctx, g.URL, g.Owner, g.Repository, g.Password) if err != nil { g.logger.Error(ctx, fmt.Sprintf("GetPulls error: %s", err)) return err } prExist := false var b string // Name of the branch to be deleted for _, pull := range g.pulls { if strings.Contains(pull.Title, path) && pull.Base.Ref == branch { g.logger.Info(ctx, fmt.Sprintf("PR for %s exists: %s", path, pull.URL)) prExist = true b = pull.Head.Ref } } if !prExist { g.logger.Error(ctx, fmt.Sprintf("skip %s since pr does not exist", path)) return ErrPRNotExist } req, err := g.DeleteBranch(ctx, g.URL, g.Owner, g.Repository, b, g.Password) if err != nil { g.logger.Error(ctx, fmt.Sprintf("failed to create request for delete the branch: %s, err: %s", branch, err)) return err } rsp, err := http.DefaultClient.Do(req) if err != nil { g.logger.Error(ctx, fmt.Sprintf("failed to do request for delete the branch: %s, err: %s, code: %v", branch, err, rsp.StatusCode)) return err } g.logger.Info(ctx, fmt.Sprintf("Delete branch for %s successful", path)) return nil } func (g *Gitea) RequestUpdate(ctx context.Context, branch string, path string, mod modules.Update) error { g.logger.Debug(ctx, fmt.Sprintf("RequestUpdate start, mod title: %s", path)) var err error g.pulls, err = g.GetPulls(ctx, g.URL, g.Owner, g.Repository, g.Password) if err != nil { g.logger.Error(ctx, fmt.Sprintf("GetPulls error: %s", err)) return err } prExist := false var pullId int64 var targetBranch plumbing.ReferenceName for _, pull := range g.pulls { if strings.Contains(pull.Title, path) && pull.Base.Ref == branch { g.logger.Info(ctx, fmt.Sprintf("don't skip %s since pr exist %s", path, pull.URL)) // todo tVersion := getVersions(pull.Head.Ref) // Надо взять просто из названия ветки последнюю версию if !modules.IsNewerVersion(tVersion, mod.Version, false) { g.logger.Debug(ctx, "The existing PR is relevant") return nil } prExist = true pullId = pull.ID targetBranch = plumbing.ReferenceName(pull.Head.Ref) } } if !prExist { g.logger.Error(ctx, fmt.Sprintf("skip %s since pr does not exist", path)) return ErrPRNotExist } // создания шаблона названия для пулл реквеста tplTitle, err := template.New("pull_request_title").Parse(g.PRTitle) if err != nil { g.logger.Fatal(ctx, fmt.Sprintf("failed to parse template: %v", err)) } wTitle := bytes.NewBuffer(nil) // создания шаблона тела для пулл реквеста tplBody, err := template.New("pull_request_body").Parse(g.PRTitle) if err != nil { g.logger.Fatal(ctx, fmt.Sprintf("failed to parse template: %v", err)) } wBody := bytes.NewBuffer(nil) data := map[string]string{ "Name": path, "VersionOld": mod.Module.Version, "VersionNew": mod.Version, } if err = tplTitle.Execute(wTitle, data); err != nil { g.logger.Error(ctx, "failed to execute template", err) return err } if err = tplBody.Execute(wBody, data); err != nil { g.logger.Error(ctx, "failed to execute template", err) return err } // открытие гит репозитория с опцией обхода репозитория для нахождения .git repo, err := git.PlainOpenWithOptions(".", &git.PlainOpenOptions{DetectDotGit: true}) if err != nil { g.logger.Fatal(ctx, fmt.Sprintf("failed to open repo: %v", err)) } wtree, headRef, err := g.fetchCheckout(ctx, repo, targetBranch.Short(), path, mod) if err != nil { g.logger.Error(ctx, "failed to checkout", err) return err } defer func() { _ = g.checkout(wtree, headRef) }() if err = g.scopeUpdateDep(ctx, path, mod); err != nil { return err } g.logger.Info(ctx, "worktree add go.mod") if err = wtree.AddWithOptions(&git.AddOptions{Path: "go.mod"}); err != nil { g.logger.Error(ctx, fmt.Sprintf("failed to add file: %v", err)) return err } g.logger.Info(ctx, "worktree add go.sum") if err = wtree.AddWithOptions(&git.AddOptions{Path: "go.sum"}); err != nil { g.logger.Error(ctx, fmt.Sprintf("failed to add file: %v", err)) return err } g.logger.Info(ctx, "worktree commit") _, err = wtree.Commit(wTitle.String(), &git.CommitOptions{ Parents: []plumbing.Hash{headRef.Hash()}, Author: &object.Signature{ Name: "gitea-actions", Email: "info@unistack.org", When: time.Now(), }, }) // хотим за коммитить изменения if err != nil { g.logger.Error(ctx, fmt.Sprintf("failed to commit: %v", err)) return err } refspec := gitconfig.RefSpec(fmt.Sprintf("+refs/heads/pkgdash/go_modules/%s-%s:refs/heads/pkgdash/go_modules/%s-%s", path, mod.Version, path, mod.Version)) // todo как будто нужно переделать g.logger.Info(ctx, fmt.Sprintf("try to push refspec %s", refspec)) if err = repo.PushContext(ctx, &git.PushOptions{ //RefSpecs: []gitconfig.RefSpec{refspec}, Auth: &httpauth.BasicAuth{Username: g.Username, Password: g.Password}, Force: true, }); err != nil { g.logger.Error(ctx, "failed to push repo branch", err) return err } // пытаемся за пушить изменения err = g.patchPullRequest(ctx, wBody, wTitle, pullId) if err != nil { return err } g.logger.Info(ctx, fmt.Sprintf("PR update for %s-%s", path, mod.Version)) return nil } func (g *Gitea) RequestList(ctx context.Context, branch string) (map[string]string, error) { g.logger.Debug(ctx, fmt.Sprintf("RequestList for %s", branch)) var err error g.pulls, err = g.GetPulls(ctx, g.URL, g.Owner, g.Repository, g.Password) if err != nil { g.logger.Error(ctx, fmt.Sprintf("GetPulls error: %s", err)) return nil, err } var path string rMap := make(map[string]string) for _, pull := range g.pulls { if !strings.HasPrefix(pull.Title, "Bump ") || pull.Base.Ref != branch { // добавляем только реквесты бота по обновлению модулей continue } path = strings.Split(pull.Title, " ")[1] // todo Работет только для дефолтного шаблона rMap[path] = pull.Title } return rMap, nil } func getVersions(s string) string { re := regexp.MustCompile("[vV][0-9]+\\.[0-9]+\\.[0-9]+") version := re.FindString(s) return version } func (g *Gitea) DeleteBranch(ctx context.Context, url, owner, repo, branch, password string) (*http.Request, error) { var buf []byte req, err := http.NewRequestWithContext(ctx, http.MethodDelete, fmt.Sprintf("https://%s/api/v1/repos/%s/%s/branches/%s", url, owner, repo, branch), bytes.NewReader(buf)) if err != nil { return nil, err } req.Header.Add("Accept", "application/json") req.Header.Add("Content-Type", "application/json") req.Header.Add("Authorization", "Bearer "+password) return req, err } func (g *Gitea) GetPulls(ctx context.Context, url, owner, repo, password string) ([]*giteaPull, error) { var pullsAll []*giteaPull page := 1 for { pulls := make([]*giteaPull, 0, 10) req, err := http.NewRequestWithContext( ctx, http.MethodGet, fmt.Sprintf("https://%s/api/v1/repos/%s/%s/pulls?state=open&page=%v", url, owner, repo, page), nil) if err != nil { return nil, err } // вроде запроса к репозиторию req.Header.Add("Accept", "application/json") req.Header.Add("Content-Type", "application/json") req.Header.Add("Authorization", "Bearer "+password) rsp, err := http.DefaultClient.Do(req) // выполнение запроса if err != nil { return nil, err } buf, _ := io.ReadAll(rsp.Body) switch rsp.StatusCode { case http.StatusOK: if err = json.Unmarshal(buf, &pulls); err != nil { g.logger.Error(ctx, fmt.Sprintf("failed to decode response %s err: %v", buf, err)) return nil, err } pullsAll = append(pullsAll, pulls...) page++ case http.StatusNotFound: g.logger.Info(ctx, fmt.Sprintf("pull-request is not exist for %s", repo)) return nil, ErrPRNotExist default: return nil, fmt.Errorf("unknown error: %s", buf) } if len(pulls) == 0 { break } } return pullsAll, nil } func (g *Gitea) checkout(w *git.Worktree, ref *plumbing.Reference) error { ctx := context.Background() g.logger.Debug(ctx, "checkout: "+ref.String()) if err := w.Checkout(&git.CheckoutOptions{ Branch: ref.Name(), Create: false, Force: true, Keep: false, }); err != nil { g.logger.Error(ctx, "failed to reset", err) return err } return nil } func (g Gitea) fetchCheckout(ctx context.Context, repo *git.Repository, branch, path string, mod modules.Update) (*git.Worktree, *plumbing.Reference, error) { // обновляем ветки if err := repo.FetchContext(ctx, &git.FetchOptions{ Auth: &httpauth.BasicAuth{Username: g.Username, Password: g.Password}, Force: true, }); err != nil && err != git.NoErrAlreadyUpToDate { g.logger.Error(ctx, "failed to fetch repo", err) return nil, nil, err } var headRef *plumbing.Reference targetBranch, err := repo.Branch(plumbing.NewBranchReferenceName(branch).String()) if err != nil { g.logger.Error(ctx, "cant get repo branch", err) return nil, nil, err } g.logger.Info(ctx, "targetBranch: ", targetBranch) branches, err := repo.Branches() if err != nil { g.logger.Error(ctx, "cant get repo branch", err) return nil, nil, err } for { ref, err := branches.Next() if err != nil { return nil, nil, err } if ref.Name().Short() == branch { //Получаем ссылку на нужную ветку headRef = ref g.logger.Info(ctx, "headRef set to "+headRef.String()) break } } if headRef == nil { g.logger.Error(ctx, "failed to get repo branch head") return nil, nil, err } // Не получили нужную ветку g.logger.Info(ctx, "repo head "+headRef.String()) rev, err := repo.ResolveRevision(plumbing.Revision(fmt.Sprintf("remotes/%s/%s", targetBranch.Remote, headRef.Name().Short()))) if err != nil { g.logger.Error(ctx, "resolve revision error: ", err) return nil, nil, err } wtree, err := repo.Worktree() if err != nil { g.logger.Error(ctx, "failed to get worktree", err) return nil, nil, err } if err = wtree.Reset(&git.ResetOptions{ Commit: *rev, Mode: git.HardReset, }); err != nil { g.logger.Error(ctx, "reset work_tree error: ", err) return nil, nil, err } g.pulls, err = g.GetPulls(ctx, g.URL, g.Owner, g.Repository, g.Password) if err != nil && err != ErrPRNotExist { g.logger.Error(ctx, "GetPulls error", err) return nil, nil, err } var pullExist bool for _, pull := range g.pulls { if strings.Contains(pull.Title, path) && (strings.Contains(pull.Base.Ref, branch) || strings.Contains(pull.Head.Ref, branch)) { pullExist = true } // хотим проверить есть ли пулл реквест для этой ветки, если есть то выходим } g.logger.Info(ctx, fmt.Sprintf("update %s from %s to %s", path, mod.Module.Version, mod.Version)) wstatus, err := wtree.Status() if err != nil { g.logger.Error(ctx, "failed to get worktree status", err) return nil, nil, err } g.logger.Info(ctx, "worktree status "+wstatus.String()) if err = wtree.PullContext(ctx, &git.PullOptions{ Auth: &httpauth.BasicAuth{Username: g.Username, Password: g.Password}, // Depth: 1, // RemoteURL : ReferenceName: headRef.Name(), Force: true, RemoteName: "origin", }); err != nil && err != git.NoErrAlreadyUpToDate { g.logger.Error(ctx, fmt.Sprintf("failed to pull repo: %v", err)) // подтягиваем изменения с удаленого репозитория return nil, nil, err } g.logger.Info(ctx, fmt.Sprintf("checkout ref %s", headRef)) if pullExist { if err = wtree.Checkout(&git.CheckoutOptions{ Branch: headRef.Name(), Create: false, Force: true, }); err != nil && err != git.ErrBranchExists { g.logger.Error(ctx, fmt.Sprintf("failed to checkout tree: %v", err)) return nil, nil, err } //переходим на существующею } else { if err = wtree.Checkout(&git.CheckoutOptions{ Hash: headRef.Hash(), Branch: plumbing.NewBranchReferenceName(fmt.Sprintf("pkgdash/go_modules/%s-%s", path, mod.Version)), Create: true, Force: true, }); err != nil && err != git.ErrBranchExists { g.logger.Error(ctx, fmt.Sprintf("failed to checkout tree: %v", err)) return nil, nil, err } // создаем новую ветку } return wtree, headRef, nil } func (g *Gitea) postPullRequest(ctx context.Context, wBody, wTitle *bytes.Buffer, branch, path string, mod modules.Update) (*http.Response, error) { body := map[string]string{ "base": branch, "body": wBody.String(), "head": fmt.Sprintf("pkgdash/go_modules/%s-%s", path, mod.Version), "title": wTitle.String(), } g.logger.Info(ctx, fmt.Sprintf("raw body: %#+v", body)) buf, err := json.Marshal(body) if err != nil { g.logger.Error(ctx, "failed to marshal", err) return nil, err } g.logger.Info(ctx, fmt.Sprintf("marshal body: %s", buf)) req, err := http.NewRequestWithContext( ctx, http.MethodPost, fmt.Sprintf("https://%s/api/v1/repos/%s/%s/pulls", g.URL, g.Owner, g.Repository), bytes.NewReader(buf), ) if err != nil { g.logger.Error(ctx, "http request error", err) return nil, err } req.Header.Add("Accept", "application/json") req.Header.Add("Content-Type", "application/json") req.Header.Add("Authorization", "Bearer "+g.Password) rsp, err := http.DefaultClient.Do(req) if err != nil { g.logger.Error(ctx, "failed to call http request", err) return rsp, err } return rsp, nil } func (g *Gitea) patchPullRequest(ctx context.Context, wBody, wTitle *bytes.Buffer, indexPR int64) error { body := map[string]string{ "body": wBody.String(), "title": wTitle.String(), } g.logger.Info(ctx, fmt.Sprintf("raw body: %#+v", body)) buf, err := json.Marshal(body) if err != nil { g.logger.Error(ctx, "failed to marshal", err) return err } g.logger.Info(ctx, fmt.Sprintf("marshal body: %s", buf)) req, err := http.NewRequestWithContext( ctx, http.MethodPatch, fmt.Sprintf("https://%s/api/v1/repos/%s/%s/pulls/%d", g.URL, g.Owner, g.Repository, indexPR), bytes.NewReader(buf), ) if err != nil { g.logger.Error(ctx, "http request error", err) return err } req.Header.Add("Accept", "application/json") req.Header.Add("Content-Type", "application/json") req.Header.Add("Authorization", "Bearer "+g.Password) _, err = http.DefaultClient.Do(req) if err != nil { g.logger.Error(ctx, "failed to call http request", err) return err } return nil } func (g *Gitea) scopeUpdateDep(ctx context.Context, path string, mod modules.Update) error { epath, err := exec.LookPath("go") if errors.Is(err, exec.ErrDot) { err = nil } if err != nil { g.logger.Fatal(ctx, fmt.Sprintf("failed to find go command: %v", err)) } // ищем go файл var cmd *exec.Cmd var out []byte cmd = exec.CommandContext(ctx, epath, "mod", "edit", fmt.Sprintf("-droprequire=%s", mod.Module.Path)) if out, err = cmd.CombinedOutput(); err != nil { g.logger.Error(ctx, fmt.Sprintf("failed to run go mod edit: %s err: %v", out, err)) return err } cmd = exec.CommandContext(ctx, epath, "mod", "edit", fmt.Sprintf("-require=%s@%s", path, mod.Version)) if out, err = cmd.CombinedOutput(); err != nil { g.logger.Error(ctx, fmt.Sprintf("failed to run go mod edit: %s err: %v", out, err)) return err } // пытаемся выполнить команду go mod edit с новой версией модуля cmd = exec.CommandContext(ctx, epath, "mod", "tidy") if out, err = cmd.CombinedOutput(); err != nil { g.logger.Error(ctx, fmt.Sprintf("failed to run go mod tidy: %s err: %v", out, err)) return err } // пытаемся выполнить команду go mod tidy пытаемся подтянуть новую версию модуля return nil }