Compare commits
1 Commits
| Author | SHA256 | Date | |
|---|---|---|---|
| a7784977f9 |
45
pr-status-service/README.md
Normal file
45
pr-status-service/README.md
Normal file
@@ -0,0 +1,45 @@
|
||||
OBS Status Service
|
||||
==================
|
||||
|
||||
Caches and reports status of a PR as SVG or JSON
|
||||
|
||||
Requests for individual PRs statuses:
|
||||
GET /${PR_HASH}
|
||||
|
||||
Update requests for individual PRs statuses:
|
||||
POST /${PR_HASH}
|
||||
|
||||
POST requires cert auth to function.
|
||||
|
||||
|
||||
Areas of Responsibility
|
||||
-----------------------
|
||||
|
||||
* Listens for PR status reports from workflow-pr bot (or other interface)
|
||||
* Produces SVG output based on GET request
|
||||
* Produces JSON output based on GET request
|
||||
|
||||
|
||||
Target Usage
|
||||
------------
|
||||
|
||||
* comment section of a PR
|
||||
* 3rd party tooling
|
||||
|
||||
|
||||
PR Encoding
|
||||
-----------
|
||||
|
||||
PRs are encoded as SHA256 hashes with a salt. This allows the existence of
|
||||
individual PRs to remain secret while the hash is known to tools that have
|
||||
read access to the PRs
|
||||
|
||||
Encoding data is input into the sha256 hash as follows:
|
||||
|
||||
* Salt string, min 100 bytes.
|
||||
* Organization name of the PR
|
||||
* Repository name of the PR
|
||||
* PR number (decimal string)
|
||||
|
||||
Encoded PR is then passed to process as mime64 encoded without trailing =.
|
||||
|
||||
113
pr-status-service/main.go
Normal file
113
pr-status-service/main.go
Normal file
@@ -0,0 +1,113 @@
|
||||
package main
|
||||
|
||||
/*
|
||||
* This file is part of Autogits.
|
||||
*
|
||||
* Copyright © 2024 SUSE LLC
|
||||
*
|
||||
* Autogits is free software: you can redistribute it and/or modify it under
|
||||
* the terms of the GNU General Public License as published by the Free Software
|
||||
* Foundation, either version 2 of the License, or (at your option) any later
|
||||
* version.
|
||||
*
|
||||
* Autogits is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
|
||||
* PARTICULAR PURPOSE. See the GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License along with
|
||||
* Foobar. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"src.opensuse.org/autogits/common"
|
||||
)
|
||||
|
||||
const (
|
||||
AppName = "obs-status-service"
|
||||
)
|
||||
|
||||
var obs *common.ObsClient
|
||||
var debug bool
|
||||
var salt string
|
||||
var RabbitMQHost, Topic, orgs *string
|
||||
|
||||
func LogDebug(v ...any) {
|
||||
if debug {
|
||||
log.Println(v...)
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
cert := flag.String("cert-file", "", "TLS certificates file")
|
||||
key := flag.String("key-file", "", "Private key for the TLS certificate")
|
||||
listen := flag.String("listen", "[::1]:8080", "Listening string")
|
||||
disableTls := flag.Bool("no-tls", false, "Disable TLS")
|
||||
obsHost := flag.String("obs-host", "https://api.opensuse.org", "OBS API endpoint for package status information")
|
||||
flag.BoolVar(&debug, "debug", false, "Enable debug logging")
|
||||
RabbitMQHost = flag.String("rabbit-mq", "amqps://rabbit.opensuse.org", "RabbitMQ message bus server")
|
||||
Topic = flag.String("topic", "opensuse.obs", "RabbitMQ topic prefix")
|
||||
orgs = flag.String("orgs", "opensuse", "Comma separated list of orgs to watch")
|
||||
flag.Parse()
|
||||
|
||||
salt = os.Getenv("PR_STATUS_SALT")
|
||||
if len(salt) < 100 {
|
||||
log.Fatal("PR_STATUS_SALT must be at least 100 bytes")
|
||||
}
|
||||
|
||||
common.PanicOnError(common.RequireObsSecretToken())
|
||||
|
||||
var err error
|
||||
if obs, err = common.NewObsClient(*obsHost); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
http.HandleFunc("GET /{PR_HASH}", func(res http.ResponseWriter, req *http.Request) {
|
||||
hash := req.PathValue("PR_HASH")
|
||||
isJSON := strings.HasSuffix(hash, ".json") || req.Header.Get("Accept") == "application/json"
|
||||
hash = strings.TrimSuffix(hash, ".json")
|
||||
hash = strings.TrimSuffix(hash, ".svg")
|
||||
|
||||
status := GetPRStatus(hash)
|
||||
if status == nil {
|
||||
res.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
if isJSON {
|
||||
res.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(res).Encode(status)
|
||||
return
|
||||
}
|
||||
|
||||
// default SVG
|
||||
res.Header().Set("Content-Type", "image/svg+xml")
|
||||
res.Write([]byte(status.ToSVG()))
|
||||
})
|
||||
|
||||
http.HandleFunc("POST /{PR_HASH}", func(res http.ResponseWriter, req *http.Request) {
|
||||
hash := req.PathValue("PR_HASH")
|
||||
|
||||
var status PRStatus
|
||||
if err := json.NewDecoder(req.Body).Decode(&status); err != nil {
|
||||
res.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
UpdatePRStatus(hash, &status)
|
||||
res.WriteHeader(http.StatusNoContent)
|
||||
})
|
||||
|
||||
go ProcessUpdates()
|
||||
|
||||
if *disableTls {
|
||||
log.Fatal(http.ListenAndServe(*listen, nil))
|
||||
} else {
|
||||
log.Fatal(http.ListenAndServeTLS(*listen, *cert, *key, nil))
|
||||
}
|
||||
}
|
||||
110
pr-status-service/rabbit.go
Normal file
110
pr-status-service/rabbit.go
Normal file
@@ -0,0 +1,110 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"src.opensuse.org/autogits/common"
|
||||
)
|
||||
|
||||
type PRHandler struct{}
|
||||
|
||||
func (h *PRHandler) ProcessFunc(request *common.Request) error {
|
||||
event, ok := request.Data.(*common.PullRequestWebhookEvent)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
org := event.Repository.Owner.Username
|
||||
repo := event.Repository.Name
|
||||
prNum := fmt.Sprint(event.Number)
|
||||
hash := CalculatePRHash(salt, org, repo, prNum)
|
||||
|
||||
status := &PRStatus{
|
||||
PR: fmt.Sprintf("%s/%s#%s", org, repo, prNum),
|
||||
IsReviewed: false,
|
||||
IsMergeable: event.Pull_Request.State == "open",
|
||||
MergeStatus: event.Pull_Request.State,
|
||||
}
|
||||
|
||||
UpdatePRStatus(hash, status)
|
||||
return nil
|
||||
}
|
||||
|
||||
type ReviewHandler struct {
|
||||
Approved bool
|
||||
}
|
||||
|
||||
func (h *ReviewHandler) ProcessFunc(request *common.Request) error {
|
||||
event, ok := request.Data.(*common.PullRequestWebhookEvent)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
org := event.Repository.Owner.Username
|
||||
repo := event.Repository.Name
|
||||
prNum := fmt.Sprint(event.Number)
|
||||
hash := CalculatePRHash(salt, org, repo, prNum)
|
||||
|
||||
status := GetPRStatus(hash)
|
||||
if status == nil {
|
||||
status = &PRStatus{
|
||||
PR: fmt.Sprintf("%s/%s#%s", org, repo, prNum),
|
||||
}
|
||||
}
|
||||
|
||||
status.IsReviewed = true
|
||||
isApproved := IsApproved_No
|
||||
if h.Approved {
|
||||
isApproved = IsApproved_Yes
|
||||
status.MergeStatus = "approved"
|
||||
} else {
|
||||
status.MergeStatus = "rejected"
|
||||
}
|
||||
|
||||
found := false
|
||||
for i, r := range status.Reviews {
|
||||
if r.Reviewer == event.Sender.Username {
|
||||
status.Reviews[i].IsApproved = isApproved
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
status.Reviews = append(status.Reviews, ReviewStatus{
|
||||
Reviewer: event.Sender.Username,
|
||||
IsApproved: isApproved,
|
||||
})
|
||||
}
|
||||
|
||||
UpdatePRStatus(hash, status)
|
||||
return nil
|
||||
}
|
||||
|
||||
func ProcessUpdates() {
|
||||
if RabbitMQHost == nil || *RabbitMQHost == "" {
|
||||
return
|
||||
}
|
||||
|
||||
u, err := url.Parse(*RabbitMQHost)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
processor := &common.RabbitMQGiteaEventsProcessor{
|
||||
Handlers: map[string]common.RequestProcessor{
|
||||
common.RequestType_PR: &PRHandler{},
|
||||
common.RequestType_PRReviewAccepted: &ReviewHandler{Approved: true},
|
||||
common.RequestType_PRReviewRejected: &ReviewHandler{Approved: false},
|
||||
},
|
||||
Orgs: strings.Split(*orgs, ","),
|
||||
}
|
||||
processor.Connection().RabbitURL = u
|
||||
|
||||
err = common.ProcessRabbitMQEvents(processor)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
91
pr-status-service/status.go
Normal file
91
pr-status-service/status.go
Normal file
@@ -0,0 +1,91 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
const (
|
||||
IsApproved_Pending = 0
|
||||
IsApproved_Yes = 1
|
||||
IsApproved_No = 2
|
||||
)
|
||||
|
||||
type ReviewStatus struct {
|
||||
Reviewer string
|
||||
IsApproved int
|
||||
}
|
||||
|
||||
type PRStatus struct {
|
||||
PR string
|
||||
IsReviewed bool
|
||||
IsMergeable bool
|
||||
|
||||
MergeStatus string
|
||||
Reviews []ReviewStatus
|
||||
}
|
||||
|
||||
func (status *PRStatus) ToSVG() string {
|
||||
mergeableText := "NO"
|
||||
mergeableColor := "red"
|
||||
if status.IsMergeable {
|
||||
mergeableText = "YES"
|
||||
mergeableColor = "green"
|
||||
}
|
||||
|
||||
reviewedText := "NO"
|
||||
reviewedColor := "red"
|
||||
if status.IsReviewed {
|
||||
reviewedText = "YES"
|
||||
reviewedColor = "green"
|
||||
}
|
||||
|
||||
var reviewsBuilder strings.Builder
|
||||
for i, r := range status.Reviews {
|
||||
color := "orange"
|
||||
if r.IsApproved == IsApproved_Yes {
|
||||
color = "green"
|
||||
} else if r.IsApproved == IsApproved_No {
|
||||
color = "red"
|
||||
}
|
||||
if i > 0 {
|
||||
reviewsBuilder.WriteString(", ")
|
||||
}
|
||||
fmt.Fprintf(&reviewsBuilder, `<tspan fill="%s">%s</tspan>`, color, r.Reviewer)
|
||||
}
|
||||
|
||||
return fmt.Sprintf(`<svg xmlns="http://www.w3.org/2000/svg" width="400" height="100">
|
||||
<rect width="400" height="100" fill="#f8f9fa" stroke="#dee2e6"/>
|
||||
<text x="10" y="25" font-family="sans-serif" font-size="14" fill="#333">Is Mergeable: <tspan fill="%s" font-weight="bold">%s</tspan></text>
|
||||
<text x="10" y="45" font-family="sans-serif" font-size="14" fill="#333">Is Reviewed?: <tspan fill="%s" font-weight="bold">%s</tspan></text>
|
||||
<text x="10" y="65" font-family="sans-serif" font-size="14" fill="#333">Merge Status: %s</text>
|
||||
<text x="10" y="85" font-family="sans-serif" font-size="14" fill="#333">Reviews: %s</text>
|
||||
</svg>`, mergeableColor, mergeableText, reviewedColor, reviewedText, status.MergeStatus, reviewsBuilder.String())
|
||||
}
|
||||
|
||||
var prStatuses = make(map[string]*PRStatus)
|
||||
var prStatusesLock sync.RWMutex
|
||||
|
||||
func GetPRStatus(hash string) *PRStatus {
|
||||
prStatusesLock.RLock()
|
||||
defer prStatusesLock.RUnlock()
|
||||
return prStatuses[hash]
|
||||
}
|
||||
|
||||
func UpdatePRStatus(hash string, status *PRStatus) {
|
||||
prStatusesLock.Lock()
|
||||
defer prStatusesLock.Unlock()
|
||||
prStatuses[hash] = status
|
||||
}
|
||||
|
||||
func CalculatePRHash(salt, org, repo, prNum string) string {
|
||||
h := sha256.New()
|
||||
h.Write([]byte(salt))
|
||||
h.Write([]byte(org))
|
||||
h.Write([]byte(repo))
|
||||
h.Write([]byte(prNum))
|
||||
return base64.RawStdEncoding.EncodeToString(h.Sum(nil))
|
||||
}
|
||||
185
pr-status-service/status_test.go
Normal file
185
pr-status-service/status_test.go
Normal file
@@ -0,0 +1,185 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"src.opensuse.org/autogits/common"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCalculatePRHash(t *testing.T) {
|
||||
salt = "1234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890" // 100 bytes
|
||||
org := "opensuse"
|
||||
repo := "pr-status-service"
|
||||
prNum := "1"
|
||||
|
||||
hash := CalculatePRHash(salt, org, repo, prNum)
|
||||
if hash == "" {
|
||||
t.Errorf("Hash is empty")
|
||||
}
|
||||
|
||||
hash2 := CalculatePRHash(salt, org, repo, prNum)
|
||||
if hash != hash2 {
|
||||
t.Errorf("Hash is not consistent")
|
||||
}
|
||||
|
||||
// Different inputs should give different hashes
|
||||
hash3 := CalculatePRHash(salt, org, repo, "2")
|
||||
if hash == hash3 {
|
||||
t.Errorf("Hashes for different PR numbers are same")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPRStatusToSVG(t *testing.T) {
|
||||
status := &PRStatus{
|
||||
PR: "org/repo#1",
|
||||
IsMergeable: true,
|
||||
IsReviewed: true,
|
||||
MergeStatus: "can be merged",
|
||||
Reviews: []ReviewStatus{
|
||||
{Reviewer: "alice", IsApproved: IsApproved_Yes},
|
||||
{Reviewer: "bob", IsApproved: IsApproved_No},
|
||||
{Reviewer: "charlie", IsApproved: IsApproved_Pending},
|
||||
},
|
||||
}
|
||||
|
||||
svg := status.ToSVG()
|
||||
|
||||
// Check for key elements in the SVG
|
||||
expectedStrings := []string{
|
||||
`Is Mergeable: <tspan fill="green" font-weight="bold">YES</tspan>`,
|
||||
`Is Reviewed?: <tspan fill="green" font-weight="bold">YES</tspan>`,
|
||||
`Merge Status: can be merged`,
|
||||
`<tspan fill="green">alice</tspan>`,
|
||||
`<tspan fill="red">bob</tspan>`,
|
||||
`<tspan fill="orange">charlie</tspan>`,
|
||||
}
|
||||
|
||||
for _, expected := range expectedStrings {
|
||||
if !contains(svg, expected) {
|
||||
t.Errorf("SVG missing expected string: %s", expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPRStatusStore(t *testing.T) {
|
||||
hash := "test-hash"
|
||||
status := &PRStatus{PR: "org/repo#123"}
|
||||
|
||||
UpdatePRStatus(hash, status)
|
||||
|
||||
ret := GetPRStatus(hash)
|
||||
if ret == nil || ret.PR != status.PR {
|
||||
t.Errorf("Retrieved status does not match stored status")
|
||||
}
|
||||
|
||||
retNil := GetPRStatus("non-existent")
|
||||
if retNil != nil {
|
||||
t.Errorf("Expected nil for non-existent hash")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlers(t *testing.T) {
|
||||
salt = "1234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890"
|
||||
|
||||
repo := &common.Repository{
|
||||
Name: "test-repo",
|
||||
Owner: &common.Organization{
|
||||
Username: "test-org",
|
||||
},
|
||||
}
|
||||
|
||||
prHandler := &PRHandler{}
|
||||
prEvent := &common.PullRequestWebhookEvent{
|
||||
Number: 1,
|
||||
Repository: repo,
|
||||
Pull_Request: &common.PullRequest{
|
||||
State: "open",
|
||||
},
|
||||
}
|
||||
|
||||
err := prHandler.ProcessFunc(&common.Request{Data: prEvent})
|
||||
if err != nil {
|
||||
t.Errorf("PRHandler error: %v", err)
|
||||
}
|
||||
|
||||
hash := CalculatePRHash(salt, "test-org", "test-repo", "1")
|
||||
status := GetPRStatus(hash)
|
||||
if status == nil {
|
||||
t.Fatalf("Status not found after PRHandler")
|
||||
}
|
||||
|
||||
if status.IsMergeable != true {
|
||||
t.Errorf("Expected mergeable true")
|
||||
}
|
||||
|
||||
// Test ReviewHandler
|
||||
reviewHandler := &ReviewHandler{Approved: true}
|
||||
reviewEvent := &common.PullRequestWebhookEvent{
|
||||
Number: 1,
|
||||
Repository: repo,
|
||||
Sender: common.User{
|
||||
Username: "reviewer1",
|
||||
},
|
||||
}
|
||||
|
||||
err = reviewHandler.ProcessFunc(&common.Request{Data: reviewEvent})
|
||||
if err != nil {
|
||||
t.Errorf("ReviewHandler error: %v", err)
|
||||
}
|
||||
|
||||
status = GetPRStatus(hash)
|
||||
if !status.IsReviewed {
|
||||
t.Errorf("Expected IsReviewed true")
|
||||
}
|
||||
if len(status.Reviews) != 1 || status.Reviews[0].Reviewer != "reviewer1" || status.Reviews[0].IsApproved != IsApproved_Yes {
|
||||
t.Errorf("Review not correctly recorded")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReviewUpdate(t *testing.T) {
|
||||
repo := &common.Repository{
|
||||
Name: "test-repo",
|
||||
Owner: &common.Organization{
|
||||
Username: "test-org",
|
||||
},
|
||||
}
|
||||
hash := CalculatePRHash(salt, "test-org", "test-repo", "1")
|
||||
UpdatePRStatus(hash, &PRStatus{PR: "test-org/test-repo#1"})
|
||||
|
||||
reviewHandler := &ReviewHandler{Approved: true}
|
||||
reviewEvent := &common.PullRequestWebhookEvent{
|
||||
Number: 1,
|
||||
Repository: repo,
|
||||
Sender: common.User{
|
||||
Username: "reviewer1",
|
||||
},
|
||||
}
|
||||
|
||||
reviewHandler.ProcessFunc(&common.Request{Data: reviewEvent})
|
||||
|
||||
// Update same review
|
||||
reviewHandler.Approved = false
|
||||
reviewHandler.ProcessFunc(&common.Request{Data: reviewEvent})
|
||||
|
||||
status := GetPRStatus(hash)
|
||||
if len(status.Reviews) != 1 || status.Reviews[0].IsApproved != IsApproved_No {
|
||||
t.Errorf("Review not correctly updated")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerErrors(t *testing.T) {
|
||||
prHandler := &PRHandler{}
|
||||
err := prHandler.ProcessFunc(&common.Request{Data: nil})
|
||||
if err != nil {
|
||||
t.Errorf("Expected nil error for nil data in PRHandler")
|
||||
}
|
||||
|
||||
reviewHandler := &ReviewHandler{}
|
||||
err = reviewHandler.ProcessFunc(&common.Request{Data: nil})
|
||||
if err != nil {
|
||||
t.Errorf("Expected nil error for nil data in ReviewHandler")
|
||||
}
|
||||
}
|
||||
|
||||
func contains(s, substr string) bool {
|
||||
return len(s) >= len(substr) && (s == substr || (len(substr) > 0 && (s[:len(substr)] == substr || contains(s[1:], substr))))
|
||||
}
|
||||
@@ -470,8 +470,7 @@ func (pr *PRProcessor) Process(req *models.PullRequest) error {
|
||||
if pr.PR.State == "open" {
|
||||
org, repo, idx := pr.PRComponents()
|
||||
if prjGitPR.PR.HasMerged {
|
||||
// TODO: use timeline here because this can spam if ManualMergePR fails
|
||||
// Gitea.AddComment(pr.PR, "This PR is merged via the associated Project PR.")
|
||||
Gitea.AddComment(pr.PR, "This PR is merged via the associated Project PR.")
|
||||
err = Gitea.ManualMergePR(org, repo, idx, pr.PR.Head.Sha, false)
|
||||
if _, ok := err.(*repository.RepoMergePullRequestConflict); !ok {
|
||||
common.PanicOnError(err)
|
||||
|
||||
Reference in New Issue
Block a user