forked from git-workflow/autogits
Compare commits
14 Commits
git-deadlo
...
default-pa
| Author | SHA256 | Date | |
|---|---|---|---|
| 339b189c50 | |||
| 5393de0a44 | |||
| 93b23e4340 | |||
| 59b1044846 | |||
| c44574f06f | |||
| 5b5bb9a5bc | |||
| 38f4c44fd0 | |||
| 605d3dee06 | |||
| 6f26bcdccc | |||
| fffdf4fad3 | |||
| f6d2239f4d | |||
| 913fb7c046 | |||
| 79318dc169 | |||
| 377ed1c37f |
@@ -169,6 +169,7 @@ install -D -m0755 gitea-events-rabbitmq-publisher/gitea-events-rabbitmq-publishe
|
||||
install -D -m0644 systemd/gitea-events-rabbitmq-publisher.service %{buildroot}%{_unitdir}/gitea-events-rabbitmq-publisher.service
|
||||
install -D -m0755 gitea_status_proxy/gitea_status_proxy %{buildroot}%{_bindir}/gitea_status_proxy
|
||||
install -D -m0755 group-review/group-review %{buildroot}%{_bindir}/group-review
|
||||
install -D -m0644 systemd/group-review@.service %{buildroot}%{_unitdir}/group-review@.service
|
||||
install -D -m0755 obs-forward-bot/obs-forward-bot %{buildroot}%{_bindir}/obs-forward-bot
|
||||
install -D -m0755 obs-staging-bot/obs-staging-bot %{buildroot}%{_bindir}/obs-staging-bot
|
||||
install -D -m0644 systemd/obs-staging-bot.service %{buildroot}%{_unitdir}/obs-staging-bot.service
|
||||
@@ -191,6 +192,18 @@ install -D -m0755 utils/hujson/hujson
|
||||
%postun gitea-events-rabbitmq-publisher
|
||||
%service_del_postun gitea-events-rabbitmq-publisher.service
|
||||
|
||||
%pre group-review
|
||||
%service_add_pre group-review@.service
|
||||
|
||||
%post group-review
|
||||
%service_add_post group-review@.service
|
||||
|
||||
%preun group-review
|
||||
%service_del_preun group-review@.service
|
||||
|
||||
%postun group-review
|
||||
%service_del_postun group-review@.service
|
||||
|
||||
%pre obs-staging-bot
|
||||
%service_add_pre obs-staging-bot.service
|
||||
|
||||
@@ -251,6 +264,7 @@ install -D -m0755 utils/hujson/hujson
|
||||
%license COPYING
|
||||
%doc group-review/README.md
|
||||
%{_bindir}/group-review
|
||||
%{_unitdir}/group-review@.service
|
||||
|
||||
%files obs-forward-bot
|
||||
%license COPYING
|
||||
|
||||
@@ -39,3 +39,16 @@ Requirements
|
||||
+ R/W Notification
|
||||
+ R User
|
||||
|
||||
Env Variables
|
||||
-------------
|
||||
The following variables can be used (and override) command line parameters.
|
||||
|
||||
* `AUTOGITS_CONFIG` - config file location
|
||||
* `AUTOGITS_URL` - Gitea URL
|
||||
* `AUTOGITS_RABBITURL` - RabbitMQ url
|
||||
* `AUTOGITS_DEBUG` - when set, debug level logging enabled
|
||||
|
||||
Authentication env variables
|
||||
* `GITEA_TOKEN` - Gitea user token
|
||||
* `AMQP_USERNAME`, `AMQP_PASSWORD` - username and password for rabbitmq
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/url"
|
||||
"os"
|
||||
"regexp"
|
||||
"runtime/debug"
|
||||
"slices"
|
||||
@@ -17,20 +18,23 @@ import (
|
||||
"src.opensuse.org/autogits/common/gitea-generated/models"
|
||||
)
|
||||
|
||||
var configs common.AutogitConfigs
|
||||
var acceptRx *regexp.Regexp
|
||||
var rejectRx *regexp.Regexp
|
||||
var groupName string
|
||||
|
||||
func InitRegex(newGroupName string) {
|
||||
groupName = newGroupName
|
||||
acceptRx = regexp.MustCompile("^:\\s*(LGTM|approved?)")
|
||||
rejectRx = regexp.MustCompile("^:\\s*")
|
||||
type ReviewBot struct {
|
||||
configs common.AutogitConfigs
|
||||
acceptRx *regexp.Regexp
|
||||
rejectRx *regexp.Regexp
|
||||
groupName string
|
||||
gitea common.Gitea
|
||||
}
|
||||
|
||||
func ParseReviewLine(reviewText string) (bool, string) {
|
||||
func (bot *ReviewBot) InitRegex(newGroupName string) {
|
||||
bot.groupName = newGroupName
|
||||
bot.acceptRx = regexp.MustCompile("^:\\s*(LGTM|approved?)")
|
||||
bot.rejectRx = regexp.MustCompile("^:\\s*")
|
||||
}
|
||||
|
||||
func (bot *ReviewBot) ParseReviewLine(reviewText string) (bool, string) {
|
||||
line := strings.TrimSpace(reviewText)
|
||||
groupTextName := "@" + groupName
|
||||
groupTextName := "@" + bot.groupName
|
||||
glen := len(groupTextName)
|
||||
if len(line) < glen || line[0:glen] != groupTextName {
|
||||
return false, line
|
||||
@@ -50,20 +54,20 @@ func ParseReviewLine(reviewText string) (bool, string) {
|
||||
return false, line
|
||||
}
|
||||
|
||||
func ReviewAccepted(reviewText string) bool {
|
||||
func (bot *ReviewBot) ReviewAccepted(reviewText string) bool {
|
||||
for _, line := range common.SplitStringNoEmpty(reviewText, "\n") {
|
||||
if matched, reviewLine := ParseReviewLine(line); matched {
|
||||
return acceptRx.MatchString(reviewLine)
|
||||
if matched, reviewLine := bot.ParseReviewLine(line); matched {
|
||||
return bot.acceptRx.MatchString(reviewLine)
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func ReviewRejected(reviewText string) bool {
|
||||
func (bot *ReviewBot) ReviewRejected(reviewText string) bool {
|
||||
for _, line := range common.SplitStringNoEmpty(reviewText, "\n") {
|
||||
if matched, reviewLine := ParseReviewLine(line); matched {
|
||||
if rejectRx.MatchString(reviewLine) {
|
||||
return !acceptRx.MatchString(reviewLine)
|
||||
if matched, reviewLine := bot.ParseReviewLine(line); matched {
|
||||
if bot.rejectRx.MatchString(reviewLine) {
|
||||
return !bot.acceptRx.MatchString(reviewLine)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -113,10 +117,10 @@ var commentStrings = []string{
|
||||
"change_time_estimate",
|
||||
}*/
|
||||
|
||||
func FindAcceptableReviewInTimeline(user string, timeline []*models.TimelineComment, reviews []*models.PullReview) *models.TimelineComment {
|
||||
func (bot *ReviewBot) FindAcceptableReviewInTimeline(user string, timeline []*models.TimelineComment, reviews []*models.PullReview) *models.TimelineComment {
|
||||
for _, t := range timeline {
|
||||
if t.Type == common.TimelineCommentType_Comment && t.User.UserName == user && t.Created == t.Updated {
|
||||
if ReviewAccepted(t.Body) || ReviewRejected(t.Body) {
|
||||
if bot.ReviewAccepted(t.Body) || bot.ReviewRejected(t.Body) {
|
||||
return t
|
||||
}
|
||||
}
|
||||
@@ -125,9 +129,9 @@ func FindAcceptableReviewInTimeline(user string, timeline []*models.TimelineComm
|
||||
return nil
|
||||
}
|
||||
|
||||
func FindOurLastReviewInTimeline(timeline []*models.TimelineComment) *models.TimelineComment {
|
||||
func (bot *ReviewBot) FindOurLastReviewInTimeline(timeline []*models.TimelineComment) *models.TimelineComment {
|
||||
for _, t := range timeline {
|
||||
if t.Type == common.TimelineCommentType_Review && t.User.UserName == groupName && t.Created == t.Updated {
|
||||
if t.Type == common.TimelineCommentType_Review && t.User.UserName == bot.groupName && t.Created == t.Updated {
|
||||
return t
|
||||
}
|
||||
}
|
||||
@@ -135,13 +139,13 @@ func FindOurLastReviewInTimeline(timeline []*models.TimelineComment) *models.Tim
|
||||
return nil
|
||||
}
|
||||
|
||||
func UnrequestReviews(gitea common.Gitea, org, repo string, id int64, users []string) {
|
||||
if err := gitea.UnrequestReview(org, repo, id, users...); err != nil {
|
||||
func (bot *ReviewBot) UnrequestReviews(org, repo string, id int64, users []string) {
|
||||
if err := bot.gitea.UnrequestReview(org, repo, id, users...); err != nil {
|
||||
common.LogError("Can't remove reviewrs after a review:", err)
|
||||
}
|
||||
}
|
||||
|
||||
func ProcessNotifications(notification *models.NotificationThread, gitea common.Gitea) {
|
||||
func (bot *ReviewBot) ProcessNotifications(notification *models.NotificationThread) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
common.LogInfo("panic cought --- recovered")
|
||||
@@ -168,14 +172,14 @@ func ProcessNotifications(notification *models.NotificationThread, gitea common.
|
||||
id, _ := strconv.ParseInt(match[3], 10, 64)
|
||||
|
||||
common.LogInfo("processing:", fmt.Sprintf("%s/%s!%d", org, repo, id))
|
||||
pr, err := gitea.GetPullRequest(org, repo, id)
|
||||
pr, err := bot.gitea.GetPullRequest(org, repo, id)
|
||||
if err != nil {
|
||||
common.LogError(" ** Cannot fetch PR associated with review:", subject.URL, "Error:", err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := ProcessPR(pr); err == nil && !common.IsDryRun {
|
||||
if err := gitea.SetNotificationRead(notification.ID); err != nil {
|
||||
if err := bot.ProcessPR(pr); err == nil && !common.IsDryRun {
|
||||
if err := bot.gitea.SetNotificationRead(notification.ID); err != nil {
|
||||
common.LogDebug(" Cannot set notification as read", err)
|
||||
}
|
||||
} else if err != nil && err != ReviewNotFinished {
|
||||
@@ -185,24 +189,24 @@ func ProcessNotifications(notification *models.NotificationThread, gitea common.
|
||||
|
||||
var ReviewNotFinished = fmt.Errorf("Review is not finished")
|
||||
|
||||
func ProcessPR(pr *models.PullRequest) error {
|
||||
func (bot *ReviewBot) ProcessPR(pr *models.PullRequest) error {
|
||||
org := pr.Base.Repo.Owner.UserName
|
||||
repo := pr.Base.Repo.Name
|
||||
id := pr.Index
|
||||
|
||||
found := false
|
||||
for _, reviewer := range pr.RequestedReviewers {
|
||||
if reviewer != nil && reviewer.UserName == groupName {
|
||||
if reviewer != nil && reviewer.UserName == bot.groupName {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
common.LogInfo(" review is not requested for", groupName)
|
||||
common.LogInfo(" review is not requested for", bot.groupName)
|
||||
return nil
|
||||
}
|
||||
|
||||
config := configs.GetPrjGitConfig(org, repo, pr.Base.Name)
|
||||
config := bot.configs.GetPrjGitConfig(org, repo, pr.Base.Name)
|
||||
if config == nil {
|
||||
return fmt.Errorf("Cannot find config for: %s", pr.URL)
|
||||
}
|
||||
@@ -212,17 +216,17 @@ func ProcessPR(pr *models.PullRequest) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
reviews, err := gitea.GetPullRequestReviews(org, repo, id)
|
||||
reviews, err := bot.gitea.GetPullRequestReviews(org, repo, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Failed to fetch reviews for: %v: %w", pr.URL, err)
|
||||
}
|
||||
|
||||
timeline, err := common.FetchTimelineSinceReviewRequestOrPush(gitea, groupName, pr.Head.Sha, org, repo, id)
|
||||
timeline, err := common.FetchTimelineSinceReviewRequestOrPush(bot.gitea, bot.groupName, pr.Head.Sha, org, repo, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Failed to fetch timeline to review. %w", err)
|
||||
}
|
||||
|
||||
groupConfig, err := config.GetReviewGroup(groupName)
|
||||
groupConfig, err := config.GetReviewGroup(bot.groupName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Failed to fetch review group. %w", err)
|
||||
}
|
||||
@@ -233,30 +237,30 @@ func ProcessPR(pr *models.PullRequest) error {
|
||||
// pr.Head.Sha
|
||||
|
||||
for _, reviewer := range requestReviewers {
|
||||
if review := FindAcceptableReviewInTimeline(reviewer, timeline, reviews); review != nil {
|
||||
if ReviewAccepted(review.Body) {
|
||||
if review := bot.FindAcceptableReviewInTimeline(reviewer, timeline, reviews); review != nil {
|
||||
if bot.ReviewAccepted(review.Body) {
|
||||
if !common.IsDryRun {
|
||||
text := reviewer + " approved a review on behalf of " + groupName
|
||||
if review := FindOurLastReviewInTimeline(timeline); review == nil || review.Body != text {
|
||||
_, err := gitea.AddReviewComment(pr, common.ReviewStateApproved, text)
|
||||
text := reviewer + " approved a review on behalf of " + bot.groupName
|
||||
if review := bot.FindOurLastReviewInTimeline(timeline); review == nil || review.Body != text {
|
||||
_, err := bot.gitea.AddReviewComment(pr, common.ReviewStateApproved, text)
|
||||
if err != nil {
|
||||
common.LogError(" -> failed to write approval comment", err)
|
||||
}
|
||||
UnrequestReviews(gitea, org, repo, id, requestReviewers)
|
||||
bot.UnrequestReviews(org, repo, id, requestReviewers)
|
||||
}
|
||||
}
|
||||
common.LogInfo(" -> approved by", reviewer)
|
||||
common.LogInfo(" review at", review.Created)
|
||||
return nil
|
||||
} else if ReviewRejected(review.Body) {
|
||||
} else if bot.ReviewRejected(review.Body) {
|
||||
if !common.IsDryRun {
|
||||
text := reviewer + " requested changes on behalf of " + groupName + ". See " + review.HTMLURL
|
||||
if review := FindOurLastReviewInTimeline(timeline); review == nil || review.Body != text {
|
||||
_, err := gitea.AddReviewComment(pr, common.ReviewStateRequestChanges, "Changes requested. See review by: "+reviewer)
|
||||
text := reviewer + " requested changes on behalf of " + bot.groupName + ". See " + review.HTMLURL
|
||||
if review := bot.FindOurLastReviewInTimeline(timeline); review == nil || review.Body != text {
|
||||
_, err := bot.gitea.AddReviewComment(pr, common.ReviewStateRequestChanges, text)
|
||||
if err != nil {
|
||||
common.LogError(" -> failed to write rejecting comment", err)
|
||||
}
|
||||
UnrequestReviews(gitea, org, repo, id, requestReviewers)
|
||||
bot.UnrequestReviews(org, repo, id, requestReviewers)
|
||||
}
|
||||
}
|
||||
common.LogInfo(" -> declined by", reviewer)
|
||||
@@ -270,7 +274,7 @@ func ProcessPR(pr *models.PullRequest) error {
|
||||
if !groupConfig.Silent && len(requestReviewers) > 0 {
|
||||
common.LogDebug(" Requesting reviews for:", requestReviewers)
|
||||
if !common.IsDryRun {
|
||||
if _, err := gitea.RequestReviews(pr, requestReviewers...); err != nil {
|
||||
if _, err := bot.gitea.RequestReviews(pr, requestReviewers...); err != nil {
|
||||
common.LogDebug(" -> err:", err)
|
||||
}
|
||||
} else {
|
||||
@@ -283,42 +287,40 @@ func ProcessPR(pr *models.PullRequest) error {
|
||||
// add a helpful comment, if not yet added
|
||||
found_help_comment := false
|
||||
for _, t := range timeline {
|
||||
if t.Type == common.TimelineCommentType_Comment && t.User != nil && t.User.UserName == groupName {
|
||||
if t.Type == common.TimelineCommentType_Comment && t.User != nil && t.User.UserName == bot.groupName {
|
||||
found_help_comment = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !found_help_comment && !common.IsDryRun {
|
||||
helpComment := fmt.Sprintln("Review by", groupName, "represents a group of reviewers:", strings.Join(requestReviewers, ", "), ".\n\n"+
|
||||
helpComment := fmt.Sprintln("Review by", bot.groupName, "represents a group of reviewers:", strings.Join(requestReviewers, ", "), ".\n\n"+
|
||||
"Do **not** use standard review interface to review on behalf of the group.\n"+
|
||||
"To accept the review on behalf of the group, create the following comment: `@"+groupName+": approve`.\n"+
|
||||
"To request changes on behalf of the group, create the following comment: `@"+groupName+": decline` followed with lines justifying the decision.\n"+
|
||||
"To accept the review on behalf of the group, create the following comment: `@"+bot.groupName+": approve`.\n"+
|
||||
"To request changes on behalf of the group, create the following comment: `@"+bot.groupName+": decline` followed with lines justifying the decision.\n"+
|
||||
"Future edits of the comments are ignored, a new comment is required to change the review state.")
|
||||
if slices.Contains(groupConfig.Reviewers, pr.User.UserName) {
|
||||
helpComment = helpComment + "\n\n" +
|
||||
"Submitter is member of this review group, hence they are excluded from being one of the reviewers here"
|
||||
}
|
||||
gitea.AddComment(pr, helpComment)
|
||||
bot.gitea.AddComment(pr, helpComment)
|
||||
}
|
||||
|
||||
return ReviewNotFinished
|
||||
}
|
||||
|
||||
func PeriodReviewCheck() {
|
||||
notifications, err := gitea.GetNotifications(common.GiteaNotificationType_Pull, nil)
|
||||
func (bot *ReviewBot) PeriodReviewCheck() {
|
||||
notifications, err := bot.gitea.GetNotifications(common.GiteaNotificationType_Pull, nil)
|
||||
if err != nil {
|
||||
common.LogError(" Error fetching unread notifications: %w", err)
|
||||
return
|
||||
}
|
||||
|
||||
for _, notification := range notifications {
|
||||
ProcessNotifications(notification, gitea)
|
||||
bot.ProcessNotifications(notification)
|
||||
}
|
||||
}
|
||||
|
||||
var gitea common.Gitea
|
||||
|
||||
func main() {
|
||||
giteaUrl := flag.String("gitea-url", "https://src.opensuse.org", "Gitea instance used for reviews")
|
||||
rabbitMqHost := flag.String("rabbit-url", "amqps://rabbit.opensuse.org", "RabbitMQ instance where Gitea webhook notifications are sent")
|
||||
@@ -328,6 +330,24 @@ func main() {
|
||||
flag.BoolVar(&common.IsDryRun, "dry", false, "Dry run, no effect. For debugging")
|
||||
flag.Parse()
|
||||
|
||||
if err := common.SetLoggingLevelFromString(*logging); err != nil {
|
||||
common.LogError(err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if cf := os.Getenv("AUTOGITS_CONFIG"); len(cf) > 0 {
|
||||
*configFile = cf
|
||||
}
|
||||
if url := os.Getenv("AUTOGITS_URL"); len(url) > 0 {
|
||||
*giteaUrl = url
|
||||
}
|
||||
if url := os.Getenv("AUTOGITS_RABBITURL"); len(url) > 0 {
|
||||
*rabbitMqHost = url
|
||||
}
|
||||
if debug := os.Getenv("AUTOGITS_DEBUG"); len(debug) > 0 {
|
||||
common.SetLoggingLevel(common.LogLevelDebug)
|
||||
}
|
||||
|
||||
args := flag.Args()
|
||||
if len(args) != 1 {
|
||||
log.Println(" syntax:")
|
||||
@@ -336,7 +356,7 @@ func main() {
|
||||
flag.Usage()
|
||||
return
|
||||
}
|
||||
groupName = args[0]
|
||||
targetGroupName := args[0]
|
||||
|
||||
if *configFile == "" {
|
||||
common.LogError("Missing config file")
|
||||
@@ -359,36 +379,35 @@ func main() {
|
||||
return
|
||||
}
|
||||
|
||||
gitea = common.AllocateGiteaTransport(*giteaUrl)
|
||||
configs, err = common.ResolveWorkflowConfigs(gitea, configData)
|
||||
giteaTransport := common.AllocateGiteaTransport(*giteaUrl)
|
||||
configs, err := common.ResolveWorkflowConfigs(giteaTransport, configData)
|
||||
if err != nil {
|
||||
common.LogError("Cannot parse workflow configs:", err)
|
||||
return
|
||||
}
|
||||
|
||||
reviewer, err := gitea.GetCurrentUser()
|
||||
reviewer, err := giteaTransport.GetCurrentUser()
|
||||
if err != nil {
|
||||
common.LogError("Cannot fetch review user:", err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := common.SetLoggingLevelFromString(*logging); err != nil {
|
||||
common.LogError(err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if *interval < 1 {
|
||||
*interval = 1
|
||||
}
|
||||
|
||||
InitRegex(groupName)
|
||||
bot := &ReviewBot{
|
||||
gitea: giteaTransport,
|
||||
configs: configs,
|
||||
}
|
||||
bot.InitRegex(targetGroupName)
|
||||
|
||||
common.LogInfo(" ** processing group reviews for group:", groupName)
|
||||
common.LogInfo(" ** processing group reviews for group:", bot.groupName)
|
||||
common.LogInfo(" ** username in Gitea:", reviewer.UserName)
|
||||
common.LogInfo(" ** polling interval:", *interval, "min")
|
||||
common.LogInfo(" ** connecting to RabbitMQ:", *rabbitMqHost)
|
||||
|
||||
if groupName != reviewer.UserName {
|
||||
if bot.groupName != reviewer.UserName {
|
||||
common.LogError(" ***** Reviewer does not match group name. Aborting. *****")
|
||||
return
|
||||
}
|
||||
@@ -400,10 +419,13 @@ func main() {
|
||||
}
|
||||
|
||||
config_update := ConfigUpdatePush{
|
||||
bot: bot,
|
||||
config_modified: make(chan *common.AutogitConfig),
|
||||
}
|
||||
|
||||
process_issue_pr := IssueCommentProcessor{}
|
||||
process_issue_pr := IssueCommentProcessor{
|
||||
bot: bot,
|
||||
}
|
||||
|
||||
configUpdates := &common.RabbitMQGiteaEventsProcessor{
|
||||
Orgs: []string{},
|
||||
@@ -413,7 +435,7 @@ func main() {
|
||||
},
|
||||
}
|
||||
configUpdates.Connection().RabbitURL = u
|
||||
for _, c := range configs {
|
||||
for _, c := range bot.configs {
|
||||
if org, _, _ := c.GetPrjGit(); !slices.Contains(configUpdates.Orgs, org) {
|
||||
configUpdates.Orgs = append(configUpdates.Orgs, org)
|
||||
}
|
||||
@@ -426,17 +448,17 @@ func main() {
|
||||
select {
|
||||
case configTouched, ok := <-config_update.config_modified:
|
||||
if ok {
|
||||
for idx, c := range configs {
|
||||
for idx, c := range bot.configs {
|
||||
if c == configTouched {
|
||||
org, repo, branch := c.GetPrjGit()
|
||||
prj := fmt.Sprintf("%s/%s#%s", org, repo, branch)
|
||||
common.LogInfo("Detected config update for", prj)
|
||||
|
||||
new_config, err := common.ReadWorkflowConfig(gitea, prj)
|
||||
new_config, err := common.ReadWorkflowConfig(bot.gitea, prj)
|
||||
if err != nil {
|
||||
common.LogError("Failed parsing Project config for", prj, err)
|
||||
} else {
|
||||
configs[idx] = new_config
|
||||
bot.configs[idx] = new_config
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -446,7 +468,7 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
PeriodReviewCheck()
|
||||
bot.PeriodReviewCheck()
|
||||
time.Sleep(time.Duration(*interval * int64(time.Minute)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,359 @@
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"go.uber.org/mock/gomock"
|
||||
"src.opensuse.org/autogits/common"
|
||||
"src.opensuse.org/autogits/common/gitea-generated/models"
|
||||
mock_common "src.opensuse.org/autogits/common/mock"
|
||||
)
|
||||
|
||||
func TestProcessPR(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
defer ctrl.Finish()
|
||||
|
||||
mockGitea := mock_common.NewMockGitea(ctrl)
|
||||
groupName := "testgroup"
|
||||
|
||||
bot := &ReviewBot{
|
||||
gitea: mockGitea,
|
||||
groupName: groupName,
|
||||
}
|
||||
bot.InitRegex(groupName)
|
||||
|
||||
org := "myorg"
|
||||
repo := "myrepo"
|
||||
prIndex := int64(1)
|
||||
headSha := "abcdef123456"
|
||||
|
||||
pr := &models.PullRequest{
|
||||
Index: prIndex,
|
||||
URL: "http://gitea/pr/1",
|
||||
State: "open",
|
||||
Base: &models.PRBranchInfo{
|
||||
Name: "main",
|
||||
Repo: &models.Repository{
|
||||
Name: repo,
|
||||
Owner: &models.User{
|
||||
UserName: org,
|
||||
},
|
||||
},
|
||||
},
|
||||
Head: &models.PRBranchInfo{
|
||||
Sha: headSha,
|
||||
},
|
||||
User: &models.User{
|
||||
UserName: "submitter",
|
||||
},
|
||||
RequestedReviewers: []*models.User{
|
||||
{UserName: groupName},
|
||||
},
|
||||
}
|
||||
|
||||
prjConfig := &common.AutogitConfig{
|
||||
GitProjectName: org + "/" + repo + "#main",
|
||||
ReviewGroups: []*common.ReviewGroup{
|
||||
{
|
||||
Name: groupName,
|
||||
Reviewers: []string{"reviewer1", "reviewer2"},
|
||||
},
|
||||
},
|
||||
}
|
||||
bot.configs = common.AutogitConfigs{prjConfig}
|
||||
|
||||
t.Run("Review not requested for group", func(t *testing.T) {
|
||||
prNoRequest := *pr
|
||||
prNoRequest.RequestedReviewers = nil
|
||||
err := bot.ProcessPR(&prNoRequest)
|
||||
if err != nil {
|
||||
t.Errorf("Expected no error, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("PR is closed", func(t *testing.T) {
|
||||
prClosed := *pr
|
||||
prClosed.State = "closed"
|
||||
err := bot.ProcessPR(&prClosed)
|
||||
if err != nil {
|
||||
t.Errorf("Expected no error, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Successful Approval", func(t *testing.T) {
|
||||
common.IsDryRun = false
|
||||
mockGitea.EXPECT().GetPullRequestReviews(org, repo, prIndex).Return(nil, nil)
|
||||
// reviewer1 approved in timeline
|
||||
timeline := []*models.TimelineComment{
|
||||
{
|
||||
Type: common.TimelineCommentType_Comment,
|
||||
User: &models.User{UserName: "reviewer1"},
|
||||
Body: "@" + groupName + ": approve",
|
||||
},
|
||||
}
|
||||
|
||||
mockGitea.EXPECT().GetTimeline(org, repo, prIndex).Return(timeline, nil)
|
||||
|
||||
expectedText := "reviewer1 approved a review on behalf of " + groupName
|
||||
mockGitea.EXPECT().AddReviewComment(pr, common.ReviewStateApproved, expectedText).Return(nil, nil)
|
||||
mockGitea.EXPECT().UnrequestReview(org, repo, prIndex, gomock.Any()).Return(nil)
|
||||
|
||||
err := bot.ProcessPR(pr)
|
||||
if err != nil {
|
||||
t.Errorf("Expected nil error, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Dry Run - No actions taken", func(t *testing.T) {
|
||||
common.IsDryRun = true
|
||||
mockGitea.EXPECT().GetPullRequestReviews(org, repo, prIndex).Return(nil, nil)
|
||||
timeline := []*models.TimelineComment{
|
||||
{
|
||||
Type: common.TimelineCommentType_Comment,
|
||||
User: &models.User{UserName: "reviewer1"},
|
||||
Body: "@" + groupName + ": approve",
|
||||
},
|
||||
}
|
||||
mockGitea.EXPECT().GetTimeline(org, repo, prIndex).Return(timeline, nil)
|
||||
|
||||
// No AddReviewComment or UnrequestReview should be called
|
||||
err := bot.ProcessPR(pr)
|
||||
if err != nil {
|
||||
t.Errorf("Expected nil error, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Approval already exists - No new comment", func(t *testing.T) {
|
||||
common.IsDryRun = false
|
||||
mockGitea.EXPECT().GetPullRequestReviews(org, repo, prIndex).Return(nil, nil)
|
||||
|
||||
approvalText := "reviewer1 approved a review on behalf of " + groupName
|
||||
timeline := []*models.TimelineComment{
|
||||
{
|
||||
Type: common.TimelineCommentType_Review,
|
||||
User: &models.User{UserName: groupName},
|
||||
Body: approvalText,
|
||||
},
|
||||
{
|
||||
Type: common.TimelineCommentType_Comment,
|
||||
User: &models.User{UserName: "reviewer1"},
|
||||
Body: "@" + groupName + ": approve",
|
||||
},
|
||||
{
|
||||
Type: common.TimelineCommentType_Comment,
|
||||
User: &models.User{UserName: groupName},
|
||||
Body: "Help comment",
|
||||
},
|
||||
}
|
||||
|
||||
mockGitea.EXPECT().GetTimeline(org, repo, prIndex).Return(timeline, nil)
|
||||
|
||||
// No AddReviewComment, UnrequestReview, or AddComment should be called
|
||||
err := bot.ProcessPR(pr)
|
||||
if err != nil {
|
||||
t.Errorf("Expected nil error, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Rejection already exists - No new comment", func(t *testing.T) {
|
||||
common.IsDryRun = false
|
||||
mockGitea.EXPECT().GetPullRequestReviews(org, repo, prIndex).Return(nil, nil)
|
||||
|
||||
rejectionText := "reviewer1 requested changes on behalf of " + groupName + ". See http://gitea/comment/123"
|
||||
timeline := []*models.TimelineComment{
|
||||
{
|
||||
Type: common.TimelineCommentType_Review,
|
||||
User: &models.User{UserName: groupName},
|
||||
Body: rejectionText,
|
||||
},
|
||||
{
|
||||
Type: common.TimelineCommentType_Comment,
|
||||
User: &models.User{UserName: "reviewer1"},
|
||||
Body: "@" + groupName + ": decline",
|
||||
HTMLURL: "http://gitea/comment/123",
|
||||
},
|
||||
{
|
||||
Type: common.TimelineCommentType_Comment,
|
||||
User: &models.User{UserName: groupName},
|
||||
Body: "Help comment",
|
||||
},
|
||||
}
|
||||
|
||||
mockGitea.EXPECT().GetTimeline(org, repo, prIndex).Return(timeline, nil)
|
||||
|
||||
err := bot.ProcessPR(pr)
|
||||
if err != nil {
|
||||
t.Errorf("Expected nil error, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Pending review - Help comment already exists", func(t *testing.T) {
|
||||
common.IsDryRun = false
|
||||
mockGitea.EXPECT().GetPullRequestReviews(org, repo, prIndex).Return(nil, nil)
|
||||
|
||||
timeline := []*models.TimelineComment{
|
||||
{
|
||||
Type: common.TimelineCommentType_Comment,
|
||||
User: &models.User{UserName: groupName},
|
||||
Body: "Some help comment",
|
||||
},
|
||||
}
|
||||
|
||||
mockGitea.EXPECT().GetTimeline(org, repo, prIndex).Return(timeline, nil)
|
||||
|
||||
// It will try to request reviews
|
||||
mockGitea.EXPECT().RequestReviews(pr, "reviewer1", "reviewer2").Return(nil, nil)
|
||||
|
||||
// AddComment should NOT be called because bot already has a comment in timeline
|
||||
err := bot.ProcessPR(pr)
|
||||
if err != ReviewNotFinished {
|
||||
t.Errorf("Expected ReviewNotFinished error, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Submitter is group member - Excluded from review request", func(t *testing.T) {
|
||||
common.IsDryRun = false
|
||||
prSubmitterMember := *pr
|
||||
prSubmitterMember.User = &models.User{UserName: "reviewer1"}
|
||||
mockGitea.EXPECT().GetPullRequestReviews(org, repo, prIndex).Return(nil, nil)
|
||||
mockGitea.EXPECT().GetTimeline(org, repo, prIndex).Return(nil, nil)
|
||||
mockGitea.EXPECT().RequestReviews(&prSubmitterMember, "reviewer2").Return(nil, nil)
|
||||
mockGitea.EXPECT().AddComment(&prSubmitterMember, gomock.Any()).Return(nil)
|
||||
err := bot.ProcessPR(&prSubmitterMember)
|
||||
if err != ReviewNotFinished {
|
||||
t.Errorf("Expected ReviewNotFinished error, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Successful Rejection", func(t *testing.T) {
|
||||
common.IsDryRun = false
|
||||
mockGitea.EXPECT().GetPullRequestReviews(org, repo, prIndex).Return(nil, nil)
|
||||
timeline := []*models.TimelineComment{
|
||||
{
|
||||
Type: common.TimelineCommentType_Comment,
|
||||
User: &models.User{UserName: "reviewer2"},
|
||||
Body: "@" + groupName + ": decline",
|
||||
HTMLURL: "http://gitea/comment/999",
|
||||
},
|
||||
}
|
||||
mockGitea.EXPECT().GetTimeline(org, repo, prIndex).Return(timeline, nil)
|
||||
expectedText := "reviewer2 requested changes on behalf of " + groupName + ". See http://gitea/comment/999"
|
||||
mockGitea.EXPECT().AddReviewComment(pr, common.ReviewStateRequestChanges, expectedText).Return(nil, nil)
|
||||
mockGitea.EXPECT().UnrequestReview(org, repo, prIndex, gomock.Any()).Return(nil)
|
||||
err := bot.ProcessPR(pr)
|
||||
if err != nil {
|
||||
t.Errorf("Expected nil error, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Config not found", func(t *testing.T) {
|
||||
bot.configs = common.AutogitConfigs{}
|
||||
err := bot.ProcessPR(pr)
|
||||
if err == nil {
|
||||
t.Error("Expected error when config is missing, got nil")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Gitea error in GetPullRequestReviews", func(t *testing.T) {
|
||||
bot.configs = common.AutogitConfigs{prjConfig}
|
||||
mockGitea.EXPECT().GetPullRequestReviews(org, repo, prIndex).Return(nil, fmt.Errorf("gitea error"))
|
||||
err := bot.ProcessPR(pr)
|
||||
if err == nil {
|
||||
t.Error("Expected error from gitea, got nil")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestProcessNotifications(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
defer ctrl.Finish()
|
||||
|
||||
mockGitea := mock_common.NewMockGitea(ctrl)
|
||||
groupName := "testgroup"
|
||||
|
||||
bot := &ReviewBot{
|
||||
gitea: mockGitea,
|
||||
groupName: groupName,
|
||||
}
|
||||
bot.InitRegex(groupName)
|
||||
|
||||
org := "myorg"
|
||||
repo := "myrepo"
|
||||
prIndex := int64(123)
|
||||
notificationID := int64(456)
|
||||
|
||||
notification := &models.NotificationThread{
|
||||
ID: notificationID,
|
||||
Subject: &models.NotificationSubject{
|
||||
URL: fmt.Sprintf("http://gitea/api/v1/repos/%s/%s/pulls/%d", org, repo, prIndex),
|
||||
},
|
||||
}
|
||||
|
||||
t.Run("Notification Success", func(t *testing.T) {
|
||||
common.IsDryRun = false
|
||||
pr := &models.PullRequest{
|
||||
Index: prIndex,
|
||||
Base: &models.PRBranchInfo{
|
||||
Name: "main",
|
||||
Repo: &models.Repository{
|
||||
Name: repo,
|
||||
Owner: &models.User{UserName: org},
|
||||
},
|
||||
},
|
||||
|
||||
Head: &models.PRBranchInfo{
|
||||
Sha: "headsha",
|
||||
Repo: &models.Repository{
|
||||
Name: repo,
|
||||
Owner: &models.User{UserName: org},
|
||||
},
|
||||
},
|
||||
|
||||
User: &models.User{UserName: "submitter"},
|
||||
RequestedReviewers: []*models.User{{UserName: groupName}},
|
||||
}
|
||||
|
||||
mockGitea.EXPECT().GetPullRequest(org, repo, prIndex).Return(pr, nil)
|
||||
|
||||
prjConfig := &common.AutogitConfig{
|
||||
GitProjectName: org + "/" + repo + "#main",
|
||||
ReviewGroups: []*common.ReviewGroup{{Name: groupName, Reviewers: []string{"r1"}}},
|
||||
}
|
||||
bot.configs = common.AutogitConfigs{prjConfig}
|
||||
mockGitea.EXPECT().GetPullRequestReviews(org, repo, prIndex).Return(nil, nil)
|
||||
timeline := []*models.TimelineComment{
|
||||
{
|
||||
Type: common.TimelineCommentType_Comment,
|
||||
User: &models.User{UserName: "r1"},
|
||||
Body: "@" + groupName + ": approve",
|
||||
},
|
||||
}
|
||||
mockGitea.EXPECT().GetTimeline(org, repo, prIndex).Return(timeline, nil)
|
||||
expectedText := "r1 approved a review on behalf of " + groupName
|
||||
mockGitea.EXPECT().AddReviewComment(pr, common.ReviewStateApproved, expectedText).Return(nil, nil)
|
||||
mockGitea.EXPECT().UnrequestReview(org, repo, prIndex, gomock.Any()).Return(nil)
|
||||
|
||||
mockGitea.EXPECT().SetNotificationRead(notificationID).Return(nil)
|
||||
|
||||
bot.ProcessNotifications(notification)
|
||||
|
||||
})
|
||||
|
||||
t.Run("Invalid Notification URL", func(t *testing.T) {
|
||||
badNotification := &models.NotificationThread{
|
||||
Subject: &models.NotificationSubject{
|
||||
URL: "http://gitea/invalid/url",
|
||||
},
|
||||
}
|
||||
bot.ProcessNotifications(badNotification)
|
||||
})
|
||||
|
||||
t.Run("Gitea error in GetPullRequest", func(t *testing.T) {
|
||||
mockGitea.EXPECT().GetPullRequest(org, repo, prIndex).Return(nil, fmt.Errorf("gitea error"))
|
||||
bot.ProcessNotifications(notification)
|
||||
})
|
||||
}
|
||||
|
||||
func TestReviewApprovalCheck(t *testing.T) {
|
||||
tests := []struct {
|
||||
@@ -60,16 +413,78 @@ func TestReviewApprovalCheck(t *testing.T) {
|
||||
InString: "@group2: disapprove",
|
||||
Rejected: true,
|
||||
},
|
||||
{
|
||||
Name: "Whitespace before colon",
|
||||
GroupName: "group",
|
||||
InString: "@group : LGTM",
|
||||
Approved: true,
|
||||
},
|
||||
{
|
||||
Name: "No whitespace after colon",
|
||||
GroupName: "group",
|
||||
InString: "@group:LGTM",
|
||||
Approved: true,
|
||||
},
|
||||
{
|
||||
Name: "Leading and trailing whitespace on line",
|
||||
GroupName: "group",
|
||||
InString: " @group: LGTM ",
|
||||
Approved: true,
|
||||
},
|
||||
{
|
||||
Name: "Multiline: Approved on second line",
|
||||
GroupName: "group",
|
||||
InString: "Random noise\n@group: approved",
|
||||
Approved: true,
|
||||
},
|
||||
{
|
||||
Name: "Multiline: Multiple group mentions, first wins",
|
||||
GroupName: "group",
|
||||
InString: "@group: decline\n@group: approve",
|
||||
Rejected: true,
|
||||
},
|
||||
{
|
||||
Name: "Multiline: Approved on second line",
|
||||
GroupName: "group",
|
||||
InString: "noise\n@group: approve\nmore noise",
|
||||
Approved: true,
|
||||
},
|
||||
{
|
||||
Name: "Not at start of line (even with whitespace)",
|
||||
GroupName: "group",
|
||||
InString: "Hello @group: approve",
|
||||
Approved: false,
|
||||
},
|
||||
{
|
||||
Name: "Rejecting with reason",
|
||||
GroupName: "group",
|
||||
InString: "@group: decline because of X, Y and Z",
|
||||
Rejected: true,
|
||||
},
|
||||
{
|
||||
Name: "No colon after group",
|
||||
GroupName: "group",
|
||||
InString: "@group LGTM",
|
||||
Approved: false,
|
||||
Rejected: false,
|
||||
},
|
||||
{
|
||||
Name: "Invalid char after group",
|
||||
GroupName: "group",
|
||||
InString: "@group! LGTM",
|
||||
Approved: false,
|
||||
Rejected: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.Name, func(t *testing.T) {
|
||||
InitRegex(test.GroupName)
|
||||
bot := &ReviewBot{}
|
||||
bot.InitRegex(test.GroupName)
|
||||
|
||||
if r := ReviewAccepted(test.InString); r != test.Approved {
|
||||
if r := bot.ReviewAccepted(test.InString); r != test.Approved {
|
||||
t.Error("ReviewAccepted() returned", r, "expecting", test.Approved)
|
||||
}
|
||||
if r := ReviewRejected(test.InString); r != test.Rejected {
|
||||
if r := bot.ReviewRejected(test.InString); r != test.Rejected {
|
||||
t.Error("ReviewRejected() returned", r, "expecting", test.Rejected)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -7,7 +7,9 @@ import (
|
||||
"src.opensuse.org/autogits/common"
|
||||
)
|
||||
|
||||
type IssueCommentProcessor struct{}
|
||||
type IssueCommentProcessor struct {
|
||||
bot *ReviewBot
|
||||
}
|
||||
|
||||
func (s *IssueCommentProcessor) ProcessFunc(req *common.Request) error {
|
||||
if req.Type != common.RequestType_IssueComment {
|
||||
@@ -19,14 +21,15 @@ func (s *IssueCommentProcessor) ProcessFunc(req *common.Request) error {
|
||||
repo := data.Repository.Name
|
||||
index := int64(data.Issue.Number)
|
||||
|
||||
pr, err := gitea.GetPullRequest(org, repo, index)
|
||||
pr, err := s.bot.gitea.GetPullRequest(org, repo, index)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Failed to fetch PullRequest from event: %s/%s!%d Error: %w", org, repo, index, err)
|
||||
}
|
||||
return ProcessPR(pr)
|
||||
return s.bot.ProcessPR(pr)
|
||||
}
|
||||
|
||||
type ConfigUpdatePush struct {
|
||||
bot *ReviewBot
|
||||
config_modified chan *common.AutogitConfig
|
||||
}
|
||||
|
||||
@@ -46,7 +49,7 @@ func (s *ConfigUpdatePush) ProcessFunc(req *common.Request) error {
|
||||
}
|
||||
branch := data.Ref[len(branch_ref):]
|
||||
|
||||
c := configs.GetPrjGitConfig(org, repo, branch)
|
||||
c := s.bot.configs.GetPrjGitConfig(org, repo, branch)
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
@@ -64,7 +67,7 @@ func (s *ConfigUpdatePush) ProcessFunc(req *common.Request) error {
|
||||
}
|
||||
|
||||
if modified_config {
|
||||
for _, config := range configs {
|
||||
for _, config := range s.bot.configs {
|
||||
if o, r, _ := config.GetPrjGit(); o == org && r == repo {
|
||||
s.config_modified <- config
|
||||
}
|
||||
|
||||
203
group-review/rabbit_test.go
Normal file
203
group-review/rabbit_test.go
Normal file
@@ -0,0 +1,203 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"go.uber.org/mock/gomock"
|
||||
"src.opensuse.org/autogits/common"
|
||||
"src.opensuse.org/autogits/common/gitea-generated/models"
|
||||
mock_common "src.opensuse.org/autogits/common/mock"
|
||||
)
|
||||
|
||||
func TestIssueCommentProcessor(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
defer ctrl.Finish()
|
||||
|
||||
mockGitea := mock_common.NewMockGitea(ctrl)
|
||||
groupName := "testgroup"
|
||||
bot := &ReviewBot{
|
||||
gitea: mockGitea,
|
||||
groupName: groupName,
|
||||
}
|
||||
bot.InitRegex(groupName)
|
||||
|
||||
processor := &IssueCommentProcessor{bot: bot}
|
||||
|
||||
org := "myorg"
|
||||
repo := "myrepo"
|
||||
index := 123
|
||||
|
||||
event := &common.IssueCommentWebhookEvent{
|
||||
Repository: &common.Repository{
|
||||
Name: repo,
|
||||
Owner: &common.Organization{
|
||||
Username: org,
|
||||
},
|
||||
},
|
||||
Issue: &common.IssueDetail{
|
||||
Number: index,
|
||||
},
|
||||
}
|
||||
|
||||
req := &common.Request{
|
||||
Type: common.RequestType_IssueComment,
|
||||
Data: event,
|
||||
}
|
||||
|
||||
t.Run("Successful Processing", func(t *testing.T) {
|
||||
pr := &models.PullRequest{
|
||||
Index: int64(index),
|
||||
Base: &models.PRBranchInfo{
|
||||
Name: "main",
|
||||
Repo: &models.Repository{
|
||||
Name: repo,
|
||||
Owner: &models.User{UserName: org},
|
||||
},
|
||||
},
|
||||
Head: &models.PRBranchInfo{
|
||||
Sha: "headsha",
|
||||
Repo: &models.Repository{
|
||||
Name: repo,
|
||||
Owner: &models.User{UserName: org},
|
||||
},
|
||||
},
|
||||
User: &models.User{UserName: "submitter"},
|
||||
RequestedReviewers: []*models.User{{UserName: groupName}},
|
||||
}
|
||||
|
||||
mockGitea.EXPECT().GetPullRequest(org, repo, int64(index)).Return(pr, nil)
|
||||
|
||||
prjConfig := &common.AutogitConfig{
|
||||
GitProjectName: org + "/" + repo + "#main",
|
||||
ReviewGroups: []*common.ReviewGroup{{Name: groupName, Reviewers: []string{"r1"}}},
|
||||
}
|
||||
bot.configs = common.AutogitConfigs{prjConfig}
|
||||
mockGitea.EXPECT().GetPullRequestReviews(org, repo, int64(index)).Return(nil, nil)
|
||||
mockGitea.EXPECT().GetTimeline(org, repo, int64(index)).Return(nil, nil)
|
||||
mockGitea.EXPECT().RequestReviews(pr, "r1").Return(nil, nil)
|
||||
mockGitea.EXPECT().AddComment(pr, gomock.Any()).Return(nil)
|
||||
|
||||
err := processor.ProcessFunc(req)
|
||||
if err != ReviewNotFinished {
|
||||
t.Errorf("Expected ReviewNotFinished, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Gitea error in GetPullRequest", func(t *testing.T) {
|
||||
mockGitea.EXPECT().GetPullRequest(org, repo, int64(index)).Return(nil, fmt.Errorf("gitea error"))
|
||||
err := processor.ProcessFunc(req)
|
||||
if err == nil {
|
||||
t.Error("Expected error, got nil")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Wrong Request Type", func(t *testing.T) {
|
||||
wrongReq := &common.Request{Type: common.RequestType_Push}
|
||||
err := processor.ProcessFunc(wrongReq)
|
||||
if err == nil {
|
||||
t.Error("Expected error for wrong request type, got nil")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestConfigUpdatePush(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
defer ctrl.Finish()
|
||||
|
||||
groupName := "testgroup"
|
||||
bot := &ReviewBot{
|
||||
groupName: groupName,
|
||||
}
|
||||
bot.InitRegex(groupName)
|
||||
|
||||
configChan := make(chan *common.AutogitConfig, 1)
|
||||
processor := &ConfigUpdatePush{
|
||||
bot: bot,
|
||||
config_modified: configChan,
|
||||
}
|
||||
|
||||
org := "myorg"
|
||||
repo := "myrepo"
|
||||
branch := "main"
|
||||
|
||||
prjConfig := &common.AutogitConfig{
|
||||
GitProjectName: org + "/" + repo + "#" + branch,
|
||||
Organization: org,
|
||||
Branch: branch,
|
||||
}
|
||||
bot.configs = common.AutogitConfigs{prjConfig}
|
||||
|
||||
event := &common.PushWebhookEvent{
|
||||
Ref: "refs/heads/" + branch,
|
||||
Repository: &common.Repository{
|
||||
Name: repo,
|
||||
Owner: &common.Organization{
|
||||
Username: org,
|
||||
},
|
||||
},
|
||||
Commits: []common.Commit{
|
||||
{
|
||||
Modified: []string{common.ProjectConfigFile},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
req := &common.Request{
|
||||
Type: common.RequestType_Push,
|
||||
Data: event,
|
||||
}
|
||||
|
||||
t.Run("Config Modified", func(t *testing.T) {
|
||||
err := processor.ProcessFunc(req)
|
||||
if err != nil {
|
||||
t.Errorf("Expected nil error, got %v", err)
|
||||
}
|
||||
|
||||
select {
|
||||
case modified := <-configChan:
|
||||
if modified != prjConfig {
|
||||
t.Errorf("Expected modified config to be %v, got %v", prjConfig, modified)
|
||||
}
|
||||
default:
|
||||
t.Error("Expected config modification signal, but none received")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("No Config Modified", func(t *testing.T) {
|
||||
noConfigEvent := *event
|
||||
noConfigEvent.Commits = []common.Commit{{Modified: []string{"README.md"}}}
|
||||
noConfigReq := &common.Request{Type: common.RequestType_Push, Data: &noConfigEvent}
|
||||
|
||||
err := processor.ProcessFunc(noConfigReq)
|
||||
if err != nil {
|
||||
t.Errorf("Expected nil error, got %v", err)
|
||||
}
|
||||
|
||||
select {
|
||||
case <-configChan:
|
||||
t.Error("Did not expect config modification signal")
|
||||
default:
|
||||
// Success
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Wrong Branch Ref", func(t *testing.T) {
|
||||
wrongBranchEvent := *event
|
||||
wrongBranchEvent.Ref = "refs/tags/v1.0"
|
||||
wrongBranchReq := &common.Request{Type: common.RequestType_Push, Data: &wrongBranchEvent}
|
||||
|
||||
err := processor.ProcessFunc(wrongBranchReq)
|
||||
if err == nil {
|
||||
t.Error("Expected error for wrong branch ref, got nil")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Config Not Found", func(t *testing.T) {
|
||||
bot.configs = common.AutogitConfigs{}
|
||||
err := processor.ProcessFunc(req)
|
||||
if err != nil {
|
||||
t.Errorf("Expected nil error even if config not found, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -266,13 +266,37 @@ func main() {
|
||||
}
|
||||
}()
|
||||
|
||||
// Serve static files (CSS, JS, images)
|
||||
http.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.Dir("static"))))
|
||||
|
||||
// Serve favicon
|
||||
http.HandleFunc("GET /favicon.ico", func(res http.ResponseWriter, req *http.Request) {
|
||||
data, err := os.ReadFile("static/favicon.svg")
|
||||
if err != nil {
|
||||
res.WriteHeader(404)
|
||||
return
|
||||
}
|
||||
res.Header().Set("content-type", "image/svg+xml")
|
||||
res.Write(data)
|
||||
})
|
||||
|
||||
http.HandleFunc("GET /", func(res http.ResponseWriter, req *http.Request) {
|
||||
if rescanRepoError != nil {
|
||||
res.WriteHeader(500)
|
||||
return
|
||||
}
|
||||
res.WriteHeader(404)
|
||||
res.Write([]byte("404 page not found\n"))
|
||||
|
||||
path := "static/index.html"
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
res.WriteHeader(404)
|
||||
res.Write([]byte("404 page not found\n"))
|
||||
return
|
||||
}
|
||||
|
||||
res.Header().Add("content-type", "text/html")
|
||||
res.Write(data)
|
||||
|
||||
})
|
||||
http.HandleFunc("GET /status/{Project}", func(res http.ResponseWriter, req *http.Request) {
|
||||
mime := ParseMimeHeader(req)
|
||||
|
||||
1
obs-status-service/static/favicon.svg
Normal file
1
obs-status-service/static/favicon.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="#73BA25" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 458 512"><path d="M201.741 0A256.473 256.473 0 0089.477 26.06 256.206 256.206 0 000 98.632c31.256 8.342 53.38 15.512 60.005 17.736.104-4.061.778-40.382.778-40.382s.086-.832.53-1.265c.572-.558 1.398-.39 1.398-.39 8.221 1.19 183.695 27.173 257.658 70.23 9.134 5.339 13.643 11.014 19.276 16.768 20.446 21.125 47.462 108.961 50.363 127.074.114.712-.766 1.485-1.142 1.778h-.01c-2.101 1.637-4.388 3.34-6.679 4.861-17.501 11.731-57.82 39.926-109.549 35.322-46.465-4.106-107.171-30.732-180.354-78.903 7.195 16.83 14.285 33.712 21.378 50.585 10.598 5.499 112.893 57.571 163.379 56.554 40.664-.846 84.155-20.66 101.554-31.121 0 0 3.823-2.302 5.487-1.017 1.82 1.404 1.317 3.557.886 5.754-1.071 4.99-3.507 14.093-5.165 18.414l-1.399 3.522c-1.991 5.33-3.903 10.285-7.589 13.336-10.25 9.311-26.609 16.718-52.242 27.848-39.621 17.314-103.904 28.325-163.586 27.946-21.376-.475-42.028-2.844-60.162-4.961-37.216-4.197-67.497-7.602-85.96 5.739a256.232 256.232 0 0086.911 64.833A256.481 256.481 0 00201.741 512c33.652 0 66.975-6.621 98.066-19.487a256.285 256.285 0 0083.137-55.493 255.99 255.99 0 0055.55-83.053 255.776 255.776 0 000-195.934 255.981 255.981 0 00-55.55-83.052 256.277 256.277 0 00-83.137-55.494A256.48 256.48 0 00201.741 0zm57.158 148.539c-20.058-.639-39.184 6.432-53.847 20.108-14.657 13.631-23.044 32.215-23.787 52.239-1.386 41.309 31.08 76.116 72.395 77.638 20.149.676 39.234-6.4 53.897-20.157 14.619-13.589 23.007-32.173 23.787-52.198 1.42-41.272-31.085-76.152-72.445-77.63zm-.629 22.093c28.905 1.024 51.535 25.281 50.562 54.148-.447 13.922-6.325 26.826-16.537 36.397-10.226 9.526-23.593 14.479-37.617 14.056-28.838-1.063-51.469-25.348-50.495-54.223.424-13.973 6.419-26.879 16.586-36.405 10.167-9.526 23.469-14.478 37.501-13.973zm8.566 26.251c-12.842 0-23.215 6.905-23.215 15.495 0 8.509 10.373 15.453 23.215 15.453 12.836 0 23.249-6.944 23.249-15.453 0-8.59-10.406-15.495-23.249-15.495z" fill="#73BA25"/></svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
129
obs-status-service/static/index.html
Normal file
129
obs-status-service/static/index.html
Normal file
@@ -0,0 +1,129 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-theme="dark">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>OBS Status Service</title>
|
||||
<meta name="description" content="Generate embeddable SVG badges showing build status from Open Build Service">
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.ico">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css" />
|
||||
<link rel="stylesheet" href="/static/style.css" />
|
||||
<script src="/static/script.js" defer></script>
|
||||
</head>
|
||||
|
||||
<body class="container">
|
||||
<header>
|
||||
<h1><img src="/static/logo.svg" alt="openSUSE Logo" class="logo-icon"> OBS Status Service</h1>
|
||||
<p>Generate embeddable SVG badges showing build status from Open Build Service.</p>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<section>
|
||||
<h2>Get your Build Result Image</h2>
|
||||
<div class="grid">
|
||||
<article>
|
||||
<fieldset>
|
||||
<label>
|
||||
Project <small>(required)</small>
|
||||
<input id="project" name="project" placeholder="devel:languages:python:Factory"
|
||||
oninput="updatePreview()" autocomplete="off" />
|
||||
</label>
|
||||
<label>
|
||||
Package <small>(optional)</small>
|
||||
<input id="package" name="package" placeholder="python313" oninput="updatePreview()"
|
||||
autocomplete="off" />
|
||||
</label>
|
||||
<label>
|
||||
Repository / Architecture <small>(optional)</small>
|
||||
<input id="repo" name="repo" placeholder="openSUSE_Tumbleweed/x86_64"
|
||||
oninput="updatePreview()" autocomplete="off" />
|
||||
</label>
|
||||
</fieldset>
|
||||
</article>
|
||||
<article>
|
||||
<h3>Preview</h3>
|
||||
<div id="preview-container">
|
||||
<object id="preview" data="" type="image/svg+xml" aria-label="Build Result"
|
||||
style="display: none;"></object>
|
||||
<p id="preview-hint"><small>Enter a project name to see the preview</small></p>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div id="link-section" class="result-section">
|
||||
<h3>Link</h3>
|
||||
<div class="input-group">
|
||||
<input id="result-link" readonly />
|
||||
<button id="copy-link-btn" class="copy-btn" onclick="copyResult('result-link', 'copy-link-btn')">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none"
|
||||
class="copy-icon">
|
||||
<rect width="14" height="14" x="8" y="8" rx="2" ry="2" />
|
||||
<path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="markdown-section" class="result-section">
|
||||
<h3>Markdown</h3>
|
||||
<div class="markdown-container">
|
||||
<pre id="result-markdown"></pre>
|
||||
<button id="copy-markdown-btn" class="copy-btn"
|
||||
onclick="copyResult('result-markdown', 'copy-markdown-btn')">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none"
|
||||
class="copy-icon">
|
||||
<rect width="14" height="14" x="8" y="8" rx="2" ry="2" />
|
||||
<path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<hr />
|
||||
|
||||
<section>
|
||||
<h2>Usage</h2>
|
||||
|
||||
<h4>Request Formats</h4>
|
||||
<p>Requests for individual build results:</p>
|
||||
<pre>/status/{project}/{package}/{repo}/{arch}</pre>
|
||||
<p>Where <code>package</code>, <code>repo</code> and <code>arch</code> are optional parameters.</p>
|
||||
|
||||
<p>Requests for project results:</p>
|
||||
<pre>/status/{project}</pre>
|
||||
|
||||
<h4>Output Formats</h4>
|
||||
<p>By default, SVG output is generated. JSON and XML output is possible by setting the <code>Accept</code>
|
||||
request header:</p>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Accept Header</th>
|
||||
<th>Output Format</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><em>(default)</em></td>
|
||||
<td>SVG image</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>application/json</code></td>
|
||||
<td>JSON data</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>application/obs+xml</code></td>
|
||||
<td>XML output</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
</main>
|
||||
<footer>
|
||||
<p><small>Powered by <a href="https://src.opensuse.org/git-workflow/autogits" target="_blank" rel="noopener">Autogits</a> | openSUSE Project</small></p>
|
||||
</footer>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
1
obs-status-service/static/logo.svg
Normal file
1
obs-status-service/static/logo.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="#73BA25" viewBox="0 0 24 24" role="img" xmlns="http://www.w3.org/2000/svg"><g id="SVGRepo_bgCarrier" stroke-width="0"></g><g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"></g><g id="SVGRepo_iconCarrier"><title>openSUSE icon</title><path d="M21.51 8.107a.976.976 0 0 0-.708.264.993.993 0 0 0 .64 1.714.991.991 0 0 0 1.024-.954.992.992 0 0 0-.955-1.024zm.162 1.082c-.242 0-.438-.131-.438-.292 0-.163.196-.293.438-.293.243 0 .44.13.44.293 0 .16-.197.292-.44.292zm2.306 1.18c.007-.006.024-.02.022-.034-.055-.343-.565-2.004-.952-2.404-.106-.109-.191-.216-.364-.317-1.398-.814-4.713-1.306-4.869-1.328 0 0-.015-.004-.026.007-.009.008-.01.024-.01.024l-.015.764c-.339-.114-2.8-.91-5.108-.99C10.7 6.024 7.85 5.77 4.072 8.093l-.111.07C2.184 9.27.957 10.637.316 12.224c-.201.5-.472 1.628-.204 2.688.116.464.331.93.621 1.347.656.943 1.757 1.568 2.943 1.674 1.674.15 2.941-.602 3.392-2.01.31-.971 0-2.397-1.188-3.124-.967-.591-2.006-.457-2.609-.058-.523.347-.819.886-.814 1.477.012 1.05.917 1.608 1.567 1.61.189 0 .378-.033.592-.103a.921.921 0 0 0 .227-.1l.025-.015.015-.01-.005.003a.535.535 0 0 0 .217-.587.533.533 0 0 0-.612-.377l-.036.008-.05.015-.072.025c-.15.037-.262.04-.286.041-.076-.005-.45-.117-.45-.527v-.005c0-.151.06-.257.093-.314.117-.183.435-.362.866-.325.565.05.973.34 1.243.886.25.508.185 1.134-.17 1.592-.35.454-.976.647-1.809.557a2.48 2.48 0 0 1-1.946-1.327c-.389-.735-.41-1.607-.055-2.276.85-1.604 2.455-1.587 3.334-1.435 1.302.226 2.784 1.427 3.309 2.814.085.22.128.396.166.556l.057.24 1.47.718c.032.015.043.02.055.011.016-.011.007-.042.007-.042-.01-.033-.03-.063-.065-.475-.027-.365-.084-1.365.42-1.86.195-.195.492-.367.728-.423.964-.235 2.094-.073 3.163 1.164.553.64.823.93.959 1.061 0 0 .03.03.047.043.018.015.03.027.055.041.045.025 1.838.85 1.838.85s.022.011.037-.008c.016-.02.001-.038.001-.038-.012-.014-1.137-1.468-.937-2.665.158-.954.917-.867 1.967-.749.343.04.733.085 1.137.094 1.127.007 2.342-.201 3.09-.529.485-.21.794-.35.988-.526.07-.058.106-.152.143-.253l.027-.066c.031-.082.077-.254.097-.348.009-.042.018-.083-.016-.11-.032-.024-.104.02-.104.02-.329.198-1.15.573-1.919.589-.954.019-2.887-.966-3.087-1.07-.134-.32-.268-.639-.404-.957 1.383.911 2.53 1.415 3.408 1.492.977.088 1.74-.446 2.07-.668.043-.028.086-.06.126-.092zm-3.923-1.311c.014-.379.173-.73.45-.988a1.414 1.414 0 0 1 1.017-.38 1.423 1.423 0 0 1 1.37 1.468c-.015.379-.174.73-.45.987-.277.26-.638.394-1.019.381a1.424 1.424 0 0 1-1.368-1.468z"></path></g></svg>
|
||||
|
After Width: | Height: | Size: 2.4 KiB |
83
obs-status-service/static/script.js
Normal file
83
obs-status-service/static/script.js
Normal file
@@ -0,0 +1,83 @@
|
||||
function updatePreview() {
|
||||
const project = document.getElementById('project').value.trim();
|
||||
const pkg = document.getElementById('package').value.trim();
|
||||
const repo = document.getElementById('repo').value.trim();
|
||||
const hint = document.getElementById('preview-hint');
|
||||
const preview = document.getElementById('preview');
|
||||
const link = document.getElementById('result-link');
|
||||
const markdown = document.getElementById('result-markdown');
|
||||
const linkSection = document.getElementById('link-section');
|
||||
const markdownSection = document.getElementById('markdown-section');
|
||||
|
||||
if (!project) {
|
||||
hint.style.display = 'block';
|
||||
preview.style.display = 'none';
|
||||
preview.data = '';
|
||||
link.value = '';
|
||||
markdown.textContent = '';
|
||||
linkSection.classList.remove('visible');
|
||||
markdownSection.classList.remove('visible');
|
||||
return;
|
||||
}
|
||||
|
||||
// Build the path correctly with all components
|
||||
let path = '/status/' + encodeURIComponent(project);
|
||||
if (pkg) {
|
||||
path += '/' + encodeURIComponent(pkg);
|
||||
}
|
||||
if (repo) {
|
||||
path += '/' + repo; // repo already contains / for arch
|
||||
}
|
||||
|
||||
const fullUrl = window.location.origin + path;
|
||||
|
||||
hint.style.display = 'none';
|
||||
preview.style.display = 'block';
|
||||
|
||||
// Force reload of object to ensure it updates even if path is similar or cached
|
||||
const newPreview = preview.cloneNode(true);
|
||||
newPreview.data = path;
|
||||
preview.parentNode.replaceChild(newPreview, preview);
|
||||
// Update reference if we were using it elsewhere, though here we just exit
|
||||
|
||||
link.value = fullUrl;
|
||||
markdown.textContent = '';
|
||||
|
||||
// Show result sections with animation
|
||||
linkSection.classList.add('visible');
|
||||
markdownSection.classList.add('visible');
|
||||
}
|
||||
|
||||
function copyResult(elementId, btnId) {
|
||||
let textToCopy = '';
|
||||
const element = document.getElementById(elementId);
|
||||
|
||||
if (element.tagName === 'INPUT' || element.tagName === 'TEXTAREA') {
|
||||
textToCopy = element.value;
|
||||
} else {
|
||||
textToCopy = element.textContent;
|
||||
}
|
||||
|
||||
const btn = document.getElementById(btnId);
|
||||
|
||||
if (!textToCopy) return;
|
||||
|
||||
navigator.clipboard.writeText(textToCopy).then(() => {
|
||||
const originalHTML = btn.innerHTML;
|
||||
btn.textContent = '✓';
|
||||
btn.classList.add('copied');
|
||||
|
||||
setTimeout(() => {
|
||||
btn.innerHTML = originalHTML;
|
||||
btn.classList.remove('copied');
|
||||
}, 2000);
|
||||
}).catch(err => {
|
||||
console.error('Failed to copy:', err);
|
||||
btn.textContent = 'Error';
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize on page load
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
updatePreview();
|
||||
});
|
||||
324
obs-status-service/static/style.css
Normal file
324
obs-status-service/static/style.css
Normal file
@@ -0,0 +1,324 @@
|
||||
/* OBS Status Service - Custom Styles */
|
||||
|
||||
/* openSUSE Brand Colors */
|
||||
:root {
|
||||
--opensuse-green: #73ba25;
|
||||
--opensuse-dark-green: #6da741;
|
||||
--opensuse-darker: #35b4a1;
|
||||
--bg-dark: #1a1a2e;
|
||||
--bg-card: #16213e;
|
||||
--text-muted: #8892b0;
|
||||
--border-color: #233554;
|
||||
|
||||
/* Override Pico primary color */
|
||||
--pico-primary: var(--opensuse-green);
|
||||
--pico-primary-hover: var(--opensuse-dark-green);
|
||||
|
||||
--font-family: "Lucida Sans", "Lucida Sans Regular", "Lucida Grande", "Lucida Sans Unicode", Geneva, Verdana, sans-serif;
|
||||
}
|
||||
|
||||
/* Dark theme */
|
||||
html {
|
||||
background: linear-gradient(135deg, var(--bg-dark) 0%, #0f0f1a 100%);
|
||||
min-height: 100vh;
|
||||
font-family: var(--font-family);
|
||||
}
|
||||
|
||||
body.container {
|
||||
max-width: 900px;
|
||||
padding-top: 2rem;
|
||||
padding-bottom: 3rem;
|
||||
}
|
||||
|
||||
/* Header styling */
|
||||
h1 {
|
||||
color: var(--opensuse-green);
|
||||
font-size: 2.5rem;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
/* Removed emoji ::before content */
|
||||
|
||||
.logo-icon {
|
||||
height: 2.5rem !important;
|
||||
/* Adjusted to match text size */
|
||||
width: auto;
|
||||
}
|
||||
|
||||
h2 {
|
||||
color: #e6f1ff;
|
||||
border-bottom: 2px solid var(--opensuse-green);
|
||||
padding-bottom: 0.5rem;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
h3,
|
||||
h4 {
|
||||
color: #ccd6f6;
|
||||
}
|
||||
|
||||
/* Card styling */
|
||||
article {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
/* Form inputs */
|
||||
fieldset {
|
||||
border: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
input {
|
||||
background: rgba(255, 255, 255, 0.05) !important;
|
||||
border: 1px solid var(--border-color) !important;
|
||||
border-radius: 8px !important;
|
||||
color: #e6f1ff !important;
|
||||
transition: border-color 0.2s ease, box-shadow 0.2s ease;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
input:focus {
|
||||
border-color: var(--opensuse-green) !important;
|
||||
box-shadow: 0 0 0 3px rgba(115, 186, 37, 0.2) !important;
|
||||
}
|
||||
|
||||
input::placeholder {
|
||||
color: var(--text-muted) !important;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
label {
|
||||
color: #a8b2d1;
|
||||
font-weight: 500;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* Preview section */
|
||||
#preview-container {
|
||||
min-height: 150px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
#preview {
|
||||
max-width: 100%;
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
min-height: 80px;
|
||||
}
|
||||
|
||||
#preview-hint {
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
border: 2px dashed var(--border-color);
|
||||
border-radius: 8px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Results section - hidden by default */
|
||||
.result-section {
|
||||
margin-top: 1.5rem;
|
||||
opacity: 0;
|
||||
max-height: 0;
|
||||
overflow: hidden;
|
||||
transition: opacity 0.3s ease, max-height 0.3s ease;
|
||||
}
|
||||
|
||||
.result-section.visible {
|
||||
opacity: 1;
|
||||
max-height: 200px;
|
||||
overflow: visible;
|
||||
/* Allow copy button to be interactive */
|
||||
}
|
||||
|
||||
.result-section h3 {
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 1rem;
|
||||
color: var(--opensuse-green);
|
||||
}
|
||||
|
||||
/* Input Groups for Result Sections */
|
||||
.input-group,
|
||||
.markdown-container {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.input-group input {
|
||||
background: transparent !important;
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
margin-bottom: 0;
|
||||
font-family: 'Fira Code', 'Monaco', monospace;
|
||||
font-size: 0.9rem;
|
||||
color: var(--opensuse-green) !important;
|
||||
}
|
||||
|
||||
.input-group input:focus {
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
/* Markdown specific container override */
|
||||
.markdown-container {
|
||||
padding: 1rem;
|
||||
padding-right: 4rem;
|
||||
/* Space for button */
|
||||
display: block;
|
||||
/* Use block for pre tag */
|
||||
}
|
||||
|
||||
#result-markdown {
|
||||
margin: 0;
|
||||
font-family: 'Fira Code', 'Monaco', monospace;
|
||||
font-size: 0.85rem;
|
||||
color: #a8b2d1;
|
||||
word-break: break-all;
|
||||
white-space: pre-wrap;
|
||||
border: none;
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
/* Copy button */
|
||||
.copy-btn {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 0.5rem;
|
||||
transform: translateY(-50%);
|
||||
background: transparent;
|
||||
color: var(--opensuse-green);
|
||||
border: 1px solid var(--opensuse-green);
|
||||
border-radius: 6px;
|
||||
padding: 0.4rem 0.8rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
transition: all 0.2s ease;
|
||||
z-index: 10;
|
||||
font-weight: bold;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.copy-icon {
|
||||
stroke: currentColor;
|
||||
stroke-width: 2;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.copy-btn:hover {
|
||||
background: var(--opensuse-green);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* .markdown-container .copy-btn specific overrides removed to allow vertical centering */
|
||||
|
||||
/* Hover state handled above */
|
||||
|
||||
.copy-btn:active {
|
||||
transform: translateY(-50%) scale(0.95);
|
||||
}
|
||||
|
||||
.markdown-container .copy-btn:active {
|
||||
transform: translateY(-50%) scale(0.95);
|
||||
}
|
||||
|
||||
.copy-btn.copied {
|
||||
background: #28a745;
|
||||
}
|
||||
|
||||
/* Horizontal rules */
|
||||
hr {
|
||||
border: none;
|
||||
height: 1px;
|
||||
background: linear-gradient(to right, transparent, var(--border-color), transparent);
|
||||
margin: 2.5rem 0;
|
||||
}
|
||||
|
||||
/* Code blocks */
|
||||
pre,
|
||||
code {
|
||||
background: rgba(0, 0, 0, 0.3) !important;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
color: var(--opensuse-green) !important;
|
||||
font-family: 'Fira Code', 'Monaco', monospace;
|
||||
}
|
||||
|
||||
pre {
|
||||
padding: 1rem;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
code {
|
||||
padding: 0.2rem 0.5rem;
|
||||
}
|
||||
|
||||
/* Table styling */
|
||||
table {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
thead {
|
||||
background: rgba(115, 186, 37, 0.1);
|
||||
}
|
||||
|
||||
th {
|
||||
color: var(--opensuse-green) !important;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
td,
|
||||
th {
|
||||
border-color: var(--border-color) !important;
|
||||
}
|
||||
|
||||
/* Lists */
|
||||
ul {
|
||||
color: #a8b2d1;
|
||||
}
|
||||
|
||||
li {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
/* Paragraphs */
|
||||
p {
|
||||
color: #8892b0;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 768px) {
|
||||
h1 {
|
||||
font-size: 1.8rem;
|
||||
}
|
||||
|
||||
.logo-icon {
|
||||
height: 2rem !important;
|
||||
}
|
||||
|
||||
.grid {
|
||||
grid-template-columns: 1fr !important;
|
||||
}
|
||||
}
|
||||
15
systemd/group-review@.service
Normal file
15
systemd/group-review@.service
Normal file
@@ -0,0 +1,15 @@
|
||||
[Unit]
|
||||
Description=Group Review bot for %i
|
||||
After=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=exec
|
||||
ExecStart=/usr/bin/group-review %i
|
||||
EnvironmentFile=-/etc/default/group-review/%i.env
|
||||
DynamicUser=yes
|
||||
NoNewPrivileges=yes
|
||||
ProtectSystem=strict
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
||||
Reference in New Issue
Block a user