diff --git a/bots-common/associated_pr_scanner.go b/bots-common/associated_pr_scanner.go new file mode 100644 index 0000000..9034aba --- /dev/null +++ b/bots-common/associated_pr_scanner.go @@ -0,0 +1,92 @@ +package common + +import ( + "bufio" + "errors" + "fmt" + "io" + "regexp" + "strconv" + "strings" +) + +const PrPattern = "PR: %s/%s#%d" + +type BasicPR struct { + org, repo string + num uint64 +} + +var validOrgAndRepoRx *regexp.Regexp = regexp.MustCompile("^[A-Za-z0-9_-]+$") + +func parsePrLine(line string) (BasicPR, error) { + var ret BasicPR + trimmedLine := strings.TrimSpace(line) + + // min size > 9 -> must fit all parameters in th PrPattern with at least one item per parameter + if len(trimmedLine) < 9 || trimmedLine[0:4] != "PR: " { + return ret, errors.New("Line too short") + } + + trimmedLine = trimmedLine[4:] + org := strings.SplitN(trimmedLine, "/", 2) + ret.org = org[0] + if len(org) != 2 { + return ret, errors.New("missing / separator") + } + + repo := strings.SplitN(org[1], "#", 2) + ret.repo = repo[0] + if len(repo) != 2 { + return ret, errors.New("Missing # separator") + } + + // Gitea requires that each org and repo be [A-Za-z0-9_-]+ + var err error + if ret.num, err = strconv.ParseUint(repo[1], 10, 64); err != nil { + return ret, errors.New("Invalid number") + } + + if !validOrgAndRepoRx.MatchString(repo[0]) || !validOrgAndRepoRx.MatchString(org[0]) { + return ret, errors.New("Invalid repo or org character set") + } + + return ret, nil +} + +func ExtractAssociatedDescriptionAndPRs(data *bufio.Scanner) (string, []BasicPR) { + prs := make([]BasicPR, 0, 1) + var desc strings.Builder + + for data.Scan() { + line := data.Text() + + pr, err := parsePrLine(line) + if err != nil { + desc.WriteString(line) + desc.WriteByte('\n') + } else { + prs = append(prs, pr) + } + } + + return strings.TrimSpace(desc.String()), prs +} + +func prToLine(writer io.Writer, pr BasicPR) { + fmt.Fprintf(writer, PrPattern, pr.org, pr.repo, pr.num) + writer.Write([]byte("\n")) +} + +func AppendPRsToDescription(desc string, prs []BasicPR) string { + var out strings.Builder + + out.WriteString(strings.TrimSpace(desc)) + out.WriteString("\n\n") + + for _, pr := range prs { + prToLine(&out, pr) + } + + return out.String() +} diff --git a/bots-common/associated_pr_scanner_test.go b/bots-common/associated_pr_scanner_test.go new file mode 100644 index 0000000..bf977e9 --- /dev/null +++ b/bots-common/associated_pr_scanner_test.go @@ -0,0 +1,192 @@ +package common + +import ( + "bufio" + "slices" + "strings" + "testing" +) + +func newStringScanner(s string) *bufio.Scanner { + return bufio.NewScanner(strings.NewReader(s)) +} + +func TestAssociatedPRScanner(t *testing.T) { + t.Run("No PRs", func(t *testing.T) { + if _, out := ExtractAssociatedDescriptionAndPRs(newStringScanner("")); len(out) != 0 { + t.Error("Unexpected output", out) + } + }) + + t.Run("Single PR", func(t *testing.T) { + const singlePRText = `Some header of the issue + +Followed by some description +PR: test/foo#4 +` + _, out := ExtractAssociatedDescriptionAndPRs(newStringScanner(singlePRText)) + if len(out) != 1 { + t.Error("Unexpected output", out) + return + } + + expected := BasicPR{ + org: "test", + repo: "foo", + num: 4, + } + if out[0] != expected { + t.Error("Unexpected", out, "Expected", expected) + } + }) + + t.Run("Multiple PRs", func(t *testing.T) { + const multiplePRText = `Some header of the issue + +Followed by some description +PR: test/foo#4 + +PR: test/goo#5 +` + + _, out := ExtractAssociatedDescriptionAndPRs(newStringScanner(multiplePRText)) + if len(out) != 2 { + t.Error("Unexpected output", out) + return + } + + expected1 := BasicPR{ + org: "test", + repo: "foo", + num: 4, + } + expected2 := BasicPR{ + org: "test", + repo: "goo", + num: 5, + } + + if !slices.Contains(out, expected1) { + t.Error("Unexpected", out, "Expected", expected1) + } + if !slices.Contains(out, expected2) { + t.Error("Unexpected", out, "Expected", expected2) + } + }) + + t.Run("Multiple PRs with whitespace", func(t *testing.T) { + const whitespacePRText = `Some header of the issue + + PR: test/goo#5 + + Followed by some description + PR: test/foo#4 +` + + desc, out := ExtractAssociatedDescriptionAndPRs(newStringScanner(whitespacePRText)) + if len(out) != 2 { + t.Error("Unexpected output", out) + return + } + + const expectedDesc = `Some header of the issue + + + Followed by some description` + expected1 := BasicPR{ + org: "test", + repo: "foo", + num: 4, + } + expected2 := BasicPR{ + org: "test", + repo: "goo", + num: 5, + } + + if !slices.Contains(out, expected1) { + t.Error("Unexpected", out, "Expected", expected1) + } + if !slices.Contains(out, expected2) { + t.Error("Unexpected", out, "Expected", expected2) + } + if desc != expectedDesc { + t.Error("unexpected desc", desc) + } + }) + + t.Run("Multiple PRs with missing names and other special cases to ignore", func(t *testing.T) { + const whitespacePRText = `Some header of the issue + + PR: foobar#5 + PR: rd/goo5 + PR: test/#5 + PR: /goo#5 + PR: test/goo# + PR: test / goo # 10 + PR: test/gool# 10 + PR: test/goo#5 + + Followed by some description + PR: test/foo#4 + + + ` + + desc, out := ExtractAssociatedDescriptionAndPRs(newStringScanner(whitespacePRText)) + if len(out) != 2 { + t.Error("Unexpected output", out) + return + } + + const expectedDesc = `Some header of the issue + + PR: foobar#5 + PR: rd/goo5 + PR: test/#5 + PR: /goo#5 + PR: test/goo# + PR: test / goo # 10 + PR: test/gool# 10 + + Followed by some description` + + if desc != expectedDesc { + t.Error(len(desc), "vs", len(expectedDesc)) + t.Error("description doesn't match expected. ", desc) + } + + expected1 := BasicPR{ + org: "test", + repo: "foo", + num: 4, + } + expected2 := BasicPR{ + org: "test", + repo: "goo", + num: 5, + } + + if !slices.Contains(out, expected1) { + t.Error("Unexpected", out, "Expected", expected1) + } + if !slices.Contains(out, expected2) { + t.Error("Unexpected", out, "Expected", expected2) + } + }) + + t.Run("Append PRs to end of description", func(t *testing.T) { + d := AppendPRsToDescription("something", []BasicPR{ + BasicPR{org: "a", repo: "b", num: 100}, + }) + + const expectedDesc = `something + +PR: a/b#100 +` + if d != expectedDesc { + t.Error(len(d), "vs", len(expectedDesc)) + t.Error("unpected output", d) + } + }) +} diff --git a/bots-common/gitea_utils.go b/bots-common/gitea_utils.go index 3ec0316..49ae666 100644 --- a/bots-common/gitea_utils.go +++ b/bots-common/gitea_utils.go @@ -37,8 +37,6 @@ import ( //go:generate mockgen -source=gitea_utils.go -destination=mock/gitea_utils.go -typed -const PrPattern = "PR: %s/%s#%d" - // maintainer list file in ProjectGit const MaintainershipFile = "_maitnainership.json" @@ -58,6 +56,10 @@ const ( ReviewStateUnknown models.ReviewStateType = "" ) +type GiteaPRFetcher interface { + GetAssociatedPRs(org, repo string, prNo int64) ([]*models.PullRequest, error) +} + type Gitea interface { GetPullRequestAndReviews(org, project string, num int64) (*models.PullRequest, []*models.PullReview, error) GetPullNotifications(since *time.Time) ([]*models.NotificationThread, error) @@ -73,6 +75,8 @@ type Gitea interface { GetPullRequestFileContent(pr *models.PullRequest, path string) ([]byte, error) GetRecentPullRequests(org, repo string) ([]*models.PullRequest, error) GetRecentCommits(org, repo, branch string, commitNo int64) ([]*models.Commit, error) + + GiteaPRFetcher } type GiteaTransport struct { @@ -407,6 +411,25 @@ func (gitea *GiteaTransport) GetAssociatedPrjGitPR(pr *PullRequestWebhookEvent) return nil, nil } +func (gitea *GiteaTransport) GetAssociatedPRs(org, repo string, prNo int64) ([]*models.PullRequest, error) { + prData, err := gitea.client.Repository.RepoGetPullRequest( + repository.NewRepoGetPullRequestParams(). + WithOwner(org). + WithRepo(repo). + WithIndex(prNo), + gitea.transport.DefaultAuthentication) + + if err != nil { + return nil, err + } + + desc := prData.Payload.Body + strings.Split(desc, "\n") + + + return nil, nil +} + func (gitea *GiteaTransport) GetRepositoryFileContent(org, repo, hash, path string) ([]byte, error) { var retData []byte diff --git a/workflow-pr/maintainership.go b/workflow-pr/maintainership.go index ead0473..76e54ab 100644 --- a/workflow-pr/maintainership.go +++ b/workflow-pr/maintainership.go @@ -15,7 +15,6 @@ type MaintainershipMap map[string][]string type GiteaMaintainershipInterface interface { FetchMaintainershipFile(org, prjGit, branch string) ([]byte, error) - GetPullRequestAndReviews(org, pkg string, num int64) (*models.PullRequest, []*models.PullReview, error) } func parseMaintainershipData(data []byte) (MaintainershipMap, error) { @@ -62,7 +61,7 @@ prjMaintainer: return pkgMaintainers } -func CheckIfMaintainersApproved(gitea GiteaMaintainershipInterface, config common.AutogitConfig, prjGitPRNumber int64) (bool, error) { +func CheckIfMaintainersApproved(gitea GiteaPRInterface, config common.AutogitConfig, prjGitPRNumber int64) (bool, error) { pr, reviews, _ := gitea.GetPullRequestAndReviews(config.Organization, config.GitProjectName, prjGitPRNumber) data, _ := gitea.FetchMaintainershipFile(config.Organization, config.GitProjectName, config.Branch) diff --git a/workflow-pr/pr.go b/workflow-pr/pr.go index 1ba36b4..3349d4a 100644 --- a/workflow-pr/pr.go +++ b/workflow-pr/pr.go @@ -1,32 +1,15 @@ package main import ( - "src.opensuse.org/autogits/common" "src.opensuse.org/autogits/common/gitea-generated/models" ) //go:generate mockgen -source=pr.go -destination=mock/pr.go -typed type GiteaPRInterface interface { + GetPullRequestAndReviews(org, pkg string, num int64) (*models.PullRequest, []*models.PullReview, error) + GetAssociatedPrjGitPR(org, repo string, id int) (*models.PullRequest, error) GetAssociatedPRs(org, repo string, id int) ([]*models.PullRequest, error) } -type PRInterface struct { - gitea common.Gitea -} - -func AllocatePRInterface(gitea common.Gitea) GiteaPRInterface { - return &PRInterface{ - gitea: gitea, - } -} - -func (s *PRInterface) GetAssociatedPrjGitPR(org, repo string, id int) (*models.PullRequest, error) { - return nil, nil -} - -func (s *PRInterface) GetAssociatedPRs(org, repo string, id int) ([]*models.PullRequest, error) { - return nil, nil -} -