Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 12 additions & 5 deletions .github/workflows/docker.yml
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
name: Publish Docker image

# Called by go.yml after GoReleaser: a release it publishes with GITHUB_TOKEN
# does not trigger workflows, so `on: release` never fired. Manual dispatch
# rebuilds a tag without moving `latest`.
on:
release:
types: [published]
workflow_call:
inputs:
tag:
type: string
required: true
workflow_dispatch:
inputs:
tag:
Expand All @@ -20,7 +26,7 @@ jobs:
- name: Check out the repo
uses: actions/checkout@v5
with:
ref: ${{ inputs.tag || github.ref }}
ref: ${{ inputs.tag }}

- name: Log in to Docker Hub
uses: docker/login-action@v3
Expand All @@ -43,7 +49,8 @@ jobs:
vmkteam/reviewer
ghcr.io/${{ github.repository }}
tags: |
type=raw,value=${{ inputs.tag }},enable=${{ github.event_name == 'workflow_dispatch' }}
type=raw,value=${{ inputs.tag }}
type=raw,value=latest,enable=${{ github.event_name != 'workflow_dispatch' }}

- name: Build and push Docker images
uses: docker/build-push-action@v6
Expand All @@ -53,4 +60,4 @@ jobs:
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
VERSION=${{ inputs.tag || github.ref_name }}
VERSION=${{ inputs.tag }}
10 changes: 10 additions & 0 deletions .github/workflows/go.yml
Original file line number Diff line number Diff line change
Expand Up @@ -58,3 +58,13 @@ jobs:
args: release --clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

docker:
needs: release
permissions:
contents: read
packages: write
uses: ./.github/workflows/docker.yml
with:
tag: ${{ github.ref_name }}
secrets: inherit
11 changes: 8 additions & 3 deletions docs/llm/Slack.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,18 +49,23 @@ Webhook URL привязываСтся ΠΊ ΠΊΠ°Π½Π°Π»Ρƒ Ρ‡Π΅Ρ€Π΅Π· VT-Π°Π΄ΠΌΠΈΠ½

```json
{
"text": ":red_circle: *<reviewURL|Review Title>* by author (`source` β†’ `target`) β€” 2 critical, 1 high, 3 medium, 0 low"
"text": ":red_circle: [project] <mrURL|MR !42> *<reviewURL|Review Title>* by author (`source` β†’ `target`) β€” 2 critical, 1 high, 3 medium, 0 low"
}
```

Бсылка Π½Π° MR (`<mrURL|MR !42>`) выводится, Ρ‚ΠΎΠ»ΡŒΠΊΠΎ Ссли `externalId` Ρ€Π΅Π²ΡŒΡŽ β€” ΠΏΠΎΠ»ΠΎΠΆΠΈΡ‚Π΅Π»ΡŒΠ½Ρ‹ΠΉ Π½ΠΎΠΌΠ΅Ρ€ MR,
Π° Ρƒ ΠΏΡ€ΠΎΠ΅ΠΊΡ‚Π° Π·Π°Π΄Π°Π½ `vcsURL` (`reviewer.Project.MergeRequestLink`). Для GitLab β€”
`<vcsURL>/-/merge_requests/<id>` с ΠΌΠ΅Ρ‚ΠΊΠΎΠΉ `MR !<id>`, для GitHub β€” `<vcsURL>/pull/<id>` с ΠΌΠ΅Ρ‚ΠΊΠΎΠΉ `PR #<id>`.
Π˜Π½Π°Ρ‡Π΅ ссылки Π½Π΅Ρ‚, ΠΎΡΡ‚Π°Π»ΡŒΠ½ΠΎΠΉ тСкст Π½Π΅ мСняСтся. Π‘ΠΈΠΌΠ²ΠΎΠ»Ρ‹ `& < >` Π² ΠΏΡ€ΠΎΠ΅ΠΊΡ‚Π΅, Π·Π°Π³ΠΎΠ»ΠΎΠ²ΠΊΠ΅, Π°Π²Ρ‚ΠΎΡ€Π΅ ΠΈ Π²Π΅Ρ‚ΠΊΠ°Ρ… ΡΠΊΡ€Π°Π½ΠΈΡ€ΡƒΡŽΡ‚ΡΡ.

ΠŸΡ€ΠΈΠΌΠ΅Ρ€Ρ‹:

```
:red_circle: *<https://reviewsrv.example.com/reviews/5/|CHT-47: Add payment processing>* by john (`feature/pay` β†’ `main`) β€” 2 critical, 1 high, 3 medium, 0 low
:red_circle: [group/app] <https://git.example.com/group/app/-/merge_requests/42|MR !42> *<https://reviewsrv.example.com/reviews/5/|CHT-47: Add payment processing>* by john (`feature/pay` β†’ `main`) β€” 2 critical, 1 high, 3 medium, 0 low
```

```
:large_green_circle: *<https://reviewsrv.example.com/reviews/5/|CHT-52: Fix typo in readme>* by anna (`fix/typo` β†’ `main`) β€” 0 critical, 0 high, 1 medium, 2 low
:large_green_circle: [group/app] *<https://reviewsrv.example.com/reviews/5/|CHT-52: Fix typo in readme>* by anna (`fix/typo` β†’ `main`) β€” 0 critical, 0 high, 1 medium, 2 low
```

Маппинг `trafficLight` β†’ emoji:
Expand Down
3 changes: 3 additions & 0 deletions pkg/rest/rest.go
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ func (h *Handler) notifySlack(project *reviewer.Project, rv *reviewer.Review) {
stats.Low += rf.IssueStats.Low
}

mrURL, mrLabel := project.MergeRequestLink(rv.ExternalID)
notif := slack.ReviewNotification{
WebhookURL: project.SlackChannel.WebhookURL,
ProjectTitle: project.Title,
Expand All @@ -114,6 +115,8 @@ func (h *Handler) notifySlack(project *reviewer.Project, rv *reviewer.Review) {
TrafficLight: rv.TrafficLight,
IssueStats: stats,
ReviewURL: fmt.Sprintf("%s/reviews/%d/", h.baseURL, rv.ID),
MRURL: mrURL,
MRLabel: mrLabel,
}

go h.notifier.Send(context.Background(), notif)
Expand Down
22 changes: 22 additions & 0 deletions pkg/rest/rest_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package rest

import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strconv"
Expand Down Expand Up @@ -247,3 +248,24 @@ func TestNotifySlackSkipsMemberReviews(t *testing.T) {
require.Eventually(t, func() bool { return posts.Load() == 1 }, 5*time.Second, 10*time.Millisecond)
assert.Never(t, func() bool { return posts.Load() > 1 }, 200*time.Millisecond, 10*time.Millisecond, "a member review is not announced")
}

func TestNotifySlackLinksMergeRequest(t *testing.T) {
texts := make(chan string, 1)
webhook := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
var msg struct{ Text string }
_ = json.NewDecoder(r.Body).Decode(&msg)
texts <- msg.Text
}))
t.Cleanup(webhook.Close)
h := &Handler{notifier: slack.NewNotifier(embedlog.NewDevLogger()), baseURL: "http://localhost"}
project := &reviewer.Project{Project: db.Project{Title: "p", VcsURL: "https://git.example.com/group/app", SlackChannel: &db.SlackChannel{WebhookURL: webhook.URL}}}

h.notifySlack(project, &reviewer.Review{Review: db.Review{ID: 1, ExternalID: "42", ReviewRole: reviewer.ReviewRoleSingle}})

select {
case text := <-texts:
assert.Contains(t, text, "<https://git.example.com/group/app/-/merge_requests/42|MR !42>")
case <-time.After(5 * time.Second):
t.Fatal("no slack notification sent")
}
}
20 changes: 20 additions & 0 deletions pkg/reviewer/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@ package reviewer

import (
"errors"
"fmt"
"slices"
"strconv"
"strings"
"time"

"reviewsrv/pkg/db"
Expand Down Expand Up @@ -129,6 +132,23 @@ func (p *Project) HasSlackWebhook() bool {
return p.SlackChannel != nil && p.SlackChannel.WebhookURL != ""
}

// MergeRequestLink returns the VCS URL and label ("MR !42" on GitLab, "PR #42" on
// GitHub) of the merge request with the given external ID. Both are empty unless
// externalID is a positive MR number and the project has a VCS URL.
func (p *Project) MergeRequestLink(externalID string) (url, label string) {
iid, err := strconv.Atoi(externalID)
if err != nil || iid <= 0 || p.VcsURL == "" {
return "", ""
}

base := strings.TrimRight(p.VcsURL, "/")
if strings.Contains(base, "github.com") {
return fmt.Sprintf("%s/pull/%d", base, iid), fmt.Sprintf("PR #%d", iid)
}

return fmt.Sprintf("%s/-/merge_requests/%d", base, iid), fmt.Sprintf("MR !%d", iid)
}

type TaskTracker struct {
db.TaskTracker
}
Expand Down
27 changes: 27 additions & 0 deletions pkg/reviewer/model_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,33 @@ func TestHasSlackWebhook(t *testing.T) {
}
}

func TestMergeRequestLink(t *testing.T) {
tests := []struct {
name string
vcsURL string
externalID string
wantURL string
wantLabel string
}{
{"gitlab", "https://git.example.com/group/app", "42", "https://git.example.com/group/app/-/merge_requests/42", "MR !42"},
{"github", "https://github.com/org/app", "7", "https://github.com/org/app/pull/7", "PR #7"},
{"trailing slash", "https://git.example.com/group/app/", "42", "https://git.example.com/group/app/-/merge_requests/42", "MR !42"},
{"empty id", "https://git.example.com/group/app", "", "", ""},
{"zero id", "https://git.example.com/group/app", "0", "", ""},
{"not a number", "https://git.example.com/group/app", "%EXTERNAL_ID%", "", ""},
{"no vcs url", "", "42", "", ""},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
p := &Project{db.Project{VcsURL: tt.vcsURL}}
url, label := p.MergeRequestLink(tt.externalID)
assert.Equal(t, tt.wantURL, url)
assert.Equal(t, tt.wantLabel, label)
})
}
}

func TestReviewSearch_ToDB(t *testing.T) {
t.Run("nil receiver", func(t *testing.T) {
var s *ReviewSearch
Expand Down
26 changes: 20 additions & 6 deletions pkg/slack/slack.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"fmt"
"io"
"net/http"
"strings"
"time"

"github.com/vmkteam/embedlog"
Expand Down Expand Up @@ -46,6 +47,9 @@ type ReviewNotification struct {
TrafficLight string
IssueStats IssueStats
ReviewURL string
// MRURL and MRLabel are empty when the review has no merge request.
MRURL string
MRLabel string
}

func trafficLightEmoji(tl string) string {
Expand All @@ -61,15 +65,25 @@ func trafficLightEmoji(tl string) string {
}
}

// escaper escapes the control characters of Slack mrkdwn, so a "<" or ">" in a
// title does not break the surrounding link.
var escaper = strings.NewReplacer("&", "&amp;", "<", "&lt;", ">", "&gt;")

func (n ReviewNotification) text() string {
return fmt.Sprintf("%s [%s] *<%s|%s>* by %s (`%s` β†’ `%s`) β€” %d critical, %d high, %d medium, %d low",
var mr string
if n.MRURL != "" {
mr = fmt.Sprintf("<%s|%s> ", n.MRURL, escaper.Replace(n.MRLabel))
}

return fmt.Sprintf("%s [%s] %s*<%s|%s>* by %s (`%s` β†’ `%s`) β€” %d critical, %d high, %d medium, %d low",
trafficLightEmoji(n.TrafficLight),
n.ProjectTitle,
escaper.Replace(n.ProjectTitle),
mr,
n.ReviewURL,
n.Title,
n.Author,
n.SourceBranch,
n.TargetBranch,
escaper.Replace(n.Title),
escaper.Replace(n.Author),
escaper.Replace(n.SourceBranch),
escaper.Replace(n.TargetBranch),
n.IssueStats.Critical,
n.IssueStats.High,
n.IssueStats.Medium,
Expand Down
54 changes: 54 additions & 0 deletions pkg/slack/slack_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package slack

import (
"testing"

"github.com/stretchr/testify/assert"
)

func TestReviewNotificationText(t *testing.T) {
base := ReviewNotification{
ProjectTitle: "group/app",
Title: "ABC-1 Add upload",
Author: "john",
SourceBranch: "ABC-1",
TargetBranch: "main",
TrafficLight: "yellow",
IssueStats: IssueStats{Medium: 7, Low: 11},
ReviewURL: "https://reviewer.example.com/reviews/5/",
}

tests := []struct {
name string
edit func(*ReviewNotification)
want string
}{
{
name: "without merge request",
edit: func(*ReviewNotification) {},
want: ":large_yellow_circle: [group/app] *<https://reviewer.example.com/reviews/5/|ABC-1 Add upload>* by john (`ABC-1` β†’ `main`) β€” 0 critical, 0 high, 7 medium, 11 low",
},
{
name: "with merge request",
edit: func(n *ReviewNotification) {
n.MRURL, n.MRLabel = "https://git.example.com/group/app/-/merge_requests/42", "MR !42"
},
want: ":large_yellow_circle: [group/app] <https://git.example.com/group/app/-/merge_requests/42|MR !42> *<https://reviewer.example.com/reviews/5/|ABC-1 Add upload>* by john (`ABC-1` β†’ `main`) β€” 0 critical, 0 high, 7 medium, 11 low",
},
{
name: "mrkdwn control characters are escaped",
edit: func(n *ReviewNotification) {
n.Title, n.Author, n.SourceBranch = "Use <T> & friends", "a>b", "x<y"
},
want: ":large_yellow_circle: [group/app] *<https://reviewer.example.com/reviews/5/|Use &lt;T&gt; &amp; friends>* by a&gt;b (`x&lt;y` β†’ `main`) β€” 0 critical, 0 high, 7 medium, 11 low",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
n := base
tt.edit(&n)
assert.Equal(t, tt.want, n.text())
})
}
}
Loading