Skip to content

Commit bfc5dfb

Browse files
committed
BUILD/MINOR: ci: add CI question for backport need
1 parent 0281d6a commit bfc5dfb

File tree

3 files changed

+334
-0
lines changed

3 files changed

+334
-0
lines changed

.aspell.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,3 +44,5 @@ allowed:
4444
- CORS
4545
- dataplaneapi
4646
- usr
47+
- backport
48+
- cmd

.gitlab-ci.yml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
stages:
2+
- bots
23
- diff
34
- lint
45
- unit-tests
@@ -46,6 +47,18 @@ diff-crd:
4647
- git diff
4748
- test -z "$(git diff 2> /dev/null)" || exit "CRD generation was not generated, issue \`make cr_generate\` and commit the result"
4849
- test -z "$(git ls-files --others --exclude-standard 2> /dev/null)" || exit "CRD generation created untracked files, cannot proceed"
50+
mr-backport-question:
51+
stage: bots
52+
needs: []
53+
image:
54+
name: $CI_REGISTRY_GO/docker:$DOCKER_VERSION-go$GO_VERSION
55+
entrypoint: [ "" ]
56+
rules:
57+
- if: $CI_PIPELINE_SOURCE == 'merge_request_event' && $CI_MERGE_REQUEST_TARGET_BRANCH_NAME == $CI_DEFAULT_BRANCH
58+
tags:
59+
- go
60+
script:
61+
- go run cmd/gitlab-mr-checker/main.go
4962
tidy:
5063
stage: lint
5164
needs: []

cmd/gitlab-mr-checker/main.go

Lines changed: 319 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,319 @@
1+
// Copyright 2019 HAProxy Technologies LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package main
16+
17+
import (
18+
"bytes"
19+
"context"
20+
"encoding/json"
21+
"errors"
22+
"fmt"
23+
"io"
24+
"log/slog"
25+
"net/http"
26+
"net/url"
27+
"os"
28+
"slices"
29+
"strconv"
30+
"strings"
31+
"time"
32+
33+
docs "github.com/haproxytech/kubernetes-ingress/documentation"
34+
)
35+
36+
type Issue struct {
37+
Title string `json:"title"`
38+
State string `json:"state"`
39+
ID int `json:"id"`
40+
IID int `json:"iid"`
41+
}
42+
43+
type Note struct {
44+
CreatedAt time.Time `json:"created_at"`
45+
UpdatedAt time.Time `json:"updated_at"`
46+
Body string `json:"body"`
47+
Attachment string `json:"attachment"`
48+
Author Author `json:"author"`
49+
ID int `json:"id"`
50+
ProjectID int `json:"project_id"`
51+
System bool `json:"system"`
52+
Resolvable bool `json:"resolvable"`
53+
Confidential bool `json:"confidential"`
54+
Internal bool `json:"internal"`
55+
}
56+
57+
type Author struct {
58+
CreatedAt time.Time `json:"created_at"`
59+
Username string `json:"username"`
60+
Email string `json:"email"`
61+
Name string `json:"name"`
62+
State string `json:"state"`
63+
ID int `json:"id"`
64+
}
65+
66+
type Thread struct {
67+
Body string `json:"body"`
68+
ID int `json:"id"`
69+
IID int `json:"iid"`
70+
}
71+
72+
// GitlabLabel defines the structure for a GitLab label.
73+
// It includes common fields; you are primarily using Name.
74+
type GitlabLabel struct {
75+
ID int `json:"id"`
76+
Name string `json:"name"`
77+
Color string `json:"color"`
78+
Description string `json:"description,omitempty"` // omitempty handles cases where description might be null or absent
79+
}
80+
81+
var baseURL string
82+
83+
const LABEL_COLOR = "#8fbc8f" //nolint:stylecheck
84+
85+
func main() {
86+
fmt.Print(hello) //nolint:forbidigo
87+
88+
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
89+
AddSource: true,
90+
ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
91+
if a.Key == "source" {
92+
x := a.Value
93+
src := x.Any().(*slog.Source)
94+
path := strings.Split(src.File, "/")
95+
src.File = path[len(path)-1]
96+
return slog.Attr{
97+
Key: "source",
98+
Value: slog.AnyValue(src),
99+
}
100+
}
101+
return a
102+
},
103+
}))
104+
slog.SetDefault(logger)
105+
106+
baseURL = os.Getenv("CI_API_V4_URL")
107+
if baseURL == "" {
108+
slog.Error("CI_API_V4_URL not set")
109+
os.Exit(1)
110+
}
111+
112+
docs, err := docs.GetLifecycle()
113+
if err != nil {
114+
slog.Error(err.Error())
115+
os.Exit(1)
116+
}
117+
118+
token := os.Getenv("GITLAB_TOKEN")
119+
120+
CI_MERGE_REQUEST_IID_STR := os.Getenv("CI_MERGE_REQUEST_IID") //nolint:stylecheck
121+
if CI_MERGE_REQUEST_IID_STR == "" {
122+
slog.Error("CI_MERGE_REQUEST_IID not set")
123+
os.Exit(1)
124+
}
125+
CI_MERGE_REQUEST_IID, err := strconv.Atoi(CI_MERGE_REQUEST_IID_STR) //nolint:stylecheck
126+
if err != nil {
127+
slog.Error(err.Error())
128+
os.Exit(1)
129+
}
130+
131+
CI_PROJECT_ID := os.Getenv("CI_PROJECT_ID") //nolint:stylecheck
132+
if CI_PROJECT_ID == "" {
133+
slog.Error("CI_PROJECT_ID not set")
134+
os.Exit(1)
135+
}
136+
question := `<!-- MR BACKPORT QUESTION -->` + "\n" + "Does this needs backport ? \n| Version | EOL | label |\n|:--:|:---|:--:|"
137+
backportLabels := map[string]struct{}{
138+
"backport-ee": {},
139+
}
140+
for _, version := range docs.Versions {
141+
if !version.Maintained {
142+
continue
143+
}
144+
question += "\n" + "| " + version.Version + " | ~ " + version.EOLHuman + " | " + "backport-" + version.Version + " |"
145+
backportLabels["backport-"+version.Version] = struct{}{}
146+
}
147+
question += "\n\n" + "please add labels for backporting."
148+
149+
notes, err := getMergeRequestComments(baseURL, token, CI_PROJECT_ID, CI_MERGE_REQUEST_IID)
150+
if err != nil {
151+
slog.Error(err.Error())
152+
os.Exit(1)
153+
}
154+
index := slices.IndexFunc(notes, func(note Note) bool {
155+
return strings.Contains(note.Body, "<!-- MR BACKPORT QUESTION -->")
156+
})
157+
if index == -1 {
158+
// add missing labels
159+
err = getProjectlabels(backportLabels, CI_PROJECT_ID)
160+
if err != nil {
161+
slog.Error(err.Error())
162+
os.Exit(1)
163+
}
164+
slog.Info("No backport question found, creating one as thread")
165+
startThreadOnMergeRequest(baseURL, token, CI_PROJECT_ID, CI_MERGE_REQUEST_IID, question)
166+
}
167+
}
168+
169+
func startThreadOnMergeRequest(baseURL, token, projectID string, mergeRequestIID int, threadBody string) {
170+
client := &http.Client{}
171+
threadData := map[string]interface{}{
172+
"body": threadBody,
173+
}
174+
threadDataBytes, err := json.Marshal(threadData)
175+
if err != nil {
176+
slog.Error(err.Error())
177+
os.Exit(1)
178+
}
179+
180+
req, err := http.NewRequestWithContext(context.Background(), http.MethodPost,
181+
fmt.Sprintf("%s/projects/%s/merge_requests/%d/discussions", baseURL, url.PathEscape(projectID), mergeRequestIID), bytes.NewBuffer(threadDataBytes))
182+
if err != nil {
183+
slog.Error(err.Error())
184+
os.Exit(1)
185+
}
186+
req.Header.Add("PRIVATE-TOKEN", token) //nolint:canonicalheader
187+
req.Header.Add("Content-Type", "application/json")
188+
189+
resp, err := client.Do(req)
190+
if err != nil {
191+
slog.Error(err.Error())
192+
os.Exit(1)
193+
}
194+
defer resp.Body.Close()
195+
196+
// body, err := io.ReadAll(resp.Body)
197+
// if err != nil {
198+
// slog.Error(err.Error())
199+
// os.Exit(1)
200+
// }
201+
202+
// var thread Thread
203+
// err = json.Unmarshal(body, &thread)
204+
// if err != nil {
205+
// slog.Error(err.Error())
206+
// os.Exit(1)
207+
// }
208+
209+
// slog.Info("Thread started with ID " + strconv.Itoa(thread.ID))
210+
}
211+
212+
func getMergeRequestComments(baseURL, token, projectID string, mergeRequestIID int) ([]Note, error) {
213+
client := &http.Client{}
214+
215+
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet,
216+
fmt.Sprintf("%s/projects/%s/merge_requests/%d/notes", baseURL, url.PathEscape(projectID), mergeRequestIID), nil)
217+
if err != nil {
218+
return nil, err
219+
}
220+
req.Header.Add("PRIVATE-TOKEN", token) //nolint:canonicalheader
221+
222+
resp, err := client.Do(req)
223+
if err != nil {
224+
return nil, err
225+
}
226+
defer resp.Body.Close()
227+
228+
body, err := io.ReadAll(resp.Body)
229+
if err != nil {
230+
return nil, err
231+
}
232+
233+
var notes []Note
234+
err = json.Unmarshal(body, &notes)
235+
if err != nil {
236+
return nil, err
237+
}
238+
239+
return notes, nil
240+
}
241+
242+
func getProjectlabels(backportLabels map[string]struct{}, projectID string) error {
243+
client := &http.Client{}
244+
token := os.Getenv("GITLAB_TOKEN")
245+
if token == "" {
246+
return errors.New("GITLAB_TOKEN not set")
247+
}
248+
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet,
249+
fmt.Sprintf("%s/projects/%s/labels", baseURL, url.PathEscape(projectID)), nil)
250+
if err != nil {
251+
return fmt.Errorf("failed to create request: %w", err)
252+
}
253+
req.Header.Add("PRIVATE-TOKEN", token) //nolint:canonicalheader
254+
resp, err := client.Do(req)
255+
if err != nil {
256+
return fmt.Errorf("failed to get project labels: %w", err)
257+
}
258+
defer resp.Body.Close()
259+
260+
body, err := io.ReadAll(resp.Body)
261+
if err != nil {
262+
return fmt.Errorf("failed to read response body: %w", err)
263+
}
264+
265+
if resp.StatusCode != http.StatusOK {
266+
return fmt.Errorf("failed to get project labels: status %s, body: %s", resp.Status, string(body))
267+
}
268+
269+
var projectLabels []GitlabLabel
270+
err = json.Unmarshal(body, &projectLabels)
271+
if err != nil {
272+
return fmt.Errorf("failed to unmarshal response body (status %s): %w. Body: %s", resp.Status, err, string(body))
273+
}
274+
275+
for _, label := range projectLabels {
276+
_, ok := backportLabels[label.Name]
277+
if ok {
278+
delete(backportLabels, label.Name)
279+
}
280+
}
281+
for label := range backportLabels {
282+
// Create the label if it doesn't exist
283+
labelData := map[string]string{
284+
"name": label,
285+
"color": LABEL_COLOR,
286+
"description": "Label for backporting to " + label + " branch",
287+
}
288+
labelDataBytes, err := json.Marshal(labelData)
289+
if err != nil {
290+
return fmt.Errorf("failed to marshal label data: %w", err)
291+
}
292+
req, err := http.NewRequestWithContext(context.Background(), http.MethodPost,
293+
fmt.Sprintf("%s/projects/%s/labels", baseURL, url.PathEscape(projectID)), bytes.NewBuffer(labelDataBytes))
294+
if err != nil {
295+
return fmt.Errorf("failed to create request to create label: %w", err)
296+
}
297+
req.Header.Add("PRIVATE-TOKEN", token) //nolint:canonicalheader
298+
req.Header.Add("Content-Type", "application/json")
299+
resp, err := client.Do(req)
300+
if err != nil {
301+
return fmt.Errorf("failed to create label %s: %w", label, err)
302+
}
303+
defer resp.Body.Close()
304+
if resp.StatusCode != http.StatusCreated {
305+
return fmt.Errorf("failed to create label %s, status code: %d", label, resp.StatusCode)
306+
}
307+
}
308+
309+
return nil
310+
}
311+
312+
const hello = `
313+
__ __ ____ _ _
314+
| \/ | _ \ ___| |__ ___ ___| | _____ _ __
315+
| |\/| | |_) | / __| '_ \ / _ \/ __| |/ / _ \ '__|
316+
| | | | _ < | (__| | | | __/ (__| < __/ |
317+
|_| |_|_| \_\ \___|_| |_|\___|\___|_|\_\___|_|
318+
319+
`

0 commit comments

Comments
 (0)